/** * 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). */ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 16; const KEY_LENGTH = 32; const PREFIX = "enc:v1:"; let _derivedKey: 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 a 256-bit key from the env secret using scrypt. * Returns null if no encryption key is configured. */ function getKey(): Buffer | null { if (_derivedKey !== null) return _derivedKey; const secret = process.env.STORAGE_ENCRYPTION_KEY; if (!secret) return null; if (typeof secret !== "string" || secret.trim().length === 0) { console.error( "[Encryption] STORAGE_ENCRYPTION_KEY is set but empty or invalid. " + "Generate a valid key with: openssl rand -base64 32" ); return null; } // Fixed salt derived from app name — deterministic so same key always produces same derived key const salt = "omniroute-field-encryption-v1"; try { _derivedKey = scryptSync(secret, 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 _derivedKey; } /** Check if encryption is enabled. */ export function isEncryptionEnabled(): boolean { return !!process.env.STORAGE_ENCRYPTION_KEY; } /** * Encrypt a plaintext string. Returns ciphertext with prefix. * 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 = getKey(); if (!key) 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. If not encrypted (no prefix), returns as-is. */ 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 key = getKey(); if (!key) { console.warn( "[Encryption] Found encrypted data but STORAGE_ENCRYPTION_KEY is not set. Cannot decrypt." ); // Return null instead of encrypted ciphertext to prevent sending encrypted tokens to providers return null; } const body = ciphertext.slice(PREFIX.length); const parts = body.split(":"); if (parts.length !== 3) { console.error("[Encryption] Malformed encrypted value"); // Return null instead of encrypted ciphertext to prevent sending malformed encrypted tokens to providers return null; } const [ivHex, encryptedHex, authTagHex] = parts; try { const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedHex, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error("[Encryption] Decryption failed:", message); // Return null instead of encrypted ciphertext to prevent sending encrypted tokens to providers return null; } } /** * Encrypt sensitive fields in a connection object (mutates in-place). */ 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). */ export function decryptConnectionFields(row: T): T { if (!row) return row; if (!isEncryptionEnabled()) return row; return { ...row, apiKey: decrypt(row.apiKey), accessToken: decrypt(row.accessToken), refreshToken: decrypt(row.refreshToken), idToken: decrypt(row.idToken), }; } /** * Validate encryption configuration at startup. * Returns { valid: true } or { valid: false, error: string } with actionable guidance. */ export function validateEncryptionConfig(): { valid: boolean; error?: string } { const secret = process.env.STORAGE_ENCRYPTION_KEY; // No key set — passthrough mode is fine if (!secret) return { valid: true }; if (typeof secret !== "string" || secret.trim().length === 0) { return { valid: false, error: "STORAGE_ENCRYPTION_KEY is set but empty. " + "Either remove it (passthrough mode) or set a valid key: openssl rand -base64 32", }; } // Try deriving a key to verify it works try { scryptSync(secret, "omniroute-field-encryption-v1", KEY_LENGTH); return { valid: true }; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); return { valid: false, error: `STORAGE_ENCRYPTION_KEY is invalid (${message}). ` + `Generate a valid key with: openssl rand -base64 32`, }; } }