Files
HealthLog/tests/integration/encryption-key-rotation-blobs.test.ts
T
Marc-André Bombeck 45d0fe442f fix(crypto): rotate the backup blob and stop keying the registry on names
`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.
2026-09-03 06:29:57 +02:00

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");
});
});