fix(crypto): rotate the ECG waveform column + guard rotation coverage

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 12:43:54 +02:00
1 parent 54fa76de6f
commit c1a2a2610a
2 files changed
+93

No files matched your search

+13
View File
@@ -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;
@@ -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([]);
});
});