2026-01-17 13:38:17 +01:00
'use strict' ;
exports = module . exports = {
createCsr ,
generateKey ,
getModulus ,
pemToDer ,
getCertificateDates ,
getSubjectAndIssuer ,
generateCertificate ,
hasExpired ,
getPublicKey ,
checkHost ,
generateDkimKey ,
generateDhparam ,
2026-01-17 22:31:36 +01:00
validateCertificate ,
getSerial ,
getAuthorityKeyId
2026-01-17 13:38:17 +01:00
} ;
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' ) ;
2026-01-17 22:31:36 +01:00
async function generateKey ( type ) {
debug ( ` generateKey: generating new key for ${ type } ` ) ;
2026-01-17 13:38:17 +01:00
if ( type === 'rsa4096' ) {
return await shell . spawn ( 'openssl' , [ 'genrsa' , '4096' ] , { encoding : 'utf8' } ) ;
2026-01-17 22:20:39 +01:00
} else if ( type === 'secp256r1' ) {
2026-01-17 13:38:17 +01:00
return await shell . spawn ( 'openssl' , [ 'ecparam' , '-genkey' , '-name' , type ] , { encoding : 'utf8' } ) ;
}
2026-01-17 22:31:36 +01:00
throw new BoxError ( BoxError . INTERNAL _ERROR , ` Unhandled key type ${ type } ` ) ;
2026-01-17 13:38:17 +01:00
}
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] \n subjectAltName=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 ;
}
2026-01-17 22:31:36 +01:00
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' ) ;
}