Files
HealthLog/tests/integration/encryption-key-check.test.ts
T
Marc-André Bombeck b04b1f2995 fix(encryption): sample several stored values before declaring a key mismatch
The boot check decided on the first value it found under a key id, so
one damaged or foreign-sealed row could take a correctly keyed server
down with 503 on every request. The probe now samples up to five
values, one per column first. One value that opens clears the key; a
mismatch needs at least two values and none opening. A single value
that does not open is inconclusive: the server keeps serving, writes no
canary, logs a warning and probes again at the next start.

Integration setup truncates encryption_key_canaries between tests.
2026-10-03 18:27:28 +02:00

328 lines
11 KiB
TypeScript

/**
* The boot encryption key check and the "Back up your encryption key" step,
* 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,
* every API route answers 503 `encryption.key_mismatch`, `/api/health`
* names the reason, and nothing is written;
* - 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.
*/
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,
encrypt,
getKeyFingerprint,
} from "@/lib/crypto";
import { runBootKeyCheck } from "@/lib/boot/key-check";
import {
isKeyMismatch,
setKeyMismatchState,
} from "@/lib/boot/key-mismatch-state";
const KEY_A =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const KEY_B =
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
function useKey(hex: string) {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", hex);
_resetCryptoCacheForTests();
}
async function seedAdmin(): Promise<string> {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "key-admin",
email: "key-admin@example.test",
role: "ADMIN",
},
});
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;
}
/** The GET handlers take no parameter; `apiHandler` still reads the request. */
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),
});
}
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);
setKeyMismatchState(null);
});
afterEach(() => {
setKeyMismatchState(null);
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
describe("boot encryption key check (real Postgres)", () => {
it("writes a canary on first boot and opens it on the next", async () => {
const prisma = getPrismaClient();
expect(await runBootKeyCheck(prisma)).toBe(false);
const rows = await prisma.encryptionKeyCanary.findMany();
expect(rows.map((r) => r.keyId)).toEqual(["v1"]);
expect(rows[0].ciphertext.startsWith("v1.")).toBe(true);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(isKeyMismatch()).toBe(false);
});
it("refuses to serve when the key changed under the same id", async () => {
const prisma = getPrismaClient();
await runBootKeyCheck(prisma);
const before = await prisma.encryptionKeyCanary.findMany();
useKey(KEY_B);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(true);
expect(errors.mock.calls[0]?.[0]).toContain("encryption.key_mismatch");
errors.mockRestore();
expect(isKeyMismatch()).toBe(true);
// Nothing re-sealed under the wrong key.
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"),
);
expect(refused.status).toBe(503);
const body = await refused.json();
expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" });
const { GET: healthGet } = await import("@/app/api/health/route");
const health = await asRoute(healthGet)(
new NextRequest("http://localhost/api/health"),
);
expect(health.status).toBe(503);
const healthBody = await health.json();
expect(healthBody.status).toBe("degraded");
expect(healthBody.reason).toBe("encryption_key_mismatch");
// Restoring the original key clears it on the next boot.
useKey(KEY_A);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(isKeyMismatch()).toBe(false);
});
it("refuses rather than sealing the wrong key over data that predates the canary", async () => {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
username: "existing",
email: "existing@example.test",
codexAccessTokenEncrypted: encrypt("a stored token"),
codexRefreshTokenEncrypted: encrypt("another stored token"),
},
});
useKey(KEY_B);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(true);
errors.mockRestore();
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
// With the right key the same data lets the canary be written.
useKey(KEY_A);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(1);
});
});
describe("boot key check probe over existing data (real Postgres)", () => {
it("one damaged row among good ones still lets the right key through", async () => {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
username: "damaged",
email: "damaged@example.test",
codexAccessTokenEncrypted:
"v1." + Buffer.alloc(40, 7).toString("base64"),
codexRefreshTokenEncrypted: encrypt("a good token"),
},
});
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(isKeyMismatch()).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(1);
});
it("a single value that does not open neither refuses nor writes a canary", async () => {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
username: "single",
email: "single@example.test",
codexAccessTokenEncrypted: encrypt("the only token"),
},
});
useKey(KEY_B);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(warn.mock.calls[0]?.[0]).toContain("inconclusive");
warn.mockRestore();
expect(isKeyMismatch()).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
});
});
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);
});
});