Files
SRCmail/lib/smime-ca/ejbca.ts
T
Bernd RodlerandClaude Opus 5 3afa7ce012 feat(smime): CaProvider seam + server-side enrolment route (A-02, C-08 half)
Corrects an architecture call I got wrong earlier in the session. I had said
CaProvider would live in the plugin. It cannot, for two independent reasons:

  1. EJBCA's REST API authenticates with a CLIENT CERTIFICATE. A browser
     cannot present one from fetch, and must not hold one anyway - the RA
     credential is the authority to mint certificates, so putting it
     anywhere script-reachable turns any XSS into a certificate factory.
  2. Only the server can answer "does this person actually own this
     address?" A browser asserting its own identity to a CA is not
     authentication.

So: the plugin generates the keypair and CSR (private key never leaves the
device), and this layer decides which addresses the certificate may assert.
api.http.post is the bridge, and the fact that it forwards the user's JMAP
auth header is what makes the identity check possible at all.

The design decision worth calling out: the CSR is NOT trusted for identity,
and the route does not parse it to police what it asks for. It doesn't need
to. The route supplies the subject and the rfc822Name SAN itself from
addresses it verified independently; the CSR contributes only a public key
and proof of possession. A CSR hand-crafted to claim the CEO's address does
not have to be detected and rejected - the extension it asks for simply
never reaches the certificate.

That property depends entirely on EJBCA ignoring CSR-supplied subjects and
extensions, which is three checkboxes in the certificate profile. Added to
the runbook as the most important line in it, with a concrete verification
using a hostile CSR - because with those overrides ON, the enrolment route
still looks correct in review while issuing certificates for any address.

Identity comes from Stalwart via Identity/get, not from the auth cookie's
username. The cookie is encrypted and server-minted so it cannot be forged,
but it is still the wrong authority: the right answer to "may this person
have a signing certificate for this address" is held by the mail server
that already decides "may this person send from this address". Anything else
invents a second, weaker answer to a settled question.

It also handles two cases the cookie cannot:

  - an alias the account legitimately sends as, which belongs ON the
    certificate and which the cookie does not know about
  - an administrative principal with no mailbox, which must get NOTHING.
    Not hypothetical: admin@sandbox.vnc.de authenticates successfully and
    has no mail session, so trusting the cookie would have issued it a
    certificate for an address it cannot send from.

Wildcard identities (*@domain) are filtered out. Stalwart can legitimately
report one for an account allowed to send as anything in a domain, but it is
a capability, not an address - and a rfc822Name SAN of *@vnc.de is either
rejected by clients or, worse, honoured.

Other deliberate choices:

- Pins EJBCA's own chain for the mTLS connection instead of the public root
  store. EJBCA serves a self-signed cert on that listener by design, and
  rejectUnauthorized:false would be worse than either option - it would let
  anything on the cluster network impersonate the CA and harvest CSRs.
- CA error bodies are logged server-side and replaced with generic messages.
  An enrolment endpoint should not double as a way to probe CA config.
- DN component values are RFC 4514 escaped. The CN comes from a display
  name; an unescaped comma or plus would inject additional RDNs.
- getCaProvider() returns null rather than throwing when unconfigured, so
  the route 503s and nothing else is affected. Enrolment is opt-in; a
  missing CA secret must not stop anyone reading their mail.
- revoke() is documented as needing to work when enrolment is broken. It is
  the incident-response path, and a design that can only revoke through the
  same path that issues is one outage from being unable to answer a key
  compromise.

Typechecks clean. Not yet exercised against a live CA - the browser half of
C-08 (keypair + CSR generation in the plugin) and a real EJBCA to enrol
against are both still outstanding, so nothing here has issued a
certificate yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 13:02:21 +02:00

225 lines
8.3 KiB
TypeScript

/**
* 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<RevocationReason, string> = {
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<IssuedCertificate> {
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<void> {
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<string> {
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<readonly string[]> {
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}-----`;
}