/** * 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, * 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 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"; 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), })); /** * The start-up probe is bounded in time on purpose: ten seconds in all and * two per query, so a host with a huge table still starts. Those bounds are * not what this file asks about. On a loaded runner a probe query can take * longer than two seconds, and the check then answers "inconclusive" * (`incomplete`) where the data says "mismatch", which is the right answer * for a slow host and the wrong one for a test of the verdicts. So the check * runs here with time to finish, unless a case passes its own budget or * sampler: the cases that test the bounds themselves do. */ vi.mock("@/lib/crypto/canary", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, checkEncryptionKeyCanaries: ( client: Parameters[0], options: Parameters[1] = {}, ) => { let sampler; try { sampler = actual.sqlColumnSampler(client, { statementTimeoutMs: 60_000, }); } catch { // A client the sampler cannot use: the check reports that itself. } return actual.checkEncryptionKeyCanaries(client, { probeBudgetMs: 120_000, ...(sampler ? { sampler } : {}), ...options, }); }, }; }); import { _resetCryptoCacheForTests, encrypt, encryptUnderKeyId, } from "@/lib/crypto"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { runBootKeyCheck } from "@/lib/boot/key-check"; import { canaryPlaintext, checkEncryptionKeyCanaries, retireCanariesWithoutData, sqlColumnSampler, type CanaryClient, } from "@/lib/crypto/canary"; import { getKeyMismatchWarning, isKeyMismatch, setKeyMismatchState, setKeyMismatchWarning, } 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 { 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; 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`; 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: 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(); expect(body.meta).toEqual({ requestId: expect.any(String), 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); }); }); /** Two users, one oldest, with ciphertext in string AND Bytes columns. */ async function seedUser( name: string, writtenAt: Date, seal: () => { text: string; bytes: Uint8Array }, ) { const prisma = getPrismaClient(); const a = seal(); const b = seal(); const user = await prisma.user.create({ data: { username: name, email: `${name}@example.test`, codexAccessTokenEncrypted: a.text, codexRefreshTokenEncrypted: b.text, createdAt: writtenAt, updatedAt: writtenAt, }, }); await prisma.userHealthProfile.create({ data: { userId: user.id, aboutMeEncrypted: a.bytes, conditionsEncrypted: b.bytes, createdAt: writtenAt, updatedAt: writtenAt, }, }); return user.id; } const sealNow = () => ({ text: encrypt("a stored value"), bytes: encryptToBytes("a stored note"), }); describe("boot key check: what decides, and what may seal (real Postgres)", () => { it("probes Bytes columns: an install whose ciphertext is only Bytes does not seal a wrong key", async () => { const prisma = getPrismaClient(); const user = await prisma.user.create({ data: { username: "bytes-only", email: "bytes-only@example.test" }, }); await prisma.userHealthProfile.create({ data: { userId: user.id, aboutMeEncrypted: encryptToBytes("about me"), conditionsEncrypted: encryptToBytes("conditions"), allergiesEncrypted: encryptToBytes("allergies"), }, }); 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); useKey(KEY_A); expect(await runBootKeyCheck(prisma)).toBe(false); expect(await prisma.encryptionKeyCanary.count()).toBe(1); }); it("a probe that runs out of time seals nothing and keeps serving", async () => { const prisma = getPrismaClient(); await seedUser("slow", new Date("2024-01-01T00:00:00Z"), sealNow); const outcome = await checkEncryptionKeyCanaries(prisma, { probeBudgetMs: -1, }); expect(outcome.state).toBe("ok"); if (outcome.state !== "ok") return; expect(outcome.written).toEqual([]); expect(outcome.inconclusive.map((i) => i.keyId)).toEqual(["v1"]); expect(await prisma.encryptionKeyCanary.count()).toBe(0); }); it("a fresh database with no ciphertext under the key id seals it", async () => { const prisma = getPrismaClient(); // Ciphertext exists, but under another key id: not evidence about v1. vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_A, v2: KEY_B })); vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v2"); vi.stubEnv("ENCRYPTION_KEY", ""); _resetCryptoCacheForTests(); await seedUser("v2-only", new Date("2024-01-01T00:00:00Z"), sealNow); expect(await runBootKeyCheck(prisma)).toBe(false); const ids = (await prisma.encryptionKeyCanary.findMany()) .map((r) => r.keyId) .sort(); expect(ids).toEqual(["v1", "v2"]); }); it("rows a wrong key wrote recently never decide: old rows under A, newer under B", async () => { const prisma = getPrismaClient(); // The original data, under A. await seedUser("original", new Date("2024-01-01T00:00:00Z"), sealNow); await seedUser("original-2", new Date("2024-02-01T00:00:00Z"), sealNow); // A process holding B served for a while (no canary yet) and wrote more. useKey(KEY_B); for (let i = 0; i < 6; i++) { await seedUser( `recent-${i}`, new Date(Date.UTC(2026, 8, 1 + i)), sealNow, ); } const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const errors = vi.spyOn(console, "error").mockImplementation(() => {}); await runBootKeyCheck(prisma); warn.mockRestore(); errors.mockRestore(); // B is not sealed as the right key, whatever else the verdict is. expect(await prisma.encryptionKeyCanary.count()).toBe(0); const outcome = await checkEncryptionKeyCanaries(prisma); expect( outcome.state === "mismatch" || (outcome.state === "ok" && outcome.written.length === 0), ).toBe(true); // The key the oldest data was written with is the right one. useKey(KEY_A); setKeyMismatchState(null); expect(await runBootKeyCheck(prisma)).toBe(false); expect(isKeyMismatch()).toBe(false); expect(await prisma.encryptionKeyCanary.count()).toBe(1); }); it("old rows under a lost key, newer rows under the configured key: serves, warns, seals nothing", async () => { const prisma = getPrismaClient(); // Sealed under a key that is gone: the eight oldest values the probe // reads first (two per column, four columns). await seedUser("lost-1", new Date("2023-01-01T00:00:00Z"), sealNow); await seedUser("lost-2", new Date("2023-02-01T00:00:00Z"), sealNow); // Everything since, under the key the install runs with today. useKey(KEY_B); for (let i = 0; i < 4; i++) { await seedUser( `current-${i}`, new Date(Date.UTC(2025, 0, 1 + i)), sealNow, ); } const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const errors = vi.spyOn(console, "error").mockImplementation(() => {}); expect(await runBootKeyCheck(prisma)).toBe(false); expect(isKeyMismatch()).toBe(false); expect(errors).not.toHaveBeenCalled(); expect(warn.mock.calls[0]?.[0]).toContain("inconclusive"); warn.mockRestore(); errors.mockRestore(); expect(await prisma.encryptionKeyCanary.count()).toBe(0); expect(await checkEncryptionKeyCanaries(prisma)).toEqual({ state: "ok", written: [], verified: [], inconclusive: [{ keyId: "v1", reason: "mixed" }], }); }); it("old AND newest values that do not open still refuse", async () => { const prisma = getPrismaClient(); await seedUser("a-1", new Date("2023-01-01T00:00:00Z"), sealNow); await seedUser("a-2", new Date("2023-02-01T00:00:00Z"), sealNow); for (let i = 0; i < 4; i++) { await seedUser(`a-new-${i}`, new Date(Date.UTC(2025, 0, 1 + i)), sealNow); } useKey(KEY_B); expect(await checkEncryptionKeyCanaries(prisma)).toEqual({ state: "mismatch", keyIds: ["v1"], }); expect(await prisma.encryptionKeyCanary.count()).toBe(0); }); it("a probe query held up by a lock gives up at its statement timeout: inconclusive, not a hang", async () => { const prisma = getPrismaClient(); await seedUser("locked", new Date("2024-01-01T00:00:00Z"), sealNow); const outcome = await prisma.$transaction( async (tx) => { // Another session holds the table: every read of it waits. await tx.$executeRaw`LOCK TABLE users IN ACCESS EXCLUSIVE MODE`; const started = Date.now(); const result = await checkEncryptionKeyCanaries(prisma, { sampler: sqlColumnSampler(prisma, { statementTimeoutMs: 200 }), }); return { result, elapsed: Date.now() - started }; }, { timeout: 20_000 }, ); expect(outcome.result).toEqual({ state: "ok", written: [], verified: [], inconclusive: [{ keyId: "v1", reason: "incomplete" }], }); expect(outcome.elapsed).toBeLessThan(5_000); expect(await prisma.encryptionKeyCanary.count()).toBe(0); }); it("losing the first-seal race to another key is a mismatch, not ok", async () => { const prisma = getPrismaClient(); // The other process, holding A, wins the insert between our probe and // our write. const sealedUnderA = encryptUnderKeyId(canaryPlaintext("v1"), "v1"); useKey(KEY_B); let raced = false; const racing = new Proxy(prisma, { get(target, prop, receiver) { if (prop === "$executeRaw") { return async (q: TemplateStringsArray, ...values: unknown[]) => { if (!raced) { raced = true; await target.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES ('v1', ${sealedUnderA})`; } return target.$executeRaw(q, ...values); }; } const value = Reflect.get(target, prop, receiver); return typeof value === "function" ? value.bind(target) : value; }, }) as unknown as CanaryClient; const outcome = await checkEncryptionKeyCanaries(racing); expect(raced).toBe(true); expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] }); // The same race lost to a process holding the SAME key is fine. await prisma.$executeRaw`DELETE FROM encryption_key_canaries`; raced = false; useKey(KEY_A); const same = await checkEncryptionKeyCanaries(racing); expect(same.state).toBe("ok"); }); }); describe("ENCRYPTION_KEY_CHECK (real Postgres)", () => { afterEach(() => setKeyMismatchWarning(null)); it("warn: a mismatch is logged and reported, never refused, never recorded", async () => { const prisma = getPrismaClient(); await seedUser("warned", new Date("2024-01-01T00:00:00Z"), sealNow); useKey(KEY_B); vi.stubEnv("ENCRYPTION_KEY_CHECK", "warn"); const errors = vi.spyOn(console, "error").mockImplementation(() => {}); expect(await runBootKeyCheck(prisma)).toBe(false); const logged = errors.mock.calls.map((c) => String(c[0])).join("\n"); errors.mockRestore(); expect(logged).toContain("encryption.key_mismatch"); expect(logged).toContain("ENCRYPTION_KEY_CHECK=warn"); expect(isKeyMismatch()).toBe(false); expect(getKeyMismatchWarning()?.keyIds).toEqual(["v1"]); expect(await prisma.encryptionKeyCanary.count()).toBe(0); // Requests are served, through apiHandler and outside it. await seedAdmin(); 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"); const mcpRes = await mcp( new Request("http://localhost/mcp", { method: "POST", body: "{}" }), ); expect(mcpRes.status).not.toBe(503); const { GET: healthGet } = await import("@/app/api/health/route"); const healthBody = await ( await asRoute(healthGet)(new NextRequest("http://localhost/api/health")) ).json(); expect(healthBody.warning).toBe("encryption_key_mismatch"); expect(healthBody.reason).toBeUndefined(); }); it("warn: nothing is recorded while any key id is inconclusive", async () => { const prisma = getPrismaClient(); vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_A, v2: KEY_B })); vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v1"); vi.stubEnv("ENCRYPTION_KEY", ""); _resetCryptoCacheForTests(); await prisma.user.create({ data: { username: "lone", email: "lone@example.test", codexAccessTokenEncrypted: encrypt("the only value"), }, }); // v1 now holds a different key: its one value does not open. v2 has no // data, which on its own would be recorded. vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_B, v2: KEY_A })); _resetCryptoCacheForTests(); vi.stubEnv("ENCRYPTION_KEY_CHECK", "warn"); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(await runBootKeyCheck(prisma)).toBe(false); warn.mockRestore(); expect(await prisma.encryptionKeyCanary.count()).toBe(0); // Enforce records the proven id, as before. vi.stubEnv("ENCRYPTION_KEY_CHECK", "enforce"); const warn2 = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(await runBootKeyCheck(prisma)).toBe(false); warn2.mockRestore(); const ids = (await prisma.encryptionKeyCanary.findMany()).map( (r) => r.keyId, ); expect(ids).toEqual(["v2"]); }); it("enforce, set explicitly or by any other value, refuses as before", async () => { const prisma = getPrismaClient(); await seedUser("enforced", new Date("2024-01-01T00:00:00Z"), sealNow); useKey(KEY_B); for (const value of ["enforce", "", "off"]) { vi.stubEnv("ENCRYPTION_KEY_CHECK", value); setKeyMismatchState(null); const errors = vi.spyOn(console, "error").mockImplementation(() => {}); expect(await runBootKeyCheck(prisma)).toBe(true); errors.mockRestore(); expect(isKeyMismatch()).toBe(true); expect(getKeyMismatchWarning()).toBeNull(); } expect(await prisma.encryptionKeyCanary.count()).toBe(0); }); }); describe("retiring a key id after rotation (real Postgres)", () => { it("removes the canary of a key id only once no ciphertext remains under it", async () => { const prisma = getPrismaClient(); vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_A, v2: KEY_B })); vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v1"); vi.stubEnv("ENCRYPTION_KEY", ""); _resetCryptoCacheForTests(); const userId = await seedUser( "rotating", new Date("2024-01-01T00:00:00Z"), sealNow, ); expect(await runBootKeyCheck(prisma)).toBe(false); expect(await prisma.encryptionKeyCanary.count()).toBe(2); // Rotation to v2 has not finished: one Bytes value still under v1. vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v2"); _resetCryptoCacheForTests(); await prisma.user.update({ where: { id: userId }, data: { codexAccessTokenEncrypted: encrypt("rotated"), codexRefreshTokenEncrypted: encrypt("rotated"), }, }); await prisma.userHealthProfile.update({ where: { userId }, data: { aboutMeEncrypted: encryptToBytes("rotated") }, }); const partial = await retireCanariesWithoutData(prisma); expect(partial.removed).toEqual([]); expect(partial.remaining).toEqual({ v1: ["UserHealthProfile.conditionsEncrypted"], }); expect(await prisma.encryptionKeyCanary.count()).toBe(2); await prisma.userHealthProfile.update({ where: { userId }, data: { conditionsEncrypted: encryptToBytes("rotated") }, }); const done = await retireCanariesWithoutData(prisma); expect(done.removed).toEqual(["v1"]); const left = await prisma.encryptionKeyCanary.findMany(); expect(left.map((r) => r.keyId)).toEqual(["v2"]); }); }); describe("routes outside apiHandler refuse while the key does not match", () => { beforeEach(() => { setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" }); }); it("/mcp answers 503 encryption.key_mismatch before auth", async () => { const { POST } = await import("@/app/mcp/route"); const res = await POST( new Request("http://localhost/mcp", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer hlk_whatever", }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), }), ); expect(res.status).toBe(503); expect((await res.json()).meta).toEqual({ errorCode: "encryption.key_mismatch", }); }); it("the MCP OAuth endpoints refuse in their RFC shape", async () => { const { POST: token } = await import("@/app/api/mcp/oauth/token/route"); const { POST: register } = await import("@/app/api/mcp/oauth/register/route"); const { GET: authorizeGet, POST: authorizePost } = await import("@/app/api/mcp/oauth/authorize/route"); const responses = [ await token( new NextRequest("http://localhost/api/mcp/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "grant_type=authorization_code&code=x", }), ), await register( post("/api/mcp/oauth/register", { redirect_uris: ["https://client.example/cb"], }), ), await authorizeGet( new NextRequest( "http://localhost/api/mcp/oauth/authorize?response_type=code", ), ), await authorizePost( new NextRequest("http://localhost/api/mcp/oauth/authorize", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "decision=approve", }), ), ]; for (const res of responses) { expect(res.status).toBe(503); const body = await res.json(); expect(body.error).toBe("temporarily_unavailable"); expect(body.error_description).toContain("encryption key"); } }); });