mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
7400852fb4
commit
130ace74ee
6 files changed
+181
-12
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
: {};
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user