/** * Sanity-check the `OBO_CACHE_ENCRYPTION_KEY` env var early in * bootstrap. Mirrors `assertSessionEncryptionKey` — same AES-256-GCM * 32-byte requirement, same placeholder rejection, same fail-fast * posture. * * `OBO_CACHE_ENCRYPTION_KEY` is the dedicated AES-256-GCM key for * the OBO downstream-token cache per * [ADR-0014](../../../../docs/decisions/0014-downstream-api-access-obo-pattern.md) * §"Token cache (for OBO)". Per the ADR, this key is intentionally * **distinct** from `SESSION_ENCRYPTION_KEY`: * * "The cached value is encrypted with AES-256-GCM using a * dedicated key (OBO_CACHE_ENCRYPTION_KEY), distinct from * SESSION_ENCRYPTION_KEY (ADR-0010) so a cache-key compromise * does not cascade into session compromise." * * Treat the two keys as separate trust boundaries: rotating one * must never require rotating the other, and a leak of either * must not expose data encrypted by the other. * * Returns the decoded 32-byte key as a `Buffer` so callers can pass * it straight to `crypto.createCipheriv('aes-256-gcm', key, iv)` * without re-decoding per request. */ const PLACEHOLDER = 'replace_with_32_random_bytes_base64url'; const REQUIRED_KEY_BYTES = 32; export function assertOboCacheEncryptionKey(): Buffer { const raw = process.env['OBO_CACHE_ENCRYPTION_KEY']; if (!raw || raw === '') { throw new Error( `OBO_CACHE_ENCRYPTION_KEY is not set. Generate one with ` + `"node -e \\"console.log(require('crypto').randomBytes(32).toString('base64url'))\\"" ` + `and put it in apps/portal-bff/.env. It MUST differ from SESSION_ENCRYPTION_KEY.`, ); } if (raw === PLACEHOLDER) { throw new Error( `OBO_CACHE_ENCRYPTION_KEY is still set to the .env.example placeholder ` + `("${PLACEHOLDER}"). Replace with a real random value.`, ); } // Defense in depth: refuse the exact same value as the session // key. The ADR mandates distinct keys; catching it here means a // copy-paste accident in .env never makes it past boot. const sessionKey = process.env['SESSION_ENCRYPTION_KEY']; if (sessionKey !== undefined && sessionKey !== '' && sessionKey === raw) { throw new Error( `OBO_CACHE_ENCRYPTION_KEY must differ from SESSION_ENCRYPTION_KEY ` + `(ADR-0014 §"Token cache (for OBO)"). Generate a fresh value.`, ); } let decoded: Buffer; try { decoded = Buffer.from(raw, 'base64url'); } catch { throw new Error( `OBO_CACHE_ENCRYPTION_KEY must be a base64url-encoded string. Got: ${truncate(raw)}`, ); } if (decoded.length !== REQUIRED_KEY_BYTES) { throw new Error( `OBO_CACHE_ENCRYPTION_KEY decodes to ${decoded.length} bytes, ` + `but AES-256-GCM requires exactly ${REQUIRED_KEY_BYTES}. ` + `Generate a fresh value.`, ); } return decoded; } function truncate(s: string): string { return s.length > 16 ? `${s.slice(0, 16)}…` : s; }