Files
SRCmail/lib/smime-ca/local-dev-provider.ts
T
Bernd Rodler c2c07293b7 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.
2026-08-06 00:30:27 +02:00

233 lines
9.6 KiB
TypeScript

// 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<webcrypto.CryptoKeyPair> {
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<webcrypto.CryptoKeyPair>;
}
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<void> {
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<IssuedCertificate> {
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<void> {
await persistRevocation(serialNumber);
}
async getChain(): Promise<readonly string[]> {
const ca = await loadOrCreateCa();
return [ca.certificatePem];
}
}