import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { logger } from '@/lib/logger'; import { getSessionSecret } from '@/lib/auth/session-secret'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; const TAG_LENGTH = 16; const MIN_SECRET_LENGTH = 32; function getKey(): Buffer { const secret = getSessionSecret(); if (!secret) throw new Error('SESSION_SECRET not configured'); if (secret.length < MIN_SECRET_LENGTH) { throw new Error( `SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` + `Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` ); } return createHash('sha256').update(secret).digest(); } /** * A remembered sign-in: the account's password (Basic auth), or the access * token the user signed in with (Bearer auth, e.g. a Fastmail API token). * `signedInAt`: when the user signed in (ms), for the organisation's maximum * session length (lib/auth/session-age.ts); absent in cookies written * before it was recorded. */ export type SessionCredentials = | { serverUrl: string; username: string; password: string; token?: undefined; signedInAt?: number } | { serverUrl: string; username: string; token: string; password?: undefined; signedInAt?: number }; /** The Authorization header remembered credentials sign in with. */ export function sessionAuthHeader(credentials: SessionCredentials): string { if (credentials.token !== undefined) return `Bearer ${credentials.token}`; return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; } export function encryptSession( serverUrl: string, username: string, secret: string | { token: string }, signedInAt: number = Date.now(), ): string { const key = getKey(); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); const credential = typeof secret === 'string' ? { password: secret } : { token: secret.token }; const payload = JSON.stringify({ v: 1, serverUrl, username, ...credential, signedInAt }); const encrypted = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, tag, encrypted]).toString('base64'); } export function decryptSession(token: string): SessionCredentials | null { try { const key = getKey(); const data = Buffer.from(token, 'base64'); if (data.length < IV_LENGTH + TAG_LENGTH) return null; const iv = data.subarray(0, IV_LENGTH); const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); const parsed = JSON.parse(decrypted.toString('utf8')); if (parsed.v !== 1 || !parsed.serverUrl || !parsed.username) return null; const signedInAt = typeof parsed.signedInAt === 'number' && Number.isFinite(parsed.signedInAt) && parsed.signedInAt > 0 ? { signedInAt: parsed.signedInAt as number } : {}; if (typeof parsed.password === 'string' && parsed.password) { return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password, ...signedInAt }; } if (typeof parsed.token === 'string' && parsed.token) { return { serverUrl: parsed.serverUrl, username: parsed.username, token: parsed.token, ...signedInAt }; } return null; } catch (error) { logger.warn('Session decryption failed', { error: error instanceof Error ? error.message : 'Unknown error', }); return null; } } /** * The purpose an encrypted payload was minted for. * * Several unrelated things travel as AES-256-GCM blobs under the same * SESSION_SECRET-derived key: the session context cookie, the OAuth pending * state, the pairing step-up proof, the office editor token. The purpose is * bound into the GCM tag as additional authenticated data, so a blob minted * for one of them cannot be opened on the path of another - the confusion * fails as a decryption error rather than as a validation check somebody has * to remember to write (GHSA-cqqx-mjcf-mh55). */ export type PayloadPurpose = /** jmap_stalwart_ctx - the signed-in user's Stalwart credentials. */ | 'session-context' /** OAuth state + PKCE verifier, held between /sso/start and /sso/complete. */ | 'sso-pending' /** Fresh-IdP-login proof required before a device pairing code is issued. */ | 'pair-reauth' /** A paired phone's refresh token, renewed through the webmail's token proxy. */ | 'pair-refresh' /** WOPI access token, scoped to one file node and a six-hour expiry. */ | 'wopi-token' /** The app password an impersonation handoff minted, kept for revocation. */ | 'impersonation-grant' /** A user's plugin credentials (lib/plugin-secrets/vault.ts). */ | 'plugin-secrets' /** When a token sign-in happened, for the maximum session length (lib/auth/session-age.ts). */ | 'session-age'; /** * Whether a blob minted before purposes were bound may be re-admitted for * `purpose`, recognised by the plaintext markers that version wrote. * * Without this, upgrading would log every user out; with it, an editor token * still cannot pass as a session cookie, because the WOPI mint has always * stamped `t: 'wopi'` and the session context has never carried a marker at * all. Removable once the pre-upgrade cookies have expired. */ const LEGACY_PAYLOAD_MARKERS: Record) => boolean> = { 'session-context': (p) => p.t === undefined && p.purpose === undefined, // `purpose: 'reauth'` distinguishes step-up from plain login within the SSO // flow; it predates these envelope purposes and is unrelated to them. 'sso-pending': (p) => p.t === undefined && (p.purpose === undefined || p.purpose === 'reauth'), 'pair-reauth': (p) => p.purpose === 'pair', 'pair-refresh': () => false, 'wopi-token': (p) => p.t === 'wopi', 'impersonation-grant': () => false, 'plugin-secrets': () => false, 'session-age': () => false, }; export function encryptPayload(payload: Record, purpose: PayloadPurpose): string { const key = getKey(); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); cipher.setAAD(Buffer.from(purpose, 'utf8')); // `p` restates the bound purpose in the plaintext. The GCM tag is what // actually confines the blob; this is the readable copy, checked on the way // back out so a mismatch surfaces even if a future caller opens the // envelope by hand. const json = JSON.stringify({ ...payload, p: purpose }); const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, tag, encrypted]).toString('base64'); } function openEnvelope(data: Buffer, aad: PayloadPurpose | null): Record | null { if (data.length < IV_LENGTH + TAG_LENGTH) return null; const key = getKey(); const iv = data.subarray(0, IV_LENGTH); const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH); const decipher = createDecipheriv(ALGORITHM, key, iv); if (aad !== null) decipher.setAAD(Buffer.from(aad, 'utf8')); decipher.setAuthTag(tag); const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); const parsed: unknown = JSON.parse(decrypted.toString('utf8')); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; return parsed as Record; } /** * Decrypt a payload minted for `purpose`. A blob minted for any other purpose * returns null, even though it is authentic under the same key. */ export function decryptPayload(token: string, purpose: PayloadPurpose): Record | null { const data = Buffer.from(token, 'base64'); try { const bound = openEnvelope(data, purpose); if (bound) return bound.p === purpose ? bound : null; } catch { // Not a blob bound to this purpose: either it belongs to another purpose, // it predates the binding, or it is forged. The legacy read below tells // the second case apart from the other two. } try { const legacy = openEnvelope(data, null); if (legacy && legacy.p === undefined && LEGACY_PAYLOAD_MARKERS[purpose](legacy)) { return legacy; } } catch (error) { logger.warn('Payload decryption failed', { purpose, error: error instanceof Error ? error.message : 'Unknown error', }); return null; } logger.warn('Payload rejected: not minted for this purpose', { purpose }); return null; }