mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(admin): replace the key backup step with a recovery kit
The step confirmed one key, the active application key. A restore needs more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy written before a rotation needs the retired application key it was written under, and every session, token and share link hangs off API_TOKEN_HMAC_KEY. An operator could do everything the step asked and still be unable to read a single backup. The recovery kit lists every key a restore of this server needs, by id and fingerprint, never key material: - each id in ENCRYPTION_KEYS, plus any retired id a stored copy (innerKeyIds) or an off-host object inside the bucket's retention (OffhostBackupKeyUse) still lists, also once the server dropped it; - BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry, named by the id their objects record; - API_TOKEN_HMAC_KEY. Confirming records the set the admin was shown and refuses a stale one with 409 recovery.kit.stale. The kit is due again as soon as a key it lists is not in that set (a rotation, a new backup key, a re-keyed install); a key that merely drops out does not make it due. "Check my copy" now checks any key in the kit. Migration 0487 carries a confirmation given under the old step forward as a kit holding that key, so that part stays confirmed, and drops the four app_settings columns. It also creates the two tables the readiness evaluation uses (restore_drill_outcomes, recovery_readiness_notices). The routes move to /api/admin/recovery-kit; the reader of the keys the backups still need moves out of the encryption status route into src/lib/recovery/backup-key-needs.ts so the kit and the status view share one definition.
This commit is contained in:
1 parent
953c64f7a4
commit
d3c672cc3f
56 files changed
+3042
-1611
No files matched your search
@@ -54,10 +54,6 @@
|
||||
},
|
||||
"documentMaxFileBytes": 3,
|
||||
"documentQuotaBytes": "5",
|
||||
"encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z",
|
||||
"encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId",
|
||||
"encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint",
|
||||
"encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId",
|
||||
"correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z",
|
||||
"pulseDayMeanMethod": "hour_mean_v1",
|
||||
"pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z",
|
||||
@@ -2504,10 +2500,6 @@
|
||||
"aiUsageMaxLevel": "FRUGAL",
|
||||
"aiUsageDefaultLevel": "FRUGAL",
|
||||
"aiSyncRegenGapHours": 3,
|
||||
"encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z",
|
||||
"encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId",
|
||||
"encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint",
|
||||
"encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId",
|
||||
"correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z",
|
||||
"pulseDayMeanMethod": "hour_mean_v1",
|
||||
"pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z",
|
||||
|
||||
@@ -2902,10 +2902,6 @@
|
||||
},
|
||||
"documentMaxFileBytes": 3,
|
||||
"documentQuotaBytes": "bigint:5",
|
||||
"encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z",
|
||||
"encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId",
|
||||
"encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint",
|
||||
"encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId",
|
||||
"correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z",
|
||||
"pulseDayMeanMethod": "hour_mean_v1",
|
||||
"pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z",
|
||||
@@ -2970,10 +2966,6 @@
|
||||
},
|
||||
"documentMaxFileBytes": 3,
|
||||
"documentQuotaBytes": "5",
|
||||
"encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z",
|
||||
"encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId",
|
||||
"encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint",
|
||||
"encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId",
|
||||
"correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z",
|
||||
"pulseDayMeanMethod": "hour_mean_v1",
|
||||
"pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z",
|
||||
|
||||
@@ -2396,10 +2396,6 @@
|
||||
},
|
||||
"documentMaxFileBytes": 3,
|
||||
"documentQuotaBytes": "bigint:5",
|
||||
"encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z",
|
||||
"encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId",
|
||||
"encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint",
|
||||
"encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId",
|
||||
"correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z",
|
||||
"pulseDayMeanMethod": "hour_mean_v1",
|
||||
"pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z",
|
||||
|
||||
@@ -1004,10 +1004,6 @@
|
||||
},
|
||||
"documentMaxFileBytes": 10000000,
|
||||
"documentQuotaBytes": "bigint:500000000",
|
||||
"encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z",
|
||||
"encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId",
|
||||
"encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint",
|
||||
"encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId",
|
||||
"correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z",
|
||||
"pulseDayMeanMethod": "hour_mean_v1",
|
||||
"pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z",
|
||||
|
||||
@@ -1137,10 +1137,6 @@
|
||||
},
|
||||
"documentMaxFileBytes": 10000000,
|
||||
"documentQuotaBytes": "bigint:500000000",
|
||||
"encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z",
|
||||
"encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId",
|
||||
"encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint",
|
||||
"encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId",
|
||||
"correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z",
|
||||
"pulseDayMeanMethod": "hour_mean_v1",
|
||||
"pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z",
|
||||
@@ -1203,10 +1199,6 @@
|
||||
},
|
||||
"documentMaxFileBytes": 10000000,
|
||||
"documentQuotaBytes": "500000000",
|
||||
"encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z",
|
||||
"encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId",
|
||||
"encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint",
|
||||
"encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId",
|
||||
"correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z",
|
||||
"pulseDayMeanMethod": "hour_mean_v1",
|
||||
"pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z",
|
||||
|
||||
@@ -2515,10 +2515,6 @@
|
||||
},
|
||||
"documentMaxFileBytes": 3,
|
||||
"documentQuotaBytes": "bigint:5",
|
||||
"encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z",
|
||||
"encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId",
|
||||
"encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint",
|
||||
"encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId",
|
||||
"correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z",
|
||||
"pulseDayMeanMethod": "hour_mean_v1",
|
||||
"pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* The boot encryption key check and the "Back up your encryption key" step,
|
||||
* against real Postgres.
|
||||
* The boot encryption key check, against real Postgres.
|
||||
*
|
||||
* - a first boot writes a canary; a second boot with the same key opens it;
|
||||
* - a boot with another key under the same id refuses: the state is set,
|
||||
@@ -9,9 +8,9 @@
|
||||
* - a database written before the canary existed is probed: data that does
|
||||
* not open under the configured key refuses rather than sealing the wrong
|
||||
* key as the right one;
|
||||
* - the confirmation binds to the active key id and fingerprint, refuses a
|
||||
* stale one with 409, and is due again after a re-keyed install;
|
||||
* - "Check my copy" answers a boolean, stores nothing, and is rate limited.
|
||||
*
|
||||
* The recovery kit that replaced the key backup step is pinned in
|
||||
* `recovery-kit.test.ts`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -83,7 +82,6 @@ import {
|
||||
_resetCryptoCacheForTests,
|
||||
encrypt,
|
||||
encryptUnderKeyId,
|
||||
getKeyFingerprint,
|
||||
} from "@/lib/crypto";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { runBootKeyCheck } from "@/lib/boot/key-check";
|
||||
@@ -145,7 +143,6 @@ beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.$executeRaw`DELETE FROM encryption_key_canaries`;
|
||||
await prisma.$executeRaw`DELETE FROM rate_limits WHERE key LIKE 'key-backup-verify:%'`;
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
useKey(KEY_A);
|
||||
@@ -185,10 +182,10 @@ describe("boot encryption key check (real Postgres)", () => {
|
||||
expect(await prisma.encryptionKeyCanary.findMany()).toEqual(before);
|
||||
|
||||
await seedAdmin();
|
||||
const { GET: keyBackupGet } =
|
||||
await import("@/app/api/admin/encryption/key-backup/route");
|
||||
const refused = await asRoute(keyBackupGet)(
|
||||
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
|
||||
const { GET: recoveryKitGet } =
|
||||
await import("@/app/api/admin/recovery-kit/route");
|
||||
const refused = await asRoute(recoveryKitGet)(
|
||||
new NextRequest("http://localhost/api/admin/recovery-kit"),
|
||||
);
|
||||
expect(refused.status).toBe(503);
|
||||
const body = await refused.json();
|
||||
@@ -526,10 +523,10 @@ describe("ENCRYPTION_KEY_CHECK (real Postgres)", () => {
|
||||
|
||||
// Requests are served, through apiHandler and outside it.
|
||||
await seedAdmin();
|
||||
const { GET: keyBackupGet } =
|
||||
await import("@/app/api/admin/encryption/key-backup/route");
|
||||
const served = await asRoute(keyBackupGet)(
|
||||
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
|
||||
const { GET: recoveryKitGet } =
|
||||
await import("@/app/api/admin/recovery-kit/route");
|
||||
const served = await asRoute(recoveryKitGet)(
|
||||
new NextRequest("http://localhost/api/admin/recovery-kit"),
|
||||
);
|
||||
expect(served.status).toBe(200);
|
||||
const { POST: mcp } = await import("@/app/mcp/route");
|
||||
@@ -707,106 +704,3 @@ describe("routes outside apiHandler refuse while the key does not match", () =>
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryption key backup step (real Postgres)", () => {
|
||||
it("is due, refuses a stale confirmation, records the right one, and is due again after a re-key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const adminId = await seedAdmin();
|
||||
const { GET } = await import("@/app/api/admin/encryption/key-backup/route");
|
||||
const { POST: confirm } =
|
||||
await import("@/app/api/admin/encryption/key-backup/confirm/route");
|
||||
const status = async () =>
|
||||
(
|
||||
await (
|
||||
await asRoute(GET)(
|
||||
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
|
||||
)
|
||||
).json()
|
||||
).data;
|
||||
|
||||
const first = await status();
|
||||
expect(first.due).toBe(true);
|
||||
expect(first.activeKeyId).toBe("v1");
|
||||
expect(first.fingerprint).toBe(getKeyFingerprint("v1"));
|
||||
expect(first.platformHint).toBe("compose");
|
||||
expect(JSON.stringify(first)).not.toContain(KEY_A);
|
||||
|
||||
const stale = await confirm(
|
||||
post("/api/admin/encryption/key-backup/confirm", {
|
||||
keyId: "v1",
|
||||
fingerprint: "000000000000",
|
||||
}),
|
||||
);
|
||||
expect(stale.status).toBe(409);
|
||||
expect((await stale.json()).meta.errorCode).toBe(
|
||||
"encryption.keyBackup.stale",
|
||||
);
|
||||
expect(
|
||||
(await prisma.appSettings.findUnique({ where: { id: "singleton" } }))
|
||||
?.encryptionKeyBackupConfirmedAt ?? null,
|
||||
).toBeNull();
|
||||
|
||||
const ok = await confirm(
|
||||
post("/api/admin/encryption/key-backup/confirm", {
|
||||
keyId: "v1",
|
||||
fingerprint: first.fingerprint,
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
const confirmed = (await ok.json()).data;
|
||||
expect(confirmed.due).toBe(false);
|
||||
expect(confirmed.confirmedBy).toEqual({
|
||||
id: adminId,
|
||||
email: "key-admin@example.test",
|
||||
});
|
||||
const audit = await prisma.auditLog.findFirst({
|
||||
where: { action: "encryption.keyBackup.confirmed" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
|
||||
vi.stubEnv("HEALTHLOG_PLATFORM", "truenas");
|
||||
useKey(KEY_B);
|
||||
const after = await status();
|
||||
expect(after.due).toBe(true);
|
||||
expect(after.confirmedKeyId).toBe("v1");
|
||||
expect(after.platformHint).toBe("truenas");
|
||||
});
|
||||
|
||||
it("checks a copy without storing it, and limits the checks", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdmin();
|
||||
const { POST: verify } =
|
||||
await import("@/app/api/admin/encryption/key-backup/verify/route");
|
||||
const check = (encryptionKey: string) =>
|
||||
verify(
|
||||
post("/api/admin/encryption/key-backup/verify", { encryptionKey }),
|
||||
);
|
||||
|
||||
const good = await check(`${KEY_A}\n`);
|
||||
expect(good.status).toBe(200);
|
||||
expect((await good.json()).data).toEqual({ matches: true, keyId: "v1" });
|
||||
|
||||
const bad = await check(KEY_B);
|
||||
expect((await bad.json()).data.matches).toBe(false);
|
||||
|
||||
const invalid = await verify(
|
||||
post("/api/admin/encryption/key-backup/verify", { encryptionKey: "" }),
|
||||
);
|
||||
expect(invalid.status).toBe(422);
|
||||
expect(JSON.stringify(await invalid.json())).not.toContain("issues");
|
||||
|
||||
// Nothing of the candidate is stored anywhere a row could hold it.
|
||||
const audits = await prisma.auditLog.findMany();
|
||||
expect(JSON.stringify(audits)).not.toContain(KEY_A);
|
||||
expect(JSON.stringify(audits)).not.toContain(KEY_B);
|
||||
expect(
|
||||
(await prisma.appSettings.findUnique({ where: { id: "singleton" } }))
|
||||
?.encryptionKeyBackupConfirmedAt ?? null,
|
||||
).toBeNull();
|
||||
|
||||
await check(KEY_B);
|
||||
await check(KEY_B);
|
||||
const limited = await check(KEY_A);
|
||||
expect(limited.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* The recovery kit against real Postgres, through its routes.
|
||||
*
|
||||
* - the kit lists the application key, the token key and, when set, the
|
||||
* off-host backup key, by id and fingerprint and never key material;
|
||||
* - a confirmation of a kit the admin was not shown is refused with 409
|
||||
* and records nothing; the right one records the set and is audited;
|
||||
* - a rotation makes the kit due again, and the retired key stays listed,
|
||||
* named, for as long as a stored copy lists it, also once the server no
|
||||
* longer holds it; when the last such copy is gone it drops out;
|
||||
* - the readiness model names a needed key nobody confirmed by its id;
|
||||
* - "Check my copy" answers a boolean for each kind of key, stores
|
||||
* nothing, and is rate limited;
|
||||
* - migration 0487 carries a confirmation given under the old one-key step
|
||||
* forward as a kit holding that key.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
import { _resetCryptoCacheForTests, getKeyFingerprint } from "@/lib/crypto";
|
||||
import { offhostKeyId } from "@/lib/jobs/offhost-key";
|
||||
import type { RecoveryKit } from "@/lib/recovery/kit-rules";
|
||||
|
||||
const KEY_A =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const KEY_B =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
const BACKUP_KEY = "ab".repeat(32);
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function useKeys(keys: Record<string, string>, active: string) {
|
||||
vi.stubEnv("ENCRYPTION_KEY", "");
|
||||
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify(keys));
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", active);
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
async function seedAdmin(createdAt = new Date()): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
username: "kit-admin",
|
||||
email: "kit-admin@example.test",
|
||||
role: "ADMIN",
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: admin.id, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return admin.id;
|
||||
}
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
const asRoute = (fn: unknown) => fn as RouteFn;
|
||||
|
||||
function post(path: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function readKit(): Promise<RecoveryKit> {
|
||||
const { GET } = await import("@/app/api/admin/recovery-kit/route");
|
||||
const res = await asRoute(GET)(
|
||||
new NextRequest("http://localhost/api/admin/recovery-kit"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()).data;
|
||||
}
|
||||
|
||||
const shown = (kit: RecoveryKit) => ({
|
||||
entries: kit.keys.map(({ kind, id, fingerprint }) => ({
|
||||
kind,
|
||||
id,
|
||||
fingerprint,
|
||||
})),
|
||||
});
|
||||
|
||||
async function confirm(body: unknown): Promise<Response> {
|
||||
const { POST } = await import("@/app/api/admin/recovery-kit/confirm/route");
|
||||
return POST(post("/api/admin/recovery-kit/confirm", body));
|
||||
}
|
||||
|
||||
async function storedCopyListing(userId: string, keyIds: string[]) {
|
||||
await getPrismaClient().dataBackup.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "WEEKLY_AUTO",
|
||||
data: "sealed",
|
||||
innerKeyIds: keyIds,
|
||||
innerKeyIdsRecorded: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.$executeRaw`DELETE FROM rate_limits WHERE key LIKE 'recovery-kit-verify:%'`;
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
vi.stubEnv("BACKUP_ENCRYPTION_KEY", "");
|
||||
vi.stubEnv("BACKUP_ENCRYPTION_PREVIOUS_KEYS", "");
|
||||
vi.stubEnv("BACKUP_S3_ENDPOINT", "");
|
||||
useKeys({ v1: KEY_A }, "v1");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
describe("recovery kit (real Postgres)", () => {
|
||||
it("lists every key by id and fingerprint, refuses a stale confirmation, and records the right one", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
vi.stubEnv("BACKUP_ENCRYPTION_KEY", BACKUP_KEY);
|
||||
vi.stubEnv("HEALTHLOG_PLATFORM", "truenas");
|
||||
const adminId = await seedAdmin();
|
||||
|
||||
const kit = await readKit();
|
||||
expect(kit.due).toBe(true);
|
||||
expect(kit.platformHint).toBe("truenas");
|
||||
expect(kit.keys.map((k) => [k.kind, k.id, k.fingerprint])).toEqual([
|
||||
["application", "v1", getKeyFingerprint("v1")],
|
||||
[
|
||||
"backup",
|
||||
offhostKeyId(Buffer.from(BACKUP_KEY, "hex")),
|
||||
offhostKeyId(Buffer.from(BACKUP_KEY, "hex")),
|
||||
],
|
||||
["token", "API_TOKEN_HMAC_KEY", expect.stringMatching(/^[0-9a-f]{12}$/)],
|
||||
]);
|
||||
const wire = JSON.stringify(kit);
|
||||
expect(wire).not.toContain(KEY_A);
|
||||
expect(wire).not.toContain(BACKUP_KEY);
|
||||
expect(wire).not.toContain(process.env.API_TOKEN_HMAC_KEY ?? "<unset>");
|
||||
|
||||
const tampered = shown(kit);
|
||||
tampered.entries[0] = {
|
||||
...tampered.entries[0],
|
||||
fingerprint: "000000000000",
|
||||
};
|
||||
const stale = await confirm(tampered);
|
||||
expect(stale.status).toBe(409);
|
||||
expect((await stale.json()).meta.errorCode).toBe("recovery.kit.stale");
|
||||
expect(await prisma.recoveryKitConfirmation.count()).toBe(0);
|
||||
|
||||
const ok = await confirm(shown(kit));
|
||||
expect(ok.status).toBe(200);
|
||||
const confirmed: RecoveryKit = (await ok.json()).data;
|
||||
expect(confirmed.due).toBe(false);
|
||||
expect(confirmed.confirmedBy).toEqual({
|
||||
id: adminId,
|
||||
email: "kit-admin@example.test",
|
||||
});
|
||||
const audit = await prisma.auditLog.findFirst({
|
||||
where: { action: "recovery.kit.confirmed" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
expect(JSON.stringify(audit)).not.toContain(KEY_A);
|
||||
});
|
||||
|
||||
it("is due again after a rotation and keeps naming the retired key while a copy lists it", async () => {
|
||||
const adminId = await seedAdmin();
|
||||
await storedCopyListing(adminId, ["v1"]);
|
||||
expect((await confirm(shown(await readKit()))).status).toBe(200);
|
||||
|
||||
// Rotate: v2 becomes active, v1 stays configured for the old data.
|
||||
useKeys({ v1: KEY_A, v2: KEY_B }, "v2");
|
||||
const rotated = await readKit();
|
||||
expect(rotated.due).toBe(true);
|
||||
expect(rotated.unconfirmed).toEqual([{ kind: "application", id: "v2" }]);
|
||||
expect(rotated.keys.find((k) => k.id === "v1")).toMatchObject({
|
||||
confirmed: true,
|
||||
need: { active: false, storedCopies: 1 },
|
||||
});
|
||||
expect((await confirm(shown(rotated))).status).toBe(200);
|
||||
expect((await readKit()).due).toBe(false);
|
||||
|
||||
// The operator drops v1 from the server while the copy still lists it:
|
||||
// the kit keeps naming it, covered by the earlier confirmation of v1.
|
||||
useKeys({ v2: KEY_B }, "v2");
|
||||
const dropped = await readKit();
|
||||
expect(dropped.keys.find((k) => k.id === "v1")).toMatchObject({
|
||||
fingerprint: null,
|
||||
problem: "not_configured",
|
||||
confirmed: true,
|
||||
});
|
||||
expect(dropped.due).toBe(false);
|
||||
|
||||
// Once no copy lists v1, it leaves the kit.
|
||||
await getPrismaClient().dataBackup.deleteMany({});
|
||||
expect((await readKit()).keys.map((k) => k.id)).not.toContain("v1");
|
||||
});
|
||||
|
||||
it("names a needed key nobody confirmed in the readiness verdict", async () => {
|
||||
const adminId = await seedAdmin(new Date(Date.now() - 30 * DAY));
|
||||
useKeys({ v1: KEY_A, v2: KEY_B }, "v2");
|
||||
await storedCopyListing(adminId, ["v1"]);
|
||||
// A confirmation that covers only the new key and the token key.
|
||||
const kit = await readKit();
|
||||
await getPrismaClient().recoveryKitConfirmation.create({
|
||||
data: {
|
||||
confirmedAt: new Date(),
|
||||
entries: shown(kit).entries.filter((e) => e.id !== "v1"),
|
||||
},
|
||||
});
|
||||
|
||||
const { readRecoveryReadiness } = await import("@/lib/recovery/readiness");
|
||||
const readiness = await readRecoveryReadiness();
|
||||
expect(
|
||||
readiness.signals.find((s) => s.id === "recovery_kit"),
|
||||
).toMatchObject({
|
||||
state: "attention",
|
||||
reason: "out_of_date",
|
||||
params: { keys: "v1", count: 1 },
|
||||
});
|
||||
|
||||
expect((await confirm(shown(kit))).status).toBe(200);
|
||||
const after = await readRecoveryReadiness();
|
||||
expect(after.signals.find((s) => s.id === "recovery_kit")?.state).toBe(
|
||||
"ok",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks a copy of each kind of key without storing it, and limits the checks", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
vi.stubEnv("BACKUP_ENCRYPTION_KEY", BACKUP_KEY);
|
||||
await seedAdmin();
|
||||
const { POST: verify } =
|
||||
await import("@/app/api/admin/recovery-kit/verify/route");
|
||||
const check = (kind: string, id: string, keyCopy: string) =>
|
||||
verify(post("/api/admin/recovery-kit/verify", { kind, id, keyCopy }));
|
||||
const backupId = offhostKeyId(Buffer.from(BACKUP_KEY, "hex"));
|
||||
const token = process.env.API_TOKEN_HMAC_KEY ?? "";
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
|
||||
const good = await check("application", "v1", `${KEY_A}\n`);
|
||||
expect(good.status).toBe(200);
|
||||
expect((await good.json()).data).toEqual({
|
||||
matches: true,
|
||||
kind: "application",
|
||||
id: "v1",
|
||||
});
|
||||
expect(
|
||||
(await (await check("backup", backupId, BACKUP_KEY)).json()).data.matches,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await (await check("token", "API_TOKEN_HMAC_KEY", token)).json()).data
|
||||
.matches,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await (await check("application", "v1", KEY_B)).json()).data.matches,
|
||||
).toBe(false);
|
||||
|
||||
const invalid = await verify(
|
||||
post("/api/admin/recovery-kit/verify", { kind: "application", id: "v1" }),
|
||||
);
|
||||
expect(invalid.status).toBe(422);
|
||||
expect(JSON.stringify(await invalid.json())).not.toContain("issues");
|
||||
|
||||
const audits = JSON.stringify(await prisma.auditLog.findMany());
|
||||
for (const secret of [KEY_A, KEY_B, BACKUP_KEY, token]) {
|
||||
expect(audits).not.toContain(secret);
|
||||
}
|
||||
expect(await prisma.recoveryKitConfirmation.count()).toBe(0);
|
||||
|
||||
const limited = await check("application", "v1", KEY_A);
|
||||
expect(limited.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration 0487", () => {
|
||||
it("carries a confirmation of the active key forward as a kit holding that key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const adminId = await seedAdmin();
|
||||
const fingerprint = getKeyFingerprint("v1");
|
||||
// The shape before 0487: the four columns on the settings row.
|
||||
await prisma.$executeRawUnsafe(`
|
||||
ALTER TABLE app_settings
|
||||
ADD COLUMN IF NOT EXISTS encryption_key_backup_confirmed_at TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS encryption_key_backup_confirmed_key_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS encryption_key_backup_confirmed_fingerprint TEXT,
|
||||
ADD COLUMN IF NOT EXISTS encryption_key_backup_confirmed_by_user_id TEXT
|
||||
`);
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO app_settings (id, encryption_key_backup_confirmed_at,
|
||||
encryption_key_backup_confirmed_key_id,
|
||||
encryption_key_backup_confirmed_fingerprint,
|
||||
encryption_key_backup_confirmed_by_user_id)
|
||||
VALUES ('singleton', NOW(), 'v1', ${fingerprint}, ${adminId})
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
encryption_key_backup_confirmed_at = NOW(),
|
||||
encryption_key_backup_confirmed_key_id = 'v1',
|
||||
encryption_key_backup_confirmed_fingerprint = ${fingerprint},
|
||||
encryption_key_backup_confirmed_by_user_id = ${adminId}
|
||||
`;
|
||||
|
||||
const sql = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"prisma/migrations/0487_recovery_readiness/migration.sql",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await prisma.$executeRawUnsafe(sql);
|
||||
// Running it again changes nothing.
|
||||
await prisma.$executeRawUnsafe(sql);
|
||||
|
||||
const columns = await prisma.$queryRaw<Array<{ column_name: string }>>`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'app_settings'
|
||||
AND column_name LIKE 'encryption_key_backup%'
|
||||
`;
|
||||
expect(columns).toEqual([]);
|
||||
|
||||
const kit = await readKit();
|
||||
expect(kit.confirmedBy?.id).toBe(adminId);
|
||||
expect(kit.keys.find((k) => k.id === "v1")?.confirmed).toBe(true);
|
||||
// The old step never covered the token key, so the kit asks for it.
|
||||
expect(kit.unconfirmed).toEqual([
|
||||
{ kind: "token", id: "API_TOKEN_HMAC_KEY" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user