/* ──────────────────────────────────────────────────────────────── AES‑GCM field‑level encryption helper backed by Google Cloud KMS ---------------------------------------------------------------- */ import { randomBytes, createCipheriv, createDecipheriv, createHmac } from 'crypto'; import { KeyManagementServiceClient } from '@google-cloud/kms'; import { open as fsOpen, readFile, mkdir, writeFile } from 'fs/promises'; import { constants as FS } from 'fs'; import path from 'path'; /* ── constants ─────────────────────────────────────────────── */ const ALGO = 'aes-256-gcm'; // symmetric cipher const IV_LEN = 12; // 96‑bit nonce for GCM const TAG_LEN = 16; // 128‑bit auth‑tag const MAGIC = 'gcm:'; // prefix to mark ciphertext /* ── env config (injected via docker‑compose) ──────────────── */ const KMS_KEY = (process.env.KMS_KEY_NAME || '').trim(); // projects/*/locations/*/keyRings/*/cryptoKeys/* const EDEK_PATH = (process.env.DEK_PATH || '').trim(); // e.g. /run/secrets/dev/dek.enc if (!EDEK_PATH) { console.error('FATAL: DEK_PATH env var is unset — check deploy script'); process.exit(1); } let dek; // in‑memory data‑encryption‑key let initPromise = null; /* ───────────────────────────────────────────────────────────── One‑time DEK unwrap (or generate + wrap) ──────────────────────────────────────────────────────────── */ export async function initEncryption () { /* fast path ─ already done in this process */ if (dek) return; if (initPromise) return initPromise; initPromise = (async () => { const kms = new KeyManagementServiceClient(); const dir = path.dirname(EDEK_PATH); /* 1 ── try the happy path: read existing wrapped key */ try { const edek = await readFile(EDEK_PATH); const [resp] = await kms.decrypt({ name: KMS_KEY, ciphertext: edek }); dek = resp.plaintext; process.env.DEBUG_ENCRYPTION === 'true' && console.log('[ENCRYPT] DEK unwrapped from KMS'); return; } catch (err) { if (err.code !== 'ENOENT') throw err; // genuine failure } /* 2 ── file does not exist ⇒ we’re in a race to create it */ await mkdir(dir, { recursive: true }); try { /* exclusive create – succeeds for exactly ONE container */ const fh = await fsOpen(EDEK_PATH, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL, 0o600); try { dek = randomBytes(32); // 256‑bit AES key const [wrapResp] = await kms.encrypt({ name: KMS_KEY, plaintext: dek }); await fh.writeFile(wrapResp.ciphertext); process.env.DEBUG_ENCRYPTION === 'true' && console.log('[ENCRYPT] New DEK generated & wrapped'); } finally { await fh.close(); } return; // we are the winner } catch (err) { if (err.code !== 'EEXIST') throw err; // unexpected error /* another container won – fall through to retry loop */ } /* 3 ── wait until the winner finishes writing, then read */ const maxRetries = 30; // ~3 s total for (let i = 0; i < maxRetries; i++) { try { const edek = await readFile(EDEK_PATH); const [resp] = await kms.decrypt({ name: KMS_KEY, ciphertext: edek }); dek = resp.plaintext; process.env.DEBUG_ENCRYPTION === 'true' && console.log('[ENCRYPT] DEK unwrapped after retry'); return; } catch (err) { if (err.code !== 'ENOENT') throw err; await new Promise(r => setTimeout(r, 100)); // back‑off 100 ms } } throw new Error(`Timed out waiting for ${EDEK_PATH} to appear`); })(); return initPromise; } /* ───────────────────────────────────────────────────────────── Symmetric encryption helpers ──────────────────────────────────────────────────────────── */ export function encrypt (plain) { if (plain == null) return null; // leave NULLs untouched if (isEncrypted(plain)) return plain; // already encrypted const iv = randomBytes(IV_LEN); const cipher = createCipheriv(ALGO, dek, iv); const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return MAGIC + Buffer.concat([iv, tag, ct]).toString('base64'); } export function decrypt (val) { if (!isEncrypted(val)) return val; // fast‑exit for plain text const buf = Buffer.from(val.slice(MAGIC.length), 'base64'); const iv = buf.subarray(0, IV_LEN); const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN); const ct = buf.subarray(IV_LEN + TAG_LEN); const decipher = createDecipheriv(ALGO, dek, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); } /* ───────────────────────────────────────────────────────────── Deterministic HMAC (for indexed look‑ups, optional) ──────────────────────────────────────────────────────────── */ export function hashForLookup (plain) { if (plain == null) return null; const h = createHmac('sha256', dek).update(String(plain)).digest('hex'); return h; // 64‑char hex } /* ───────────────────────────────────────────────────────────── Utility: is this value an AES‑GCM ciphertext we created? ──────────────────────────────────────────────────────────── */ export function isEncrypted (val) { return typeof val === 'string' && val.startsWith(MAGIC) && /^[A-Za-z0-9+/]+={0,2}$/.test(val.slice(MAGIC.length)); }