181 lines
7.2 KiB
JavaScript
181 lines
7.2 KiB
JavaScript
/* ────────────────────────────────────────────────────────────────
|
||
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/<env>/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));
|
||
}
|