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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 13:07:22 +02:00
1 parent 7400852fb4
commit 130ace74ee
6 files changed
+181 -12

No files matched your search

+44 -7
View File
@@ -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<V> {
where?: { id: { gt: string } };
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
data: Record<string, V>;
where: { id: string; updatedAt?: Date };
data: Record<string, V | Date>;
}) => Promise<unknown>;
}
@@ -139,11 +150,16 @@ const PAGE_SIZE = 5_000;
async function* pagedRows(
delegate: PagedDelegate<unknown>,
field: string,
table: string,
): AsyncGenerator<Record<string, unknown>> {
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<unknown>,
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<unknown>,
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 } : {}),
@@ -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([]);
});
});
@@ -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<string>();
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);
});
});
+65
View File
@@ -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<string> = 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 }
: {};
}
+16 -5
View File
@@ -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<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: Record<string, string>;
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
/** 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> | void,
): Promise<void> {
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);
}
@@ -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" },