/** * Application-layer AES-256-GCM encryption for sensitive DB fields. * * Encrypted format stored in DB: * enc::: * * Set DB_ENCRYPTION_KEY to a 64-char hex string (32 bytes): * export DB_ENCRYPTION_KEY=$(openssl rand -hex 32) * * If DB_ENCRYPTION_KEY is not set, values are stored as plaintext (with warning). */ import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; // 96-bit IV recommended for GCM const TAG_LENGTH = 16; // 128-bit auth tag const ENC_PREFIX = "enc:"; let _key = null; let _warned = false; function getKey() { if (_key) return _key; const hex = process.env.DB_ENCRYPTION_KEY; if (!hex) { if (!_warned) { console.warn("[crypto] WARNING: DB_ENCRYPTION_KEY is not set. WireGuard private keys are stored as PLAINTEXT in the database."); _warned = true; } return null; } if (hex.length !== 64) { throw new Error("DB_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). Generate with: openssl rand -hex 32"); } _key = Buffer.from(hex, "hex"); return _key; } /** * Encrypt a plaintext string. * Returns the encrypted string with "enc:" prefix, or the original value if no key is set. * @param {string} plaintext * @returns {string} */ export function encrypt(plaintext) { if (!plaintext) return plaintext; // Already encrypted — idempotent if (plaintext.startsWith(ENC_PREFIX)) return plaintext; const key = getKey(); if (!key) return plaintext; // passthrough if no key configured const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }); const encrypted = Buffer.concat([ cipher.update(plaintext, "utf8"), cipher.final(), ]); const tag = cipher.getAuthTag(); return `${ENC_PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`; } /** * Decrypt an encrypted string. * Returns plaintext, or the original value if it is not encrypted. * @param {string} value * @returns {string} */ export function decrypt(value) { if (!value) return value; // Not encrypted — return as-is (backward compat with plaintext rows) if (!value.startsWith(ENC_PREFIX)) return value; const key = getKey(); if (!key) { throw new Error("DB_ENCRYPTION_KEY is required to decrypt WireGuard keys but is not set."); } const parts = value.slice(ENC_PREFIX.length).split(":"); if (parts.length !== 3) { throw new Error("Invalid encrypted value format in database."); } const [ivB64, tagB64, dataB64] = parts; const iv = Buffer.from(ivB64, "base64"); const tag = Buffer.from(tagB64, "base64"); const data = Buffer.from(dataB64,"base64"); const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8"); }