dev1/backend/shared/crypto/encryption.js

181 lines
7.2 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, 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; // 96bit nonce
const TAG_LEN = 16; // 128bit authtag
const MAGIC = 'gcm:'; // ciphertext prefix
/* ── env config (dockercompose) ────────────────────────────── */
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})`);
}
/* ─────────────────────────────────────────────────────────────
Onetime 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 fireandforget
.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 (firstwriter 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));
}