// A real, working local CA implementing the same `CaProvider` seam as the // production EJBCA integration (lib/smime-ca/ejbca.ts) — for when the real // CA isn't reachable (it needs a client mTLS certificate + password this // dev environment doesn't have; the real CA lives on the private dev-k8s // network anyway). This is NOT a mock: it generates a real RSA-2048 root // key, signs real CSRs into real, correctly-extensioned X.509 certificates // using pkijs, and every plugin crypto operation (sign/encrypt/decrypt/ // verify) that runs against a cert issued here is exercising the exact same // code path it would against a production EJBCA-issued cert. The ONLY // difference from production is who signed the leaf. // // Loudly NOT for production: the root key is generated on first use and // persisted in the app's own state dir, unprotected by an HSM or even a // passphrase — exactly the kind of shortcut a real CA (§4 of // deploy/k8s/ca/README.md) exists to avoid. `build()` in lib/smime-ca/index.ts // only reaches for this when SMIME_CA_DEV_LOCAL=true is explicitly set, // never as a silent fallback. import { readFile, writeFile, rename } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import * as asn1js from 'asn1js'; import * as pkijs from 'pkijs'; import { webcrypto } from 'node:crypto'; import { getStatePath, ensureStateDir } from '@/lib/admin/paths'; import { CaError, type CaProvider, type EnrollRequest, type IssuedCertificate, type RevocationReason } from './types'; pkijs.setEngine('node-webcrypto', new pkijs.CryptoEngine({ name: 'node-webcrypto', crypto: webcrypto as Crypto })); const CA_STATE_FILE = 'smime-dev-ca.json'; const CERT_VALIDITY_DAYS = 397; // matches typical public-CA S/MIME leaf lifetimes const CA_VALIDITY_YEARS = 5; interface StoredCa { privateKeyPkcs8Base64: string; certificatePem: string; revokedSerials: string[]; } function pemToBer(pem: string): ArrayBuffer { const b64 = pem.replace(/-----BEGIN[^-]+-----/, '').replace(/-----END[^-]+-----/, '').replace(/\s+/g, ''); const bin = Buffer.from(b64, 'base64'); return bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength); } function berToPem(der: ArrayBuffer, label: string): string { const b64 = Buffer.from(der).toString('base64'); const lines = b64.match(/.{1,64}/g) ?? []; return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`; } function randomSerial(): asn1js.Integer { const bytes = webcrypto.getRandomValues(new Uint8Array(16)); bytes[0] &= 0x7f; // keep it a positive INTEGER return new asn1js.Integer({ valueHex: bytes.buffer }); } function serialToHex(serial: asn1js.Integer): string { return Buffer.from(serial.valueBlock.valueHexView).toString('hex'); } function buildName(commonName: string, org: string): pkijs.AttributeTypeAndValue[] { return [ new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', // commonName value: new asn1js.Utf8String({ value: commonName }), }), new pkijs.AttributeTypeAndValue({ type: '2.5.4.10', // organizationName value: new asn1js.Utf8String({ value: org }), }), ]; } async function generateCaKeyPair(): Promise { return webcrypto.subtle.generateKey( { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['sign', 'verify'], ) as Promise; } async function createSelfSignedCa(): Promise<{ cert: pkijs.Certificate; keys: webcrypto.CryptoKeyPair }> { const keys = await generateCaKeyPair(); const cert = new pkijs.Certificate(); cert.version = 2; cert.serialNumber = randomSerial(); cert.issuer.typesAndValues = buildName('VNCmail+ LOCAL DEV S/MIME CA — NOT FOR PRODUCTION', 'VNCmail+ dev'); cert.subject.typesAndValues = buildName('VNCmail+ LOCAL DEV S/MIME CA — NOT FOR PRODUCTION', 'VNCmail+ dev'); const now = new Date(); cert.notBefore.value = now; cert.notAfter.value = new Date(now.getTime() + CA_VALIDITY_YEARS * 365 * 24 * 60 * 60 * 1000); // pkijs's own type declarations expect the DOM CryptoKey type; Node's // webcrypto.CryptoKey is structurally compatible at runtime (verified by // the passing round-trip tests) but nominally distinct (KeyUsage union // differs), hence the boundary casts at every pkijs call below. await cert.subjectPublicKeyInfo.importKey(keys.publicKey as unknown as CryptoKey); cert.extensions = [ new pkijs.Extension({ extnID: '2.5.29.19', // basicConstraints critical: true, extnValue: new pkijs.BasicConstraints({ cA: true, pathLenConstraint: 0 }).toSchema().toBER(false), }), new pkijs.Extension({ extnID: '2.5.29.15', // keyUsage: keyCertSign, cRLSign critical: true, extnValue: new asn1js.BitString({ valueHex: new Uint8Array([0b00000110]).buffer }).toBER(false), }), ]; await cert.sign(keys.privateKey as unknown as CryptoKey, 'SHA-256'); return { cert, keys }; } async function loadOrCreateCa(): Promise<{ cert: pkijs.Certificate; privateKey: webcrypto.CryptoKey; certificatePem: string; revokedSerials: string[] }> { const path = getStatePath(CA_STATE_FILE); if (existsSync(path)) { const stored = JSON.parse(await readFile(path, 'utf-8')) as StoredCa; const privateKey = await webcrypto.subtle.importKey( 'pkcs8', Buffer.from(stored.privateKeyPkcs8Base64, 'base64'), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'], ); const cert = pkijs.Certificate.fromBER(pemToBer(stored.certificatePem)); return { cert, privateKey, certificatePem: stored.certificatePem, revokedSerials: stored.revokedSerials }; } const { cert, keys } = await createSelfSignedCa(); const certificatePem = berToPem(cert.toSchema().toBER(false), 'CERTIFICATE'); const pkcs8 = await webcrypto.subtle.exportKey('pkcs8', keys.privateKey); const stored: StoredCa = { privateKeyPkcs8Base64: Buffer.from(pkcs8).toString('base64'), certificatePem, revokedSerials: [], }; await ensureStateDir(); const tmp = path + '.tmp'; await writeFile(tmp, JSON.stringify(stored, null, 2), 'utf-8'); await rename(tmp, path); return { cert, privateKey: keys.privateKey, certificatePem, revokedSerials: [] }; } async function persistRevocation(serial: string): Promise { const path = getStatePath(CA_STATE_FILE); const stored = JSON.parse(await readFile(path, 'utf-8')) as StoredCa; if (!stored.revokedSerials.includes(serial)) stored.revokedSerials.push(serial); await ensureStateDir(); const tmp = path + '.tmp'; await writeFile(tmp, JSON.stringify(stored, null, 2), 'utf-8'); await rename(tmp, path); } export class LocalDevCaProvider implements CaProvider { readonly id = 'local-dev'; async enroll(request: EnrollRequest): Promise { const ca = await loadOrCreateCa(); let csr: pkijs.CertificationRequest; try { csr = pkijs.CertificationRequest.fromBER(pemToBer(request.csrPem)); } catch (cause) { throw new CaError('CSR could not be parsed', 400, cause); } // Proof of possession: the CSR must be signed by the private key // matching its own public key. This is NOT identity verification (the // interface's whole point is that identity comes from `request.addresses`, // never the CSR) - it only confirms the requester actually holds the // key they're asking to be certified, same as any CA would check. const verified = await csr.verify().catch(() => false); if (!verified) { throw new CaError('CSR signature does not verify against its own public key', 400); } const leaf = new pkijs.Certificate(); leaf.version = 2; leaf.serialNumber = randomSerial(); leaf.issuer.typesAndValues = ca.cert.subject.typesAndValues; leaf.subject.typesAndValues = buildName(request.commonName, 'VNCmail+ dev'); const now = new Date(); leaf.notBefore.value = now; leaf.notAfter.value = new Date(now.getTime() + CERT_VALIDITY_DAYS * 24 * 60 * 60 * 1000); leaf.subjectPublicKeyInfo = csr.subjectPublicKeyInfo; const sanNames = request.addresses.map((address) => new pkijs.GeneralName({ type: 1, value: address })); // type 1 = rfc822Name leaf.extensions = [ new pkijs.Extension({ extnID: '2.5.29.19', critical: true, extnValue: new pkijs.BasicConstraints({ cA: false }).toSchema().toBER(false), }), new pkijs.Extension({ // digitalSignature + nonRepudiation + keyEncipherment extnID: '2.5.29.15', critical: true, extnValue: new asn1js.BitString({ valueHex: new Uint8Array([0b11100000]).buffer }).toBER(false), }), new pkijs.Extension({ extnID: '2.5.29.37', // extKeyUsage critical: false, extnValue: new pkijs.ExtKeyUsage({ keyPurposes: ['1.3.6.1.5.5.7.3.4'] }).toSchema().toBER(false), // emailProtection }), new pkijs.Extension({ extnID: '2.5.29.17', // subjectAltName critical: false, extnValue: new pkijs.GeneralNames({ names: sanNames }).toSchema().toBER(false), }), ]; await leaf.sign(ca.privateKey as unknown as CryptoKey, 'SHA-256'); return { certificatePem: berToPem(leaf.toSchema().toBER(false), 'CERTIFICATE'), chainPem: [ca.certificatePem], serialNumber: serialToHex(leaf.serialNumber), issuerDn: 'CN=VNCmail+ LOCAL DEV S/MIME CA — NOT FOR PRODUCTION,O=VNCmail+ dev', notAfter: leaf.notAfter.value.toISOString(), }; } async revoke(serialNumber: string, _reason: RevocationReason): Promise { await persistRevocation(serialNumber); } async getChain(): Promise { const ca = await loadOrCreateCa(); return [ca.certificatePem]; } }