Files
SRCmail/app/api/smime/enroll/route.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

181 lines
7.2 KiB
TypeScript

/**
* 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 };
}