mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
707 lines
25 KiB
TypeScript
707 lines
25 KiB
TypeScript
/**
|
|
* 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<typeof import("@/lib/crypto/canary")>();
|
|
return {
|
|
...actual,
|
|
checkEncryptionKeyCanaries: (
|
|
client: Parameters<typeof actual.checkEncryptionKeyCanaries>[0],
|
|
options: Parameters<typeof actual.checkEncryptionKeyCanaries>[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<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`;
|
|
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<ArrayBuffer> },
|
|
) {
|
|
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");
|
|
}
|
|
});
|
|
});
|