diff --git a/src/__tests__/key-mismatch-refusal-guard.test.ts b/src/__tests__/key-mismatch-refusal-guard.test.ts new file mode 100644 index 000000000..9d145e319 --- /dev/null +++ b/src/__tests__/key-mismatch-refusal-guard.test.ts @@ -0,0 +1,127 @@ +/** + * Every request surface outside `apiHandler` refuses while the boot key check + * has found a mismatch. + * + * `apiHandler` answers 503 `encryption.key_mismatch` before auth and before + * the handler, so a process holding the wrong key never writes through it. + * A route that does not wrap in `apiHandler` gets none of that: the remote + * MCP endpoint and its OAuth bridge went without it, and an MCP write tool + * would have sealed new rows under the wrong key, rows the next boot's probe + * could then read as evidence that the wrong key is right. + * + * So: a `route.ts` under `src/app` that does not wrap in `apiHandler` must + * call `refuseOnKeyMismatch` / `refuseOAuthOnKeyMismatch`, or sit in the + * allowlist below with the reason it cannot touch encrypted data. A server + * action (`"use server"`) is held to the same rule; there are none today. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * Routes outside `apiHandler` that need no refusal, each with why. A file + * belongs here only if it reads nothing from the database and writes nothing. + */ +const NO_DATA_ROUTES: Record = { + "app/.well-known/apple-app-site-association/route.ts": + "static JSON built from environment variables; no database access", + "app/.well-known/oauth-authorization-server/route.ts": + "RFC 8414 metadata built from the configured origin; no database access", + "app/.well-known/oauth-protected-resource/route.ts": + "RFC 9728 metadata built from the configured origin; no database access", +}; + +const REFUSAL_CALL = /\brefuse(?:OAuth)?OnKeyMismatch\s*\(/; + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +/** The text of a top-level function starting at `from`, up to its closing brace. */ +function functionBody(src: string, from: number): string { + const end = src.indexOf("\n}\n", from); + return src.slice(from, end === -1 ? undefined : end); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +describe("key mismatch refusal outside apiHandler", () => { + const routes = sourceFiles().filter( + (p) => p.startsWith("app/") && /\/route\.tsx?$/.test(p), + ); + const outside = routes.filter((p) => !/\bapiHandler\s*\(/.test(read(p))); + + it("finds the route tree and the routes outside apiHandler", () => { + // A matcher that matches nothing would pass every check below. + expect(routes.length).toBeGreaterThan(300); + expect(outside).toEqual( + expect.arrayContaining([ + "app/mcp/route.ts", + "app/api/mcp/oauth/authorize/route.ts", + "app/api/mcp/oauth/register/route.ts", + "app/api/mcp/oauth/token/route.ts", + ]), + ); + }); + + it("every route outside apiHandler refuses, or is allowlisted with a reason", () => { + const missing = outside.filter( + (p) => !(p in NO_DATA_ROUTES) && !REFUSAL_CALL.test(read(p)), + ); + expect(missing).toEqual([]); + }); + + it("an allowlisted route really stays away from the database", () => { + for (const p of Object.keys(NO_DATA_ROUTES)) { + expect(outside, `${p} is no longer outside apiHandler`).toContain(p); + const src = read(p); + expect(src, p).not.toMatch(/@\/lib\/db\b|prisma|\$queryRaw/); + } + }); + + it("every exported handler of a refusing route calls the refusal itself", () => { + // `export async function POST(...) { ... }` must refuse in its own body; + // `export const POST = handleX` must refuse in `handleX`. A refusal in one + // handler of a file proves nothing about its siblings. + let checked = 0; + for (const p of outside.filter((f) => !(f in NO_DATA_ROUTES))) { + const src = read(p); + const bodies: Array<[string, string]> = []; + for (const m of src.matchAll( + /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/g, + )) { + bodies.push([m[1], functionBody(src, m.index)]); + } + for (const m of src.matchAll( + /export\s+const\s+(GET|POST|PUT|PATCH|DELETE)\s*=\s*(\w+)\s*;/g, + )) { + const def = new RegExp(`function\\s+${m[2]}\\s*\\(`).exec(src); + expect(def, `${p}: ${m[1]} → ${m[2]} not found`).not.toBeNull(); + bodies.push([m[1], functionBody(src, def!.index)]); + } + expect(bodies.length, p).toBeGreaterThan(0); + for (const [name, body] of bodies) { + expect(REFUSAL_CALL.test(body), `${p} ${name}`).toBe(true); + checked += 1; + } + } + expect(checked).toBeGreaterThanOrEqual(6); + }); + + it("server actions refuse too", () => { + const actions = sourceFiles().filter((p) => + /^\s*["']use server["']/m.test(read(p)), + ); + const missing = actions.filter((p) => !REFUSAL_CALL.test(read(p))); + expect(missing).toEqual([]); + }); +}); diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts index b0ad1ef5e..c8a71d9a8 100644 --- a/src/app/api/mcp/oauth/authorize/route.ts +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -29,6 +29,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { annotate } from "@/lib/logging/context"; +import { refuseOAuthOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal"; import { withBackgroundEvent } from "@/lib/logging/background"; import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; @@ -344,10 +345,15 @@ function ensureAuthorizeCsp(response: Response): Response { } export async function GET(request: NextRequest): Promise { + // The boot key check refused this process: no grant is issued or stored. + const refusal = refuseOAuthOnKeyMismatch(); + if (refusal) return ensureAuthorizeCsp(refusal); return ensureAuthorizeCsp(await authorizeGet(request)); } export async function POST(request: NextRequest): Promise { + const refusal = refuseOAuthOnKeyMismatch(); + if (refusal) return ensureAuthorizeCsp(refusal); return ensureAuthorizeCsp(await authorizePost(request)); } diff --git a/src/app/api/mcp/oauth/register/route.ts b/src/app/api/mcp/oauth/register/route.ts index aa5d76a83..7e9e1d13c 100644 --- a/src/app/api/mcp/oauth/register/route.ts +++ b/src/app/api/mcp/oauth/register/route.ts @@ -16,6 +16,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { annotate } from "@/lib/logging/context"; +import { refuseOAuthOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal"; import { withBackgroundEvent } from "@/lib/logging/background"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { isApiGloballyEnabled } from "@/lib/app-settings"; @@ -68,6 +69,10 @@ function oauthError( export async function POST(request: NextRequest): Promise { return withBackgroundEvent("mcp.oauth.register", async () => { + // The boot key check refused this process: mint nothing, write nothing. + const refusal = refuseOAuthOnKeyMismatch(); + if (refusal) return refusal; + const rl = await checkAuthSurfaceRateLimit( request, "mcp:oauth:register", diff --git a/src/app/api/mcp/oauth/token/route.ts b/src/app/api/mcp/oauth/token/route.ts index a82b732a5..fbdaf01af 100644 --- a/src/app/api/mcp/oauth/token/route.ts +++ b/src/app/api/mcp/oauth/token/route.ts @@ -32,6 +32,7 @@ import { NextRequest } from "next/server"; import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; +import { refuseOAuthOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal"; import { withBackgroundEvent } from "@/lib/logging/background"; import { auditLog } from "@/lib/auth/audit"; import { issueApiToken } from "@/lib/auth/issue-token"; @@ -201,6 +202,10 @@ async function finalizeTokenPair( export async function POST(request: NextRequest): Promise { return withBackgroundEvent("mcp.oauth.token", async () => { + // The boot key check refused this process: mint nothing, write nothing. + const refusal = refuseOAuthOnKeyMismatch(); + if (refusal) return refusal; + // M1 — fail closed without a pinned origin; M4 — honour the operator's // global API kill-switch. Either off and the bridge mints no tokens. if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) { diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index c71635dc8..30b4b1738 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -50,6 +50,7 @@ import { resolveMcpAuthContext, SCOPE_HEALTH_READ, } from "@/lib/mcp"; +import { refuseOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal"; import { withBackgroundEvent } from "@/lib/logging/background"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -135,6 +136,12 @@ function readBearer(request: Request): string | null { async function handleMcp(request: Request): Promise { return withBackgroundEvent("mcp.request", async () => { + // 00. KEY CHECK — the boot key check found that the configured encryption + // key does not open this database. The write tools would seal new + // rows under it, so refuse before anything else, as apiHandler does. + const refusal = refuseOnKeyMismatch(); + if (refusal) return refusal; + // 0. ORIGIN — refuse a cross-origin browser request before anything else. if (!originAllowed(request)) { annotate({ action: { name: "mcp.origin.rejected" } }); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index a47b66ab2..4f00f4b9e 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -53,11 +53,7 @@ import { assertRecordSessionFence, attachRecordContextEcho, } from "./sharing/record-session-fence"; -import { - isKeyMismatch, - KEY_MISMATCH_ERROR_CODE, - KEY_MISMATCH_EXEMPT_PATHS, -} from "./boot/key-mismatch-state"; +import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal"; import { capturedRateLimit, rateLimitResponseHeaders, @@ -266,17 +262,9 @@ export function apiHandler Promise>( // The boot key check found that the configured encryption key // cannot open this database. Refuse before auth, before the // handler: nothing here may read or write encrypted data. - if (isKeyMismatch() && !KEY_MISMATCH_EXEMPT_PATHS.has(url.pathname)) { - annotate({ action: { name: "encryption.key_mismatch.refused" } }); - response = NextResponse.json( - { - data: null, - error: - "The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database.", - meta: { errorCode: KEY_MISMATCH_ERROR_CODE }, - }, - { status: 503, headers: { "Retry-After": "300" } }, - ); + const refusal = refuseOnKeyMismatch(url.pathname); + if (refusal) { + response = refusal; } else { response = await handler(...args); } diff --git a/src/lib/boot/key-mismatch-refusal.ts b/src/lib/boot/key-mismatch-refusal.ts new file mode 100644 index 000000000..10533a122 --- /dev/null +++ b/src/lib/boot/key-mismatch-refusal.ts @@ -0,0 +1,67 @@ +/** + * The refusal a request surface answers while the boot key check has found + * that the configured encryption key does not open this database. + * + * `apiHandler` applies it to every API route. A route outside `apiHandler` + * (the remote MCP endpoint, its OAuth bridge) calls one of the two helpers + * below as the first thing it does, before auth and before any read or + * write: a process holding the wrong key must never seal new rows, because + * those rows would open under that key at the next boot and read as evidence + * that it is the right one. `key-mismatch-refusal-guard.test.ts` fails when a + * route outside `apiHandler` does neither. + */ +import { annotate } from "@/lib/logging/context"; +import { + isKeyMismatch, + KEY_MISMATCH_ERROR_CODE, + KEY_MISMATCH_EXEMPT_PATHS, +} from "./key-mismatch-state"; + +export const KEY_MISMATCH_MESSAGE = + "The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database."; + +const RETRY_AFTER = { "Retry-After": "300" }; + +/** The 503 in the standard `{ data, error, meta }` envelope. */ +export function keyMismatchResponse(): Response { + annotate({ action: { name: "encryption.key_mismatch.refused" } }); + return Response.json( + { + data: null, + error: KEY_MISMATCH_MESSAGE, + meta: { errorCode: KEY_MISMATCH_ERROR_CODE }, + }, + { status: 503, headers: RETRY_AFTER }, + ); +} + +/** + * The envelope refusal when the process refuses and `pathname` is not one of + * the exempt paths; otherwise null and the caller carries on. + */ +export function refuseOnKeyMismatch(pathname?: string): Response | null { + if (!isKeyMismatch()) return null; + if (pathname !== undefined && KEY_MISMATCH_EXEMPT_PATHS.has(pathname)) { + return null; + } + return keyMismatchResponse(); +} + +/** + * The same refusal in the RFC 6749 error shape the OAuth endpoints speak: + * `temporarily_unavailable` with a description, plus the HealthLog error + * code as an extension member a client may ignore. + */ +export function refuseOAuthOnKeyMismatch(): Response | null { + if (!isKeyMismatch()) return null; + annotate({ action: { name: "encryption.key_mismatch.refused" } }); + return Response.json( + { + error: "temporarily_unavailable", + error_description: + "The server's encryption key does not match its database.", + error_code: KEY_MISMATCH_ERROR_CODE, + }, + { status: 503, headers: { ...RETRY_AFTER, "Cache-Control": "no-store" } }, + ); +} diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts index 01f2796af..6e7a99a51 100644 --- a/tests/integration/encryption-key-check.test.ts +++ b/tests/integration/encryption-key-check.test.ts @@ -439,6 +439,70 @@ describe("retiring a key id after rotation (real Postgres)", () => { }); }); +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"); + } + }); +}); + 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();