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>
This commit is contained in:
Bernd Rodler
2026-08-04 13:02:21 +02:00
co-authored by Claude Opus 5
parent 759ab7fe8c
commit 3afa7ce012
5 changed files with 613 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
/**
* S/MIME certificate enrolment (`C-08`, server half).
*
* The plugin generates a keypair in the browser and sends only a CSR here. The
* private key never leaves the device — this route never sees it and has no way
* to ask for it.
*
* What this route exists to decide: **which addresses the issued certificate is
* allowed to assert.** That question cannot be answered in the browser, and it
* must not be answered by the CSR — a CSR is a self-assertion, and honouring its
* `subjectAltName` would let anyone mint a certificate for any address, which is
* indistinguishable from having no CA at all.
*/
import { NextResponse } from 'next/server';
import { readStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
import { CaError, getCaProvider } from '@/lib/smime-ca';
export const runtime = 'nodejs';
const MAX_CSR_BYTES = 8 * 1024;
export async function POST(request: Request) {
const provider = getCaProvider();
if (!provider) {
return NextResponse.json(
{ error: 'S/MIME enrolment is not configured on this server' },
{ status: 503 },
);
}
let body: { csrPem?: unknown; slot?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const csrPem = typeof body.csrPem === 'string' ? body.csrPem.trim() : '';
if (!csrPem) {
return NextResponse.json({ error: 'csrPem is required' }, { status: 400 });
}
if (csrPem.length > MAX_CSR_BYTES) {
return NextResponse.json({ error: 'csrPem too large' }, { status: 413 });
}
// Shape check only. This is not a security control — see the module comment on
// why the CSR's contents are not trusted regardless of what they contain.
if (!/^-----BEGIN (NEW )?CERTIFICATE REQUEST-----[\s\S]+-----END (NEW )?CERTIFICATE REQUEST-----$/
.test(csrPem)) {
return NextResponse.json({ error: 'csrPem is not a PEM PKCS#10 request' }, { status: 400 });
}
const slot = Number.isInteger(body.slot) ? (body.slot as number) : 0;
if (slot < 0 || slot > 9) {
return NextResponse.json({ error: 'invalid slot' }, { status: 400 });
}
// The auth context is an encrypted, server-minted cookie, so `username` cannot
// be forged by the client. It still isn't sufficient on its own — see below.
const auth = await readStalwartAuthContext(slot);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
let identity: { addresses: string[]; displayName?: string };
try {
identity = await resolveIdentity(auth.serverUrl, auth.authHeader);
} catch (cause) {
console.error('[smime-enroll] identity resolution failed:', cause);
return NextResponse.json(
{ error: 'could not confirm your sending addresses with the mail server' },
{ status: 502 },
);
}
if (identity.addresses.length === 0) {
// An authenticated principal with no sending identity — an admin-only
// account, or a mailbox with submission disabled. Refuse rather than falling
// back to the cookie's username, which would issue a certificate for an
// address the mail server will not actually let this account send from.
return NextResponse.json(
{ error: 'this account has no sending address, so no certificate can be issued for it' },
{ status: 403 },
);
}
try {
const issued = await provider.enroll({
csrPem,
addresses: identity.addresses,
commonName: identity.displayName || identity.addresses[0],
});
// Audit before returning. A certificate that exists with no record of who
// asked for it is the thing you most want during an incident.
console.info(
`[smime-enroll] issued serial=${issued.serialNumber} ca=${provider.id} `
+ `account=${auth.username} addresses=${identity.addresses.join(',')}`,
);
return NextResponse.json({
certificatePem: issued.certificatePem,
chainPem: issued.chainPem,
serialNumber: issued.serialNumber,
issuerDn: issued.issuerDn,
notAfter: issued.notAfter,
addresses: identity.addresses,
});
} catch (error) {
if (error instanceof CaError) {
console.error(`[smime-enroll] CA error for ${auth.username}:`, error.message, error.cause);
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error('[smime-enroll] unexpected error:', error);
return NextResponse.json({ error: 'enrolment failed' }, { status: 500 });
}
}
/**
* Ask Stalwart which addresses this session may send from, via `Identity/get`.
*
* This is deliberately not derived from the auth cookie's `username`. The right
* authority for "may this person have a signing certificate for this address" is
* the mail server that already decides "may this person send from this address" —
* anything else invents a second, weaker answer to a question already settled.
*
* It also handles the cases the cookie cannot: an alias the account legitimately
* sends as (which should be on the certificate) and an administrative principal
* with no mailbox at all (which should get no certificate). The latter is not
* hypothetical here — `admin@sandbox.vnc.de` authenticates successfully and has
* no mail session, and trusting the cookie would have issued it a certificate.
*/
async function resolveIdentity(
serverUrl: string,
authHeader: string,
): Promise<{ addresses: string[]; displayName?: string }> {
const session = await fetchJmapSession(serverUrl, authHeader);
if (!session) throw new Error('no JMAP session');
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!accountId) throw new Error('no primary mail account');
const apiUrl = rebaseApiUrl(session, serverUrl);
if (!apiUrl) throw new Error('session advertises no usable apiUrl');
const res = await postJmap(apiUrl, authHeader, JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission'],
methodCalls: [['Identity/get', { accountId }, '0']],
}));
if (!res.ok) throw new Error(`Identity/get returned ${res.status}`);
const payload = await res.json() as {
methodResponses?: [string, { list?: { email?: string; name?: string }[] }, string][];
};
const first = payload.methodResponses?.[0];
if (!first || first[0] !== 'Identity/get') {
throw new Error('Identity/get failed');
}
const seen = new Set<string>();
const addresses: string[] = [];
let displayName: string | undefined;
for (const entry of first[1]?.list ?? []) {
const email = typeof entry.email === 'string' ? entry.email.trim().toLowerCase() : '';
// Stalwart can report a wildcard identity (`*@domain`) for accounts allowed
// to send as anything in a domain. That is a real capability, but it is not
// an address and must never reach a certificate — a `rfc822Name` SAN of
// `*@vnc.de` is either rejected by clients or, worse, honoured.
if (!email || email.includes('*') || !email.includes('@')) continue;
if (seen.has(email)) continue;
seen.add(email);
addresses.push(email);
if (!displayName && typeof entry.name === 'string' && entry.name.trim()) {
displayName = entry.name.trim();
}
}
return { addresses, displayName };
}
+28
View File
@@ -275,6 +275,34 @@ intermediate's CDP actually resolves.
| CRL Distribution Point | use CA default | |
| OCSP Service Locator (AIA) | `http://ca.sandbox.vnc.de/ejbca/publicweb/status/ocsp` | |
| Allow key recovery | **on** | see §7 |
| Allow subject DN override by CSR | **OFF** | load-bearing, see below |
| Allow extension override by CSR | **OFF** | load-bearing, see below |
| Allow subject alt name override by CSR | **OFF** | load-bearing, see below |
**The three override settings must be OFF, and this is the single most important
line in this document.**
The enrolment route deliberately does *not* inspect the CSR to police what it
asks for. It doesn't need to: the route supplies the subject and the
`rfc822Name` SAN itself, from addresses Stalwart confirmed the account may send
from, and the CSR contributes only a public key plus proof the requester holds
the matching private key.
That reasoning is only sound while EJBCA ignores the CSR's own subject and
extensions. Turn any of these overrides on and a hand-crafted CSR claiming
`rfc822Name=ceo@vnc.de` gets exactly that certificate — no code change, no
alert, and the enrolment route still looks correct in review. It is a
one-checkbox path from "authenticated users get certificates for their own
addresses" to "authenticated users get certificates for anyone's address".
Verify it rather than trusting the profile screen, once the route is live:
```bash
openssl req -new -key /tmp/t.key -subj "/CN=Impostor" -addext "subjectAltName=email:ceo@vnc.de" -out /tmp/t.csr
```
Submit that CSR through the enrolment route as an ordinary user. The certificate
that comes back must carry **your own** address, not `ceo@vnc.de`.
Two of these carry real weight:
+224
View File
@@ -0,0 +1,224 @@
/**
* 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}-----`;
}
+59
View File
@@ -0,0 +1,59 @@
import { readFileSync } from 'node:fs';
import { EjbcaProvider } from './ejbca';
import type { CaProvider } from './types';
export * from './types';
/**
* Build the configured provider, or `null` when S/MIME enrolment is not set up.
*
* `null` rather than a throw, so an unconfigured deployment answers 503 on the
* enrolment route and is otherwise unaffected. Enrolment is an opt-in feature of
* a mail client; a missing CA secret must not stop anyone reading their mail.
*/
let cached: CaProvider | null | undefined;
export function getCaProvider(): CaProvider | null {
if (cached !== undefined) return cached;
cached = build();
return cached;
}
function build(): CaProvider | null {
const baseUrl = process.env.SMIME_CA_URL;
if (!baseUrl) 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.
const pfxPath = process.env.SMIME_CA_CLIENT_PFX_PATH ?? '/etc/smime-ca/client.p12';
const caPath = process.env.SMIME_CA_CHAIN_PATH ?? '/etc/smime-ca/ca-chain.pem';
const password = process.env.SMIME_CA_CLIENT_PFX_PASSWORD;
if (!password) {
console.error('[smime-ca] SMIME_CA_URL is set but SMIME_CA_CLIENT_PFX_PASSWORD is not');
return null;
}
let clientPfx: Buffer;
let serverCaPem: string;
try {
clientPfx = readFileSync(pfxPath);
serverCaPem = readFileSync(caPath, 'utf8');
} catch (cause) {
// Loud, because the symptom otherwise is "enrolment returns 503" with no
// indication that a file is simply not mounted.
console.error(`[smime-ca] cannot read RA credential (${pfxPath} / ${caPath}):`, cause);
return null;
}
return new EjbcaProvider({
id: process.env.SMIME_CA_ID ?? 'ejbca',
baseUrl: baseUrl.replace(/\/$/, ''),
clientPfx,
clientPfxPassword: password,
serverCaPem,
caName: process.env.SMIME_CA_NAME ?? 'VNC S/MIME Issuing CA Sandbox R1',
certificateProfile: process.env.SMIME_CA_CERT_PROFILE ?? 'VNC S/MIME 1y',
endEntityProfile: process.env.SMIME_CA_EE_PROFILE ?? 'VNC S/MIME User',
});
}
+122
View File
@@ -0,0 +1,122 @@
/**
* `CaProvider` — the seam between VNCmail+ and whoever signs its S/MIME
* certificates (`A-02`).
*
* There is one implementation today (EJBCA Community, in-cluster). The point of
* the interface is that moving to a public CA later — SwissSign, for
* ZertES/eIDAS-qualified signatures external parties validate without
* installing a trust anchor — is a second implementation rather than a rewrite
* of enrolment, storage, signing, or any UI.
*
* ── Where this runs, and why not in the browser ──────────────────────────────
*
* Server side, always. The obvious-looking design is for the plugin to talk to
* the CA directly; it cannot, for two independent reasons:
*
* 1. EJBCA's REST API authenticates with a client certificate. A browser
* cannot present one from `fetch`, and it must not hold one anyway — the RA
* credential is the authority to mint certificates, so putting it in
* reachable-by-script storage means any XSS becomes 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 split is: the plugin generates the keypair and the CSR (the private key
* never leaves the device), and this layer decides *which addresses* the
* resulting certificate may assert.
*/
/** An address the mail server itself confirms this session may send from. */
export interface VerifiedIdentity {
/** Authenticated account, from the server-minted auth context. */
readonly account: string;
/**
* Addresses Stalwart reports via `Identity/get` for this account. This is the
* authority for what a certificate may claim — a certificate for signing mail
* should only ever assert an address the mail server will let you send from.
*/
readonly addresses: readonly string[];
/** Display name for the certificate's CN. Cosmetic; never a security input. */
readonly displayName?: string;
}
export interface EnrollRequest {
/**
* PEM PKCS#10 from the browser.
*
* IMPORTANT — this contributes exactly two things: the public key, and proof
* that the requester holds the matching private key. It is NOT trusted for
* identity. Any subject DN or `subjectAltName` the CSR asks for is discarded;
* the issued certificate's addresses come from `addresses` below, which the
* server derived from `VerifiedIdentity`.
*
* This is why the enrolment route does not parse the CSR to police it. A CSR
* hand-crafted to claim the CEO's address does not need to be detected and
* rejected — the extension it asks for simply never reaches the certificate.
* That property depends on the CA being configured not to honour extensions
* from the CSR; see `deploy/k8s/ca/README.md` § 4.
*/
readonly csrPem: string;
/** Addresses to place in the certificate's `rfc822Name` SAN. Server-chosen. */
readonly addresses: readonly string[];
/** Certificate CN. */
readonly commonName: string;
}
export interface IssuedCertificate {
readonly certificatePem: string;
/** Issuing chain, leaf-adjacent first, root last. Excludes the leaf. */
readonly chainPem: readonly string[];
readonly serialNumber: string;
readonly issuerDn: string;
readonly notAfter: string;
}
/** RFC 5280 CRL reason codes, for `revoke`. */
export type RevocationReason =
| 'unspecified'
| 'keyCompromise'
| 'affiliationChanged'
| 'superseded'
| 'cessationOfOperation';
export class CaError extends Error {
constructor(
message: string,
/** HTTP status to surface to the caller. Never leak CA internals upward. */
readonly status: number = 502,
readonly cause?: unknown,
) {
super(message);
this.name = 'CaError';
}
}
export interface CaProvider {
/** Stable identifier for logs and audit entries, e.g. `ejbca-sandbox`. */
readonly id: string;
/**
* Sign a CSR for the given addresses.
*
* Implementations must not derive identity from `csrPem`.
*/
enroll(request: EnrollRequest): Promise<IssuedCertificate>;
/**
* Revoke by serial.
*
* Revocation must remain possible when enrolment is broken — it is the
* incident-response path, and an implementation that can only revoke via the
* same code path that issues is one outage away from being unable to respond
* to a key compromise.
*/
revoke(serialNumber: string, reason: RevocationReason): Promise<void>;
/**
* The chain clients need to validate certificates from this CA, root last.
*
* Fetched rather than hardcoded so that an intermediate rotation does not
* require a redeploy.
*/
getChain(): Promise<readonly string[]>;
}