diff --git a/src/lib/export/__tests__/records-backup.test.ts b/src/lib/export/__tests__/records-backup.test.ts index c94532062..50cd952c0 100644 --- a/src/lib/export/__tests__/records-backup.test.ts +++ b/src/lib/export/__tests__/records-backup.test.ts @@ -19,6 +19,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { encryptDocumentSummary } from "@/lib/documents/store"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { buildRecordsBackupSection, countRecordsBackupSection, @@ -450,10 +451,14 @@ describe("buildRecordsBackupSection", () => { }); }); - it("fails soft (null, not a throw) on an undecryptable note", async () => { + it("marks an undecryptable note (not a throw, and not a silent null)", async () => { const prisma = buildMockPrisma(); - // Corrupt the ciphertext so decrypt throws internally — the row must - // still come back with `note: null`, never abort the whole backup. + // Corrupt the ciphertext so decrypt throws internally. The row must still + // come back and must never abort the whole backup — that part is + // unchanged. What it must NOT come back as is `null`, which this test used + // to require: in an export file a null note is byte-identical to a note + // that was never written, so the restore writes nothing and the loss is + // invisible. It comes back as a marker instead. prisma.labResult.findMany.mockResolvedValueOnce([ { panel: null, @@ -471,6 +476,6 @@ describe("buildRecordsBackupSection", () => { ]); const section = await buildRecordsBackupSection(prisma as never, USER_ID); - expect(section.labResults[0].note).toBeNull(); + expect(section.labResults[0].note).toBe(UNREADABLE_EXPORT_MARKER); }); }); diff --git a/src/lib/export/coach-backup.ts b/src/lib/export/coach-backup.ts index c4894fc7f..19c1c8363 100644 --- a/src/lib/export/coach-backup.ts +++ b/src/lib/export/coach-backup.ts @@ -53,6 +53,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker"; import { recordUnknownKeys, type RestoreSkipLog, @@ -169,7 +170,7 @@ function decryptTurnSoft(bytes: Uint8Array | null): string | null { try { return decryptFromBytes(bytes); } catch { - return "[unreadable: encrypted with a key this instance no longer holds]"; + return UNREADABLE_EXPORT_MARKER; } } diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 8d0130372..383212b0c 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -7,14 +7,16 @@ * verbatim. The DR path never decrypts document content or summaries. * * Every read is scoped to the owner. Soft-deleted rows are excluded so a - * restore cannot resurrect tombstones. Other encrypted free-text fields keep - * the established fail-soft portable representation and are re-encrypted by - * the restore importer. + * restore cannot resurrect tombstones. Other encrypted free-text fields are + * carried as plaintext and re-encrypted by the restore importer; one this + * instance cannot decrypt is carried as a visible marker rather than as + * nothing (see `readForExport` below). */ import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; -import { decryptNoteSoft } from "@/lib/labs/store"; -import { decryptContextSoft } from "@/lib/labs/biomarker-store"; +import { decryptNoteFromBytes } from "@/lib/labs/store"; +import { decryptContextFromBytes } from "@/lib/labs/biomarker-store"; +import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker"; import { decryptDocumentSummary } from "@/lib/documents/store"; import { getEvent } from "@/lib/logging/context"; import { @@ -44,6 +46,39 @@ export const WORKOUTS_MANIFEST_NOTE = "steps) are included. GPS routes and per-sample heart-rate/pace time " + "series are not included in this export."; +/** + * Decrypt an encrypted free-text column for a portable export, or say plainly + * that this one could not be read. + * + * `decryptNoteSoft` / `decryptContextSoft` answer `null` on a wrong or dropped + * key. In a list view that is right — one bad row must not take the page down. + * In an export it is not: `null` is exactly what a column that was never + * written looks like, so the restore importer writes nothing and a note the + * person did write disappears without anyone being told. The Coach transcript + * and the doctor report both refuse that trade, one with a placeholder + * sentence and one with a rendered flag; this follows the same rule. + * + * The warning the soft helpers emitted is kept, so a systemic key gap still + * shows up in the wide event rather than only in the file. + */ +function readForExport( + buf: Uint8Array | null, + label: string, + decode: (bytes: Uint8Array) => string, +): string | null { + if (!buf || buf.byteLength === 0) return null; + try { + return decode(buf); + } catch (err) { + getEvent()?.addWarning( + `${label} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNREADABLE_EXPORT_MARKER; + } +} + export interface LabResultBackupEntry { /** Present in canonical DR payloads so rows keep a stable identity. */ id?: string; @@ -339,7 +374,13 @@ export async function buildRecordsBackupSection( deletedAt: r.deletedAt?.toISOString() ?? null, note: null, } - : { note: decryptNoteSoft(r.noteEncrypted) }), + : { + note: readForExport( + r.noteEncrypted, + "lab note", + decryptNoteFromBytes, + ), + }), panel: r.panel, analyte: r.analyte, value: r.value, @@ -369,7 +410,11 @@ export async function buildRecordsBackupSection( upperBound: b.upperBound, panel: b.panel, hidden: b.hidden, - context: decryptContextSoft(b.contextEncrypted), + context: readForExport( + b.contextEncrypted, + "biomarker context", + decryptContextFromBytes, + ), })); const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery diff --git a/src/lib/export/unreadable-marker.ts b/src/lib/export/unreadable-marker.ts new file mode 100644 index 000000000..dca59f216 --- /dev/null +++ b/src/lib/export/unreadable-marker.ts @@ -0,0 +1,19 @@ +/** + * What an export writes where a row could not be decrypted. + * + * A portable export carries plaintext, so an encrypted column that this + * instance can no longer read has to become something. The fail-soft decrypt + * helpers answer `null`, which in an export file is indistinguishable from a + * column that was never written — and a restore then writes nothing, so the + * loss is complete and silent. It is not: the ciphertext was there, and the + * person is entitled to know a key gap ate it rather than to find a note they + * remember writing simply gone. + * + * The marker is a sentence, not an empty string, for the same reason the Coach + * transcript's placeholder is: an empty string reads as "there was nothing + * here", and that is not what happened. It survives a round trip through the + * restore importer like any other text, so the gap stays visible in the + * restored account instead of closing over. + */ +export const UNREADABLE_EXPORT_MARKER = + "[unreadable: encrypted with a key this instance no longer holds]"; diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index a6daaf312..aba427cd9 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -63,6 +63,9 @@ import { encryptWaveformToBytes, } from "@/lib/withings/ecg-waveform-codec"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; +import { decryptNoteFromBytes } from "@/lib/labs/store"; +import { decryptContextFromBytes } from "@/lib/labs/biomarker-store"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; import { POST } from "@/app/api/admin/backups/[id]/restore/route"; @@ -2619,4 +2622,103 @@ describe("every model the plan claims two-ended survives a real restore", () => }, }); }); + + /** + * A note this instance can no longer decrypt leaves the export as a marker, + * not as nothing. + * + * `decryptNoteSoft` answers `null` on a wrong or dropped key, and in a + * portable file `null` is byte-for-byte what a note that was never written + * looks like. The restore importer then writes no note, so a note the person + * did write is gone and no one is told — the whole loss fits inside a + * fail-soft `catch` written for a list view, where returning null is right. + * + * The Coach transcript and the doctor report both refuse that trade, one + * with a placeholder sentence and one with a rendered flag. This asserts the + * lab note now does the same, and that the marker is still there after the + * file has been through a real restore: the ciphertext is unreadable either + * way, but a person opening the restored account can see that something was + * there rather than believing they never wrote it. + */ + it("carries a lab note it cannot decrypt through the round trip as a visible marker", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + + const biomarker = await prisma.biomarker.create({ + data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" }, + }); + // Ciphertext under a key id this instance does not hold — the shape a row + // written before a key rotation that dropped its key has. `decrypt` is + // fail-closed on an unknown key id, so this throws rather than returning + // garbage, which is the case the soft helper swallowed. + const unreadable = new Uint8Array( + Buffer.from("v9.AAAAAAAAAAAAAAAAAAAAAAAA", "utf8"), + ); + await prisma.labResult.create({ + data: { + userId: OWNER_ID, + biomarkerId: biomarker.id, + analyte: "Ferritin", + value: 91, + unit: "ng/mL", + takenAt: AT("2026-06-30T09:00:00.000Z"), + noteEncrypted: unreadable, + }, + }); + await prisma.biomarker.update({ + where: { id: biomarker.id }, + data: { contextEncrypted: unreadable }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "portable-export", + }); + + const exported = payload.labResults as Array<{ note: string | null }>; + expect(exported).toHaveLength(1); + expect(exported[0].note).toContain("unreadable"); + const exportedBiomarkers = payload.biomarkers as Array<{ + context: string | null; + }>; + expect(exportedBiomarkers[0].context).toContain("unreadable"); + + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + + const restoredLab = await prisma.labResult.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + // Back in the column it came from, readable again because the restore + // re-encrypted it under the CURRENT key — and saying what happened. + expect(restoredLab.noteEncrypted).not.toBeNull(); + expect(decryptNoteFromBytes(restoredLab.noteEncrypted!)).toBe( + UNREADABLE_EXPORT_MARKER, + ); + + const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptContextFromBytes(restoredBiomarker.contextEncrypted!)).toBe( + UNREADABLE_EXPORT_MARKER, + ); + }); });