/** * EJBCA Community provider (`A-01`). * * Talks to the REST API on port 8443 over mutual TLS, using the RA client * certificate provisioned in `deploy/k8s/ca/README.md` § 5. */ import { Agent, request as undiciRequest } from 'undici'; import { CaError, type CaProvider, type EnrollRequest, type IssuedCertificate, type RevocationReason, } from './types'; export interface EjbcaConfig { /** e.g. `https://ejbca.vnc-ca.svc.cluster.local:8443` */ readonly baseUrl: string; /** RA client credential, PKCS#12 DER. */ readonly clientPfx: Buffer; readonly clientPfxPassword: string; /** PEM chain used to verify EJBCA's server certificate. */ readonly serverCaPem: string; readonly caName: string; readonly certificateProfile: string; readonly endEntityProfile: string; readonly id: string; } /** RFC 5280 reason code numbers, which is what the REST API wants. */ const REASON_CODES: Record = { unspecified: 'UNSPECIFIED', keyCompromise: 'KEY_COMPROMISE', affiliationChanged: 'AFFILIATION_CHANGED', superseded: 'SUPERSEDED', cessationOfOperation: 'CESSATION_OF_OPERATION', }; const TIMEOUT_MS = 20_000; export class EjbcaProvider implements CaProvider { readonly id: string; private readonly agent: Agent; constructor(private readonly config: EjbcaConfig) { this.id = config.id; this.agent = new Agent({ connect: { // Mutual TLS. `pfx` + `passphrase` are passed through to tls.connect. pfx: config.clientPfx, passphrase: config.clientPfxPassword, // Pin the CA's own chain rather than trusting the public root store. // EJBCA serves a self-signed certificate on this listener by design // (`TLS_SETUP_ENABLED=simple`), so public roots are the wrong anchor — // and `rejectUnauthorized: false` would be worse than either, since it // would let anything on the cluster network impersonate the CA and // harvest CSRs. ca: config.serverCaPem, rejectUnauthorized: true, }, headersTimeout: TIMEOUT_MS, bodyTimeout: TIMEOUT_MS, }); } private async call(path: string, method: 'GET' | 'POST' | 'PUT', body?: unknown) { let res; try { res = await undiciRequest(`${this.config.baseUrl}${path}`, { method, dispatcher: this.agent, headers: { 'content-type': 'application/json', accept: 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); } catch (cause) { // Transport failures include "the RA certificate was rejected" and "the // pinned chain does not match". Both are configuration faults on our side, // not the user's, so they must not surface as a client error. throw new CaError('certificate authority unreachable', 503, cause); } const text = await res.body.text(); if (res.statusCode >= 400) { // EJBCA error bodies can echo request content. Log server-side, return a // generic message — an enrolment endpoint should not become a way to probe // CA configuration. console.error(`[smime-ca] ${method} ${path} -> ${res.statusCode}: ${text.slice(0, 500)}`); if (res.statusCode === 401 || res.statusCode === 403) { throw new CaError('certificate authority rejected our credential', 503); } throw new CaError('certificate authority refused the request', 502); } try { return text ? JSON.parse(text) : {}; } catch (cause) { throw new CaError('unparseable response from certificate authority', 502, cause); } } async enroll(request: EnrollRequest): Promise { if (request.addresses.length === 0) { throw new CaError('no verified address to enrol', 400); } // Both forms, deliberately. The SAN `rfc822Name` is authoritative under RFC // 5280/8550; the DN `emailAddress` attribute is legacy but still read by // older Outlook. They must agree exactly — see finding 11 in // `vnc/plugins/smime/`, where preferring the DN value over the SAN made // genuine signatures read as "signer != From". const primary = request.addresses[0]; const san = request.addresses.map((a) => `rfc822Name=${a}`).join(', '); const body = { certificate_request: request.csrPem, certificate_profile_name: this.config.certificateProfile, end_entity_profile_name: this.config.endEntityProfile, certificate_authority_name: this.config.caName, username: primary, // The subject is supplied here, by us, from the verified identity — never // taken from the CSR. See the note on `EnrollRequest.csrPem`. subject_dn: `CN=${escapeDn(request.commonName)},E=${escapeDn(primary)},O=VNC AG,C=CH`, subject_alt_name: san, email: primary, include_chain: true, }; const data = await this.call( '/ejbca/ejbca-rest-api/v1/certificate/pkcs10enroll', 'POST', body, ); const cert = pemFromBase64(data?.certificate, 'CERTIFICATE'); if (!cert) throw new CaError('certificate authority returned no certificate', 502); const chain: string[] = Array.isArray(data?.certificate_chain) ? data.certificate_chain .map((c: unknown) => pemFromBase64(c, 'CERTIFICATE')) .filter((c: string | null): c is string => !!c) : []; return { certificatePem: cert, chainPem: chain, serialNumber: String(data?.serial_number ?? ''), issuerDn: String(data?.issuer_dn ?? ''), notAfter: String(data?.expire_date ?? ''), }; } async revoke(serialNumber: string, reason: RevocationReason): Promise { if (!/^[0-9a-fA-F:]+$/.test(serialNumber)) { throw new CaError('invalid serial number', 400); } const serial = serialNumber.replace(/:/g, '').toLowerCase(); const issuer = encodeURIComponent(await this.issuerDn()); await this.call( `/ejbca/ejbca-rest-api/v1/certificate/${issuer}/${serial}/revoke` + `?reason=${REASON_CODES[reason]}`, 'PUT', ); } private cachedIssuerDn: string | null = null; private async issuerDn(): Promise { if (this.cachedIssuerDn) return this.cachedIssuerDn; const data = await this.call('/ejbca/ejbca-rest-api/v1/ca', 'GET'); const list: unknown[] = Array.isArray(data?.certificate_authorities) ? data.certificate_authorities : []; const match = list.find( (ca) => (ca as { name?: string })?.name === this.config.caName, ) as { subject_dn?: string } | undefined; if (!match?.subject_dn) { throw new CaError(`CA "${this.config.caName}" not found`, 502); } this.cachedIssuerDn = match.subject_dn; return match.subject_dn; } async getChain(): Promise { const issuer = encodeURIComponent(await this.issuerDn()); const data = await this.call( `/ejbca/ejbca-rest-api/v1/ca/${issuer}/certificate/download`, 'GET', ); const chain: string[] = Array.isArray(data?.certificate_chain) ? data.certificate_chain .map((c: unknown) => pemFromBase64(c, 'CERTIFICATE')) .filter((c: string | null): c is string => !!c) : []; if (chain.length === 0) throw new CaError('certificate authority returned no chain', 502); return chain; } } /** * Escape a DN component value per RFC 4514. * * The CN comes from a display name, which is attacker-influenced in the general * case: an unescaped `,` or `+` would let it inject additional RDNs and change * what the certificate asserts. The addresses are already constrained to the * verified set, but escaping them costs nothing and removes the need to reason * about whether a mail server could ever report an address containing a comma. */ function escapeDn(value: string): string { return value .replace(/([\\,+"<>;=])/g, '\\$1') .replace(/^([ #])/, '\\$1') .replace(/ $/, '\\ ') // Control characters have no legitimate place in a DN. .replace(/[\x00-\x1f\x7f]/g, ''); } function pemFromBase64(value: unknown, label: string): string | null { if (typeof value !== 'string' || value.length === 0) return null; if (value.includes('-----BEGIN')) return value.trim(); if (!/^[A-Za-z0-9+/=\s]+$/.test(value)) return null; const b64 = value.replace(/\s+/g, ''); const lines = b64.match(/.{1,64}/g) ?? []; return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`; }