diff --git a/src/lib/cycle/__tests__/day-log-write.test.ts b/src/lib/cycle/__tests__/day-log-write.test.ts new file mode 100644 index 000000000..8fdd8119e --- /dev/null +++ b/src/lib/cycle/__tests__/day-log-write.test.ts @@ -0,0 +1,156 @@ +/** + * Cycle day-log upsert helper — C-1 encryption write-path data-loss + * regression coverage. + * + * The partial-update upsert loads the existing row before writing. When the + * stored encrypted note / sensitive envelope cannot be decrypted (rotation + * gap, GCM corruption), the helper must NOT collapse the column to a re-encrypt + * of a failed soft-decrypt (which would permanently wipe the user's data when + * they edit an unrelated field). The stored ciphertext is preserved verbatim + * unless the user explicitly sets that field. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + cycleDayLog: { + findUnique: vi.fn(), + upsert: vi.fn(), + update: vi.fn(), + }, + cycleSymptom: { findMany: vi.fn() }, + cycleSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() }, +})); + +vi.mock("@/lib/db", () => ({ prisma: db })); +vi.mock("@/lib/cycle/profile", () => ({ + getOrCreateCycleProfile: vi.fn(async () => ({ + sensitiveCategoryEncryption: true, + })), +})); + +// `decrypt` round-trips an `enc:` prefix and THROWS on any other ciphertext, +// simulating an undecryptable column. The write path must never let that throw +// reach the column write — it preserves the stored ciphertext verbatim. +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (c: string) => { + if (!c.startsWith("enc:")) throw new Error("undecryptable"); + return c.replace(/^enc:/, ""); + }, +})); + +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import type { CycleDayLogInput } from "@/lib/validations/cycle"; + +function existingRow(overrides: Record = {}) { + return { + id: "cdl-1", + date: "2026-06-16", + flow: null, + intermenstrualBleeding: false, + basalBodyTempC: null, + temperatureExcluded: false, + ovulationTest: null, + cervicalMucus: null, + cervixPosition: null, + cervixFirmness: null, + cervixOpening: null, + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: null, + notesEncrypted: null, + deletedAt: null, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + db.cycleDayLog.upsert.mockResolvedValue({ id: "cdl-1" }); +}); + +describe("upsertCycleDayLog — C-1 undecryptable note preservation", () => { + it("preserves an undecryptable note verbatim when an unrelated field is edited", async () => { + db.cycleDayLog.findUnique.mockResolvedValue( + existingRow({ notesEncrypted: "CORRUPT-NOTE-CIPHERTEXT" }), + ); + // Edit only `flow`; `note` is omitted. + const entry = { + date: "2026-06-16", + source: "MANUAL", + flow: "LIGHT", + } as unknown as CycleDayLogInput; + + await upsertCycleDayLog("u1", entry, "Europe/Berlin"); + + const args = db.cycleDayLog.upsert.mock.calls[0][0]; + // The stored ciphertext is carried through unchanged — not nulled, not + // re-encrypted from a failed soft-decrypt. + expect(args.update.notesEncrypted).toBe("CORRUPT-NOTE-CIPHERTEXT"); + expect(args.update.flow).toBe("LIGHT"); + }); + + it("clears the note on an explicit null even when the stored one is undecryptable", async () => { + db.cycleDayLog.findUnique.mockResolvedValue( + existingRow({ notesEncrypted: "CORRUPT-NOTE-CIPHERTEXT" }), + ); + const entry = { + date: "2026-06-16", + source: "MANUAL", + note: null, + } as unknown as CycleDayLogInput; + + await upsertCycleDayLog("u1", entry, "Europe/Berlin"); + + const args = db.cycleDayLog.upsert.mock.calls[0][0]; + expect(args.update.notesEncrypted).toBeNull(); + }); +}); + +describe("upsertCycleDayLog — C-1 undecryptable sensitive envelope preservation", () => { + it("preserves an undecryptable sensitive envelope verbatim when an unrelated field is edited", async () => { + db.cycleDayLog.findUnique.mockResolvedValue( + existingRow({ sensitiveEncrypted: "CORRUPT-ENVELOPE-CIPHERTEXT" }), + ); + // Edit only a non-sensitive field; no sensitive field supplied. + const entry = { + date: "2026-06-16", + source: "MANUAL", + basalBodyTempC: 36.5, + } as unknown as CycleDayLogInput; + + await upsertCycleDayLog("u1", entry, "Europe/Berlin"); + + const args = db.cycleDayLog.upsert.mock.calls[0][0]; + // The envelope ciphertext is preserved, not overwritten with a defaults + // envelope minted from a failed soft-decrypt. + expect(args.update.sensitiveEncrypted).toBe("CORRUPT-ENVELOPE-CIPHERTEXT"); + expect(args.update.basalBodyTempC).toBe(36.5); + }); + + it("re-encrypts the envelope when a sensitive field is explicitly supplied", async () => { + db.cycleDayLog.findUnique.mockResolvedValue( + existingRow({ sensitiveEncrypted: "CORRUPT-ENVELOPE-CIPHERTEXT" }), + ); + const entry = { + date: "2026-06-16", + source: "MANUAL", + sexualActivity: true, + } as unknown as CycleDayLogInput; + + await upsertCycleDayLog("u1", entry, "Europe/Berlin"); + + const args = db.cycleDayLog.upsert.mock.calls[0][0]; + // An explicit sensitive write mints a fresh envelope carrying the user's + // intent — the stored (undecryptable) one is replaced on purpose. + expect(args.update.sensitiveEncrypted).not.toBe( + "CORRUPT-ENVELOPE-CIPHERTEXT", + ); + expect(String(args.update.sensitiveEncrypted)).toContain( + '"sexualActivity":true', + ); + }); +}); diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 1113c506f..10f7bc324 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -114,13 +114,22 @@ export async function replaceSymptomLinks( } } -/** Decrypt the sensitive envelope fail-soft (null on missing / undecryptable). */ -function decryptSensitive(envelope: string | null): Partial { - if (!envelope) return {}; +/** + * Decrypt the sensitive envelope fail-soft. Returns `{ ok: true, values }` on a + * clean decrypt, `{ ok: false }` when an envelope is present but undecryptable + * (rotation gap, GCM corruption). The caller MUST treat `ok: false` as + * "do not re-write this column" — never as "the field is empty". + */ +function decryptSensitive( + envelope: string, +): { ok: true; values: Partial } | { ok: false } { try { - return JSON.parse(decrypt(envelope)) as Partial; + return { + ok: true, + values: JSON.parse(decrypt(envelope)) as Partial, + }; } catch { - return {}; + return { ok: false }; } } @@ -259,21 +268,31 @@ async function writeDayLog(args: WriteArgs): Promise { // Decrypt the existing note (fail-soft) for the change/duplicate split — // re-encrypting the same plaintext yields a different ciphertext (random - // GCM IV), so a ciphertext compare would always read "changed". + // GCM IV), so a ciphertext compare would always read "changed". This is a + // DISPLAY-ONLY read (a `null` here only affects the change flag, never the + // written column — see the note-write below), so soft-decrypt is safe. const existingNote = decryptNoteSoft(existing?.notesEncrypted ?? null); // The resolved plaintext sensitive values for THIS write, after partial // merge against what's already stored (decrypted from the envelope when - // the stored row was encrypted). - const storedSensitive: SensitiveFields = existing + // the stored row was encrypted). `storedDecryptable` is false when the + // existing envelope could not be decrypted — in that case the merged + // values are DEFAULTS, not the real stored data, so they must never be + // re-encrypted back over the column (that would wipe the envelope). + const merged = existing ? mergeStoredSensitive(existing) : { - sexualActivity: false, - protectedSex: null, - pregnancyTest: null, - progesteroneTest: null, - contraceptive: null, + values: { + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + }, + decryptable: true, }; + const storedSensitive: SensitiveFields = merged.values; + const storedSensitiveDecryptable = merged.decryptable; const resolvedSensitive: SensitiveFields = { sexualActivity: @@ -298,13 +317,29 @@ async function writeDayLog(args: WriteArgs): Promise { : storedSensitive.contraceptive, }; - // Note resolution with partial merge: an omitted `note` keeps the stored - // value; an explicit null clears it. - const incomingNote = + // Note resolution with partial merge. When `note` is supplied, encrypt it + // (an explicit empty/null clears the column). When `note` is OMITTED, the + // existing ciphertext is preserved VERBATIM — never decrypt→re-encrypt, and + // never collapse an undecryptable existing value to null. A soft-decrypt that + // returns null on an undecryptable column (rotation gap, GCM corruption) must + // not be allowed to permanently wipe the stored note when an unrelated field + // is the only thing being edited (mirrors the v1.18.1 53-column rotation fix: + // a write path never re-writes an `*Encrypted` column from a soft-decrypt + // result). + const notesEncrypted: string | null = entry.note !== undefined - ? (entry.note ?? null) - : decryptNoteSoft(existing?.notesEncrypted ?? null); - const notesEncrypted = incomingNote ? encrypt(incomingNote) : null; + ? entry.note + ? encrypt(entry.note) + : null + : (existing?.notesEncrypted ?? null); + + // Note change detection for the `changed`/`duplicate` split. An omitted + // `note` preserves the column verbatim, so it can never count as a change. + // A supplied `note` is compared against the soft-decrypted existing value + // (display-only) — a ciphertext compare would always read "changed" (random + // GCM IV). + const incomingNote = entry.note !== undefined ? (entry.note ?? null) : null; + const noteChanged = entry.note !== undefined && incomingNote !== existingNote; // Non-sensitive fields, partial-merged against the stored row. const flow = @@ -342,11 +377,35 @@ async function writeDayLog(args: WriteArgs): Promise { ? (entry.cervixOpening ?? null) : (existing?.cervixOpening ?? null); + // Was any of the five sensitive fields actually supplied in THIS update? + // Used to decide whether an undecryptable existing envelope may be preserved + // verbatim (an unrelated-field edit) or must be rewritten (the user is + // explicitly setting sensitive data). + const sensitiveFieldSupplied = + entry.sexualActivity !== undefined || + entry.protectedSex !== undefined || + entry.pregnancyTest !== undefined || + entry.progesteroneTest !== undefined || + entry.contraceptive !== undefined; + // Split the sensitive fields between plaintext columns and the envelope // depending on the flag. When encrypting, the plaintext columns are NULL // (drop out of rollup/correlation) and the envelope carries the JSON. + // + // Data-loss guard: when the stored envelope could NOT be decrypted, the + // merged `resolvedSensitive` carries DEFAULTS, not the real stored values. + // Re-encrypting them over the column would permanently wipe the user's + // sensitive data on an edit of an unrelated field. So when encrypting, the + // existing envelope is undecryptable, and no sensitive field is supplied in + // this update, preserve the existing ciphertext VERBATIM (mirrors the + // v1.18.1 53-column rotation fix). An explicit sensitive-field write still + // re-encrypts the user's intent. + const preserveExistingEnvelope = + encryptSensitive && !storedSensitiveDecryptable && !sensitiveFieldSupplied; const sensitiveEncrypted = encryptSensitive - ? encrypt(JSON.stringify(resolvedSensitive)) + ? preserveExistingEnvelope + ? (existing?.sensitiveEncrypted ?? null) + : encrypt(JSON.stringify(resolvedSensitive)) : null; const sensitivePlaintext = encryptSensitive ? { @@ -391,12 +450,18 @@ async function writeDayLog(args: WriteArgs): Promise { (existing.cervixPosition ?? null) !== cervixPosition || (existing.cervixFirmness ?? null) !== cervixFirmness || (existing.cervixOpening ?? null) !== cervixOpening || - storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity || - storedSensitive.protectedSex !== resolvedSensitive.protectedSex || - storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest || - storedSensitive.progesteroneTest !== resolvedSensitive.progesteroneTest || - storedSensitive.contraceptive !== resolvedSensitive.contraceptive || - existingNote !== incomingNote || + // When the stored envelope was undecryptable and we preserve it + // verbatim (no sensitive field supplied), the sensitive values cannot + // be compared meaningfully — skip them. An explicit sensitive write + // compares the resolved values against the (default) stored ones. + (!preserveExistingEnvelope && + (storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity || + storedSensitive.protectedSex !== resolvedSensitive.protectedSex || + storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest || + storedSensitive.progesteroneTest !== + resolvedSensitive.progesteroneTest || + storedSensitive.contraceptive !== resolvedSensitive.contraceptive)) || + noteChanged || existing.deletedAt !== null || (Boolean(entry.externalId) && existing.date !== entry.date); } @@ -492,23 +557,49 @@ function isUniqueViolation(err: unknown): boolean { ); } -/** Resolve the stored plaintext sensitive fields (envelope OR columns). */ -function mergeStoredSensitive(existing: ExistingRow): SensitiveFields { +/** + * Resolve the stored plaintext sensitive fields (envelope OR columns). + * `decryptable` is false ONLY when an envelope is present but could not be + * decrypted — the returned `values` are then DEFAULTS and the caller must not + * re-encrypt them back over the column. + */ +function mergeStoredSensitive(existing: ExistingRow): { + values: SensitiveFields; + decryptable: boolean; +} { if (existing.sensitiveEncrypted) { const dec = decryptSensitive(existing.sensitiveEncrypted); + if (!dec.ok) { + return { + values: { + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + }, + decryptable: false, + }; + } return { - sexualActivity: dec.sexualActivity ?? false, - protectedSex: dec.protectedSex ?? null, - pregnancyTest: dec.pregnancyTest ?? null, - progesteroneTest: dec.progesteroneTest ?? null, - contraceptive: dec.contraceptive ?? null, + values: { + sexualActivity: dec.values.sexualActivity ?? false, + protectedSex: dec.values.protectedSex ?? null, + pregnancyTest: dec.values.pregnancyTest ?? null, + progesteroneTest: dec.values.progesteroneTest ?? null, + contraceptive: dec.values.contraceptive ?? null, + }, + decryptable: true, }; } return { - sexualActivity: existing.sexualActivity, - protectedSex: existing.protectedSex, - pregnancyTest: existing.pregnancyTest, - progesteroneTest: existing.progesteroneTest, - contraceptive: existing.contraceptive, + values: { + sexualActivity: existing.sexualActivity, + protectedSex: existing.protectedSex, + pregnancyTest: existing.pregnancyTest, + progesteroneTest: existing.progesteroneTest, + contraceptive: existing.contraceptive, + }, + decryptable: true, }; } diff --git a/src/lib/illness/__tests__/day-log-write.test.ts b/src/lib/illness/__tests__/day-log-write.test.ts index f7eb13b7b..9d63d2f43 100644 --- a/src/lib/illness/__tests__/day-log-write.test.ts +++ b/src/lib/illness/__tests__/day-log-write.test.ts @@ -25,10 +25,17 @@ const db = vi.hoisted(() => { }); vi.mock("@/lib/db", () => ({ prisma: db })); +// `decryptFromBytes` throws on any ciphertext NOT minted by this mock's +// `encryptToBytes` — simulating an undecryptable column (rotation gap / GCM +// corruption). The write path must never call it (it preserves ciphertext +// verbatim), so a throw here that reaches the column write is a regression. vi.mock("@/lib/ai/coach/bytes-codec", () => ({ encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), - decryptFromBytes: (b: Uint8Array) => - Buffer.from(b).toString("utf8").replace(/^enc:/, ""), + decryptFromBytes: (b: Uint8Array) => { + const s = Buffer.from(b).toString("utf8"); + if (!s.startsWith("enc:")) throw new Error("undecryptable"); + return s.replace(/^enc:/, ""); + }, })); import { @@ -188,4 +195,29 @@ describe("upsertIllnessDayLog", () => { expect(args.create.noteEncrypted).toBeInstanceOf(Uint8Array); expect(db.illnessSymptomLink.deleteMany).not.toHaveBeenCalled(); }); + + // C-1 data-loss regression: editing an unrelated field on a row whose + // encrypted note CANNOT be decrypted must preserve the existing ciphertext + // byte-for-byte — never null it from a failed soft-decrypt. + it("preserves an undecryptable note byte-for-byte when an unrelated field is edited", async () => { + const corrupt = new Uint8Array(Buffer.from("CORRUPT-CIPHERTEXT")); + db.illnessDayLog.findUnique.mockResolvedValue({ + id: "dl-1", + functionalImpact: 1, + feverC: null, + noteEncrypted: corrupt, + }); + // Only fever is supplied; `note` is omitted. + await upsertIllnessDayLog( + "u1", + "ep1", + { date: "2026-06-16", feverC: 38 }, + null, + ); + const args = db.illnessDayLog.upsert.mock.calls[0][0]; + // The stored ciphertext is carried through verbatim, never decrypted+ + // re-encrypted and never nulled. + expect(args.update.noteEncrypted).toBe(corrupt); + expect(args.update.feverC).toBe(38); + }); }); diff --git a/src/lib/illness/day-log-write.ts b/src/lib/illness/day-log-write.ts index a24311c07..6a1f01526 100644 --- a/src/lib/illness/day-log-write.ts +++ b/src/lib/illness/day-log-write.ts @@ -15,7 +15,7 @@ * dropped silently. */ import { prisma } from "@/lib/db"; -import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import type { IllnessDayLogInput } from "@/lib/validations/illness"; import { dayLogSymptomInclude, toIllnessDayLogDTO } from "@/lib/illness/dto"; import type { IllnessDayLogDTO } from "@/lib/illness/dto"; @@ -88,16 +88,6 @@ export async function replaceIllnessSymptomLinks( } } -/** Decrypt a Bytes note fail-soft (null on missing / undecryptable). */ -function decryptNoteSoft(noteEncrypted: Uint8Array | null): string | null { - if (!noteEncrypted || noteEncrypted.byteLength === 0) return null; - try { - return decryptFromBytes(noteEncrypted); - } catch { - return null; - } -} - /** * Upsert one illness day-log on the `(episodeId, date)` key. The caller is * responsible for verifying the episode is owned + live before calling. @@ -136,11 +126,21 @@ export async function upsertIllnessDayLog( ? (entry.feverC ?? null) : (existing?.feverC ?? null); - const incomingNote = + // Note resolution with partial merge. When `note` is supplied, encrypt it + // (an explicit empty/null clears the column). When `note` is OMITTED, the + // existing ciphertext is preserved BYTE-FOR-BYTE — never decrypt→re-encrypt, + // and never collapse an undecryptable existing value to null. A soft-decrypt + // that returns null on an undecryptable column (rotation gap, GCM + // corruption) must not be allowed to permanently wipe the stored note when + // an unrelated field is the only thing being edited (mirrors the v1.18.1 + // 53-column rotation fix: a write path never re-writes an `*Encrypted` + // column from a soft-decrypt result). + const noteEncrypted = entry.note !== undefined - ? (entry.note ?? null) - : decryptNoteSoft(existing?.noteEncrypted ?? null); - const noteEncrypted = incomingNote ? encryptToBytes(incomingNote) : null; + ? entry.note + ? encryptToBytes(entry.note) + : null + : (existing?.noteEncrypted ?? null); const upserted = await tx.illnessDayLog.upsert({ where,