mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge remote-tracking branch 'origin/fix/rotation-registry' into integration/post-1385
# Conflicts: # CHANGELOG.md
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user