Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import assert from 'node:assert' ;
import BoxError from './boxerror.js' ;
import crypto from 'node:crypto' ;
2026-03-12 22:55:28 +05:30
import logger from './logger.js' ;
Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import fs from 'node:fs' ;
import os from 'node:os' ;
import path from 'node:path' ;
import safe from 'safetydance' ;
import shellModule from './shell.js' ;
2026-01-17 13:38:17 +01:00
2026-03-12 23:23:23 +05:30
const { log } = logger ( 'openssl' ) ;
Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
const shell = shellModule ( 'openssl' ) ;
2026-01-17 13:38:17 +01:00
2026-01-17 22:31:36 +01:00
async function generateKey ( type ) {
2026-03-12 22:55:28 +05:30
log ( ` 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 } ) ) ;
2026-03-12 22:55:28 +05:30
log ( ` createCsr: csr file created for ${ cn } ` ) ;
2026-01-17 13:38:17 +01:00
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 ) ;
2026-03-12 22:55:28 +05:30
log ( ` expiryDate: ${ lines [ 2 ] } notBefore= ${ notBefore } notAfter= ${ notAfter } daysLeft= ${ daysLeft } ` ) ;
2026-01-17 13:38:17 +01:00
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 ;
2026-03-12 22:55:28 +05:30
log ( ` generateCertificate: domain= ${ domain } cn= ${ cn } ` ) ;
2026-01-17 13:38:17 +01:00
// 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 ( ) {
2026-03-12 22:55:28 +05:30
log ( 'generateDhparam: generating dhparams' ) ;
2026-01-17 13:38:17 +01:00
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' ) ;
}
2026-02-14 15:43:24 +01:00
export default {
createCsr ,
generateKey ,
getModulus ,
pemToDer ,
getCertificateDates ,
getSubjectAndIssuer ,
generateCertificate ,
hasExpired ,
getPublicKey ,
checkHost ,
generateDkimKey ,
generateDhparam ,
validateCertificate ,
getSerial ,
getAuthorityKeyId
} ;