From c1a2a2610ac8e77c38279625b8ea741330a7e777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 12:43:54 +0200 Subject: [PATCH] fix(crypto): rotate the ECG waveform column + guard rotation coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.19.0 EcgRecording.waveformEncrypted Bytes column was not added to the key-rotation script, so an operator rotation would strand ECG waveforms on the legacy key and dropping it would make them permanently undecryptable — the same class as the v1.18.1 clinical-spine gap. Wire the column into the rotation and add a schema-driven coverage test that fails CI if any encrypted Bytes column is ever left out again. --- scripts/rotate-encryption-key.ts | 13 +++ .../encryption-rotation-coverage.test.ts | 80 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/lib/__tests__/encryption-rotation-coverage.test.ts diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 2e2f5acca..aa9a479c5 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -370,6 +370,19 @@ async function main() { ), ); + // ───── v1.19.0 ECG waveform (Bytes column) ───── + // "waveformEncrypted" holds the JSON-encoded micro-volt sample array in the + // same `encrypt()` ciphertext-string-as-UTF-8 shape the Coach columns use + // (see src/lib/withings/ecg-waveform-codec.ts), so the generic Bytes + // rotation re-stamps it without decoding the waveform. + results.push( + await rotateBytesColumn( + "EcgRecording", + "waveformEncrypted", + prisma.ecgRecording, + ), + ); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0; diff --git a/src/lib/__tests__/encryption-rotation-coverage.test.ts b/src/lib/__tests__/encryption-rotation-coverage.test.ts new file mode 100644 index 000000000..758a11ac3 --- /dev/null +++ b/src/lib/__tests__/encryption-rotation-coverage.test.ts @@ -0,0 +1,80 @@ +/** + * v1.19.0 — guard the key-rotation script against an uncovered encrypted column. + * + * Twice now an encrypted `Bytes` column shipped without being added to + * `scripts/rotate-encryption-key.ts` (the v1.18.1 clinical-spine notes, then the + * v1.19.0 ECG waveform). When a column is missing from the rotation, an operator + * rotation leaves those rows on the legacy key, and dropping the legacy key once + * the script reports "zero remaining" makes that data permanently undecryptable + * (crypto is fail-closed). CLAUDE.md's invariant: the script covers EVERY + * encrypted `Bytes` column. + * + * This test enumerates the encrypted `Bytes` columns straight from + * `schema.prisma` (model + field) and asserts the rotation script references + * both names, so a newly added encrypted blob fails CI until it is wired in. + * + * Non-rotation `Bytes` columns (raw avatar image, passkey public key) are not + * encrypted at rest and are excluded by the `Encrypted` / `encryptedContent` + * naming convention. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +function isEncryptedBytesField(line: string): string | null { + // matches e.g. ` waveformEncrypted Bytes @map("...")` or + // ` encryptedContent Bytes @map("...")` + const m = line.match(/^\s*(\w+)\s+Bytes\??\s/); + if (!m) return null; + const field = m[1]; + if (field.endsWith("Encrypted") || field === "encryptedContent") return field; + return null; +} + +describe("encryption key-rotation coverage", () => { + it("rotates every encrypted Bytes column declared in the schema", () => { + const schema = readFileSync( + join(process.cwd(), "prisma/schema.prisma"), + "utf8", + ); + const script = readFileSync( + join(process.cwd(), "scripts/rotate-encryption-key.ts"), + "utf8", + ); + + const pairs: Array<{ model: string; field: string }> = []; + let currentModel: string | null = null; + for (const line of schema.split("\n")) { + const model = line.match(/^model\s+(\w+)\s*\{/); + if (model) { + currentModel = model[1]; + continue; + } + if (line.trim() === "}") { + currentModel = null; + continue; + } + const field = isEncryptedBytesField(line); + if (field && currentModel) pairs.push({ model: currentModel, field }); + } + + // sanity: we actually found encrypted columns to check + expect(pairs.length).toBeGreaterThan(5); + + const missing = pairs.filter( + ({ model, field }) => + !(script.includes(`"${model}"`) && script.includes(`"${field}"`)), + ); + + expect( + missing, + `Encrypted Bytes column(s) not wired into scripts/rotate-encryption-key.ts: ${missing + .map((p) => `${p.model}.${p.field}`) + .join( + ", ", + )}. Add a rotateBytesColumn(...) call or every rotation will ` + + "strand this data on the legacy key.", + ).toEqual([]); + }); +});