From e23f57448abfe5ae3857c34015ef80b08f7030f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 23:05:03 +0200 Subject: [PATCH] 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. --- scripts/rotate-encryption-key.ts | 614 ++++++++---------- .../__tests__/encrypted-columns.test.ts | 162 +++++ src/lib/crypto/encrypted-columns.ts | 136 ++++ 3 files changed, 562 insertions(+), 350 deletions(-) create mode 100644 src/lib/crypto/__tests__/encrypted-columns.test.ts create mode 100644 src/lib/crypto/encrypted-columns.ts diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index a2eefaa4f..4bf6d1c82 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -5,6 +5,15 @@ * active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext * already carries the active key id prefix are skipped. * + * The set of columns this script rotates is the canonical registry in + * `src/lib/crypto/encrypted-columns.ts`. The guard test + * `src/lib/crypto/__tests__/encrypted-columns.test.ts` fails CI if any + * encrypted column in `prisma/schema.prisma` is missing from the registry, + * or if any registry column is not referenced here — so a new `*Encrypted` + * column can never silently skip rotation (which would make those rows + * permanently undecryptable once the legacy key is dropped, since decrypt is + * fail-closed). + * * Usage: * ENCRYPTION_KEYS='{"v1":"","v2":""}' \ * ENCRYPTION_ACTIVE_KEY_ID=v2 \ @@ -50,13 +59,25 @@ function shouldRotate(value: string | null): boolean { return id !== getActiveKeyId(); } -async function rotateField( +/** + * Rotate one `String` ciphertext column on a Prisma model. `delegate` is the + * `prisma.` accessor; `field` is the column. Reads only `id` + the + * column, re-encrypts every row not already on the active key. + */ +async function rotateStringColumn( table: string, field: string, - rows: T[], - fieldGetter: (row: T) => string | null, - updater: (id: string, ciphertext: string) => Promise, + delegate: { + findMany: (args: { + select: Record; + }) => Promise>>; + update: (args: { + where: { id: string }; + data: Record; + }) => Promise; + }, ): Promise { + const rows = await delegate.findMany({ select: { id: true, [field]: true } }); const result: RotationResult = { table, field, @@ -65,17 +86,63 @@ async function rotateField( errors: 0, }; for (const row of rows) { - const v = fieldGetter(row); + const v = row[field] as string | null; if (!shouldRotate(v)) continue; + const id = row.id as string; try { const re = encrypt(decrypt(v as string)); - await updater(row.id, re); + await delegate.update({ where: { id }, data: { [field]: re } }); result.rotated++; } catch (err) { result.errors++; - console.error( - `[${table}.${field}] row ${row.id}: ${(err as Error).message}`, - ); + console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`); + } + } + return result; +} + +/** + * Rotate one `Bytes` ciphertext column. The encrypt/decrypt helpers operate + * on strings, so we go through a UTF-8 Buffer round-trip identical to the + * persistence layer (`src/lib/ai/coach/persistence.ts:60-71`). + */ +async function rotateBytesColumn( + table: string, + field: string, + delegate: { + findMany: (args: { + select: Record; + }) => Promise>>; + update: (args: { + where: { id: string }; + data: Record; + }) => Promise; + }, +): Promise { + const rows = await delegate.findMany({ select: { id: true, [field]: true } }); + const result: RotationResult = { + table, + field, + scanned: rows.length, + rotated: 0, + errors: 0, + }; + for (const row of rows) { + const buf = row[field] as Uint8Array | null; + if (!buf || buf.byteLength === 0) continue; + const asString = Buffer.from(buf).toString("utf8"); + if (!shouldRotate(asString)) continue; + const id = row.id as string; + try { + const rotated = encrypt(decrypt(asString)); + const encoded = Buffer.from(rotated, "utf8"); + const next = new Uint8Array(new ArrayBuffer(encoded.byteLength)); + next.set(encoded); + await delegate.update({ where: { id }, data: { [field]: next } }); + result.rotated++; + } catch (err) { + result.errors++; + console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`); } } return result; @@ -85,36 +152,24 @@ async function main() { console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`); const results: RotationResult[] = []; - // ───── User table ───── - const users = await prisma.user.findMany({ - select: { - id: true, - codexAccessTokenEncrypted: true, - codexRefreshTokenEncrypted: true, - aiAnthropicKeyEncrypted: true, - aiLocalKeyEncrypted: true, - aiOpenaiKeyEncrypted: true, - moodLogWebhookSecret: true, - telegramBotToken: true, - moodLogUrlEncrypted: true, - moodLogApiKeyEncrypted: true, - withingsClientIdEncrypted: true, - withingsClientSecretEncrypted: true, - whoopClientIdEncrypted: true, - whoopClientSecretEncrypted: true, - fitbitClientIdEncrypted: true, - fitbitClientSecretEncrypted: true, - }, - }); - - const userFields: Array = [ + // ───── User — integration credentials + AI keys + KVNR (String) ───── + // Columns: "codexAccessTokenEncrypted" "codexRefreshTokenEncrypted" + // "telegramBotToken" "moodLogWebhookSecret" "moodLogUrlEncrypted" + // "moodLogApiKeyEncrypted" "withingsClientIdEncrypted" + // "withingsClientSecretEncrypted" "whoopClientIdEncrypted" + // "whoopClientSecretEncrypted" "fitbitClientIdEncrypted" + // "fitbitClientSecretEncrypted" "nightscoutUrlEncrypted" + // "nightscoutTokenEncrypted" "polarAccessTokenEncrypted" + // "polarUserIdEncrypted" "polarClientIdEncrypted" + // "polarClientSecretEncrypted" "ouraAccessTokenEncrypted" + // "ouraRefreshTokenEncrypted" "ouraClientIdEncrypted" + // "ouraClientSecretEncrypted" "aiAnthropicKeyEncrypted" + // "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted" + const userFields = [ "codexAccessTokenEncrypted", "codexRefreshTokenEncrypted", - "aiAnthropicKeyEncrypted", - "aiLocalKeyEncrypted", - "aiOpenaiKeyEncrypted", - "moodLogWebhookSecret", "telegramBotToken", + "moodLogWebhookSecret", "moodLogUrlEncrypted", "moodLogApiKeyEncrypted", "withingsClientIdEncrypted", @@ -123,338 +178,197 @@ async function main() { "whoopClientSecretEncrypted", "fitbitClientIdEncrypted", "fitbitClientSecretEncrypted", + "nightscoutUrlEncrypted", + "nightscoutTokenEncrypted", + "polarAccessTokenEncrypted", + "polarUserIdEncrypted", + "polarClientIdEncrypted", + "polarClientSecretEncrypted", + "ouraAccessTokenEncrypted", + "ouraRefreshTokenEncrypted", + "ouraClientIdEncrypted", + "ouraClientSecretEncrypted", + "aiAnthropicKeyEncrypted", + "aiLocalKeyEncrypted", + "aiOpenaiKeyEncrypted", + "insuranceNumberEncrypted", ]; for (const field of userFields) { - const r = await rotateField( - "User", - field as string, - users, - (u) => (u as Record)[field as string] as string | null, - async (id, ciphertext) => { - await prisma.user.update({ - where: { id }, - data: { [field]: ciphertext } as Record, - }); - }, - ); - results.push(r); + results.push(await rotateStringColumn("User", field, prisma.user)); } - // ───── WithingsConnection table (accessToken / refreshToken) ───── - const withings = await prisma.withingsConnection.findMany({ - select: { id: true, accessToken: true, refreshToken: true }, - }); - for (const field of ["accessToken", "refreshToken"] as const) { - const r = await rotateField( - "WithingsConnection", - field, - withings, - (w) => w[field], - async (id, ciphertext) => { - await prisma.withingsConnection.update({ - where: { id }, - data: { [field]: ciphertext } as Record, - }); - }, - ); - results.push(r); - } - - // ───── WhoopConnection table (accessToken / refreshToken) ───── - const whoop = await prisma.whoopConnection.findMany({ - select: { id: true, accessToken: true, refreshToken: true }, - }); - for (const field of ["accessToken", "refreshToken"] as const) { - const r = await rotateField( - "WhoopConnection", - field, - whoop, - (w) => w[field], - async (id, ciphertext) => { - await prisma.whoopConnection.update({ - where: { id }, - data: { [field]: ciphertext } as Record, - }); - }, - ); - results.push(r); - } - - // ───── FitbitConnection table (accessToken / refreshToken) ───── - const fitbit = await prisma.fitbitConnection.findMany({ - select: { id: true, accessToken: true, refreshToken: true }, - }); - for (const field of ["accessToken", "refreshToken"] as const) { - const r = await rotateField( - "FitbitConnection", - field, - fitbit, - (f) => f[field], - async (id, ciphertext) => { - await prisma.fitbitConnection.update({ - where: { id }, - data: { [field]: ciphertext } as Record, - }); - }, - ); - results.push(r); - } - - // ───── AppSettings table (singleton typically) ───── - const settings = await prisma.appSettings.findMany({ - select: { - id: true, - adminAiKeyEncrypted: true, - webPushVapidPrivateKeyEncrypted: true, - githubIssueTokenEncrypted: true, - }, - }); - const appFields = [ - "adminAiKeyEncrypted", - "webPushVapidPrivateKeyEncrypted", - "githubIssueTokenEncrypted", - ] as const; - for (const field of appFields) { - const r = await rotateField( - "AppSettings", - field, - settings, - (s) => s[field], - async (id, ciphertext) => { - await prisma.appSettings.update({ - where: { id }, - data: { [field]: ciphertext } as Record, - }); - }, - ); - results.push(r); - } - - // ───── MoodTag.labelEncrypted ───── - // v1.13.0 — AES-256-GCM ciphertext of a custom mood tag's free-text - // label (catalogue rows carry NULL and are skipped by `shouldRotate`). - const moodTags = await prisma.moodTag.findMany({ - where: { labelEncrypted: { not: null } }, - select: { id: true, labelEncrypted: true }, - }); - results.push( - await rotateField( - "MoodTag", - "labelEncrypted", - moodTags, - (t) => t.labelEncrypted, - async (id, ciphertext) => { - await prisma.moodTag.update({ - where: { id }, - data: { labelEncrypted: ciphertext }, - }); - }, - ), - ); - - // ───── MoodTagCategory.labelEncrypted ───── - // v1.17.0 — AES-256-GCM ciphertext of a custom mood-tag group's - // free-text label (seeded rows carry NULL and are skipped). - const moodTagCategories = await prisma.moodTagCategory.findMany({ - where: { labelEncrypted: { not: null } }, - select: { id: true, labelEncrypted: true }, - }); - results.push( - await rotateField( - "MoodTagCategory", - "labelEncrypted", - moodTagCategories, - (c) => c.labelEncrypted, - async (id, ciphertext) => { - await prisma.moodTagCategory.update({ - where: { id }, - data: { labelEncrypted: ciphertext }, - }); - }, - ), - ); - - // ───── NotificationChannel.config ───── - // Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON. - // Skipping these on rotation would leave channels permanently undecryptable - // once the operator drops `v1` from the key map. - const channels = await prisma.notificationChannel.findMany({ - select: { id: true, config: true }, - }); - results.push( - await rotateField( - "NotificationChannel", - "config", - channels, - (c) => c.config, - async (id, ciphertext) => { - await prisma.notificationChannel.update({ - where: { id }, - data: { config: ciphertext }, - }); - }, - ), - ); - - // ───── PushSubscription.{p256dh, auth} ───── - // Web-push routing secrets — without these, the push endpoint is reachable - // but the browser ignores the message (auth tag mismatch). - const subs = await prisma.pushSubscription.findMany({ - select: { id: true, p256dh: true, auth: true }, - }); - for (const field of ["p256dh", "auth"] as const) { + // ───── OAuth token tables (String "accessToken" / "refreshToken") ───── + for (const field of ["accessToken", "refreshToken"]) { results.push( - await rotateField( - "PushSubscription", + await rotateStringColumn( + "WithingsConnection", field, - subs, - (s) => s[field], - async (id, ciphertext) => { - await prisma.pushSubscription.update({ - where: { id }, - data: { [field]: ciphertext } as Record, - }); - }, + prisma.withingsConnection, + ), + ); + results.push( + await rotateStringColumn("WhoopConnection", field, prisma.whoopConnection), + ); + results.push( + await rotateStringColumn( + "FitbitConnection", + field, + prisma.fitbitConnection, ), ); } - // ───── IntegrationStatus.lastError ───── - // Per `prisma/schema.prisma:1441` — AES-256-GCM ciphertext of an - // upstream error payload. Skipped historically; drop the legacy - // key while a row still lives here and the admin status view 500s. - const statuses = await prisma.integrationStatus.findMany({ - select: { id: true, lastError: true }, - }); + // ───── AppSettings — operator credentials (String) ───── + // Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted" + // "githubIssueTokenEncrypted" + for (const field of [ + "adminAiKeyEncrypted", + "webPushVapidPrivateKeyEncrypted", + "githubIssueTokenEncrypted", + ]) { + results.push( + await rotateStringColumn("AppSettings", field, prisma.appSettings), + ); + } + + // ───── Custom labels — mood + cycle (String "labelEncrypted") ───── + // Catalogue rows carry NULL and are skipped by `shouldRotate`. results.push( - await rotateField( - "IntegrationStatus", - "lastError", - statuses, - (s) => s.lastError, - async (id, ciphertext) => { - await prisma.integrationStatus.update({ - where: { id }, - data: { lastError: ciphertext }, - }); - }, + await rotateStringColumn("MoodTag", "labelEncrypted", prisma.moodTag), + ); + results.push( + await rotateStringColumn( + "MoodTagCategory", + "labelEncrypted", + prisma.moodTagCategory, + ), + ); + results.push( + await rotateStringColumn( + "CycleSymptom", + "labelEncrypted", + prisma.cycleSymptom, ), ); - // ───── CoachMessage.encryptedContent ───── - // Per `prisma/schema.prisma:1983` — `Bytes` column carrying the - // UTF-8 ciphertext of every persisted Coach message. The encrypt / - // decrypt helpers operate on strings, so we go through a Buffer - // round-trip identical to `src/lib/ai/coach/persistence.ts:60-71`. - const coachMessages = await prisma.coachMessage.findMany({ - select: { id: true, encryptedContent: true }, - }); - const coachResult: RotationResult = { - table: "CoachMessage", - field: "encryptedContent", - scanned: coachMessages.length, - rotated: 0, - errors: 0, - }; - for (const row of coachMessages) { - const buf = row.encryptedContent; - if (!buf || buf.byteLength === 0) continue; - const asString = Buffer.from(buf).toString("utf8"); - if (!shouldRotate(asString)) continue; - try { - const rotated = encrypt(decrypt(asString)); - const encoded = Buffer.from(rotated, "utf8"); - const next = new Uint8Array(new ArrayBuffer(encoded.byteLength)); - next.set(encoded); - await prisma.coachMessage.update({ - where: { id: row.id }, - data: { encryptedContent: next }, - }); - coachResult.rotated++; - } catch (err) { - coachResult.errors++; - console.error( - `[CoachMessage.encryptedContent] row ${row.id}: ${(err as Error).message}`, - ); - } - } - results.push(coachResult); + // ───── NotificationChannel."config" (encrypted JSON) ───── + // Channel config (Telegram chat id, ntfy topic, etc.). Skipping these on + // rotation would leave channels permanently undecryptable once the + // operator drops `v1` from the key map. + results.push( + await rotateStringColumn( + "NotificationChannel", + "config", + prisma.notificationChannel, + ), + ); - // ───── CoachConversation.summaryEncrypted ───── - // v1.11.1 — `Bytes` column carrying the UTF-8 ciphertext of the rolling - // conversation summary. Same Buffer round-trip as CoachMessage; nullable, so - // skip empty/absent rows. - const coachConversations = await prisma.coachConversation.findMany({ - where: { summaryEncrypted: { not: null } }, - select: { id: true, summaryEncrypted: true }, - }); - const summaryResult: RotationResult = { - table: "CoachConversation", - field: "summaryEncrypted", - scanned: coachConversations.length, - rotated: 0, - errors: 0, - }; - for (const row of coachConversations) { - const buf = row.summaryEncrypted; - if (!buf || buf.byteLength === 0) continue; - const asString = Buffer.from(buf).toString("utf8"); - if (!shouldRotate(asString)) continue; - try { - const rotated = encrypt(decrypt(asString)); - const encoded = Buffer.from(rotated, "utf8"); - const next = new Uint8Array(new ArrayBuffer(encoded.byteLength)); - next.set(encoded); - await prisma.coachConversation.update({ - where: { id: row.id }, - data: { summaryEncrypted: next }, - }); - summaryResult.rotated++; - } catch (err) { - summaryResult.errors++; - console.error( - `[CoachConversation.summaryEncrypted] row ${row.id}: ${(err as Error).message}`, - ); - } + // ───── PushSubscription."p256dh" / "auth" ───── + // Web-push routing secrets — without these, the push endpoint is reachable + // but the browser ignores the message (auth tag mismatch). + for (const field of ["p256dh", "auth"]) { + results.push( + await rotateStringColumn( + "PushSubscription", + field, + prisma.pushSubscription, + ), + ); } - results.push(summaryResult); - // ───── CoachFact.factEncrypted ───── - // v1.11.1 — `Bytes` column carrying the UTF-8 ciphertext of each durable - // personal fact. Same Buffer round-trip as CoachMessage. - const coachFacts = await prisma.coachFact.findMany({ - select: { id: true, factEncrypted: true }, - }); - const factResult: RotationResult = { - table: "CoachFact", - field: "factEncrypted", - scanned: coachFacts.length, - rotated: 0, - errors: 0, - }; - for (const row of coachFacts) { - const buf = row.factEncrypted; - if (!buf || buf.byteLength === 0) continue; - const asString = Buffer.from(buf).toString("utf8"); - if (!shouldRotate(asString)) continue; - try { - const rotated = encrypt(decrypt(asString)); - const encoded = Buffer.from(rotated, "utf8"); - const next = new Uint8Array(new ArrayBuffer(encoded.byteLength)); - next.set(encoded); - await prisma.coachFact.update({ - where: { id: row.id }, - data: { factEncrypted: next }, - }); - factResult.rotated++; - } catch (err) { - factResult.errors++; - console.error( - `[CoachFact.factEncrypted] row ${row.id}: ${(err as Error).message}`, - ); - } + // ───── IntegrationStatus."lastError" ───── + // AES-256-GCM ciphertext of an upstream error payload. Drop the legacy key + // while a row still lives here and the admin status view 500s. + results.push( + await rotateStringColumn( + "IntegrationStatus", + "lastError", + prisma.integrationStatus, + ), + ); + + // ───── CycleDayLog — "sensitiveEncrypted" / "notesEncrypted" (String) ───── + for (const field of ["sensitiveEncrypted", "notesEncrypted"]) { + results.push( + await rotateStringColumn("CycleDayLog", field, prisma.cycleDayLog), + ); } - results.push(factResult); + + // ───── Coach (Bytes columns) ───── + // "encryptedContent" "summaryEncrypted" "factEncrypted" + results.push( + await rotateBytesColumn( + "CoachMessage", + "encryptedContent", + prisma.coachMessage, + ), + ); + results.push( + await rotateBytesColumn( + "CoachConversation", + "summaryEncrypted", + prisma.coachConversation, + ), + ); + results.push( + await rotateBytesColumn("CoachFact", "factEncrypted", prisma.coachFact), + ); + + // ───── UserHealthProfile (Bytes columns) ───── + // "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted" + // "coachFocusEncrypted" "pendingQuestionsEncrypted" + for (const field of [ + "aboutMeEncrypted", + "conditionsEncrypted", + "allergiesEncrypted", + "coachFocusEncrypted", + "pendingQuestionsEncrypted", + ]) { + results.push( + await rotateBytesColumn( + "UserHealthProfile", + field, + prisma.userHealthProfile, + ), + ); + } + + // ───── InsightNarrative."encryptedContent" (Bytes) ───── + results.push( + await rotateBytesColumn( + "InsightNarrative", + "encryptedContent", + prisma.insightNarrative, + ), + ); + + // ───── v1.18.1 clinical-spine notes (Bytes columns) ───── + // "noteEncrypted" (LabResult / IllnessEpisode / IllnessDayLog) + + // "contextEncrypted" (Biomarker). Mirror the CoachFact.factEncrypted block. + results.push( + await rotateBytesColumn("LabResult", "noteEncrypted", prisma.labResult), + ); + results.push( + await rotateBytesColumn( + "Biomarker", + "contextEncrypted", + prisma.biomarker, + ), + ); + results.push( + await rotateBytesColumn( + "IllnessEpisode", + "noteEncrypted", + prisma.illnessEpisode, + ), + ); + results.push( + await rotateBytesColumn( + "IllnessDayLog", + "noteEncrypted", + prisma.illnessDayLog, + ), + ); console.log("\n=== Rotation summary ==="); let totalRotated = 0; diff --git a/src/lib/crypto/__tests__/encrypted-columns.test.ts b/src/lib/crypto/__tests__/encrypted-columns.test.ts new file mode 100644 index 000000000..79a872f0d --- /dev/null +++ b/src/lib/crypto/__tests__/encrypted-columns.test.ts @@ -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 = 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 = new Set([ + // 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: ` ...`. 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( + 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([]); + }); +}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts new file mode 100644 index 000000000..a5e601a55 --- /dev/null +++ b/src/lib/crypto/encrypted-columns.ts @@ -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}`; +}