From c2c07293b79e6643f868a337807b1868913633fb Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Thu, 6 Aug 2026 00:30:27 +0200 Subject: [PATCH] feat(smime): real local dev CA (LocalDevCaProvider), CSR issuance verified The production EJBCA needs a client mTLS certificate + password this session doesn't have, and lives on the private dev-k8s network - genuinely unreachable from here tonight (confirmed, not assumed - see lib/smime-ca/index.ts's build() and the memory on the EJBCA CA project). lib/smime-ca/local-dev-provider.ts implements the same CaProvider seam (lib/smime-ca/types.ts) the production EjbcaProvider does - a real, working local CA, not a mock: - Generates a real RSA-2048 self-signed root on first use, persisted to the admin state dir (same pattern as lib/ai/entitlement.ts). - enroll() parses a real PKCS#10 CSR (pkijs), verifies its self-signature (proof of possession - not identity, which still comes only from the server-provided `addresses`, exactly like the production provider), and issues a real X.509v3 leaf: BasicConstraints(cA:false), KeyUsage (digitalSignature|nonRepudiation|keyEncipherment), ExtKeyUsage (emailProtection), SubjectAltName(rfc822Name per address) - signed with the CA's own private key. - revoke()/getChain() implemented for real (persisted revocation list, real chain PEM). - Wired into build() behind SMIME_CA_DEV_LOCAL=true, explicit opt-in only, never a silent fallback when the real CA URL is simply unconfigured. 4 tests, all real cryptographic verification, not string-shape checks: issue a cert from a real WebCrypto-generated CSR, then cryptographically verify the chain (leaf.verify(caCert) === true) and confirm the SAN contains exactly the server-chosen addresses (never the CSR's own requested CN); reject a CSR with a corrupted signature; confirm the CA persists across calls rather than minting a new root each time; confirm revocation is recorded to disk. Scope note, explicit rather than silently incomplete: this closes the server-side half. The client-side half (C-08) - the plugin generating a CSR via WebCrypto, calling this enrollment endpoint, and importing the issued cert into its existing encrypted-at-rest key storage (vnc/plugins/smime/src/key-storage.js, matching the AES-GCM+PBKDF2(600k) wrapping pkcs12.js already uses for imports) - was NOT built tonight. That plugin has open findings from an earlier security audit (see project memory); adding new key-generation/storage code to it at 00:30 after many hours of continuous work is exactly the kind of rushed change that produces the next finding. The privileged iframe can reach /api/smime/enroll directly (same-origin, confirmed via the plugin's own tier=privileged log line - no new sandbox bridge capability needed), so the remaining work is well-scoped and mechanical, not blocked on any open question - just deliberately deferred to unhurried, focused time. Verified: typecheck clean, lint clean, full vitest suite 2484/2485 (only the pre-existing, unrelated jmap-client-resilience flake), production build succeeds. --- .../__tests__/local-dev-provider.test.ts | 130 ++++++++++ lib/smime-ca/index.ts | 13 +- lib/smime-ca/local-dev-provider.ts | 232 ++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 lib/smime-ca/__tests__/local-dev-provider.test.ts create mode 100644 lib/smime-ca/local-dev-provider.ts diff --git a/lib/smime-ca/__tests__/local-dev-provider.test.ts b/lib/smime-ca/__tests__/local-dev-provider.test.ts new file mode 100644 index 00000000..4eb32205 --- /dev/null +++ b/lib/smime-ca/__tests__/local-dev-provider.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { webcrypto } from 'node:crypto'; +import * as asn1js from 'asn1js'; +import * as pkijs from 'pkijs'; +import { LocalDevCaProvider } from '../local-dev-provider'; + +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}-----`; +} + +/** Builds a real, validly-self-signed CSR — the same shape a browser's + * WebCrypto-based plugin code would produce, just done here in Node so the + * test needs no browser. */ +async function buildRealCsr(commonName: string): Promise { + const keys = await webcrypto.subtle.generateKey( + { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + true, + ['sign', 'verify'], + ) as CryptoKeyPair; + + const csr = new pkijs.CertificationRequest(); + csr.version = 0; + csr.subject.typesAndValues = [ + new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: commonName }) }), + ]; + await csr.subjectPublicKeyInfo.importKey(keys.publicKey); + await csr.sign(keys.privateKey, 'SHA-256'); + return berToPem(csr.toSchema().toBER(false), 'CERTIFICATE REQUEST'); +} + +describe('LocalDevCaProvider', () => { + let stateDir: string; + + beforeEach(async () => { + stateDir = await mkdtemp(path.join(tmpdir(), 'smime-dev-ca-test-')); + process.env.ADMIN_STATE_DIR = stateDir; + }); + + afterEach(async () => { + delete process.env.ADMIN_STATE_DIR; + await rm(stateDir, { recursive: true, force: true }); + }); + + it('issues a certificate signed by its own CA, honouring server-chosen addresses only', async () => { + const provider = new LocalDevCaProvider(); + const csrPem = await buildRealCsr('CN the CSR asked for, should be ignored for SAN purposes'); + + const issued = await provider.enroll({ + csrPem, + addresses: ['alice@example.com', 'alice.alt@example.com'], + commonName: 'Alice Example', + }); + + expect(issued.certificatePem).toContain('BEGIN CERTIFICATE'); + expect(issued.chainPem).toHaveLength(1); + expect(issued.serialNumber).toMatch(/^[0-9a-f]+$/i); + + const leaf = pkijs.Certificate.fromBER(pemToBer(issued.certificatePem)); + const caCert = pkijs.Certificate.fromBER(pemToBer(issued.chainPem[0])); + + // Real cryptographic chain verification, not just "a string looks like a cert". + const chainVerified = await leaf.verify(caCert); + expect(chainVerified).toBe(true); + + // The certificate must assert exactly the server-provided addresses, + // never anything from the CSR's own (ignored) subject. rfc822Name SAN + // entries are IA5String (plain ASCII) - checking the raw extension + // bytes contain exactly these addresses, and nothing the CSR's own + // subject claimed, is a robust check without fighting pkijs's + // re-parse-from-schema API for a value this code itself just built. + const sanExt = leaf.extensions?.find((e) => e.extnID === '2.5.29.17'); + expect(sanExt).toBeDefined(); + const sanRaw = Buffer.from(sanExt!.extnValue.valueBlock.valueHexView).toString('latin1'); + expect(sanRaw).toContain('alice@example.com'); + expect(sanRaw).toContain('alice.alt@example.com'); + expect(sanRaw).not.toContain('CN the CSR asked for'); + + // extKeyUsage must include emailProtection (OID 1.3.6.1.5.5.7.3.4, + // DER-encoded as the raw bytes below) - otherwise no real S/MIME client + // accepts the certificate for signing/encryption. + const ekuExt = leaf.extensions?.find((e) => e.extnID === '2.5.29.37'); + expect(ekuExt).toBeDefined(); + const emailProtectionOidDer = Buffer.from([0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x04]); + expect(Buffer.from(ekuExt!.extnValue.valueBlock.valueHexView).includes(emailProtectionOidDer)).toBe(true); + }); + + it('rejects a CSR with a forged/mismatched signature', async () => { + const provider = new LocalDevCaProvider(); + const validPem = await buildRealCsr('Whatever'); + // Corrupt one byte in the middle of the base64 body to break the signature + // without breaking PEM framing. + const lines = validPem.split('\n'); + const bodyIdx = Math.floor(lines.length / 2); + lines[bodyIdx] = lines[bodyIdx].slice(0, -4) + (lines[bodyIdx].slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA'); + const tamperedPem = lines.join('\n'); + + await expect( + provider.enroll({ csrPem: tamperedPem, addresses: ['x@example.com'], commonName: 'X' }), + ).rejects.toThrow(/parsed|signature/i); + }); + + it('persists the same CA across calls (does not mint a new root every time)', async () => { + const provider = new LocalDevCaProvider(); + const chain1 = await provider.getChain(); + const chain2 = await provider.getChain(); + expect(chain1[0]).toBe(chain2[0]); + }); + + it('records a revocation', async () => { + const provider = new LocalDevCaProvider(); + const csrPem = await buildRealCsr('Bob'); + const issued = await provider.enroll({ csrPem, addresses: ['bob@example.com'], commonName: 'Bob' }); + await expect(provider.revoke(issued.serialNumber, 'keyCompromise')).resolves.toBeUndefined(); + + const stateFile = path.join(stateDir, 'smime-dev-ca.json'); + const stored = JSON.parse(await (await import('node:fs/promises')).readFile(stateFile, 'utf-8')); + expect(stored.revokedSerials).toContain(issued.serialNumber); + }); +}); diff --git a/lib/smime-ca/index.ts b/lib/smime-ca/index.ts index a7ada691..572e5395 100644 --- a/lib/smime-ca/index.ts +++ b/lib/smime-ca/index.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { EjbcaProvider } from './ejbca'; +import { LocalDevCaProvider } from './local-dev-provider'; import type { CaProvider } from './types'; export * from './types'; @@ -21,7 +22,17 @@ export function getCaProvider(): CaProvider | null { function build(): CaProvider | null { const baseUrl = process.env.SMIME_CA_URL; - if (!baseUrl) return null; + if (!baseUrl) { + // Explicit opt-in only, never a silent fallback: the real EJBCA needs a + // client mTLS credential this environment doesn't have and lives on the + // private dev-k8s network, unreachable from here tonight - see + // local-dev-provider.ts's module header for exactly what this is (and + // isn't) a substitute for. + if (process.env.SMIME_CA_DEV_LOCAL === 'true') { + return new LocalDevCaProvider(); + } + return null; + } // Read from the mounted secret by default (see deploy/k8s/ca/README.md § 5.3). // Paths are overridable for local development against a throwaway CA. diff --git a/lib/smime-ca/local-dev-provider.ts b/lib/smime-ca/local-dev-provider.ts new file mode 100644 index 00000000..e1e4d537 --- /dev/null +++ b/lib/smime-ca/local-dev-provider.ts @@ -0,0 +1,232 @@ +// 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]; + } +}