mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The recovery kit, "Keys the backups still need" and safeToDropRetiredKeys read only the stored copies and the off-host account objects. After a rotation the page called the old key safe to drop while every dump taken before it, the pre-migration dumps that are the way back from an update among them, could only be restored with that key: db-restore refuses a dump whose active key is missing. A dump now counts for the key its rows were written under, the manifest's active key: a dump on this server while it is kept (read from the manifests), an off-host one until the bucket's rule for database/ expires it, or indefinitely when that rule cannot be read. The nightly run records the key on its ledger row (migration 0489, database_backup_runs.active_key_id). The encryption view and the kit list the dumps per key, in every locale, and the rotation and backup runbooks say so.
220 lines
7.9 KiB
TypeScript
220 lines
7.9 KiB
TypeScript
/**
|
|
* A stored backup records the keys its content needs, and the encryption view
|
|
* says a retired key is still needed while any backup lists it.
|
|
*
|
|
* Both ends and the pipe: the copy is written by the real `storeBackupBlob`,
|
|
* the key ids are read from the JSON on its way into the pieces, and the
|
|
* admin status route reads them back. The case the view used to get wrong is
|
|
* the one asserted: every row rotated, the badge said the old key was safe to
|
|
* drop, and a backup still needed it.
|
|
*
|
|
* Mutations that must turn this red: drop the scanner from `storeBackupBlob`
|
|
* (nothing is recorded, the view has no key to name), or leave
|
|
* `retiredKeysStillNeeded` out of `safeToDropRetiredKeys` (the badge says
|
|
* safe again).
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const saved = {
|
|
key: process.env.ENCRYPTION_KEY,
|
|
keys: process.env.ENCRYPTION_KEYS,
|
|
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
|
};
|
|
|
|
function useKeys(active: string) {
|
|
process.env.ENCRYPTION_KEY = "";
|
|
process.env.ENCRYPTION_KEYS = JSON.stringify({
|
|
old: "11".repeat(32),
|
|
cur: "22".repeat(32),
|
|
});
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
|
_resetCryptoCacheForTests();
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
|
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
|
_resetCryptoCacheForTests();
|
|
});
|
|
|
|
describe("the keys stored backups still need", () => {
|
|
it("names a retired key a backup's content was written under", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: { username: "keys-admin", email: "k@example.test", role: "ADMIN" },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const { storeBackupBlob } = await import("@/lib/export/store-backup-blob");
|
|
// A copy taken before the rotation: its note was written under "old".
|
|
useKeys("old");
|
|
const note = Buffer.from(encrypt("before"), "utf8").toString("base64");
|
|
// The copy itself is stored after the rotation, so its envelope is
|
|
// sealed under "cur" and only its content needs "old".
|
|
useKeys("cur");
|
|
await storeBackupBlob(
|
|
prisma,
|
|
{ userId: admin.id, type: "MANUAL_UPLOAD_KEYS" },
|
|
async (write) => {
|
|
await write('{"userId":"x","measurements":[{"notesEncrypted":');
|
|
await write(`"${note}"}]}`);
|
|
},
|
|
);
|
|
const row = await prisma.dataBackup.findFirstOrThrow({
|
|
where: { type: "MANUAL_UPLOAD_KEYS" },
|
|
});
|
|
expect(row.innerKeyIds).toEqual(["old"]);
|
|
expect(row.innerKeyIdsRecorded).toBe(true);
|
|
|
|
const { GET } = await import("@/app/api/admin/encryption/status/route");
|
|
const res = await GET();
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()).data as {
|
|
rotationComplete: boolean;
|
|
safeToDropRetiredKeys: boolean;
|
|
backups: {
|
|
stored: Array<{ keyId: string; copies: number }>;
|
|
retiredKeysStillNeeded: string[];
|
|
};
|
|
};
|
|
expect(body.backups.stored).toEqual([
|
|
expect.objectContaining({ keyId: "old", copies: 1 }),
|
|
]);
|
|
expect(body.backups.retiredKeysStillNeeded).toEqual(["old"]);
|
|
expect(body.safeToDropRetiredKeys).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("the keys the database dumps still need", () => {
|
|
// Every row rotated and no stored copy on the old key: the view called the
|
|
// old key safe to drop while the dumps from before the rotation, the
|
|
// pre-migration one that is the way back from an update among them, could
|
|
// only be restored with it.
|
|
it("names the key a dump on this server or in the bucket was written under", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: { username: "dump-admin", email: "d@example.test", role: "ADMIN" },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs");
|
|
const { tmpdir } = await import("node:os");
|
|
const { join } = await import("node:path");
|
|
const { buildManifest, dumpBaseName } =
|
|
await import("@/lib/database-backup/dump-core.mjs");
|
|
const dir = mkdtempSync(join(tmpdir(), "hl-key-needs-"));
|
|
const savedDir = process.env.DB_BACKUP_DIR;
|
|
process.env.DB_BACKUP_DIR = dir;
|
|
try {
|
|
// A pre-migration dump taken while "old" was the active key.
|
|
useKeys("old");
|
|
const base = dumpBaseName(
|
|
"pre-migration",
|
|
new Date(Date.now() - 86_400_000),
|
|
);
|
|
writeFileSync(join(dir, `${base}.dump`), "PGDMP");
|
|
writeFileSync(
|
|
join(dir, `${base}.manifest.json`),
|
|
JSON.stringify(
|
|
buildManifest({
|
|
kind: "pre-migration",
|
|
file: `${base}.dump`,
|
|
createdAt: new Date(),
|
|
latestMigration: null,
|
|
server: { version: "18", versionNum: 180000 },
|
|
pgDump: null,
|
|
archive: { bytes: 5, sha256: "x", entries: 1 },
|
|
rowCounts: {},
|
|
env: process.env,
|
|
}),
|
|
),
|
|
);
|
|
useKeys("cur");
|
|
|
|
const { GET } = await import("@/app/api/admin/encryption/status/route");
|
|
const read = async () =>
|
|
(await (await GET()).json()).data as {
|
|
safeToDropRetiredKeys: boolean;
|
|
backups: {
|
|
dumps: Array<{ keyId: string; local: number }>;
|
|
retiredKeysStillNeeded: string[];
|
|
};
|
|
};
|
|
const local = await read();
|
|
expect(local.backups.dumps).toEqual([
|
|
expect.objectContaining({ keyId: "old", local: 1 }),
|
|
]);
|
|
expect(local.backups.retiredKeysStillNeeded).toEqual(["old"]);
|
|
expect(local.safeToDropRetiredKeys).toBe(false);
|
|
|
|
// Gone from this server; its off-host copy, written under "old", is
|
|
// still in a bucket whose rule for database/ nobody could read.
|
|
rmSync(join(dir, `${base}.dump`));
|
|
rmSync(join(dir, `${base}.manifest.json`));
|
|
await prisma.databaseBackupRun.create({
|
|
data: {
|
|
startedAt: new Date(Date.now() - 86_400_000),
|
|
finishedAt: new Date(Date.now() - 86_000_000),
|
|
status: "succeeded",
|
|
fileName: "healthlog-x-daily.dump",
|
|
localResult: "not_persistent",
|
|
offhostResult: "uploaded",
|
|
activeKeyId: "old",
|
|
},
|
|
});
|
|
const offhost = await read();
|
|
expect(offhost.backups.dumps).toEqual([
|
|
expect.objectContaining({ keyId: "old", local: 0 }),
|
|
]);
|
|
expect(offhost.backups.retiredKeysStillNeeded).toEqual(["old"]);
|
|
} finally {
|
|
process.env.DB_BACKUP_DIR = savedDir ?? "";
|
|
if (savedDir === undefined) delete process.env.DB_BACKUP_DIR;
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|