start moving openssl commands into openssl.js
This commit is contained in:
222
src/openssl.js
Normal file
222
src/openssl.js
Normal file
@@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
createCsr,
|
||||
generateKey,
|
||||
getModulus,
|
||||
pemToDer,
|
||||
getCertificateDates,
|
||||
getSubjectAndIssuer,
|
||||
generateCertificate,
|
||||
hasExpired,
|
||||
getPublicKey,
|
||||
checkHost,
|
||||
generateDkimKey,
|
||||
generateDhparam,
|
||||
validateCertificate
|
||||
};
|
||||
|
||||
const assert = require('node:assert'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
crypto = require('node:crypto'),
|
||||
debug = require('debug')('box:openssl'),
|
||||
fs = require('node:fs'),
|
||||
os = require('node:os'),
|
||||
path = require('node:path'),
|
||||
safe = require('safetydance'),
|
||||
shell = require('./shell.js')('openssl');
|
||||
|
||||
async function generateKey(certName, type) {
|
||||
debug(`generateKey: generating new key for ${certName} ${type}`);
|
||||
|
||||
if (type === 'rsa4096') {
|
||||
return await shell.spawn('openssl', ['genrsa', '4096'], { encoding: 'utf8' });
|
||||
} else if (type === 'secp384r1') {
|
||||
// secp384r1 is same as prime256v1. openssl ecparam -list_curves. we used to use secp384r1 but it doesn't seem to be accepted by few mail servers
|
||||
return await shell.spawn('openssl', ['ecparam', '-genkey', '-name', type], { encoding: 'utf8' });
|
||||
}
|
||||
}
|
||||
|
||||
async function getModulus(pem) {
|
||||
assert.strictEqual(typeof pem, 'string');
|
||||
|
||||
const stdout = await shell.spawn('openssl', ['rsa', '-modulus', '-noout'], { encoding: 'utf8', input: pem });
|
||||
const match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m);
|
||||
if (!match) throw new BoxError(BoxError.OPENSSL_ERROR, 'Could not get modulus');
|
||||
return Buffer.from(match[1], 'hex');
|
||||
}
|
||||
|
||||
async function pemToDer(pem) {
|
||||
assert.strictEqual(typeof pem, 'string');
|
||||
|
||||
return await shell.spawn('openssl', ['req', '-inform', 'pem', '-outform', 'der'], { input: pem });
|
||||
}
|
||||
|
||||
async function createCsr(key, cn, altNames) {
|
||||
assert.strictEqual(typeof key, 'string');
|
||||
|
||||
const [error, tmpdir] = await safe(fs.promises.mkdtemp(path.join(os.tmpdir(), 'acme-')));
|
||||
if (error) throw new BoxError(BoxError.FS_ERROR, `Error creating temporary directory for openssl config: ${error.message}`);
|
||||
|
||||
const keyFilePath = path.join(tmpdir, 'key');
|
||||
if (!safe.fs.writeFileSync(keyFilePath, key)) throw new BoxError(BoxError.FS_ERROR, `Failed to write key file: ${safe.error.message}`);
|
||||
|
||||
// we used to use -addext to the CLI to add these but that arg doesn't work on Ubuntu 16.04
|
||||
// empty distinguished_name section is required for Ubuntu 16 openssl
|
||||
let conf = '[req]\nreq_extensions = v3_req\ndistinguished_name = req_distinguished_name\n'
|
||||
+ '[req_distinguished_name]\n\n'
|
||||
+ '[v3_req]\nbasicConstraints = CA:FALSE\nkeyUsage = nonRepudiation, digitalSignature, keyEncipherment\nsubjectAltName = @alt_names\n'
|
||||
+ '[alt_names]\n';
|
||||
|
||||
altNames.forEach((an, i) => conf += `DNS.${i+1} = ${an}\n`);
|
||||
|
||||
const opensslConfigFile = path.join(tmpdir, 'openssl.conf');
|
||||
if (!safe.fs.writeFileSync(opensslConfigFile, conf)) throw new BoxError(BoxError.FS_ERROR, `Failed to write openssl config: ${safe.error.message}`);
|
||||
|
||||
// while we pass the CN anyways, subjectAltName takes precedence
|
||||
const csrPem = await shell.spawn('openssl', ['req', '-new', '-key', keyFilePath, '-outform', 'PEM', '-subj', `/CN=${cn}`, '-config', opensslConfigFile], { encoding: 'utf8' });
|
||||
await safe(fs.promises.rm(tmpdir, { recursive: true, force: true }));
|
||||
debug(`createCsr: csr file created for ${cn}`);
|
||||
return csrPem; // inspect with openssl req -text -noout -in hostname.csr -inform pem
|
||||
};
|
||||
|
||||
async function getCertificateDates(cert) {
|
||||
assert.strictEqual(typeof cert, 'string');
|
||||
|
||||
const [error, result] = await safe(shell.spawn('openssl', ['x509', '-startdate', '-enddate', '-subject', '-noout'], { encoding: 'utf8', input: cert }));
|
||||
if (error) return { startDate: null, endDate: null } ; // some error
|
||||
|
||||
const lines = result.trim().split('\n');
|
||||
const notBefore = lines[0].split('=')[1];
|
||||
const notBeforeDate = new Date(notBefore);
|
||||
|
||||
const notAfter = lines[1].split('=')[1];
|
||||
const notAfterDate = new Date(notAfter);
|
||||
|
||||
const daysLeft = (notAfterDate - new Date())/(24 * 60 * 60 * 1000);
|
||||
debug(`expiryDate: ${lines[2]} notBefore=${notBefore} notAfter=${notAfter} daysLeft=${daysLeft}`);
|
||||
|
||||
return { startDate: notBeforeDate, endDate: notAfterDate };
|
||||
}
|
||||
|
||||
async function getSubjectAndIssuer(cert) {
|
||||
assert.strictEqual(typeof cert, 'string');
|
||||
|
||||
const subjectAndIssuer = await shell.spawn('openssl', ['x509', '-noout', '-subject', '-issuer'], { encoding: 'utf8', input: cert });
|
||||
const subject = subjectAndIssuer.match(/^subject=(.*)$/m)[1];
|
||||
const issuer = subjectAndIssuer.match(/^issuer=(.*)$/m)[1];
|
||||
|
||||
return { subject, issuer };
|
||||
}
|
||||
|
||||
// this is used in migration - 20211006200150-domains-ensure-fallbackCertificate.js
|
||||
async function generateCertificate(domain) {
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
|
||||
const certFilePath = path.join(os.tmpdir(), `${domain}-${crypto.randomBytes(4).readUInt32LE(0)}.cert`);
|
||||
const keyFilePath = path.join(os.tmpdir(), `${domain}-${crypto.randomBytes(4).readUInt32LE(0)}.key`);
|
||||
|
||||
const opensslConf = safe.fs.readFileSync('/etc/ssl/openssl.cnf', 'utf8');
|
||||
const cn = domain;
|
||||
|
||||
debug(`generateCertificate: domain=${domain} cn=${cn}`);
|
||||
|
||||
// SAN must contain all the domains since CN check is based on implementation if SAN is found. -checkhost also checks only SAN if present!
|
||||
const opensslConfWithSan = `${opensslConf}\n[SAN]\nsubjectAltName=DNS:${domain},DNS:*.${cn}\n`;
|
||||
const configFile = path.join(os.tmpdir(), 'openssl-' + crypto.randomBytes(4).readUInt32LE(0) + '.conf');
|
||||
safe.fs.writeFileSync(configFile, opensslConfWithSan, 'utf8');
|
||||
// the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176)
|
||||
await shell.spawn('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-keyout', keyFilePath, '-out', certFilePath, '-days', '800', '-subj', `/CN=*.${cn}`, '-extensions', 'SAN', '-config', configFile, '-nodes'], { encoding: 'utf8 '});
|
||||
safe.fs.unlinkSync(configFile);
|
||||
|
||||
const cert = safe.fs.readFileSync(certFilePath, 'utf8');
|
||||
if (!cert) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
||||
safe.fs.unlinkSync(certFilePath);
|
||||
|
||||
const key = safe.fs.readFileSync(keyFilePath, 'utf8');
|
||||
if (!key) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
||||
safe.fs.unlinkSync(keyFilePath);
|
||||
|
||||
return { cert, key };
|
||||
}
|
||||
|
||||
async function hasExpired(cert) {
|
||||
const [error] = await safe(shell.spawn('openssl', ['x509', '-checkend', '0'], { input: cert }));
|
||||
return !!error;
|
||||
}
|
||||
|
||||
async function getPublicKey(pem, type) {
|
||||
if (type === 'cert') {
|
||||
const [pubKeyError1, pubKeyFromCert] = await safe(shell.spawn('openssl', ['x509', '-noout', '-pubkey'], { encoding: 'utf8', input: pem }));
|
||||
if (pubKeyError1) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from cert');
|
||||
return pubKeyFromCert;
|
||||
} else if (type === 'key') {
|
||||
const [pubKeyError2, pubKeyFromKey] = await safe(shell.spawn('openssl', ['pkey', '-pubout'], { encoding: 'utf8', input: pem }));
|
||||
if (pubKeyError2) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from private key');
|
||||
return pubKeyFromKey;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHost(cert, fqdn) {
|
||||
const checkHostOutput = await shell.spawn('openssl', ['x509', '-noout', '-checkhost', fqdn], { encoding: 'utf8', input: cert });
|
||||
return checkHostOutput.indexOf('does match certificate') !== -1;
|
||||
}
|
||||
|
||||
async function generateDkimKey() {
|
||||
const publicKeyFilePath = path.join(os.tmpdir(), `dkim-${crypto.randomBytes(4).readUInt32LE(0)}.public`);
|
||||
const privateKeyFilePath = path.join(os.tmpdir(), `dkim-${crypto.randomBytes(4).readUInt32LE(0)}.private`);
|
||||
|
||||
// https://www.unlocktheinbox.com/dkim-key-length-statistics/ and https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-authentication-dkim-easy.html for key size
|
||||
await shell.spawn('openssl', ['genrsa', '-out', privateKeyFilePath, '1024'], {});
|
||||
await shell.spawn('openssl', ['rsa', '-in', privateKeyFilePath, '-out', publicKeyFilePath, '-pubout', '-outform', 'PEM'], {});
|
||||
|
||||
const publicKey = safe.fs.readFileSync(publicKeyFilePath, 'utf8');
|
||||
if (!publicKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
||||
safe.fs.unlinkSync(publicKeyFilePath);
|
||||
|
||||
const privateKey = safe.fs.readFileSync(privateKeyFilePath, 'utf8');
|
||||
if (!privateKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
||||
safe.fs.unlinkSync(privateKeyFilePath);
|
||||
|
||||
return { publicKey, privateKey };
|
||||
}
|
||||
|
||||
async function generateDhparam() {
|
||||
debug('generateDhparam: generating dhparams');
|
||||
return await shell.spawn('openssl', ['dhparam', '-dsaparam', '2048'], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
// note: https://tools.ietf.org/html/rfc4346#section-7.4.2 (certificate_list) requires that the
|
||||
// servers certificate appears first (and not the intermediate cert)
|
||||
async function validateCertificate(subdomain, domain, certificate) {
|
||||
assert.strictEqual(typeof subdomain, 'string');
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
assert(certificate && typeof certificate, 'object');
|
||||
|
||||
const { cert, key } = certificate;
|
||||
|
||||
// check for empty cert and key strings
|
||||
if (!cert && key) throw new BoxError(BoxError.BAD_FIELD, 'missing cert');
|
||||
if (cert && !key) throw new BoxError(BoxError.BAD_FIELD, 'missing key');
|
||||
|
||||
// -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN.
|
||||
const fqdn = subdomain + (subdomain ? '.' : '') + domain;
|
||||
|
||||
const [checkHostError, match] = await safe(checkHost(cert, fqdn));
|
||||
if (checkHostError) throw new BoxError(BoxError.BAD_FIELD, 'Could not validate certificate');
|
||||
if (!match) throw new BoxError(BoxError.BAD_FIELD, `Certificate is not valid for this domain. Expecting ${fqdn}`);
|
||||
|
||||
// check if public key in the cert and private key matches. pkey below works for RSA and ECDSA keys
|
||||
const [pubKeyError1, pubKeyFromCert] = await safe(getPublicKey(cert, 'cert'));
|
||||
if (pubKeyError1) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from cert');
|
||||
const [pubKeyError2, pubKeyFromKey] = await safe(getPublicKey(key, 'key'));
|
||||
if (pubKeyError2) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from private key');
|
||||
|
||||
if (pubKeyFromCert !== pubKeyFromKey) throw new BoxError(BoxError.BAD_FIELD, 'Public key does not match the certificate.');
|
||||
|
||||
// check expiration
|
||||
const expired = await hasExpired(cert);
|
||||
if (expired) throw new BoxError(BoxError.BAD_FIELD, 'Certificate has expired');
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user