From 130ace74ee6053f77fef88310a666f1b24f1edf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 13:07:22 +0200 Subject: [PATCH] fix(crypto): keep updatedAt when key rotation re-seals a value Key rotation rewrote every ciphertext column through a plain Prisma update, in the in-app corpus walk and in the rotation script alike, so a rotation stamped the run time on every encrypted row: the Coach panel then listed old conversations as recent, and every client re-pulled measurements and mood entries whose sync cursor had moved. Both now read each row's updatedAt and write it back, matching on it, so a row edited since the read is left for the next run instead of being stamped. The models that carry the column are listed beside the registry and checked against the schema, and the updatedAt guard now covers the rotation writes. --- scripts/rotate-encryption-key.ts | 51 +++++++++++++-- ...ackfill-preserves-updated-at-guard.test.ts | 36 ++++++++++ .../__tests__/encrypted-columns.test.ts | 15 +++++ src/lib/crypto/encrypted-columns.ts | 65 +++++++++++++++++++ src/lib/crypto/encryption-corpus.ts | 21 ++++-- ...ion-key-rotation-contact-and-route.test.ts | 5 ++ 6 files changed, 181 insertions(+), 12 deletions(-) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 84fc95aef..ffc137f30 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -39,6 +39,8 @@ import { import { ENCRYPTED_COLUMNS, encryptedColumnKey, + preserveUpdatedAt, + UPDATED_AT_MODELS, type EncryptedColumn, } from "@/lib/crypto/encrypted-columns"; import { tokeniseAndHash } from "@/lib/documents/content-index"; @@ -106,6 +108,15 @@ async function rotateRegistryColumn( }; } +/** True for a Prisma "record to update not found" (P2025). */ +function isRowGone(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + (err as { code?: unknown }).code === "P2025" + ); +} + function shouldRotate(value: string | null): boolean { if (!value) return false; const id = extractKeyId(value); @@ -122,8 +133,8 @@ interface PagedDelegate { where?: { id: { gt: string } }; }) => Promise>>; update: (args: { - where: { id: string }; - data: Record; + where: { id: string; updatedAt?: Date }; + data: Record; }) => Promise; } @@ -139,11 +150,16 @@ const PAGE_SIZE = 5_000; async function* pagedRows( delegate: PagedDelegate, field: string, + table: string, ): AsyncGenerator> { let cursor: string | null = null; for (;;) { const rows = await delegate.findMany({ - select: { id: true, [field]: true }, + select: { + id: true, + [field]: true, + ...(UPDATED_AT_MODELS.has(table) ? { updatedAt: true } : {}), + }, orderBy: { id: "asc" }, take: PAGE_SIZE, // `id > last`, not Prisma's `cursor` + `skip`: the cursor form looks @@ -178,6 +194,7 @@ async function rotateStringColumn( for await (const row of pagedRows( delegate as PagedDelegate, field, + table, )) { const v = row[field] as string | null; // `scanned` counts rows that hold ciphertext, as the in-app rotation does. @@ -187,9 +204,16 @@ async function rotateStringColumn( const id = row.id as string; try { const re = encrypt(decrypt(v)); - await delegate.update({ where: { id }, data: { [field]: re } }); + // Re-sealing is not an edit: the row keeps its own updatedAt. + const keepUpdatedAt = preserveUpdatedAt(table, row.updatedAt); + await delegate.update({ + where: { id, ...keepUpdatedAt }, + data: { [field]: re, ...keepUpdatedAt }, + }); result.rotated++; } catch (err) { + // Edited or deleted since the read: the next run picks it up. + if (isRowGone(err)) continue; result.errors++; console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`); } @@ -218,6 +242,7 @@ async function rotateBytesColumn( for await (const row of pagedRows( delegate as PagedDelegate, field, + table, )) { const buf = row[field] as Uint8Array | null; if (!buf || buf.byteLength === 0) continue; @@ -230,9 +255,14 @@ async function rotateBytesColumn( const encoded = Buffer.from(rotated, "utf8"); const next = new Uint8Array(new ArrayBuffer(encoded.byteLength)); next.set(encoded); - await delegate.update({ where: { id }, data: { [field]: next } }); + const keepUpdatedAt = preserveUpdatedAt(table, row.updatedAt); + await delegate.update({ + where: { id, ...keepUpdatedAt }, + data: { [field]: next, ...keepUpdatedAt }, + }); result.rotated++; } catch (err) { + if (isRowGone(err)) continue; result.errors++; console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`); } @@ -898,7 +928,12 @@ async function main() { }; for (;;) { const rows = await prisma.documentContentIndex.findMany({ - select: { id: true, textEncrypted: true, verbatimTextEncrypted: true }, + select: { + id: true, + textEncrypted: true, + verbatimTextEncrypted: true, + updatedAt: true, + }, orderBy: { id: "asc" }, take: 100, ...(cursor ? { where: { id: { gt: cursor } } } : {}), @@ -924,8 +959,10 @@ async function main() { ? reEncryptBytes(Buffer.from(verbatimBuf!).toString("utf8")) : undefined; await prisma.documentContentIndex.update({ - where: { id: row.id }, + // Re-sealing is not an edit: the row keeps its own updatedAt. + where: { id: row.id, updatedAt: row.updatedAt }, data: { + updatedAt: row.updatedAt, textEncrypted: nextBytes, searchTokens, ...(verbatimNext ? { verbatimTextEncrypted: verbatimNext } : {}), diff --git a/src/__tests__/backfill-preserves-updated-at-guard.test.ts b/src/__tests__/backfill-preserves-updated-at-guard.test.ts index 41a39bdf4..9ee7b02ec 100644 --- a/src/__tests__/backfill-preserves-updated-at-guard.test.ts +++ b/src/__tests__/backfill-preserves-updated-at-guard.test.ts @@ -189,3 +189,39 @@ describe("coach conversations move only on activity", () => { ).toEqual([]); }); }); + +/** + * Key rotation re-seals values in place across every encrypted model, through + * generic delegates the model-named matcher above cannot see. Every write it + * makes names `updatedAt`, carrying the row's own value where the model has + * one; re-sealing under a new key is not an edit. + */ +describe("key rotation keeps updatedAt", () => { + const ROTATION_FILES = [ + "src/lib/crypto/encryption-corpus.ts", + "scripts/rotate-encryption-key.ts", + ]; + const ANY_WRITE_RE = /\.\s*(update|updateMany|upsert)\s*\(/g; + + const calls = ROTATION_FILES.flatMap((file) => { + const source = readFileSync(join(ROOT, file), "utf8"); + return [...source.matchAll(ANY_WRITE_RE)].map((m) => ({ + file, + args: callArgs(source, (m.index ?? 0) + m[0].length - 1), + })); + }); + + it("finds the rotation writes at all", () => { + expect(calls.length).toBeGreaterThanOrEqual(4); + }); + + it("names updatedAt in every rotation write", () => { + const offenders = calls + .filter((c) => !/updatedAt/i.test(c.args)) + .map((c) => `${c.file}: ${c.args.slice(0, 80).replace(/\s+/g, " ")}`); + expect( + offenders, + "a rotation write must carry the row's own updatedAt", + ).toEqual([]); + }); +}); diff --git a/src/lib/crypto/__tests__/encrypted-columns.test.ts b/src/lib/crypto/__tests__/encrypted-columns.test.ts index 62ad11d73..b4641d026 100644 --- a/src/lib/crypto/__tests__/encrypted-columns.test.ts +++ b/src/lib/crypto/__tests__/encrypted-columns.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { ENCRYPTED_COLUMNS, encryptedColumnKey, + UPDATED_AT_MODELS, type EncryptedColumn, } from "../encrypted-columns"; @@ -224,3 +225,17 @@ describe("encrypted-column registry", () => { ).toEqual([]); }); }); + +describe("UPDATED_AT_MODELS", () => { + it("lists exactly the registry models that carry @updatedAt in the schema", () => { + const schema = readFileSync(SCHEMA_PATH, "utf8"); + const stamped = new Set(); + for (const m of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) { + if (/@updatedAt\b/.test(m[2])) stamped.add(m[1]); + } + const registry = [...new Set(ENCRYPTED_COLUMNS.map((c) => c.model))]; + const expected = registry.filter((m) => stamped.has(m)).sort(); + expect(expected.length).toBeGreaterThan(20); + expect([...UPDATED_AT_MODELS].sort()).toEqual(expected); + }); +}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 7432a0752..a1f399db3 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -527,3 +527,68 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ export function encryptedColumnKey(c: EncryptedColumn): string { return `${c.model}.${c.field}`; } + +/** + * The registry models that carry a Prisma `@updatedAt` column. Re-sealing a + * value under a new key is not an edit, so the rotation carries each row's + * own `updatedAt` through the write instead of letting Prisma stamp the run + * time: the Coach panel orders conversations by it and it is the sync cursor + * for measurements and mood entries. `encrypted-columns.test.ts` derives the + * set from `schema.prisma` and fails when it drifts. + */ +export const UPDATED_AT_MODELS: ReadonlySet = new Set([ + "Allergy", + "Biomarker", + "CoachConversation", + "CoachFact", + "CoachPlan", + "CoachReminder", + "CycleDayLog", + "DocumentContentIndex", + "DocumentSourceConnection", + "DocumentThumbnail", + "EcgRecording", + "Encounter", + "ExtractedFact", + "FamilyHistoryEntry", + "FitbitConnection", + "GoogleHealthConnection", + "IllnessDayLog", + "IllnessEpisode", + "InboundDocument", + "InsightNarrative", + "InsightStatusCache", + "IntegrationStatus", + "LabResult", + "Measurement", + "MedicationCategoryLabel", + "MedicationCourse", + "MedicationInventoryItem", + "MentalHealthAssessment", + "MoodContext", + "MoodEntry", + "NotificationChannel", + "Practitioner", + "SymptomDefinition", + "SymptomEvent", + "User", + "UserHealthProfile", + "VaccinationRecord", + "WhoopConnection", + "WithingsConnection", +]); + +/** + * The `where` filter and `data` fields that keep a row's `updatedAt` through + * a storage-only write: match on the value read (a row edited meanwhile is + * left for the next run, as Prisma's P2025) and write it back unchanged. + * Empty for a model without the column. + */ +export function preserveUpdatedAt( + model: string, + updatedAt: unknown, +): { updatedAt?: Date } { + return UPDATED_AT_MODELS.has(model) && updatedAt instanceof Date + ? { updatedAt } + : {}; +} diff --git a/src/lib/crypto/encryption-corpus.ts b/src/lib/crypto/encryption-corpus.ts index c55240731..3f69a8054 100644 --- a/src/lib/crypto/encryption-corpus.ts +++ b/src/lib/crypto/encryption-corpus.ts @@ -48,6 +48,8 @@ import { import { ENCRYPTED_COLUMNS, type EncryptedColumn, + preserveUpdatedAt, + UPDATED_AT_MODELS, } from "@/lib/crypto/encrypted-columns"; /** Sentinel bucket for legacy (unversioned) ciphertext under `byKeyId`. */ @@ -79,7 +81,7 @@ interface ColumnDelegate { where?: Record; }) => Promise>>; update: (args: { - where: Record; + where: Record; data: Record; }) => Promise; /** Only ever called for a `disposable` column's unreadable rows. */ @@ -183,6 +185,8 @@ async function walkColumn( id: string; value: unknown; codec: string | null; + /** The row's `updatedAt`, for a model that has one; else undefined. */ + updatedAt: unknown; }) => Promise | void, ): Promise { const codecField = col.codecField; @@ -197,6 +201,7 @@ async function walkColumn( [pk]: true, [col.field]: true, ...(codecField ? { [codecField]: true } : {}), + ...(UPDATED_AT_MODELS.has(col.model) ? { updatedAt: true } : {}), }, orderBy: { [pk]: "asc" }, take: batchSize, @@ -214,6 +219,7 @@ async function walkColumn( id: row[pk] as string, value, codec: codecField ? String(row[codecField] ?? "") : (col.codec ?? null), + updatedAt: row.updatedAt, }); } cursor = rows[rows.length - 1]![pk] as string; @@ -467,7 +473,7 @@ export async function rotateColumn( // interrupted run resumes cleanly on the next invocation. `scanned` counts // the rows that hold ciphertext, whatever the column. try { - await walkColumn(delegate, col, async ({ id, value, codec }) => { + await walkColumn(delegate, col, async ({ id, value, codec, updatedAt }) => { if (shouldStop()) throw new RotationStopped(); result.scanned += 1; if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return; @@ -481,14 +487,19 @@ export async function rotateColumn( encrypt(decrypt(toCiphertext(value, col.kind)!)), col.kind, ); + // Re-sealing is not an edit: the row keeps its own `updatedAt`, and + // the write matches on it, so a row edited since the read is left for + // the next run rather than stamped with the run time. + const keepUpdatedAt = preserveUpdatedAt(col.model, updatedAt); await delegate.update({ - where: { [pkField(col)]: id }, - data: { [col.field]: next }, + where: { [pkField(col)]: id, ...keepUpdatedAt }, + data: { [col.field]: next, ...keepUpdatedAt }, }); result.rotated += 1; } catch (err) { // Deleted between the read and the write (a backup replacing its - // pieces, an account going): nothing is left to rotate. + // pieces, an account going), or edited since the read: nothing to + // rotate now; an edited row is picked up by the next run. if (isRowGone(err)) return; await onUnreadable(id); } diff --git a/tests/integration/encryption-key-rotation-contact-and-route.test.ts b/tests/integration/encryption-key-rotation-contact-and-route.test.ts index 30810105f..5c5cd81a1 100644 --- a/tests/integration/encryption-key-rotation-contact-and-route.test.ts +++ b/tests/integration/encryption-key-rotation-contact-and-route.test.ts @@ -86,6 +86,7 @@ describe("key rotation over the v1.39.4 columns", () => { name: "Praxis Nord", phoneEncrypted: encryptNote("+49 30 1234567"), locationEncrypted: encryptNote("Hauptstr. 1"), + updatedAt: new Date("2026-05-04T10:00:00.000Z"), }, }); const startedAt = new Date("2026-09-01T06:00:00.000Z"); @@ -136,6 +137,10 @@ describe("key rotation over the v1.39.4 columns", () => { } expect(readNote(practitioner.phoneEncrypted, null)).toBe("+49 30 1234567"); expect(readNote(practitioner.locationEncrypted, null)).toBe("Hauptstr. 1"); + // Re-sealing under a new key is not an edit: the row keeps its stamp. + expect(practitioner.updatedAt).toEqual( + new Date("2026-05-04T10:00:00.000Z"), + ); const route = await prisma.workoutRoute.findUniqueOrThrow({ where: { id: "rt-1" },