Files
HealthLog/src/lib/crypto/encryption-corpus.ts
T
Marc-André Bombeck 972c5fbf48 feat(crypto): rotate the document blob column in bounded, codec-aware batches
The registry entry for InboundDocument.contentEncrypted gains a codecField
marker: the column now holds two layouts (base64v1 string codec | binary2
binary codec), recorded per row, and its rows are up to cap-sized blobs.
scan/rotate walk such a column with id-cursor pagination (25 rows per
page — never an unbounded blob findMany) and re-encrypt each row under its
OWN codec; an unknown codec value is counted as an error and the row left
untouched, same fail-closed posture as a missing key. The walk is
idempotent, so an interrupted run resumes on re-invocation with the
already-active rows skipped.

Both rotation paths share the logic: the admin-triggered pg-boss job via
rotateCorpus() and the CLI (scripts/rotate-encryption-key.ts), which now
calls the shared batched walk for this column. The registry guard test
keeps schema, registry, and script in lock-step as before.

Unit coverage over a mocked 500-row corpus asserts the batch boundedness
(take + advancing cursor per page), both-codec rotation, per-row
fail-closed on unknown codecs, and interrupted-run resume.

Task: W1-T11.
2026-07-07 11:05:43 +02:00

386 lines
12 KiB
TypeScript

/**
* v1.23 — read-only scan + in-place re-encrypt over the whole encrypted-column
* corpus, driven by the canonical registry (`encrypted-columns.ts`).
*
* Two consumers:
* - the admin encryption-status view (`GET /api/admin/encryption/status`)
* calls `scanCorpus()` to bucket every encrypted column's rows by key id;
* - the admin-triggered rotation pg-boss job (`encryption-key-rotate`) calls
* `rotateCorpus()` to re-encrypt every row that is not already on the
* active key.
*
* The standalone CLI (`scripts/rotate-encryption-key.ts`) remains the canonical
* rotation path and stays independent (its own Prisma client). This module is
* the in-app convenience that reuses the SAME registry, so the guard test keeps
* both in lock-step.
*
* GUARANTEES (the security review must confirm these on the rotation path):
* - ACTIVE-KEY-ONLY. Re-encryption is `encrypt(decrypt(value))`; `encrypt()`
* always writes the configured active key id. There is no code path here
* that selects any other write key.
* - NEVER ADDS / DROPS A KEY. This module never reads or mutates
* `ENCRYPTION_KEYS` / `ENCRYPTION_ACTIVE_KEY_ID`. The operator's env key map
* is the only place keys live; a key drop stays a deliberate env + redeploy
* act, never a button.
* - IDEMPOTENT. `shouldRotate()` skips rows already on the active key, so a
* second pass (or two racing workers) re-encrypts zero rows.
* - FAIL-CLOSED. A row written under a key id that is no longer configured
* throws on decrypt (counted as an error, the row is left untouched) rather
* than being silently dropped or overwritten — exactly the property that
* protects against dropping a legacy key too early.
*/
import { Buffer } from "node:buffer";
import {
decrypt,
encrypt,
extractKeyId,
extractKeyIdFromBytes,
getActiveKeyId,
reencryptBytesToActive,
} from "@/lib/crypto";
import {
ENCRYPTED_COLUMNS,
encryptedColumnKey,
type EncryptedColumn,
} from "@/lib/crypto/encrypted-columns";
/** Sentinel bucket for legacy (unversioned) ciphertext under `byKeyId`. */
export const LEGACY_BUCKET = "legacy";
/**
* Batch size for codec-dispatched blob columns (`codecField` present). The
* document vault's rows are up to cap-sized ciphertexts, so the walk is
* id-cursor paginated — at most this many blobs are in memory at once.
*/
export const BLOB_ROTATION_BATCH_SIZE = 25;
/** Minimal Prisma delegate shape this module needs. */
interface ColumnDelegate {
findMany: (args: {
select: Record<string, true>;
orderBy?: Record<string, "asc" | "desc">;
take?: number;
cursor?: Record<string, unknown>;
skip?: number;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
data: Record<string, unknown>;
}) => Promise<unknown>;
}
/** The subset of the Prisma client we touch: one delegate per model. */
export type CorpusClient = Record<string, ColumnDelegate>;
/** PascalCase model name -> camelCase Prisma delegate key. */
function delegateKey(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
}
function getDelegate(client: CorpusClient, model: string): ColumnDelegate {
const delegate = client[delegateKey(model)];
if (!delegate) {
throw new Error(`No Prisma delegate for model '${model}'`);
}
return delegate;
}
/** Read a registry column's value as a ciphertext string (Bytes -> utf8). */
function toCiphertext(
value: unknown,
kind: EncryptedColumn["kind"],
): string | null {
if (value == null) return null;
if (kind === "bytes") {
const buf = value as Uint8Array;
if (buf.byteLength === 0) return null;
return Buffer.from(buf).toString("utf8");
}
const s = value as string;
return s.length === 0 ? null : s;
}
/** Encode a re-encrypted ciphertext string back into the column's storage shape. */
function fromCiphertext(
value: string,
kind: EncryptedColumn["kind"],
): string | Uint8Array {
if (kind !== "bytes") return value;
const encoded = Buffer.from(value, "utf8");
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
next.set(encoded);
return next;
}
function shouldRotate(ciphertext: string): boolean {
return extractKeyId(ciphertext) !== getActiveKeyId();
}
// ─── Codec-dispatched blob columns (document vault) ─────────────────────────
/**
* The key id a codec-dispatched blob row was written under, or null when
* legacy/unparsable. "binary2" parses the binary header; every other codec
* value (notably "base64v1") is the `encrypt()`-string-as-UTF-8 shape.
*/
function blobKeyId(value: Uint8Array, codec: string): string | null {
const buf = Buffer.from(value);
if (codec === "binary2") return extractKeyIdFromBytes(buf);
return extractKeyId(buf.toString("utf8"));
}
/** Re-encrypt one codec-dispatched blob under its OWN codec (never converts). */
function reencryptBlob(value: Uint8Array, codec: string): Uint8Array {
const buf = Buffer.from(value);
if (codec === "binary2") {
const rotated = reencryptBytesToActive(buf);
const next = new Uint8Array(new ArrayBuffer(rotated.byteLength));
next.set(rotated);
return next;
}
if (codec === "base64v1") {
const rotated = encrypt(decrypt(buf.toString("utf8")));
const encoded = Buffer.from(rotated, "utf8");
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
next.set(encoded);
return next;
}
// FAIL-CLOSED: an unknown codec is counted as an error and left untouched.
throw new Error(`Unknown content codec '${codec}'`);
}
/**
* Walk a codec-dispatched blob column in bounded id-cursor batches, invoking
* `onRow` per non-empty row. At most `BLOB_ROTATION_BATCH_SIZE` blobs are in
* memory per step; an interrupted run resumes safely on re-invocation because
* processing is idempotent (already-active rows are skipped by the callers).
*/
async function walkBlobColumn(
delegate: ColumnDelegate,
col: EncryptedColumn,
onRow: (row: {
id: string;
value: Uint8Array;
codec: string;
}) => Promise<void> | void,
): Promise<void> {
const codecField = col.codecField!;
let cursor: string | null = null;
for (;;) {
const rows = await delegate.findMany({
select: { id: true, [col.field]: true, [codecField]: true },
orderBy: { id: "asc" },
take: BLOB_ROTATION_BATCH_SIZE,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
if (rows.length === 0) break;
for (const row of rows) {
const value = row[col.field] as Uint8Array | null;
if (!value || value.byteLength === 0) continue;
await onRow({
id: row.id as string,
value,
codec: String(row[codecField] ?? ""),
});
}
cursor = rows[rows.length - 1]!.id as string;
if (rows.length < BLOB_ROTATION_BATCH_SIZE) break;
}
}
export interface ColumnScan {
model: string;
field: string;
kind: EncryptedColumn["kind"];
/** Non-null ciphertext rows. */
total: number;
/** Rows per key id; legacy/unversioned rows land under `LEGACY_BUCKET`. */
byKeyId: Record<string, number>;
/** Rows under the legacy/unversioned format (= `byKeyId[LEGACY_BUCKET]`). */
legacy: number;
}
/** Scan one encrypted column: bucket every non-null ciphertext by key id. */
export async function scanColumn(
client: CorpusClient,
col: EncryptedColumn,
): Promise<ColumnScan> {
const delegate = getDelegate(client, col.model);
const byKeyId: Record<string, number> = {};
let total = 0;
if (col.codecField) {
// Codec-dispatched blob column: bounded batches, per-row codec.
await walkBlobColumn(delegate, col, ({ value, codec }) => {
total += 1;
const id = blobKeyId(value, codec) ?? LEGACY_BUCKET;
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
});
} else {
const rows = await delegate.findMany({
select: { id: true, [col.field]: true },
});
for (const row of rows) {
const ciphertext = toCiphertext(row[col.field], col.kind);
if (ciphertext == null) continue;
total += 1;
const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET;
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
}
}
return {
model: col.model,
field: col.field,
kind: col.kind,
total,
byKeyId,
legacy: byKeyId[LEGACY_BUCKET] ?? 0,
};
}
export interface CorpusScan {
activeKeyId: string;
columns: ColumnScan[];
/** Total non-null ciphertext rows across the corpus. */
totalRows: number;
/** Rows already on the active key. */
activeRows: number;
/** Rows NOT on the active key (legacy + any non-active versioned). */
staleRows: number;
/**
* True iff every column has zero rows that are not on the active key — the
* single signal an operator needs before dropping a legacy key.
*/
rotationComplete: boolean;
}
/** Scan the whole corpus. Read-only; never writes. */
export async function scanCorpus(client: CorpusClient): Promise<CorpusScan> {
const activeKeyId = getActiveKeyId();
const columns: ColumnScan[] = [];
for (const col of ENCRYPTED_COLUMNS) {
columns.push(await scanColumn(client, col));
}
let totalRows = 0;
let activeRows = 0;
for (const c of columns) {
totalRows += c.total;
activeRows += c.byKeyId[activeKeyId] ?? 0;
}
const staleRows = totalRows - activeRows;
return {
activeKeyId,
columns,
totalRows,
activeRows,
staleRows,
rotationComplete: staleRows === 0,
};
}
export interface RotationResult {
model: string;
field: string;
scanned: number;
rotated: number;
errors: number;
}
/** Re-encrypt one column's stale rows to the active key. */
export async function rotateColumn(
client: CorpusClient,
col: EncryptedColumn,
): Promise<RotationResult> {
const delegate = getDelegate(client, col.model);
const result: RotationResult = {
model: col.model,
field: col.field,
scanned: 0,
rotated: 0,
errors: 0,
};
if (col.codecField) {
// Codec-dispatched blob column: bounded id-cursor batches (never an
// unbounded blob findMany), re-encrypted under each row's OWN codec.
// Idempotent — rows already on the active key are skipped, so an
// interrupted run resumes cleanly on the next invocation.
await walkBlobColumn(delegate, col, async ({ id, value, codec }) => {
result.scanned += 1;
if (blobKeyId(value, codec) === getActiveKeyId()) return;
try {
await delegate.update({
where: { id },
data: { [col.field]: reencryptBlob(value, codec) },
});
result.rotated += 1;
} catch {
// FAIL-CLOSED: unknown codec or a no-longer-configured key throws;
// count it and leave the row untouched rather than dropping data.
result.errors += 1;
}
});
return result;
}
const rows = await delegate.findMany({
select: { id: true, [col.field]: true },
});
result.scanned = rows.length;
for (const row of rows) {
const ciphertext = toCiphertext(row[col.field], col.kind);
if (ciphertext == null || !shouldRotate(ciphertext)) continue;
const id = row.id as string;
try {
// ACTIVE-KEY-ONLY: encrypt() always writes the active key id.
const reencrypted = encrypt(decrypt(ciphertext));
await delegate.update({
where: { id },
data: { [col.field]: fromCiphertext(reencrypted, col.kind) },
});
result.rotated += 1;
} catch {
// FAIL-CLOSED: a row under a no-longer-configured key throws on decrypt;
// count it and leave the row untouched rather than dropping data.
result.errors += 1;
}
}
return result;
}
export interface CorpusRotation {
activeKeyId: string;
results: RotationResult[];
totalScanned: number;
totalRotated: number;
totalErrors: number;
}
/** Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. */
export async function rotateCorpus(
client: CorpusClient,
): Promise<CorpusRotation> {
const activeKeyId = getActiveKeyId();
const results: RotationResult[] = [];
for (const col of ENCRYPTED_COLUMNS) {
results.push(await rotateColumn(client, col));
}
let totalScanned = 0;
let totalRotated = 0;
let totalErrors = 0;
for (const r of results) {
totalScanned += r.scanned;
totalRotated += r.rotated;
totalErrors += r.errors;
}
return {
activeKeyId,
results,
totalScanned,
totalRotated,
totalErrors,
};
}
export { encryptedColumnKey };