Phase 3 (security): - P3.1: Feature gate server-side enforcement (403 on disabled features) - P3.2: Unified auth error interceptor (401→logout) - P3.3: Store-level state isolation via StoreSnapshot contract (added message-list-tabs + task stores to snapshot/restore cycle) - P3.4: Push event bus extraction — email-store no longer imports calendar/contact/filter/file stores directly - P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter Phase 4 (polish): - P4.1: Offline write queue — pending operations in localStorage, auto-retry on reconnect, offline-queue-indicator banner - P4.2: Identity spoofing — fromOverrideEmail domain validation - P4.3: WebSocket push for Electron via main-process IPC bridge (ws package with Authorization headers)
186 lines
7.4 KiB
TypeScript
186 lines
7.4 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';
|
|
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<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 };
|
|
}
|