Merge remote-tracking branch 'origin/fix/rotation-registry' into integration/post-1385

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 06:35:38 +02:00
commit 06fe87f5a3
11 files changed
+1137 -84

No files matched your search

@@ -0,0 +1,196 @@
/**
* 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");
});
});