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" },