import { Logger } from '@nestjs/common'; import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, } from 'node:crypto'; /** * Secrets the server must read back (TOTP secrets, webhook signing secrets, * voice notes, backup files) are encrypted with a key of their own per purpose, never * with a JWT secret. Each purpose has a key ring: the current key from * `` seals, the previous one from `_PREVIOUS` still * opens, so a key can be rotated without losing data. A sealed value names * its key: `k<8 hex>:..` (base64 parts, AES-256-GCM). * * The AES key comes from HKDF-SHA256 with a per-purpose label, so even the * same secret never yields the same key for two purposes. Outside * production a missing variable falls back to JWT_ACCESS_SECRET (still per * purpose, logged once); production refuses to start without the variables * (runtime/startup-checks.ts). * * Values from before the key ring carry no key id and were encrypted with * sha256(JWT_ACCESS_SECRET). They stay readable (never written) until the * rekey job reseals them - and only as long as JWT_ACCESS_SECRET is unchanged. */ export const MIN_ENCRYPTION_KEY_LENGTH = 32; export const KEY_ID_PATTERN = /^k[0-9a-f]{8}:/; export const PREVIOUS_KEY_SUFFIX = '_PREVIOUS'; export const ENCRYPTION_KEY_VARIABLES = { mfa: 'MFA_ENCRYPTION_KEY', webhook: 'WEBHOOK_ENCRYPTION_KEY', backup: 'BACKUP_ENCRYPTION_KEY', voice: 'VOICE_ENCRYPTION_KEY', } as const; export type EncryptionKeyVariable = (typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES]; /** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */ export type SecretPurpose = 'mfa' | 'webhook' | 'voice'; export interface RingKey { id: string; key: Buffer; } export interface KeyRing { current: RingKey; previous: RingKey | null; } /** The configured secrets behind one ring, before key derivation. */ export interface RingSecrets { current: string | null; previous: string | null; } /** AES-256-GCM output, in parts, so each format chooses its own encoding. */ export interface GcmSealed { iv: Buffer; tag: Buffer; ciphertext: Buffer; } const ALGORITHM = 'aes-256-gcm'; const IV_BYTES = 12; const AUTH_TAG_BYTES = 16; const KEY_BYTES = 32; const HKDF_SALT = 'apicore-key-ring'; const LEGACY_READ_WARNING = 'legacy ciphertext read – run the rekey job'; const DEV_FALLBACK_WARNING = 'not set – using development key derived from JWT_ACCESS_SECRET; never in production'; const logger = new Logger('KeyRing'); const issuedWarnings = new Set(); /** Each warning once per process: they describe configuration, not events. */ function warnOnce(message: string): void { if (issuedWarnings.has(message)) { return; } issuedWarnings.add(message); logger.warn(message); } function readKey(name: string, env: NodeJS.ProcessEnv): string | null { const value = env[name]; if (!value) { return null; } if (value.length < MIN_ENCRYPTION_KEY_LENGTH) { throw new Error( `${name} must be at least ${MIN_ENCRYPTION_KEY_LENGTH} characters - generate one with e.g. "openssl rand -hex 32"`, ); } return value; } /** * `` and `_PREVIOUS`, each at least 32 characters when * set. The current one is mandatory in production. Errors name the * variable, never its value. */ export function readRingSecrets( variable: EncryptionKeyVariable, env: NodeJS.ProcessEnv = process.env, ): RingSecrets { const current = readKey(variable, env); const previous = readKey(`${variable}${PREVIOUS_KEY_SUFFIX}`, env); if (!current && env.NODE_ENV === 'production') { throw new Error(`${variable} is not set`); } return { current, previous }; } export function requireJwtAccessSecret( env: NodeJS.ProcessEnv = process.env, ): string { const secret = env.JWT_ACCESS_SECRET; if (!secret) { throw new Error('JWT_ACCESS_SECRET is not set'); } return secret; } /** * The stand-in secret for an unset `variable` outside production (in * production readRingSecrets() has already refused). Logged once per * variable, without values. */ export function devFallbackSecret( variable: EncryptionKeyVariable, env: NodeJS.ProcessEnv = process.env, ): string { const secret = requireJwtAccessSecret(env); warnOnce(`${variable} ${DEV_FALLBACK_WARNING}`); return secret; } function deriveRingKey(purpose: SecretPurpose, secret: string): RingKey { const key = Buffer.from( hkdfSync('sha256', secret, HKDF_SALT, `apicore-${purpose}-v1`, KEY_BYTES), ); const id = `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`; return { id, key }; } /** The ring of one purpose; outside production a missing key falls back to JWT_ACCESS_SECRET. */ export function loadKeyRing( purpose: SecretPurpose, env: NodeJS.ProcessEnv = process.env, ): KeyRing { const variable = ENCRYPTION_KEY_VARIABLES[purpose]; const secrets = readRingSecrets(variable, env); return { current: deriveRingKey( purpose, secrets.current ?? devFallbackSecret(variable, env), ), previous: secrets.previous ? deriveRingKey(purpose, secrets.previous) : null, }; } export function findRingKey(ring: KeyRing, keyId: string): RingKey | null { if (ring.current.id === keyId) { return ring.current; } return ring.previous?.id === keyId ? ring.previous : null; } export function gcmSeal( key: Buffer, plaintext: Buffer, associatedData?: Buffer, ): GcmSealed { const iv = randomBytes(IV_BYTES); const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_BYTES, }); if (associatedData) { cipher.setAAD(associatedData); } const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); return { iv, tag: cipher.getAuthTag(), ciphertext }; } /** * Fails closed: a short tag (which GCM would otherwise accept), a wrong key * or any changed byte throws, and the error carries no input. */ export function gcmOpen( key: Buffer, sealed: GcmSealed, associatedData?: Buffer, ): Buffer { if (sealed.iv.length !== IV_BYTES || sealed.tag.length !== AUTH_TAG_BYTES) { throw new Error('malformed ciphertext'); } try { const decipher = createDecipheriv(ALGORITHM, key, sealed.iv, { authTagLength: AUTH_TAG_BYTES, }); if (associatedData) { decipher.setAAD(associatedData); } decipher.setAuthTag(sealed.tag); return Buffer.concat([ decipher.update(sealed.ciphertext), decipher.final(), ]); } catch { throw new Error('ciphertext failed authentication'); } } function sealPayload(key: Buffer, plaintext: string): string { const sealed = gcmSeal(key, Buffer.from(plaintext, 'utf8')); return [sealed.iv, sealed.tag, sealed.ciphertext] .map((part) => part.toString('base64')) .join('.'); } function openPayload(key: Buffer, payload: string): string { const parts = payload.split('.'); if (parts.length !== 3) { throw new Error('malformed ciphertext'); } const [iv, tag, ciphertext] = parts.map((part) => Buffer.from(part, 'base64'), ); return gcmOpen(key, { iv, tag, ciphertext }).toString('utf8'); } /** `${id}:${iv}.${tag}.${data}`, always with the current key. */ export function sealWithRing(ring: KeyRing, plaintext: string): string { return `${ring.current.id}:${sealPayload(ring.current.key, plaintext)}`; } export function openWithRing( ring: KeyRing, stored: string, ): { plaintext: string; keyId: string } { if (!KEY_ID_PATTERN.test(stored)) { throw new Error('missing key id'); } const separator = stored.indexOf(':'); const keyId = stored.slice(0, separator); const ringKey = findRingKey(ring, keyId); if (!ringKey) { throw new Error('unknown key id'); } return { plaintext: openPayload(ringKey.key, stored.slice(separator + 1)), keyId, }; } /** False for anything the rekey job should reseal: previous key, legacy or plain values. */ export function isSealedWithCurrentKey(ring: KeyRing, stored: string): boolean { return stored.startsWith(`${ring.current.id}:`); } /** sha256(JWT_ACCESS_SECRET), the key of values from before the key ring - only ever used to read. */ export function legacyJwtKey(env: NodeJS.ProcessEnv = process.env): Buffer { return createHash('sha256').update(requireJwtAccessSecret(env)).digest(); } /** Logs once per process and purpose that a pre-key-ring value was read, without the value. */ export function warnLegacyRead(purpose: SecretPurpose): void { warnOnce(`${LEGACY_READ_WARNING} (${purpose})`); } /** Opens a pre-key-ring `..` value sealed with legacyJwtKey(). */ export function openLegacyCiphertext( purpose: SecretPurpose, payload: string, env: NodeJS.ProcessEnv = process.env, ): string { const plaintext = openPayload(legacyJwtKey(env), payload); warnLegacyRead(purpose); return plaintext; }