Files
HealthLog/tests/integration/encryption-key-rotation-blobs.test.ts
T
Marc-André Bombeck b0f9bbe530 fix(crypto): rotate the backups v1.39.x stored as one stream
A weekly or uploaded backup written by v1.39.x is a single ~hlgcm1. stream
in data_backups.data. Its key id sits behind the stream marker, which the
string codec's parser does not read, so key rotation filed every such row
as legacy, failed to decrypt it and counted it as an error. On a host that
ran v1.39.x that was every weekly copy, and the rotation never finished
clean.

Rotation now reads the key id of a streamed value and re-seals it as a
stream under the active key, without touching what it holds.
2026-09-25 23:54:34 +02:00

229 lines
8.3 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 { legacyStreamedBlob } from "@/__tests__/helpers/legacy-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("rotates the single stream v1.39.1 stored, and it stays readable", async () => {
// The `~hlgcm1.` form carries its key id behind a marker the string
// codec's parser does not read, so the walk used to file it as legacy
// and fail to decrypt it: the one copy form every v1.39.x host had was
// counted as an error on every rotation.
const prisma = getPrismaClient();
const previous = process.env.ENCRYPTION_ACTIVE_KEY_ID;
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1";
_resetCryptoCacheForTests();
const stored = legacyStreamedBlob(BACKUP_JSON);
process.env.ENCRYPTION_ACTIVE_KEY_ID = previous;
_resetCryptoCacheForTests();
const row = await prisma.dataBackup.create({
data: { userId: TEST_USER_ID, type: "WEEKLY_AUTO", data: stored },
});
const result = await rotateColumn(
{ dataBackup: prisma.dataBackup } as unknown as CorpusClient,
column("DataBackup", "data"),
);
expect(result).toMatchObject({ scanned: 1, rotated: 1, errors: 0 });
process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "2".repeat(64) });
_resetCryptoCacheForTests();
const after = await prisma.dataBackup.findUniqueOrThrow({
where: { id: row.id },
});
expect(after.data!.startsWith("~hlgcm1.v2.")).toBe(true);
expect(unpackBackupBlob(after.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");
});
});