/** * 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; // Fixed salt derived from app name — deterministic so same key always produces same derived key const salt = "omniroute-field-encryption-v1"; _derivedKey = scryptSync(secret, salt, KEY_LENGTH); 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; 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}`; } /** * 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 ciphertext; } const body = ciphertext.slice(PREFIX.length); const parts = body.split(":"); if (parts.length !== 3) { console.error("[Encryption] Malformed encrypted value"); return ciphertext; } 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 ciphertext; } } /** * Encrypt sensitive fields in a connection object (mutates in-place). * Uses `any` because the DB layer returns untyped rows from rowToCamel/cleanNulls. */ export function encryptConnectionFields(conn: any): any { if (!isEncryptionEnabled()) 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). * Uses `any` because the DB layer returns untyped rows from rowToCamel/cleanNulls. */ export function decryptConnectionFields(row: any): any { 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), }; }