/** * 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'; import { isFeatureEnabledServer } from '@/lib/admin/feature-gate'; export const runtime = 'nodejs'; const MAX_CSR_BYTES = 8 * 1024; export async function POST(request: Request) { if (!isFeatureEnabledServer('smimeEnabled')) { return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }); } 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 }; }