mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
399 lines
15 KiB
TypeScript
399 lines
15 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,
|
||
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;
|
||
}
|