diff --git a/app/api/smime/enroll/route.ts b/app/api/smime/enroll/route.ts new file mode 100644 index 00000000..01ac06b2 --- /dev/null +++ b/app/api/smime/enroll/route.ts @@ -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(); + 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 }; +} diff --git a/deploy/k8s/ca/README.md b/deploy/k8s/ca/README.md index c579c896..a21578c2 100644 --- a/deploy/k8s/ca/README.md +++ b/deploy/k8s/ca/README.md @@ -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: diff --git a/lib/smime-ca/ejbca.ts b/lib/smime-ca/ejbca.ts new file mode 100644 index 00000000..61dd4028 --- /dev/null +++ b/lib/smime-ca/ejbca.ts @@ -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 = { + 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}-----`; +} diff --git a/lib/smime-ca/index.ts b/lib/smime-ca/index.ts new file mode 100644 index 00000000..a7ada691 --- /dev/null +++ b/lib/smime-ca/index.ts @@ -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', + }); +} diff --git a/lib/smime-ca/types.ts b/lib/smime-ca/types.ts new file mode 100644 index 00000000..9232adc6 --- /dev/null +++ b/lib/smime-ca/types.ts @@ -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; + + /** + * 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; + + /** + * 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; +}