dev1/backend/shared/crypto/encryption.js

146 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ────────────────────────────────────────────────────────────────
AESGCM fieldlevel 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; // 96bit nonce for GCM
const TAG_LEN = 16; // 128bit authtag
const MAGIC = 'gcm:'; // prefix to mark ciphertext
/* ── env config (injected via dockercompose) ──────────────── */
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; // inmemory dataencryptionkey
let initPromise = null;
/* ─────────────────────────────────────────────────────────────
Onetime 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 ⇒ were 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); // 256bit 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)); // backoff 100ms
}
}
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; // fastexit 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 lookups, optional)
──────────────────────────────────────────────────────────── */
export function hashForLookup (plain) {
if (plain == null) return null;
const h = createHmac('sha256', dek).update(String(plain)).digest('hex');
return h; // 64char hex
}
/* ─────────────────────────────────────────────────────────────
Utility: is this value an AESGCM 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));
}