Files
cloudron-box/src/openssl.js
Girish Ramakrishnan 36aa641cb9 migrate to "export default"
also, set no-use-before-define in linter
2026-02-14 15:43:24 +01:00

245 lines
11 KiB
JavaScript

import assert from 'node:assert';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import debugModule from 'debug';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import safe from 'safetydance';
import shellModule from './shell.js';
const debug = debugModule('box:openssl');
const shell = shellModule('openssl');
async function generateKey(type) {
debug(`generateKey: generating new key for${type}`);
if (type === 'rsa4096') {
return await shell.spawn('openssl', ['genrsa', '4096'], { encoding: 'utf8' });
} else if (type === 'secp256r1') {
return await shell.spawn('openssl', ['ecparam', '-genkey', '-name', type], { encoding: 'utf8' });
}
throw new BoxError(BoxError.INTERNAL_ERROR, `Unhandled key type ${type}`);
}
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;
}
async function getSerial(pem) {
assert.strictEqual(typeof pem, 'string');
const serialOut = await shell.spawn('openssl', ['x509', '-noout', '-serial'], { encoding: 'utf8', input: pem });
return Buffer.from(serialOut.trim().split('=')[1], 'hex'); // serial=xx
}
async function getAuthorityKeyId(pem) {
assert.strictEqual(typeof pem, 'string');
const stdout = await shell.spawn('openssl', ['x509', '-noout', '-text'], { encoding: 'utf8', input: pem });
// if there is multiple AKI, it can have "keyid:" . length is also not fixed to 59
const akiMatch = stdout.match(/Authority Key Identifier[\s\S]*?\n\s*([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2})+)/m);
if (!akiMatch) throw new BoxError(BoxError.OPENSSL_ERROR, 'AKI not found');
return Buffer.from(akiMatch[1].replace(/:/g, ''), 'hex');
}
export default {
createCsr,
generateKey,
getModulus,
pemToDer,
getCertificateDates,
getSubjectAndIssuer,
generateCertificate,
hasExpired,
getPublicKey,
checkHost,
generateDkimKey,
generateDhparam,
validateCertificate,
getSerial,
getAuthorityKeyId
};