mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The dev-only SHA-256 key padding was gated on the absence of a production marker, so a staging or preview container with NODE_ENV unset silently derived a weak key the operator never recorded — rotating to a real key later orphaned those rows (decrypt is fail-closed). Gate the padding on an explicit NODE_ENV of development or test; every other case, including unset NODE_ENV, throws the same hard error as in production.
265 lines
9.1 KiB
TypeScript
265 lines
9.1 KiB
TypeScript
/**
|
|
* AES-256-GCM encryption for sensitive data at rest.
|
|
*
|
|
* v1.4: supports key versioning + rotation. Multiple keys can coexist via
|
|
* `ENCRYPTION_KEYS` (JSON map of `{ "<id>": "<base64-or-hex-key>" }`) plus
|
|
* `ENCRYPTION_ACTIVE_KEY_ID` selecting which one to write with. Backwards
|
|
* compatible: if `ENCRYPTION_KEYS` is absent, the legacy single
|
|
* `ENCRYPTION_KEY` is used with synthetic id `"v1"`.
|
|
*
|
|
* Ciphertext format:
|
|
* - Versioned (new): "<keyId>.<base64(iv|authTag|ciphertext)>"
|
|
* - Legacy (v1): "base64(iv|authTag|ciphertext)" — still readable.
|
|
*
|
|
* The decryption path tries the versioned format first; if no `.` is present
|
|
* (or the prefix is not a known key id), it falls back to the legacy single
|
|
* key so that rows written before the rotation continue to decrypt.
|
|
*/
|
|
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
createHash,
|
|
randomBytes,
|
|
} from "node:crypto";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
|
|
const ALGORITHM = "aes-256-gcm";
|
|
const IV_LENGTH = 12;
|
|
const AUTH_TAG_LENGTH = 16;
|
|
const LEGACY_KEY_ID = "v1";
|
|
|
|
interface KeyMaterial {
|
|
id: string;
|
|
key: Buffer;
|
|
}
|
|
|
|
let cachedKeys: Map<string, Buffer> | null = null;
|
|
let cachedActiveId: string | null = null;
|
|
let cachedSignature: string | null = null;
|
|
|
|
function envSignature(): string {
|
|
return [
|
|
process.env.ENCRYPTION_KEYS ?? "",
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID ?? "",
|
|
process.env.ENCRYPTION_KEY ?? "",
|
|
].join("|");
|
|
}
|
|
|
|
function decodeKey(raw: string, label: string): Buffer {
|
|
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
|
return Buffer.from(raw, "hex");
|
|
}
|
|
// base64 form (32 bytes / 256 bits)
|
|
if (/^[A-Za-z0-9+/=]+$/.test(raw)) {
|
|
try {
|
|
const buf = Buffer.from(raw, "base64");
|
|
if (buf.length === 32) return buf;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
if (/^[0-9a-fA-F]+$/.test(raw) && raw.length >= 32) {
|
|
// Fail closed unless we are explicitly in a local dev or test run. The
|
|
// SHA-256 padding below derives a key the operator never recorded; if a
|
|
// staging/preview container runs with NODE_ENV unset it would silently
|
|
// encrypt under that derived key, and rotating to a real key later
|
|
// orphans those rows (decrypt is fail-closed → undecryptable). Only the
|
|
// two known throwaway-data environments may take the padding path; every
|
|
// other case (production AND unset NODE_ENV) throws the same hard error.
|
|
const env = process.env.NODE_ENV;
|
|
if (env !== "development" && env !== "test") {
|
|
throw new Error(
|
|
`Encryption key '${label}' must be 64 hex characters (256 bits) outside development/test. ` +
|
|
`Generate one with: openssl rand -hex 32`,
|
|
);
|
|
}
|
|
const msg = `Encryption key '${label}' should be exactly 64 hex characters (current length: ${raw.length}). Padding with SHA-256 for dev use only.`;
|
|
const event = getEvent();
|
|
if (event) event.addWarning(msg);
|
|
else if (typeof console !== "undefined") console.warn("[crypto]", msg);
|
|
const padded = raw + createHash("sha256").update(raw, "utf8").digest("hex");
|
|
return Buffer.from(padded.slice(0, 64), "hex");
|
|
}
|
|
throw new Error(
|
|
`Encryption key '${label}' must be hex (>= 32 chars) or base64 (32 bytes). ` +
|
|
`Generate one with: openssl rand -hex 32`,
|
|
);
|
|
}
|
|
|
|
function loadKeys(): { keys: Map<string, Buffer>; activeId: string } {
|
|
const sig = envSignature();
|
|
if (cachedKeys && cachedActiveId && cachedSignature === sig) {
|
|
return { keys: cachedKeys, activeId: cachedActiveId };
|
|
}
|
|
|
|
const keys = new Map<string, Buffer>();
|
|
let activeId: string | null = null;
|
|
|
|
const rawMap = process.env.ENCRYPTION_KEYS;
|
|
if (rawMap && rawMap.trim() !== "") {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(rawMap);
|
|
} catch (err) {
|
|
throw new Error(
|
|
`ENCRYPTION_KEYS must be valid JSON (e.g. {"v2":"<hex>"}): ${(err as Error).message}`,
|
|
);
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("ENCRYPTION_KEYS must be a JSON object map");
|
|
}
|
|
for (const [id, value] of Object.entries(
|
|
parsed as Record<string, unknown>,
|
|
)) {
|
|
if (typeof value !== "string" || !id) {
|
|
throw new Error(`ENCRYPTION_KEYS entry '${id}' is not a string`);
|
|
}
|
|
if (!/^[A-Za-z0-9_-]{1,32}$/.test(id)) {
|
|
throw new Error(
|
|
`ENCRYPTION_KEYS id '${id}' must match [A-Za-z0-9_-]{1,32}`,
|
|
);
|
|
}
|
|
keys.set(id, decodeKey(value, id));
|
|
}
|
|
|
|
activeId = process.env.ENCRYPTION_ACTIVE_KEY_ID ?? null;
|
|
if (!activeId) {
|
|
if (keys.size === 1) {
|
|
activeId = keys.keys().next().value as string;
|
|
} else {
|
|
throw new Error(
|
|
"ENCRYPTION_ACTIVE_KEY_ID must be set when ENCRYPTION_KEYS has multiple entries",
|
|
);
|
|
}
|
|
}
|
|
if (!keys.has(activeId)) {
|
|
throw new Error(
|
|
`ENCRYPTION_ACTIVE_KEY_ID='${activeId}' has no matching entry in ENCRYPTION_KEYS`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Always also include the legacy single-key under id `v1` if set, so old
|
|
// rows decrypt. If the operator supplies the same id explicitly, that wins.
|
|
const legacyRaw = process.env.ENCRYPTION_KEY;
|
|
if (legacyRaw && legacyRaw.trim() !== "") {
|
|
if (!keys.has(LEGACY_KEY_ID)) {
|
|
keys.set(LEGACY_KEY_ID, decodeKey(legacyRaw, LEGACY_KEY_ID));
|
|
}
|
|
if (!activeId) activeId = LEGACY_KEY_ID;
|
|
}
|
|
|
|
if (keys.size === 0 || !activeId) {
|
|
throw new Error(
|
|
"Encryption is not configured. Set ENCRYPTION_KEY or ENCRYPTION_KEYS+ENCRYPTION_ACTIVE_KEY_ID.",
|
|
);
|
|
}
|
|
|
|
cachedKeys = keys;
|
|
cachedActiveId = activeId;
|
|
cachedSignature = sig;
|
|
return { keys, activeId };
|
|
}
|
|
|
|
function getActiveKey(): KeyMaterial {
|
|
const { keys, activeId } = loadKeys();
|
|
return { id: activeId, key: keys.get(activeId)! };
|
|
}
|
|
|
|
function getKeyById(id: string): Buffer | null {
|
|
const { keys } = loadKeys();
|
|
return keys.get(id) ?? null;
|
|
}
|
|
|
|
/** Test helper — drops the cached key map so env stubs take effect. */
|
|
export function _resetCryptoCacheForTests(): void {
|
|
cachedKeys = null;
|
|
cachedActiveId = null;
|
|
cachedSignature = null;
|
|
}
|
|
|
|
export function getActiveKeyId(): string {
|
|
return getActiveKey().id;
|
|
}
|
|
|
|
function encryptWithKey(plaintext: string, key: Buffer): string {
|
|
const iv = randomBytes(IV_LENGTH);
|
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return Buffer.concat([iv, tag, enc]).toString("base64");
|
|
}
|
|
|
|
function decryptWithKey(payload: string, key: Buffer): string {
|
|
const packed = Buffer.from(payload, "base64");
|
|
const iv = packed.subarray(0, IV_LENGTH);
|
|
const tag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
const ct = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
const dec = createDecipheriv(ALGORITHM, key, iv);
|
|
dec.setAuthTag(tag);
|
|
return Buffer.concat([dec.update(ct), dec.final()]).toString("utf8");
|
|
}
|
|
|
|
/**
|
|
* Encrypt a plaintext string with the active key.
|
|
* Returns format: `<keyId>.<base64(iv+authTag+ciphertext)>`
|
|
*/
|
|
export function encrypt(plaintext: string): string {
|
|
const { id, key } = getActiveKey();
|
|
return `${id}.${encryptWithKey(plaintext, key)}`;
|
|
}
|
|
|
|
/**
|
|
* Decrypt a previously encrypted string. Accepts both the versioned
|
|
* `<id>.<payload>` format and the legacy bare-base64 format.
|
|
*
|
|
* Once the prefix is recognised as a versioned id we trust the key id.
|
|
* If decrypt fails under that key the error surfaces — silently retrying as
|
|
* legacy would mask data corruption.
|
|
*/
|
|
export function decrypt(encoded: string): string {
|
|
const dot = encoded.indexOf(".");
|
|
if (dot > 0 && dot < encoded.length - 1) {
|
|
const id = encoded.slice(0, dot);
|
|
const payload = encoded.slice(dot + 1);
|
|
if (/^[A-Za-z0-9_-]{1,32}$/.test(id)) {
|
|
const key = getKeyById(id);
|
|
if (!key) {
|
|
throw new Error(
|
|
`Encryption key id '${id}' is not configured. Add it to ` +
|
|
`ENCRYPTION_KEYS before decrypting rows written under that key.`,
|
|
);
|
|
}
|
|
return decryptWithKey(payload, key);
|
|
}
|
|
}
|
|
// Legacy bare-base64 row. Refuse to decrypt under the active key — that
|
|
// would give an opaque GCM tag error AND succeed silently with junk if a
|
|
// key collision ever occurs. Require the operator to keep the v1 key in
|
|
// `ENCRYPTION_KEYS` until rotation has fully drained legacy rows.
|
|
const legacy = getKeyById(LEGACY_KEY_ID);
|
|
if (!legacy) {
|
|
throw new Error(
|
|
"Found a legacy-format ciphertext but no v1 key is configured. " +
|
|
"Restore the original ENCRYPTION_KEY (or add a 'v1' entry to " +
|
|
"ENCRYPTION_KEYS) and run scripts/rotate-encryption-key.ts before " +
|
|
"removing it.",
|
|
);
|
|
}
|
|
return decryptWithKey(encoded, legacy);
|
|
}
|
|
|
|
/** Re-encrypt a row with the currently active key. Used by the rotation CLI. */
|
|
export function reencryptToActive(encoded: string): string {
|
|
return encrypt(decrypt(encoded));
|
|
}
|
|
|
|
/** Returns the key id portion of a versioned ciphertext, or null for legacy. */
|
|
export function extractKeyId(encoded: string): string | null {
|
|
const dot = encoded.indexOf(".");
|
|
if (dot <= 0 || dot >= encoded.length - 1) return null;
|
|
const id = encoded.slice(0, dot);
|
|
if (!/^[A-Za-z0-9_-]{1,32}$/.test(id)) return null;
|
|
return id;
|
|
}
|