'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; }