Files
HealthLog/tests/integration/encryption-key-rotation-blobs.test.ts
T
Marc-André Bombeck 8ad4610370 fix(backup): make rotation, concurrent writes and old rows safe for pieces
Key rotation re-sealed a single-value backup in place, which holds the
stored string, its bytes, the plaintext and the new ciphertext at once; a
copy can be over a hundred megabytes and the in-app rotation walked 25 of
them per page, enough to run a 1 GB container out of memory. Rotation now
converts each such copy into pieces under the active key instead, one row
at a time. The ~hlgcm1. stream of v1.38.6 to v1.39.1 is read a megabyte
at a time with its tag checked at the end, inside one transaction, so a
copy that fails the check is left untouched; the copy keeps its date. The
scan reads only the first characters of each value. Measured on a copy of
a 2.6 million-reading account (126 MB stored): converted in 25 s with the
heap flat at 57 MB under a 200 MB limit, where re-sealing in place ran out
of heap.

The weekly and manual backup leave an account alone while a restore of
its current copy is queued or running, and count it as skipped, not
failed. A reader whose copy is replaced mid-read answers 409
backup_changed, "replaced by a newer one while it was being read", rather
than the undecryptable error; a restore rolls back with the same code.

Pieces are sealed with a fixed associated-data label, so a value the same
key sealed for another purpose never opens as a piece; rotation re-seals
under the same label. A row holding both a single value and pieces, which
only an older release writing after a downgrade produces, is refused with
a message saying so, and the next weekly run replaces it. Rotation treats a
row deleted between its read and its write as gone rather than an error.
2026-09-26 01:49:02 +02:00

337 lines
12 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.
*
* Every single-value envelope is seeded on purpose: `encrypt(json)`, the
* `HLZ1:` gzip form and the `~hlgcm1.` stream of v1.38.6 to v1.39.1. Rotation does not
* re-seal them in place, which would hold a whole copy several times over; it
* converts each into pieces under the active key, reading the value a slice
* at a time, and the copy has to read back byte-identical.
*
* `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,
scanColumn,
type CorpusClient,
} from "@/lib/crypto/encryption-corpus";
import { packBackupBlob } from "@/lib/export/backup-blob";
import { extractKeyIdFromBytes } from "@/lib/crypto";
import {
convertSingleValueBackup,
storeBackupBlob,
} from "@/lib/export/store-backup-blob";
import { legacyStreamedBlob } from "@/__tests__/helpers/legacy-backup-blob";
import { getPrismaClient, truncateAllTables } from "./setup";
import { readStoredBackup } from "./stored-backup-read";
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", () => {
/**
* Rotation converts a single-value copy into pieces rather than re-sealing
* it in place. Re-sealing needs the whole value in memory several times
* over, and the in-app rotation runs in the same 1 GB container as
* everything else; converting reads the value a slice at a time.
*/
async function expectConverted(id: string, createdAt: Date) {
const prisma = getPrismaClient();
const row = await prisma.dataBackup.findUniqueOrThrow({ where: { id } });
expect(row.data).toBeNull();
expect(row.chunkCount).toBeGreaterThan(0);
expect(row.chunkStreamId).toMatch(/^[0-9a-f]{32}$/);
// The copy is still the one taken on that date.
expect(row.createdAt).toEqual(createdAt);
for (const piece of await prisma.dataBackupChunk.findMany({
where: { backupId: id },
})) {
expect(extractKeyIdFromBytes(Buffer.from(piece.data))).toBe("v2");
}
expect(await readStoredBackup(prisma, id)).toBe(BACKUP_JSON);
}
it("converts DataBackup.data in every single-value envelope into pieces under the active key", async () => {
const prisma = getPrismaClient();
const previous = process.env.ENCRYPTION_ACTIVE_KEY_ID;
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1";
_resetCryptoCacheForTests();
// The single stream of v1.38.6 to v1.39.1, whose key id sits behind a `~hlgcm1.`
// marker the string codec's parser does not read.
const streamed = legacyStreamedBlob(BACKUP_JSON);
process.env.ENCRYPTION_ACTIVE_KEY_ID = previous;
_resetCryptoCacheForTests();
const rows = [
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.
await prisma.dataBackup.create({
data: {
userId: TEST_USER_ID,
type: "MANUAL_UPLOAD_1",
data: underKey("v1", BACKUP_JSON),
},
}),
await prisma.dataBackup.create({
data: { userId: TEST_USER_ID, type: "MANUAL_UPLOAD_2", data: streamed },
}),
// Already under the active key: converted too, so no single value is
// left for a reader that has to hold it whole.
await prisma.dataBackup.create({
data: {
userId: TEST_USER_ID,
type: "MANUAL_UPLOAD_3",
data: underKey("v2", BACKUP_JSON, true),
},
}),
];
const result = await rotateColumn(
prisma as unknown as CorpusClient,
column("DataBackup", "data"),
);
expect(result).toMatchObject({
scanned: 4,
rotated: 4,
errors: 0,
dropped: 0,
});
// What the runbook tells the operator to do next: retire v1 entirely.
process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "2".repeat(64) });
_resetCryptoCacheForTests();
for (const row of rows) await expectConverted(row.id, row.createdAt);
// Idempotent: a second pass finds nothing left to do.
const again = await rotateColumn(
prisma as unknown as CorpusClient,
column("DataBackup", "data"),
);
expect(again).toMatchObject({ scanned: 0, rotated: 0, errors: 0 });
});
it("converts a single stream read in many small slices, the tag straddling one", async () => {
const prisma = getPrismaClient();
const json = JSON.stringify({
rows: Array.from({ length: 3_000 }, (_, i) => ({ i, r: Math.random() })),
});
const stored = legacyStreamedBlob(json);
const header = stored.indexOf(".", "~hlgcm1.".length) + 17;
const body = stored.length - header;
for (const slice of [64, 1_024, body - (body % 4) - 8]) {
const row = await prisma.dataBackup.create({
data: { userId: TEST_USER_ID, type: `MANUAL_${slice}`, data: stored },
});
expect(await convertSingleValueBackup(prisma, row.id, slice)).toBe(
"converted",
);
expect(await readStoredBackup(prisma, row.id)).toBe(json);
}
});
it("leaves a single-value copy that fails its check exactly as it was", async () => {
const prisma = getPrismaClient();
const previous = process.env.ENCRYPTION_ACTIVE_KEY_ID;
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1";
_resetCryptoCacheForTests();
const streamed = legacyStreamedBlob(BACKUP_JSON);
process.env.ENCRYPTION_ACTIVE_KEY_ID = previous;
_resetCryptoCacheForTests();
// Alter one character of the ciphertext body: the tag at the end no
// longer verifies, which the conversion only learns after the last slice.
const at = streamed.length - 40;
const altered =
streamed.slice(0, at) +
(streamed[at] === "A" ? "B" : "A") +
streamed.slice(at + 1);
const row = await prisma.dataBackup.create({
data: { userId: TEST_USER_ID, type: "WEEKLY_AUTO", data: altered },
});
const result = await rotateColumn(
prisma as unknown as CorpusClient,
column("DataBackup", "data"),
);
expect(result).toMatchObject({ scanned: 1, rotated: 0, errors: 1 });
const after = await prisma.dataBackup.findUniqueOrThrow({
where: { id: row.id },
});
expect(after.data).toBe(altered);
expect(after.chunkStreamId).toBeNull();
expect(await prisma.dataBackupChunk.count()).toBe(0);
});
it("reports single-value copies by key id without reading them whole", async () => {
const prisma = getPrismaClient();
await prisma.dataBackup.create({
data: {
userId: TEST_USER_ID,
type: "WEEKLY_AUTO",
data: underKey("v1", BACKUP_JSON, true),
},
});
const scan = await scanColumn(
prisma as unknown as CorpusClient,
column("DataBackup", "data"),
);
expect(scan).toMatchObject({ total: 1, byKeyId: { v1: 1 } });
});
it("rotates every piece of a chunked copy, which reads back once the old key is gone", async () => {
const prisma = getPrismaClient();
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1";
_resetCryptoCacheForTests();
const { id, chunks } = await storeBackupBlob(
prisma,
{ userId: TEST_USER_ID, type: "WEEKLY_AUTO" },
async (write) => {
await write('{"rows":[');
for (let i = 0; i < 20_000; i++) {
await write(`${i ? "," : ""}{"i":${i},"r":${Math.random()}}`);
}
await write("]}");
},
{ chunkBytes: 4 * 1024 },
);
expect(chunks).toBeGreaterThan(3);
const expected = await readStoredBackup(prisma, id);
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
_resetCryptoCacheForTests();
const client = {
dataBackupChunk: prisma.dataBackupChunk,
} as unknown as CorpusClient;
const result = await rotateColumn(
client,
column("DataBackupChunk", "data"),
);
expect(result).toMatchObject({
scanned: chunks,
rotated: chunks,
errors: 0,
});
const again = await rotateColumn(client, column("DataBackupChunk", "data"));
expect(again.rotated).toBe(0);
process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "2".repeat(64) });
_resetCryptoCacheForTests();
for (const piece of await prisma.dataBackupChunk.findMany()) {
expect(extractKeyIdFromBytes(Buffer.from(piece.data))).toBe("v2");
}
expect(await readStoredBackup(prisma, id)).toBe(expected);
});
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("DataBackupChunk.data");
expect(keys).toContain("IdempotencyKey.responseBody");
});
});