fix(crypto): stop day-log upserts wiping undecryptable encrypted fields

A partial-update upsert soft-decrypted the existing encrypted column,
returned null on failure, then re-wrote it — so editing an unrelated
field permanently nulled an undecryptable note or sensitive envelope.

Preserve the existing ciphertext verbatim when the encrypted field is
absent from the update; only re-encrypt on an explicit field write.
Mirrors the v1.18.1 rotation fix.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:38:09 +02:00
1 parent fa37941add
commit cf903f5022
4 files changed
+334 -55

No files matched your search

@@ -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<string, unknown> = {}) {
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',
);
});
});
+129 -38
View File
@@ -114,13 +114,22 @@ export async function replaceSymptomLinks(
}
}
/** Decrypt the sensitive envelope fail-soft (null on missing / undecryptable). */
function decryptSensitive(envelope: string | null): Partial<SensitiveFields> {
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<SensitiveFields> } | { ok: false } {
try {
return JSON.parse(decrypt(envelope)) as Partial<SensitiveFields>;
return {
ok: true,
values: JSON.parse(decrypt(envelope)) as Partial<SensitiveFields>,
};
} catch {
return {};
return { ok: false };
}
}
@@ -259,21 +268,31 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
// 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<DayLogWriteResult> {
: 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<DayLogWriteResult> {
? (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<DayLogWriteResult> {
(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,
};
}
@@ -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);
});
});
+15 -15
View File
@@ -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,