mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
`DataBackup.data` holds every weekly disaster-recovery snapshot and every uploaded pack, AES-256-GCM at rest like everything else, under a column called `data`. The rotation registry was keyed on the `*Encrypted` naming convention, so the script never read a backup, reported zero rows remaining, and the runbook read that zero as permission to retire the previous key. Following the documented procedure destroyed every stored backup — the copy an operator reaches for precisely when nothing else works. Register it, and register the idempotent-replay response cache the same sweep turned up. The backup column walks in bounded batches (one row is a whole compressed account) and re-seals the ciphertext without reading the plaintext, so both stored envelopes come back byte-identical and so will any later one. The cache is marked disposable: a row it cannot read is dropped rather than counted as an error, because keeping it only guarantees the next replay gets a body it cannot parse. Running the script against a seeded database rather than reading it turned up two more. It died on the mood day-context note, whose table is keyed on the entry rather than on an `id`, and abandoned every column after it — the backups included. And the document index's verbatim text rotated without ever being named in the summary. Make the naming assumption stop carrying weight. A new guard traces the encryption helpers through their wrappers, walks every Prisma write payload, and fails when a column receives ciphertext without being registered. It asserts an anchored, non-zero match count so a matcher that stops matching fails instead of passing. A second check pins each model's primary key against the schema. The script now ends by naming the registered columns it did NOT walk and exiting non-zero when any is missing: a zero that means "nothing left" and a zero that means "never looked" must not print the same. The runbook's retire-the-key step was rewritten around that signal, and tells an operator who already rotated on an older release how to get their backups back.
197 lines
6.9 KiB
TypeScript
197 lines
6.9 KiB
TypeScript
/**
|
|
* Key rotation over the two ciphertext columns that carry no `*Encrypted` in
|
|
* their name, against real Postgres.
|
|
*
|
|
* `DataBackup.data` is the one that matters. It holds the whole account,
|
|
* compressed then encrypted, and it sat outside the rotation registry until
|
|
* v1.38.6 — the script reported zero rows remaining because it never looked,
|
|
* and the runbook reads a zero as permission to retire the previous key. This
|
|
* pins the fix at the level the bug lived at: seed real rows under an old key,
|
|
* rotate, and read the backup back.
|
|
*
|
|
* Both stored envelopes are seeded on purpose. A row written before
|
|
* `packBackupBlob` existed is `encrypt(json)`; one written after is the
|
|
* `HLZ1:`-prefixed gzip form. Rotation re-seals the ciphertext without ever
|
|
* looking at the plaintext, so both come back byte-identical — and so will
|
|
* whatever envelope a later writer introduces.
|
|
*
|
|
* `IdempotencyKey.responseBody` rides along as the disposable case: a row
|
|
* under a key the deployment no longer configures is DELETED rather than
|
|
* counted as an error, because the value is a 24h cache entry and leaving it
|
|
* behind only guarantees the next replay gets a body it cannot parse.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEYS = JSON.stringify({
|
|
v1: "1".repeat(64),
|
|
v2: "2".repeat(64),
|
|
});
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1";
|
|
delete process.env.ENCRYPTION_KEY;
|
|
|
|
import { _resetCryptoCacheForTests, encrypt, extractKeyId } from "@/lib/crypto";
|
|
import {
|
|
ENCRYPTED_COLUMNS,
|
|
encryptedColumnKey,
|
|
type EncryptedColumn,
|
|
} from "@/lib/crypto/encrypted-columns";
|
|
import {
|
|
rotateColumn,
|
|
type CorpusClient,
|
|
} from "@/lib/crypto/encryption-corpus";
|
|
import { packBackupBlob, unpackBackupBlob } from "@/lib/export/backup-blob";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const TEST_USER_ID = "user-rotation-blobs";
|
|
const BACKUP_JSON = JSON.stringify({
|
|
schemaVersion: 7,
|
|
measurements: [{ type: "WEIGHT", value: 80.4, unit: "kg" }],
|
|
note: "üäö — a non-ASCII payload, so a mangled round-trip shows up",
|
|
});
|
|
|
|
function column(model: string, field: string): EncryptedColumn {
|
|
const col = ENCRYPTED_COLUMNS.find(
|
|
(c) => c.model === model && c.field === field,
|
|
);
|
|
if (!col) throw new Error(`${model}.${field} is not registered`);
|
|
return col;
|
|
}
|
|
|
|
/** Encrypt while `keyId` is active, then restore the caller's active id. */
|
|
function underKey(keyId: string, plaintext: string, pack = false): string {
|
|
const previous = process.env.ENCRYPTION_ACTIVE_KEY_ID;
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
|
|
_resetCryptoCacheForTests();
|
|
const out = pack ? packBackupBlob(plaintext) : encrypt(plaintext);
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = previous;
|
|
_resetCryptoCacheForTests();
|
|
return out;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
process.env.ENCRYPTION_KEYS = JSON.stringify({
|
|
v1: "1".repeat(64),
|
|
v2: "2".repeat(64),
|
|
});
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
|
_resetCryptoCacheForTests();
|
|
await truncateAllTables(getPrismaClient());
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: TEST_USER_ID,
|
|
username: "rotation-blobs",
|
|
email: "rotation-blobs@example.test",
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("key rotation over the non-suffixed ciphertext columns", () => {
|
|
it("rotates DataBackup.data in both stored envelopes and keeps it readable", async () => {
|
|
const prisma = getPrismaClient();
|
|
const gzipped = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
type: "WEEKLY_AUTO",
|
|
data: underKey("v1", BACKUP_JSON, true),
|
|
},
|
|
});
|
|
// The pre-envelope shape: `encrypt(json)` with no `HLZ1:` marker.
|
|
const plainEnvelope = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
type: "MANUAL_UPLOAD_1",
|
|
data: underKey("v1", BACKUP_JSON),
|
|
},
|
|
});
|
|
|
|
const result = await rotateColumn(
|
|
{ dataBackup: prisma.dataBackup } as unknown as CorpusClient,
|
|
column("DataBackup", "data"),
|
|
);
|
|
expect(result.scanned).toBe(2);
|
|
expect(result.rotated).toBe(2);
|
|
expect(result.errors).toBe(0);
|
|
expect(result.dropped).toBe(0);
|
|
|
|
for (const id of [gzipped.id, plainEnvelope.id]) {
|
|
const row = await prisma.dataBackup.findUniqueOrThrow({ where: { id } });
|
|
expect(extractKeyId(row.data)).toBe("v2");
|
|
expect(unpackBackupBlob(row.data)).toBe(BACKUP_JSON);
|
|
}
|
|
|
|
// Idempotent: a second pass finds nothing left to do.
|
|
const again = await rotateColumn(
|
|
{ dataBackup: prisma.dataBackup } as unknown as CorpusClient,
|
|
column("DataBackup", "data"),
|
|
);
|
|
expect(again.scanned).toBe(2);
|
|
expect(again.rotated).toBe(0);
|
|
});
|
|
|
|
it("survives dropping the retired key once rotation has run", async () => {
|
|
const prisma = getPrismaClient();
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
type: "WEEKLY_AUTO",
|
|
data: underKey("v1", BACKUP_JSON, true),
|
|
},
|
|
});
|
|
await rotateColumn(
|
|
{ dataBackup: prisma.dataBackup } as unknown as CorpusClient,
|
|
column("DataBackup", "data"),
|
|
);
|
|
|
|
// What the runbook tells the operator to do next: retire v1 entirely.
|
|
process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "2".repeat(64) });
|
|
_resetCryptoCacheForTests();
|
|
|
|
const row = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: backup.id },
|
|
});
|
|
expect(unpackBackupBlob(row.data)).toBe(BACKUP_JSON);
|
|
});
|
|
|
|
it("drops an unreadable idempotency row instead of failing the run", async () => {
|
|
const prisma = getPrismaClient();
|
|
const seed = async (key: string, body: string) =>
|
|
prisma.idempotencyKey.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
key,
|
|
method: "POST",
|
|
path: "/api/measurements",
|
|
responseStatus: 201,
|
|
responseBody: body,
|
|
expiresAt: new Date(Date.now() + 86_400_000),
|
|
},
|
|
});
|
|
const readable = await seed("k-readable", underKey("v1", '{"data":1}'));
|
|
// A key id the deployment no longer configures: fail-closed on decrypt.
|
|
const unreadable = await seed("k-unreadable", "v9.QUJDREVGR0hJSktM");
|
|
|
|
const result = await rotateColumn(
|
|
{ idempotencyKey: prisma.idempotencyKey } as unknown as CorpusClient,
|
|
column("IdempotencyKey", "responseBody"),
|
|
);
|
|
expect(result.rotated).toBe(1);
|
|
expect(result.dropped).toBe(1);
|
|
expect(result.errors).toBe(0);
|
|
|
|
const kept = await prisma.idempotencyKey.findUnique({
|
|
where: { id: readable.id },
|
|
});
|
|
expect(extractKeyId(kept!.responseBody)).toBe("v2");
|
|
expect(
|
|
await prisma.idempotencyKey.findUnique({ where: { id: unreadable.id } }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("keeps both columns in the registry the script and the job read", () => {
|
|
const keys = ENCRYPTED_COLUMNS.map(encryptedColumnKey);
|
|
expect(keys).toContain("DataBackup.data");
|
|
expect(keys).toContain("IdempotencyKey.responseBody");
|
|
});
|
|
});
|