Files
HealthLog/src/lib/crypto.ts
T
Marc-André Bombeck 85a343a68c feat(documents): assist, summary, content-index endpoints + search union
P2-1-T2/T3/T4/T5/T6.

- assist.ts + [id]/suggest: one provider call proposes a { title, kind,
  documentDate } filing draft over the stored original or browser-OCR text.
  Runs the full extract gauntlet (module → provider → consent → rate-limit →
  budget) but writes nothing — never touches the fact rail or status.
- describe.ts + [id]/summary?mode=summary|text: session-only summary or raw
  transcribed text. Descriptive only, no diagnosis; persists only the budget
  ledger + audit.
- content-index.ts + [id]/index: normalise → tokenise (de-accent, stopword
  drop, whole-word) → HMAC-SHA256 under an HKDF-derived subkey → store the
  token array plus AES-256-GCM ciphertext of the text. Vision path transcribes
  via the provider (consent + budget); text path indexes posted local-OCR text
  (no egress). Gated on the existing AI consent — no new per-user toggle.
- crypto.deriveSubkey: HKDF-SHA256 purpose-bound subkey off the active key.
- list search now unions title/filename ILIKE with a GIN-accelerated
  whole-word content-token match; DTO gains hasContentIndex.
- usage gains assistAvailable + contentIndex { enabled, indexedCount,
  totalCount }; detail gains hasContentIndex.
2026-07-07 18:17:13 +02:00

399 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/**
* 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,
hkdfSync,
randomBytes,
} from "node:crypto";
import { Buffer } from "node:buffer";
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;
}
/**
* The configured key ids (NOT the key material). Used by the admin
* encryption-status view to report how many keys the operator has loaded and
* by the rotation guard. Never expose the bytes — only the ids.
*/
export function getConfiguredKeyIds(): string[] {
const { keys } = loadKeys();
return [...keys.keys()];
}
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));
}
// ─── Binary codec ("binary2") ────────────────────────────────────────────────
//
// AES-256-GCM over raw bytes, skipping the base64 detour the string codec
// takes (−33 % at rest, one fewer full copy in memory). Written for large
// binary payloads (the document vault's `contentEncrypted`); the consumer
// records which codec a row uses in an explicit column — the layout is never
// header-sniffed against the string codec.
//
// Layout: [version 0x02][keyIdLen u8][keyId ascii][iv 12][tag 16][ciphertext]
//
// Same versioned-key discipline as the string codec: encrypt always writes
// the active key id, decrypt is fail-closed on an unknown version, a
// malformed header, or an unconfigured key id.
/** Version byte identifying the binary2 layout. */
const BYTES_CODEC_VERSION = 0x02;
/** Encrypt raw bytes with the active key into the binary2 layout. */
export function encryptBytes(plaintext: Buffer): Buffer {
const { id, key } = getActiveKey();
const keyId = Buffer.from(id, "ascii");
if (keyId.byteLength < 1 || keyId.byteLength > 32) {
// loadKeys() already constrains ids to [A-Za-z0-9_-]{1,32}; belt-and-braces.
throw new Error(`Encryption key id '${id}' is not header-encodable`);
}
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const header = Buffer.from([BYTES_CODEC_VERSION, keyId.byteLength]);
return Buffer.concat([header, keyId, iv, tag, ct]);
}
/** Parse a binary2 header. Throws on any malformation (fail-closed). */
function parseBytesHeader(payload: Buffer): {
keyId: string;
iv: Buffer;
tag: Buffer;
ct: Buffer;
} {
if (payload.byteLength < 2) {
throw new Error("binary2 payload is truncated (no header)");
}
if (payload[0] !== BYTES_CODEC_VERSION) {
throw new Error(
`Unknown binary ciphertext version ${payload[0]} (expected ${BYTES_CODEC_VERSION})`,
);
}
const keyIdLen = payload[1];
if (keyIdLen < 1 || keyIdLen > 32) {
throw new Error("binary2 payload has an invalid key-id length");
}
const fixed = 2 + keyIdLen + IV_LENGTH + AUTH_TAG_LENGTH;
if (payload.byteLength < fixed) {
throw new Error("binary2 payload is truncated");
}
const keyId = payload.subarray(2, 2 + keyIdLen).toString("ascii");
if (!/^[A-Za-z0-9_-]{1,32}$/.test(keyId)) {
throw new Error("binary2 payload carries a malformed key id");
}
const iv = payload.subarray(2 + keyIdLen, 2 + keyIdLen + IV_LENGTH);
const tag = payload.subarray(2 + keyIdLen + IV_LENGTH, fixed);
const ct = payload.subarray(fixed);
return { keyId, iv, tag, ct };
}
/**
* Decrypt a binary2 payload back to its raw bytes. Fail-closed: an unknown
* version, a malformed header, or an unconfigured key id throws — the caller
* must treat a throw as "cannot serve", never fall back to the ciphertext.
*/
export function decryptBytes(payload: Buffer): Buffer {
const { keyId, iv, tag, ct } = parseBytesHeader(payload);
const key = getKeyById(keyId);
if (!key) {
throw new Error(
`Encryption key id '${keyId}' is not configured. Add it to ` +
`ENCRYPTION_KEYS before decrypting rows written under that key.`,
);
}
const dec = createDecipheriv(ALGORITHM, key, iv);
dec.setAuthTag(tag);
return Buffer.concat([dec.update(ct), dec.final()]);
}
/** The key id a binary2 payload was written under, or null when unparsable. */
export function extractKeyIdFromBytes(payload: Buffer): string | null {
try {
return parseBytesHeader(payload).keyId;
} catch {
return null;
}
}
/** Re-encrypt a binary2 payload with the active key. Used by rotation. */
export function reencryptBytesToActive(payload: Buffer): Buffer {
return encryptBytes(decryptBytes(payload));
}
// ─── HKDF subkey derivation ──────────────────────────────────────────────────
//
// A purpose-bound key derived from the master key material via HKDF-SHA256.
// Used where a deterministic secondary key is needed that must NEVER be the raw
// master key, the HMAC auth key, or a stored column — the document vault's blind
// content-search index derives its HMAC token subkey this way (P2-D7). The
// derivation follows the ACTIVE key: rotating the master key changes the subkey,
// which is why the index rotation re-tokenises from the stored ciphertext.
/**
* Derive a 32-byte purpose-bound subkey from the ACTIVE encryption key using
* HKDF-SHA256 with `info` as the domain-separation label. Deterministic for a
* given (active key, info) pair; opaque without the master key. The returned
* bytes are secret — never persist or log them.
*/
export function deriveSubkey(info: string): Buffer {
const { key } = getActiveKey();
// Empty salt: HKDF-Extract uses a zero salt (RFC 5869 §2.2). Domain
// separation lives entirely in `info`, so two purposes never collide.
const derived = hkdfSync("sha256", key, new Uint8Array(0), info, 32);
return Buffer.from(derived);
}
/** 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;
}