/** * Field-level encryption at rest using AES-256-GCM. * * Output format: `enc:v1:::` * * `decryptField` is backward-compatible: values that do not carry the * `enc:v1:` prefix are assumed to be legacy plaintext and returned unchanged, * so an existing (dev) database keeps working without a data migration. */ import crypto from "node:crypto"; import { requireEnv } from "../config/env.server"; const PREFIX = "enc:v1:"; const IV_BYTES = 12; const KEY_BYTES = 32; let cachedKey: Buffer | null = null; /** * Loads and validates the 32-byte AES key from `DATA_ENCRYPTION_KEY` * (base64-encoded). Cached after first use. Throws if unset or wrong length. */ function getKey(): Buffer { if (cachedKey) return cachedKey; const b64 = requireEnv("DATA_ENCRYPTION_KEY"); let key: Buffer; try { key = Buffer.from(b64, "base64"); } catch { throw new Error('DATA_ENCRYPTION_KEY must be valid base64 of 32 bytes.'); } if (key.length !== KEY_BYTES) { throw new Error( `DATA_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes (got ${key.length}).`, ); } cachedKey = key; return key; } /** Encrypts `plaintext` and returns the `enc:v1:...` envelope. */ export function encryptField(plaintext: string): string { const key = getKey(); const iv = crypto.randomBytes(IV_BYTES); const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); const ciphertext = Buffer.concat([ cipher.update(plaintext, "utf8"), cipher.final(), ]); const tag = cipher.getAuthTag(); return ( PREFIX + iv.toString("base64") + ":" + tag.toString("base64") + ":" + ciphertext.toString("base64") ); } /** * Decrypts an `enc:v1:...` envelope. If `value` is not in that format it is * assumed to be legacy plaintext and returned unchanged. */ export function decryptField(value: string): string { if (!value.startsWith(PREFIX)) return value; const parts = value.slice(PREFIX.length).split(":"); if (parts.length !== 3) { throw new Error("Malformed encrypted field (expected iv:tag:ciphertext)."); } const [ivB64, tagB64, dataB64] = parts; const key = getKey(); const iv = Buffer.from(ivB64, "base64"); const tag = Buffer.from(tagB64, "base64"); const ciphertext = Buffer.from(dataB64, "base64"); const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); const plaintext = Buffer.concat([ decipher.update(ciphertext), decipher.final(), ]); return plaintext.toString("utf8"); }