/** * AES-256-GCM encryption at rest (messaging credentials and customer contact details) and keyed * hashes ("blind indexes") for exact-match lookups on encrypted values. * APP_ENCRYPTION_KEY = 32 random bytes, base64 (`openssl rand -base64 32`). */ import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto"; let cached: { raw: string; enc: Buffer; idx: Buffer } | null = null; function keys() { const raw = process.env.APP_ENCRYPTION_KEY ?? ""; if (cached?.raw === raw) return cached; const enc = Buffer.from(raw, "base64"); if (enc.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)"); // A separate key for hashing, derived so one secret covers both uses. const idx = createHmac("sha256", enc).update("datewise:blind-index:v1").digest(); cached = { raw, enc, idx }; return cached; } export function encrypt(plain: string): string { const iv = randomBytes(12); const c = createCipheriv("aes-256-gcm", keys().enc, iv); const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]); return [iv, c.getAuthTag(), enc].map((b) => b.toString("base64url")).join("."); } export function decrypt(blob: string): string { const [iv, tag, enc] = blob.split(".").map((p) => Buffer.from(p, "base64url")); const d = createDecipheriv("aes-256-gcm", keys().enc, iv); d.setAuthTag(tag); return Buffer.concat([d.update(enc), d.final()]).toString("utf8"); } /** Marks sealed column values so rows written before encryption (plain text) still read correctly. */ const PREFIX = "enc1:"; export function seal(plain: string | null | undefined): string | null | undefined { if (plain == null || plain === "" || plain.startsWith(PREFIX)) return plain; return PREFIX + encrypt(plain); } export function unseal(value: string | null | undefined): string | null { if (value == null) return null; return value.startsWith(PREFIX) ? decrypt(value.slice(PREFIX.length)) : value; } /** Keyed hash of a normalised email or phone, for exact-match queries. Never reversible. */ export function blindIndex(value: string | null | undefined, kind: "email" | "phone"): string | null { if (!value) return null; const norm = kind === "email" ? value.trim().toLowerCase() : value.replace(/[^\d+]/g, ""); if (!norm) return null; return createHmac("sha256", keys().idx).update(`${kind}:${norm}`).digest("hex"); }