/* ──────────────────────────────────────────────────────────────── AES‑GCM field‑level encryption helper backed by Google Cloud KMS ---------------------------------------------------------------- */ import { randomBytes, createCipheriv, createDecipheriv, createHmac, createHash, } from 'crypto'; import { KeyManagementServiceClient } from '@google-cloud/kms'; import { SecretManagerServiceClient } from '@google-cloud/secret-manager'; import { open as fsOpen, readFile, mkdir, } from 'fs/promises'; import { constants as FS } from 'fs'; import path from 'path'; /* ── constants ─────────────────────────────────────────────── */ const ALGO = 'aes-256-gcm'; const IV_LEN = 12; // 96‑bit nonce const TAG_LEN = 16; // 128‑bit auth‑tag const MAGIC = 'gcm:'; // ciphertext prefix /* ── env config (docker‑compose) ────────────────────────────── */ const KMS_KEY = (process.env.KMS_KEY_NAME || '').trim(); const EDEK_PATH = (process.env.DEK_PATH || '').trim(); // /run/secrets//dek.enc const ENV_NAME = (process.env.ENV_NAME || 'dev').trim(); // dev | staging | prod const PROJECT = (process.env.PROJECT || 'aptivaai-dev').trim(); if (!EDEK_PATH) { console.error('FATAL: DEK_PATH env var is unset — check deploy script'); process.exit(1); } const FPR_PATH = path.join(path.dirname(EDEK_PATH), 'dek.fpr'); const SM_SECRET = `WRAPPED_DEK_${ENV_NAME.toUpperCase()}`; let dek; // plaintext DEK in memory let initPromise; // so parallel callers wait only once /* ── helper: upload a backup copy to Secret Manager ─────────── */ async function backupToSecretManager (ciphertext /* Buffer */) { const sm = new SecretManagerServiceClient(); /* ensure the secret exists – idempotent */ try { await sm.getSecret({ name: `projects/${PROJECT}/secrets/${SM_SECRET}` }); } catch { await sm.createSecret({ parent : `projects/${PROJECT}`, secretId : SM_SECRET, secret : { replication: { automatic: {} } }, }); } /* add a new version (binary) */ await sm.addSecretVersion({ parent : `projects/${PROJECT}/secrets/${SM_SECRET}`, payload : { data: ciphertext }, }); console.log(`[ENCRYPT] ⬆️ copied dek.enc to Secret Manager (${SM_SECRET})`); } /* ───────────────────────────────────────────────────────────── One‑time DEK unwrap / generate + fingerprint validation ──────────────────────────────────────────────────────────── */ export async function initEncryption () { if (dek) return; if (initPromise) return initPromise; initPromise = (async () => { const kms = new KeyManagementServiceClient(); const dir = path.dirname(EDEK_PATH); /* 1 – optimistic: use existing wrapped key */ try { const wrapped = await readFile(EDEK_PATH); dek = (await kms.decrypt({ name: KMS_KEY, ciphertext: wrapped }))[0].plaintext; } catch (err) { if (err.code !== 'ENOENT') throw err; // genuine failure /* 2 – first container wins: create DEK & wrap */ await mkdir(dir, { recursive: true }); try { const fh = await fsOpen(EDEK_PATH, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL, 0o600); try { dek = randomBytes(32); const [resp] = await kms.encrypt({ name: KMS_KEY, plaintext: dek }); const wrapped = resp.ciphertext; // Buffer await fh.writeFile(wrapped); // primary copy backupToSecretManager(wrapped) // async fire‑and‑forget .catch(e => console.error('[ENCRYPT] backup failed:', e.message)); } finally { await fh.close(); } } catch (race) { if (race.code !== 'EEXIST') throw race; // unexpected /* another container already wrote the file */ const wrapped = await readFile(EDEK_PATH); dek = (await kms.decrypt({ name: KMS_KEY, ciphertext: wrapped }))[0].plaintext; } } /* 3 – fingerprint validation */ const fp = createHash('sha256').update(dek).digest('hex').slice(0, 16); try { const onDisk = (await readFile(FPR_PATH, 'utf8')).trim(); if (onDisk !== fp) throw new Error(`fingerprint mismatch: ${onDisk} ≠ ${fp}`); } catch (err) { if (err.code !== 'ENOENT') throw err; // real error /* write fingerprint (first‑writer wins) */ await fsOpen(FPR_PATH, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL, 0o444) .then(fh => fh.writeFile(fp + '\n').finally(() => fh.close())) .catch(async (dup) => { if (dup.code !== 'EEXIST') throw dup; const onDisk = (await readFile(FPR_PATH, 'utf8')).trim(); if (onDisk !== fp) throw new Error(`fingerprint mismatch: ${onDisk} ≠ ${fp}`); }); } process.env.DEBUG_ENCRYPTION === 'true' && console.log('[ENCRYPT] DEK ready – fp', fp); })(); return initPromise; } export const SENTINEL = 'aptiva-canary-v1'; /* ── optional DB sentinel helper ────────────────────────────── */ export async function verifyCanary(pool) { // nothing to verify yet → first ever launch before server1 seeded it const [rows] = await pool.query( 'SELECT value FROM encryption_canary WHERE id = 1 LIMIT 1' ); if (!rows || rows.length === 0) return; const val = rows[0].value; let plain; try { plain = decrypt(val); } catch { throw new Error('DEK cannot decrypt sentinel row'); } if (plain !== SENTINEL) { throw new Error('DEK mismatch: sentinel value differs'); } } /* ── crypto helpers (encrypt / decrypt / hash) ─────────────── */ export function encrypt (plain) { if (plain == null || isEncrypted(plain)) return plain; 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; 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'); } export function hashForLookup (plain) { if (plain == null) return null; return createHmac('sha256', dek).update(String(plain)).digest('hex'); } export function isEncrypted (val) { return typeof val === 'string' && val.startsWith(MAGIC) && /^[A-Za-z0-9+/]+={0,2}$/.test(val.slice(MAGIC.length)); }