/** * Field-Level Encryption — AES-256-GCM * * Encrypts/decrypts sensitive fields (API keys, tokens) stored in SQLite. * Format: `enc:v1:::` * * If STORAGE_ENCRYPTION_KEY is not set, operates in passthrough mode * (stores plaintext for development convenience). * * KEY DERIVATION CHANGE (v3.7.9): * The PRIMARY key is now derived with a static salt ("omniroute-field-encryption-v1"). * The LEGACY key used a dynamic salt (sha256 hash of the key). Auto-migration * re-encrypts any legacy-encrypted tokens on decrypt. * * Why the change? * The dynamic salt `createHash("sha256").update(secret).digest().slice(0, 16)` produced * a different derived key than the static salt `"omniroute-field-encryption-v1"`. When the * health-check/token-refresh path used one derivation and the main API used another, * tokens encrypted by one path became undecryptable by the other, causing: * - Persistent decrypt failures * - Re-encryption loops (health-check undoing fixes) * - CPU spikes (50%) from error cascades * * This fix makes the static salt the primary derivation and auto-migrates * legacy-encrypted tokens back to static-salt encryption. */ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash } from "crypto"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 16; const KEY_LENGTH = 32; /** * GCM authentication tag length, in bytes. Pinned to the full 16-byte tag * produced by `cipher.getAuthTag()`. Passing `authTagLength` to * `createDecipheriv` rejects truncated authentication tags up front, closing * the GCM tag-truncation forgery vector (Semgrep gcm-no-tag-length). */ const AUTH_TAG_LENGTH = 16; const PREFIX = "enc:v1:"; const STATIC_SALT = "omniroute-field-encryption-v1"; let _staticKey: Buffer | null = null; let _legacyDynamicKey: Buffer | null = null; /** Connection object with potentially encrypted credential fields. */ export interface ConnectionFields { apiKey?: string | null; accessToken?: string | null; refreshToken?: string | null; idToken?: string | null; [key: string]: unknown; } /** * Derive the PRIMARY encryption key using the static salt. * This is the canonical key derivation that all new encryptions use. * Returns null if no encryption key is configured. */ function getStaticKey(): Buffer | null { if (_staticKey !== null) return _staticKey; const secret = process.env.STORAGE_ENCRYPTION_KEY; if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null; try { _staticKey = scryptSync(secret, STATIC_SALT, KEY_LENGTH); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error( `[Encryption] Failed to derive key from STORAGE_ENCRYPTION_KEY: ${message}. ` + `Generate a valid key with: openssl rand -base64 32` ); return null; } return _staticKey; } /** * Derive the LEGACY key using the old dynamic salt method. * Used exclusively for fallback decryption of tokens encrypted by older versions. * * The old dynamic salt was: createHash("sha256").update(secret).digest().slice(0, 16) * This produced a different derived key than the static salt, causing incompatibility. */ function getLegacyDynamicKey(): Buffer | null { if (_legacyDynamicKey !== null) return _legacyDynamicKey; const secret = process.env.STORAGE_ENCRYPTION_KEY; if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null; const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16); try { _legacyDynamicKey = scryptSync(secret, dynamicSalt, KEY_LENGTH); } catch { return null; } return _legacyDynamicKey; } /** Check if encryption is enabled. */ export function isEncryptionEnabled(): boolean { return !!process.env.STORAGE_ENCRYPTION_KEY; } /** * True when `value` is a stored ciphertext (carries the `enc:v1:` prefix). * Lets callers tell "credential present but undecryptable" (stale/changed * STORAGE_ENCRYPTION_KEY) apart from "credential genuinely empty" — decrypt() * collapses both to null otherwise. See #6148. */ export function looksEncrypted(value: unknown): boolean { return typeof value === "string" && value.startsWith(PREFIX); } /** * Encrypt a plaintext string using the STATIC salt key. * If encryption is not configured, returns plaintext unchanged. */ export function encrypt(plaintext: string | null | undefined): string | null | undefined { if (!plaintext || typeof plaintext !== "string") return plaintext; const key = getStaticKey(); if (!key) { console.warn( "[Encryption] STORAGE_ENCRYPTION_KEY not set. Storing plaintext (passthrough mode)." ); return plaintext; // passthrough mode } // Already encrypted — don't double-encrypt if (plaintext.startsWith(PREFIX)) return plaintext; try { const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); let encrypted = cipher.update(plaintext, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag().toString("hex"); return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error( `[Encryption] Encryption failed: ${message}. ` + `Check your STORAGE_ENCRYPTION_KEY — generate one with: openssl rand -base64 32` ); return plaintext; // fallback to plaintext rather than crashing } } /** * Decrypt a ciphertext string. Attempts static-salt key first (primary), * then falls back to legacy dynamic-salt key for backward compatibility. * * When a token is decrypted using the legacy key, it is flagged for * auto-migration: the next encrypt() call will re-encrypt it with the * static-salt key, gradually migrating the database. */ export function decrypt(ciphertext: string | null | undefined): string | null | undefined { if (!ciphertext || typeof ciphertext !== "string") return ciphertext; // Not encrypted — return as-is (legacy plaintext or passthrough mode) if (!ciphertext.startsWith(PREFIX)) return ciphertext; const staticKey = getStaticKey(); if (!staticKey) { console.warn( "[Encryption] Found encrypted data but STORAGE_ENCRYPTION_KEY is not set. Cannot decrypt." ); return null; } const body = ciphertext.slice(PREFIX.length); const parts = body.split(":"); if (parts.length !== 3) { console.error("[Encryption] Malformed encrypted value"); return null; } const [ivHex, encryptedHex, authTagHex] = parts; const tryDecryptWithKey = (candidateKey: Buffer): string | null => { try { const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); const decipher = createDecipheriv(ALGORITHM, candidateKey, iv, { authTagLength: AUTH_TAG_LENGTH, }); decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedHex, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } catch { return null; } }; try { // PRIMARY: Try static-salt key first (canonical derivation) const decrypted = tryDecryptWithKey(staticKey); if (decrypted !== null) { return decrypted; } console.error( `[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` + `Auth tag validation likely failed.` ); return null; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error("[Encryption] Decryption failed:", message); return null; } } /** * Encrypt sensitive fields in a connection object (mutates in-place). * After decryption that required legacy key, re-encrypt with static key * to migrate tokens automatically. */ export function encryptConnectionFields(conn: T): T { if (!isEncryptionEnabled()) return conn; if (!conn) return conn; if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey); if (conn.accessToken) conn.accessToken = encrypt(conn.accessToken); if (conn.refreshToken) conn.refreshToken = encrypt(conn.refreshToken); if (conn.idToken) conn.idToken = encrypt(conn.idToken); return conn; } /** * Decrypt sensitive fields in a connection row (returns new object). * Note: If any field was decrypted using the legacy key, the migration * flag is set. The calling code should check isMigrationNeeded() and * trigger a re-encrypt (write-back) to migrate those tokens to the static key. */ export function decryptConnectionFields(row: T): T { if (!row) return row; if (!isEncryptionEnabled()) return row; const apiKey = decrypt(row.apiKey); const accessToken = decrypt(row.accessToken); const refreshToken = decrypt(row.refreshToken); const idToken = decrypt(row.idToken); // #6148 — a stored credential that is still encrypted (`enc:v1:…`) but // decrypts to null means the STORAGE_ENCRYPTION_KEY changed or was unset. // Flag it so callers surface a clear error instead of coercing the null to // "" and firing an empty-Bearer request that upstream rejects as 401. const credentialDecryptFailed = (looksEncrypted(row.apiKey) && apiKey === null) || (looksEncrypted(row.accessToken) && accessToken === null) || (looksEncrypted(row.refreshToken) && refreshToken === null) || (looksEncrypted(row.idToken) && idToken === null); return { ...row, apiKey, accessToken, refreshToken, idToken, ...(credentialDecryptFailed ? { credentialDecryptFailed: true } : {}), }; } /** * Specifically tests a ciphertext against the legacy key. If it succeeds, it * re-encrypts the decrypted value with the canonical static key. * Used exclusively by the startup migration script. */ export function migrateLegacyEncryptedString(ciphertext: string | null | undefined): { updated: boolean; value: string | null | undefined; } { if (!isEncryptionEnabled()) return { updated: false, value: ciphertext }; if (!ciphertext || ciphertext.trim().length === 0) return { updated: false, value: ciphertext }; if (!ciphertext.startsWith(PREFIX)) return { updated: false, value: ciphertext }; const staticKey = getStaticKey(); const legacyKey = getLegacyDynamicKey(); if (!staticKey) return { updated: false, value: null }; const rawPayload = ciphertext.slice(PREFIX.length); const parts = rawPayload.split(":"); if (parts.length !== 3) return { updated: false, value: ciphertext }; const [ivHex, encryptedHex, authTagHex] = parts; const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); const encrypted = Buffer.from(encryptedHex, "hex"); const tryDecryptWithKey = (key: Buffer): string | null => { try { const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH, }); decipher.setAuthTag(authTag); let decrypted = decipher.update(encrypted, undefined, "utf8"); decrypted += decipher.final("utf8"); return decrypted; } catch { return null; } }; // 1. If it already decrypts with the static key, no migration needed. if (tryDecryptWithKey(staticKey) !== null) { return { updated: false, value: ciphertext }; } // 2. If it decrypts with the legacy key, it needs migration! if (legacyKey) { const legacyDecrypted = tryDecryptWithKey(legacyKey); if (legacyDecrypted !== null) { // Re-encrypt using the canonical static key and return updated return { updated: true, value: encrypt(legacyDecrypted) }; } } // 3. Un-decryptable or corrupted, leave it alone return { updated: false, value: ciphertext }; }