fix(crypto): wire every encrypted column into key rotation behind a registry guard

The rotation script omitted the four v1.18.1 clinical-spine note columns
(LabResult.noteEncrypted, Biomarker.contextEncrypted,
IllnessEpisode.noteEncrypted, IllnessDayLog.noteEncrypted) plus a longer
pre-existing tail (Nightscout/Polar/Oura credentials, the KVNR field, the
health-profile and cycle-day-log payloads, InsightNarrative content).
Rotation reported zero legacy rows while these still held old-key
ciphertext, so dropping the legacy key left them permanently
undecryptable (decrypt is fail-closed).

Introduce src/lib/crypto/encrypted-columns.ts as the canonical registry of
every reversibly-encrypted column, drive the rotation script from it, and
add a guard test that cross-checks the registry against every encrypted
column in schema.prisma and asserts the script references each one. A new
*Encrypted column not wired into rotation now fails CI by construction.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:05:03 +02:00
1 parent b28fa4d4a5
commit e23f57448a
3 files changed
+562 -350

No files matched your search

@@ -0,0 +1,162 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
ENCRYPTED_COLUMNS,
encryptedColumnKey,
type EncryptedColumn,
} from "../encrypted-columns";
const ROOT = join(__dirname, "../../../..");
const SCHEMA_PATH = join(ROOT, "prisma", "schema.prisma");
const ROTATION_SCRIPT_PATH = join(
ROOT,
"scripts",
"rotate-encryption-key.ts",
);
/**
* Reversibly-encrypted columns whose Prisma field name does NOT end in
* `Encrypted`. These are historically-named encrypted columns; they are
* called out explicitly here so the schema scan can detect a NEW encrypted
* column even when it follows the `*Encrypted` convention, without
* mis-flagging unrelated columns. The scan's primary signal is the
* `*Encrypted` suffix; this set covers the documented exceptions.
*/
const NON_SUFFIX_ENCRYPTED: ReadonlySet<string> = new Set([
// WithingsConnection / WhoopConnection / FitbitConnection
"accessToken",
"refreshToken",
// NotificationChannel
"config",
// PushSubscription
"p256dh",
"auth",
// IntegrationStatus
"lastError",
// CoachMessage / InsightNarrative
"encryptedContent",
// User (Telegram + moodLog)
"telegramBotToken",
"moodLogWebhookSecret",
]);
/**
* Columns that match the encrypted-column scan signal but are NOT
* reversibly encrypted and therefore deliberately excluded from rotation.
*/
const NOT_ENCRYPTED: ReadonlySet<string> = new Set<string>([
// RefreshToken.accessTokenHash — HMAC, one-way (no rotation path).
"RefreshToken.accessTokenHash",
]);
interface SchemaColumn {
model: string;
field: string;
/** "String" | "Bytes" | ... — the declared Prisma scalar. */
type: string;
}
/**
* Parse schema.prisma into (model, field, type) triples. Lightweight: tracks
* the current `model X {` block and reads the first two tokens of each field
* line.
*/
function parseSchemaColumns(): SchemaColumn[] {
const src = readFileSync(SCHEMA_PATH, "utf8");
const columns: SchemaColumn[] = [];
let model: string | null = null;
for (const rawLine of src.split("\n")) {
const line = rawLine.trim();
if (line.startsWith("//") || line.startsWith("///") || line === "") {
continue;
}
const modelMatch = /^model\s+(\w+)\s*\{/.exec(line);
if (modelMatch) {
model = modelMatch[1];
continue;
}
if (line === "}") {
model = null;
continue;
}
if (!model) continue;
// Field line: `<name> <Type>...`. Skip block attributes (`@@map`) and
// relation/attribute-only lines.
const fieldMatch = /^(\w+)\s+([A-Za-z]+)(\?|\[\])?/.exec(line);
if (!fieldMatch) continue;
columns.push({ model, field: fieldMatch[1], type: fieldMatch[2] });
}
return columns;
}
/**
* Decide whether a schema column carries AES-256-GCM ciphertext. Signal:
* the `*Encrypted` suffix, OR membership in the documented non-suffix set.
*/
function isEncryptedColumn(c: SchemaColumn): boolean {
const key = `${c.model}.${c.field}`;
if (NOT_ENCRYPTED.has(key)) return false;
if (c.field.endsWith("Encrypted")) return true;
return NON_SUFFIX_ENCRYPTED.has(c.field);
}
describe("encrypted-column registry", () => {
it("has no duplicate entries", () => {
const keys = ENCRYPTED_COLUMNS.map(encryptedColumnKey);
expect(new Set(keys).size).toBe(keys.length);
});
it("assigns the right storage kind for each column type", () => {
const schema = parseSchemaColumns();
const typeByKey = new Map<string, string>(
schema.map((c) => [`${c.model}.${c.field}`, c.type]),
);
for (const col of ENCRYPTED_COLUMNS) {
const key = encryptedColumnKey(col);
const type = typeByKey.get(key);
expect(type, `registry column ${key} not found in schema`).toBeDefined();
const expectedKind = type === "Bytes" ? "bytes" : "string";
expect(col.kind, `${key} declared ${type} but kind=${col.kind}`).toBe(
expectedKind,
);
}
});
it("covers EVERY encrypted column in prisma/schema.prisma", () => {
const schemaEncrypted = parseSchemaColumns()
.filter(isEncryptedColumn)
.map((c) => `${c.model}.${c.field}`)
.sort();
const registered = ENCRYPTED_COLUMNS.map(encryptedColumnKey).sort();
// Any schema column the registry forgot is a rotation gap (data-loss
// risk on key drop). Any registry column missing from the schema is a
// stale entry. Both fail here.
const missingFromRegistry = schemaEncrypted.filter(
(k) => !registered.includes(k),
);
const staleInRegistry = registered.filter(
(k) => !schemaEncrypted.includes(k),
);
expect(
missingFromRegistry,
"encrypted schema columns NOT wired into the rotation registry",
).toEqual([]);
expect(
staleInRegistry,
"registry columns that no longer exist in the schema",
).toEqual([]);
});
it("is fully referenced by the key-rotation script", () => {
const script = readFileSync(ROTATION_SCRIPT_PATH, "utf8");
const unreferenced = ENCRYPTED_COLUMNS.filter(
(c: EncryptedColumn) => !script.includes(`"${c.field}"`),
).map(encryptedColumnKey);
expect(
unreferenced,
"registry columns NOT referenced by scripts/rotate-encryption-key.ts",
).toEqual([]);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Canonical registry of every database column that holds AES-256-GCM
* ciphertext (via `src/lib/crypto.ts`).
*
* This is the single source of truth for the key-rotation script
* (`scripts/rotate-encryption-key.ts`): the script MUST re-encrypt every
* column listed here, and the guard test
* (`encrypted-columns.test.ts`) asserts three invariants:
*
* 1. Every encrypted column in `prisma/schema.prisma` (any `*Encrypted`
* column, plus the small set of historically-named encrypted columns
* enumerated below) appears in this registry.
* 2. The rotation script references every column in this registry.
* 3. No registry entry is missing a kind / duplicated.
*
* Adding a new encrypted column WITHOUT adding it here fails CI; adding it
* here without wiring the rotation script also fails CI. Together this makes
* "a new encrypted column silently skipped on rotation" structurally
* impossible — dropping a legacy key would otherwise make those rows
* permanently undecryptable (decrypt is fail-closed).
*
* `kind` distinguishes the storage shape the rotation script must use:
* - "string" — the ciphertext is stored as a `String` column; the script
* reads/writes the value directly.
* - "bytes" — the ciphertext is stored as a `Bytes` column; the script
* goes through a UTF-8 Buffer round-trip (matching the persistence layer).
*/
export type EncryptedColumnKind = "string" | "bytes";
export interface EncryptedColumn {
/** Prisma model name (PascalCase, as declared in schema.prisma). */
readonly model: string;
/** Prisma field name (camelCase, as declared in schema.prisma). */
readonly field: string;
readonly kind: EncryptedColumnKind;
}
/**
* Every reversibly-encrypted column. HMAC-hashed columns (`*Hash`,
* `tokenHash`) and the `refresh_token`-convention columns are deliberately
* EXCLUDED — they are one-way and have no rotation path.
*/
export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// ───── User — integration credentials + AI keys + KVNR ─────
{ model: "User", field: "codexAccessTokenEncrypted", kind: "string" },
{ model: "User", field: "codexRefreshTokenEncrypted", kind: "string" },
{ model: "User", field: "telegramBotToken", kind: "string" },
{ model: "User", field: "moodLogWebhookSecret", kind: "string" },
{ model: "User", field: "moodLogUrlEncrypted", kind: "string" },
{ model: "User", field: "moodLogApiKeyEncrypted", kind: "string" },
{ model: "User", field: "withingsClientIdEncrypted", kind: "string" },
{ model: "User", field: "withingsClientSecretEncrypted", kind: "string" },
{ model: "User", field: "whoopClientIdEncrypted", kind: "string" },
{ model: "User", field: "whoopClientSecretEncrypted", kind: "string" },
{ model: "User", field: "fitbitClientIdEncrypted", kind: "string" },
{ model: "User", field: "fitbitClientSecretEncrypted", kind: "string" },
{ model: "User", field: "nightscoutUrlEncrypted", kind: "string" },
{ model: "User", field: "nightscoutTokenEncrypted", kind: "string" },
{ model: "User", field: "polarAccessTokenEncrypted", kind: "string" },
{ model: "User", field: "polarUserIdEncrypted", kind: "string" },
{ model: "User", field: "polarClientIdEncrypted", kind: "string" },
{ model: "User", field: "polarClientSecretEncrypted", kind: "string" },
{ model: "User", field: "ouraAccessTokenEncrypted", kind: "string" },
{ model: "User", field: "ouraRefreshTokenEncrypted", kind: "string" },
{ model: "User", field: "ouraClientIdEncrypted", kind: "string" },
{ model: "User", field: "ouraClientSecretEncrypted", kind: "string" },
{ model: "User", field: "aiAnthropicKeyEncrypted", kind: "string" },
{ model: "User", field: "aiLocalKeyEncrypted", kind: "string" },
{ model: "User", field: "aiOpenaiKeyEncrypted", kind: "string" },
{ model: "User", field: "insuranceNumberEncrypted", kind: "string" },
// ───── OAuth token tables (`*_token`-convention columns) ─────
{ model: "WithingsConnection", field: "accessToken", kind: "string" },
{ model: "WithingsConnection", field: "refreshToken", kind: "string" },
{ model: "WhoopConnection", field: "accessToken", kind: "string" },
{ model: "WhoopConnection", field: "refreshToken", kind: "string" },
{ model: "FitbitConnection", field: "accessToken", kind: "string" },
{ model: "FitbitConnection", field: "refreshToken", kind: "string" },
// ───── AppSettings — operator credentials ─────
{ model: "AppSettings", field: "adminAiKeyEncrypted", kind: "string" },
{
model: "AppSettings",
field: "webPushVapidPrivateKeyEncrypted",
kind: "string",
},
{ model: "AppSettings", field: "githubIssueTokenEncrypted", kind: "string" },
// ───── Custom labels (mood + cycle) ─────
{ model: "MoodTag", field: "labelEncrypted", kind: "string" },
{ model: "MoodTagCategory", field: "labelEncrypted", kind: "string" },
{ model: "CycleSymptom", field: "labelEncrypted", kind: "string" },
// ───── Notification + push secrets ─────
{ model: "NotificationChannel", field: "config", kind: "string" },
{ model: "PushSubscription", field: "p256dh", kind: "string" },
{ model: "PushSubscription", field: "auth", kind: "string" },
// ───── Integration status error payloads ─────
{ model: "IntegrationStatus", field: "lastError", kind: "string" },
// ───── Cycle day-log sensitive payloads (String?) ─────
{ model: "CycleDayLog", field: "sensitiveEncrypted", kind: "string" },
{ model: "CycleDayLog", field: "notesEncrypted", kind: "string" },
// ───── Coach (Bytes columns) ─────
{ model: "CoachMessage", field: "encryptedContent", kind: "bytes" },
{ model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" },
{ model: "CoachFact", field: "factEncrypted", kind: "bytes" },
// ───── User health profile (Bytes columns) ─────
{ model: "UserHealthProfile", field: "aboutMeEncrypted", kind: "bytes" },
{ model: "UserHealthProfile", field: "conditionsEncrypted", kind: "bytes" },
{ model: "UserHealthProfile", field: "allergiesEncrypted", kind: "bytes" },
{ model: "UserHealthProfile", field: "coachFocusEncrypted", kind: "bytes" },
{
model: "UserHealthProfile",
field: "pendingQuestionsEncrypted",
kind: "bytes",
},
// ───── Insight narratives (Bytes column) ─────
{ model: "InsightNarrative", field: "encryptedContent", kind: "bytes" },
// ───── v1.18.1 clinical-spine notes (Bytes columns) ─────
{ model: "LabResult", field: "noteEncrypted", kind: "bytes" },
{ model: "Biomarker", field: "contextEncrypted", kind: "bytes" },
{ model: "IllnessEpisode", field: "noteEncrypted", kind: "bytes" },
{ model: "IllnessDayLog", field: "noteEncrypted", kind: "bytes" },
] as const;
/** Stable `Model.field` key for a registry entry. */
export function encryptedColumnKey(c: EncryptedColumn): string {
return `${c.model}.${c.field}`;
}