223 lines
10 KiB
JavaScript
223 lines
10 KiB
JavaScript
'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;
|
|
}
|