141 lines
6.6 KiB
JavaScript
141 lines
6.6 KiB
JavaScript
/* ────────────────────────────────────────────────────────────────
|
||
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
|
||
|
||
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));
|
||
}
|