/** * Internal MCP HMAC service-auth — sign helper (U7) + canonicalisation * primitives shared with verify (U8). * * U7 of plan 2026-05-10-001 (`feat-pro-mcp-clerk-auth-quota-plan`). * * Why this module exists * ---------------------- * When `api/mcp.ts` dispatches a tool _execute fetch on behalf of a Pro * user, the downstream gateway has no `wm_*` API key to validate against * (the OAuth bearer carries a `mcpProTokens` row id, not a key). Instead, * the MCP edge signs an HMAC of the *outbound* request shape and the * gateway re-canonicalises + verifies on the way in. The verified userId * is what the gateway then trusts for entitlement / premium semantics. * * The signed payload binds the request shape so a captured signature for * `/api/news/v1/list-feed-digest?lang=en` cannot be replayed against * `/api/intelligence/v1/deduct-situation` (Codex round-2 review finding). * * payload = `${ts}:${method}:${pathname}:${queryHash}:${bodyHash}:${userId}:${nonce}` * queryHash = SHA-256(canonicalQueryString(URL)) * bodyHash = SHA-256(bodyBytes) // SHA-256("") for GET / no body * sig = HMAC-SHA-256(secret, payload) * header = `${ts}.${base64url(sig)}` * * SINGLE SOURCE OF TRUTH — both U7's signer and U8's verifier MUST import * `canonicalQueryString` and `sha256Hex` from THIS module. Drift between * sign and verify produces silent 401s for legitimate Pro tool fetches * and is the failure mode the Codex review flagged. */ // --------------------------------------------------------------------------- // Header / payload constants // --------------------------------------------------------------------------- /** Header carrying `.`. */ export const INTERNAL_MCP_SIG_HEADER = 'X-WM-MCP-Internal'; /** Header carrying the userId the signature claims to represent. */ export const INTERNAL_MCP_USER_ID_HEADER = 'X-WM-MCP-User-Id'; /** Header carrying the one-shot request nonce bound into the HMAC payload. */ export const INTERNAL_MCP_NONCE_HEADER = 'X-WM-MCP-Nonce'; /** Trusted markers set by the gateway AFTER successful verify. Downstream * handlers (`isCallerPremium`) read these — never the inbound headers. * * The verified-marker value is a per-process-startup random nonce * (`getInternalMcpVerifiedNonce()`), NOT the constant `'1'`. This is * defense-in-depth against the case where the trusted markers leak past * a non-gateway entry point (e.g. a direct edge function `api/foo.ts` * that doesn't route through `createDomainGateway` and therefore doesn't * run the strip step). An attacker who sends a guessable marker value * from outside cannot satisfy `isCallerPremium`'s check because they do * not know the per-process nonce. The gateway's strip step still runs * for gateway-routed traffic so even the nonce never round-trips. */ export const INTERNAL_MCP_VERIFIED_HEADER = 'x-wm-mcp-internal-verified'; export const TRUSTED_USER_ID_HEADER = 'x-user-id'; let _verifiedNonce: string | null = null; /** * Returns a stable per-process-startup nonce used as the value of * `x-wm-mcp-internal-verified` on requests rebuilt by the gateway after * HMAC verification. `isCallerPremium` compares with timing-safe equality. * * Generated lazily on first call (cheap; one 16-byte WebCrypto draw), * cached for the lifetime of the edge function instance. Not exported * outside server/_shared — outside callers should treat it as opaque. */ export function getInternalMcpVerifiedNonce(): string { if (_verifiedNonce !== null) return _verifiedNonce; const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); // Hex encode — short enough for an HTTP header, no base64 padding to deal with. _verifiedNonce = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); return _verifiedNonce; } /** Timestamp window (seconds) for replay defense. Default per plan: 30s. * Loosen via env if production observes clock skew. */ export const INTERNAL_MCP_TIMESTAMP_WINDOW_SECONDS = 30; /** Replay-cache TTL. The verify timestamp check is SYMMETRIC — a signature * is accepted whenever `Math.abs(nowSec - ts) <= WINDOW`, i.e. across a full * `2 * WINDOW` span (up to WINDOW seconds "in the past" and WINDOW seconds * "in the future" relative to the gateway clock). The nonce entry MUST * outlive that entire acceptance span regardless of clock-skew direction: * if the signer's clock leads the gateway's, a nonce cached at first sight * could otherwise expire while the same signature is still inside the * acceptance window, reopening the replay. So the TTL must be at least * `2 * WINDOW`; the small margin absorbs Redis EX rounding / propagation. */ export const INTERNAL_MCP_REPLAY_CACHE_TTL_SECONDS = 2 * INTERNAL_MCP_TIMESTAMP_WINDOW_SECONDS + 5; // --------------------------------------------------------------------------- // Canonicalisation primitives — exported so U8's verifier produces byte- // identical bytes for HMAC compare. Do NOT inline these elsewhere. // --------------------------------------------------------------------------- /** * Hex-encoded SHA-256 of a UTF-8 string. Edge-runtime safe (uses WebCrypto). * Mirror of `api/_crypto.js::sha256Hex` so server/_shared callers don't * need to reach into api/. */ export async function sha256Hex(input: string): Promise { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * Canonical query-string form for HMAC payload binding. * * Algorithm (deterministic, AWS SigV4-inspired): * 1. Parse the query string (with or without leading "?") via URLSearchParams. * 2. Sort entries lexicographically by key (stable: equal keys keep insertion order). * 3. URL-encode each key and value with encodeURIComponent. * 4. Join as `${key}=${value}` pairs separated by `&`. * * Empty / missing query → empty string (NOT "?", NOT undefined). * * Both `?a=1&b=2` and `?b=2&a=1` produce the SAME canonical string — * documented and tested behavior. Reordering query params at any hop * (CDN, proxy, browser) does not invalidate the signature. */ export function canonicalQueryString(searchOrUrl: string | URL): string { let search: string; if (searchOrUrl instanceof URL) { search = searchOrUrl.search; } else if (typeof searchOrUrl === 'string') { // Accept either "?a=1&b=2" or "a=1&b=2" or a full URL. if (searchOrUrl.startsWith('http://') || searchOrUrl.startsWith('https://')) { try { search = new URL(searchOrUrl).search; } catch { return ''; } } else { search = searchOrUrl; } } else { return ''; } if (!search || search === '?') return ''; const trimmed = search.startsWith('?') ? search.slice(1) : search; if (!trimmed) return ''; const params = new URLSearchParams(trimmed); const entries: [string, string][] = []; for (const [k, v] of params) entries.push([k, v]); entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); return entries .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join('&'); } /** * Build the HMAC payload string from request components. Both signer and * verifier MUST produce byte-identical strings here. * * Pathname is taken VERBATIM (no re-encoding) because URL.pathname is * already canonical for the Vercel edge runtime — re-encoding would * double-escape literals like `:` and `/` and break the compare. */ export function buildHmacPayload(args: { ts: number; method: string; pathname: string; queryHash: string; bodyHash: string; userId: string; nonce: string; }): string { return `${args.ts}:${args.method.toUpperCase()}:${args.pathname}:${args.queryHash}:${args.bodyHash}:${args.userId}:${args.nonce}`; } // --------------------------------------------------------------------------- // HMAC-SHA-256 + base64url helpers (edge-safe via WebCrypto) // --------------------------------------------------------------------------- async function importHmacKey(secret: string): Promise { return crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'], ); } function bufferToBase64Url(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf); let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** Sign a payload string → base64url(HMAC-SHA-256(secret, payload)). */ export async function hmacSha256Base64Url(secret: string, payload: string): Promise { const key = await importHmacKey(secret); const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)); return bufferToBase64Url(sig); } // --------------------------------------------------------------------------- // Public sign API — used by U7 (api/mcp.ts) and importable by U8's tests // to construct fixture headers without re-implementing the algorithm. // --------------------------------------------------------------------------- export interface SignedInternalMcpHeaders { /** Header value: `.`. Sent as `X-WM-MCP-Internal`. */ signature: string; /** UserId the signature claims; sent as `X-WM-MCP-User-Id`. */ userId: string; /** One-shot nonce bound into the signature payload. */ nonce: string; /** Unix-seconds timestamp embedded in the signature payload. */ ts: number; } function makeInternalMcpNonce(): string { if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); } function isValidInternalMcpNonce(nonce: string): boolean { return /^[A-Za-z0-9_-]{16,128}$/.test(nonce); } /** * Sign an outbound internal-MCP request. Returns header values to set on * the `fetch()` call — the caller is responsible for actually attaching them. * * @param method HTTP method (case-insensitive; payload uppercases). * @param url Full URL including query string. Pathname + canonicalised query * are extracted for the signed payload. * @param body Raw outbound body. Pass `null`/`undefined` for GET / no body. * Strings, ArrayBuffers, and Uint8Arrays are accepted; everything * else is `JSON.stringify`'d (mirrors fetch's own body handling * for the common case of objects passed via `body: JSON.stringify(x)` * — the caller must pass the SAME bytes they actually send). * @param userId The Pro userId being attributed to this request. * @param secret `MCP_INTERNAL_HMAC_SECRET`. The function does NOT read env * directly — caller passes it explicitly so tests can inject. * @param nonce Optional test injection. Production callers get a fresh * random UUID per signed request. * @param now Override Unix-seconds (test injection); defaults to `Date.now()/1000`. */ export async function signInternalMcpRequest(args: { method: string; url: string | URL; body?: BodyInit | null | undefined; userId: string; secret: string; nonce?: string; now?: number; }): Promise { if (!args.userId) throw new Error('signInternalMcpRequest: userId is required'); if (!args.secret) throw new Error('signInternalMcpRequest: secret is required'); const nonce = args.nonce ?? makeInternalMcpNonce(); if (!isValidInternalMcpNonce(nonce)) throw new Error('signInternalMcpRequest: nonce must be 16-128 URL-safe characters'); const url = args.url instanceof URL ? args.url : new URL(args.url); const ts = Math.floor(args.now ?? Date.now() / 1000); const queryHash = await sha256Hex(canonicalQueryString(url)); const bodyHash = await sha256Hex(await coerceBodyToString(args.body)); const payload = buildHmacPayload({ ts, method: args.method, pathname: url.pathname, queryHash, bodyHash, userId: args.userId, nonce, }); const sig = await hmacSha256Base64Url(args.secret, payload); return { signature: `${ts}.${sig}`, userId: args.userId, nonce, ts }; } /** * Body coercion mirrors the caller's actual `fetch()` body handling. The * signer's view of the body MUST match the bytes that hit the wire — if * the caller `JSON.stringify`'s an object before calling `fetch`, they * MUST pass the same string here. * * For convenience we handle the common shapes: * - null / undefined → empty string (matches GET / no-body convention) * - string → as-is * - Uint8Array / ArrayBuffer → utf-8 decoded * - object → JSON.stringify (LAST RESORT — prefer caller-side stringify * so the signer and the wire bytes are guaranteed identical) */ async function coerceBodyToString(body: BodyInit | null | undefined): Promise { if (body == null) return ''; if (typeof body === 'string') return body; if (body instanceof Uint8Array) return new TextDecoder().decode(body); if (body instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(body)); if (body instanceof URLSearchParams) return body.toString(); // F10 (U7+U8 review pass): Blob / FormData / ReadableStream — refuse // explicitly. The previous JSON.stringify catch-all silently produced // wrong hashes (e.g. `JSON.stringify(formData) === '{}'`) that would // 401 at the verifier with no obvious signal at the signer. Caller // must pre-stringify these shapes. // // We use `globalThis.` lookups because edge runtimes don't always // expose the constructor on the global scope at module-eval time; // optional chaining keeps this safe in Node test environments too. const G = globalThis as { Blob?: { new (): unknown }; FormData?: { new (): unknown }; ReadableStream?: { new (): unknown } }; if (G.Blob && body instanceof G.Blob) { throw new Error('signInternalMcpRequest: unsupported body shape (Blob); pre-stringify before signing'); } if (G.FormData && body instanceof G.FormData) { throw new Error('signInternalMcpRequest: unsupported body shape (FormData); pre-stringify before signing'); } if (G.ReadableStream && body instanceof G.ReadableStream) { throw new Error('signInternalMcpRequest: unsupported body shape (ReadableStream); pre-stringify before signing'); } // Final catch-all for plain objects passed by mistake — same loud failure. // The bad-old behavior was `JSON.stringify` here; that produced silent // sign/wire drift if the caller's `fetch` serialised differently. throw new Error('signInternalMcpRequest: unsupported body shape; pre-stringify before signing'); } /** * Build the headers dict to attach to a Pro-context internal-MCP fetch. * Caller composes with their other headers (Content-Type, User-Agent, ...). */ export function buildInternalMcpHeaders(signed: SignedInternalMcpHeaders): Record { return { [INTERNAL_MCP_SIG_HEADER]: signed.signature, [INTERNAL_MCP_USER_ID_HEADER]: signed.userId, [INTERNAL_MCP_NONCE_HEADER]: signed.nonce, }; } // --------------------------------------------------------------------------- // Verify side (U8) — gateway pre-check for inbound internal-MCP requests. // // Mirrors the sign side byte-for-byte. ANY drift (canonicalisation, body // coercion, payload shape, ts encoding) produces silent 401s for legitimate // Pro tool fetches. The U7 sign helpers and the U8 verify helper MUST share // this module — do NOT re-implement. // --------------------------------------------------------------------------- export interface VerifiedInternalMcpRequest { /** UserId carried by the verified `X-WM-MCP-User-Id` header. */ userId: string; /** One-shot nonce carried by `X-WM-MCP-Nonce` and bound into the HMAC. */ nonce: string; } /** * Constant-time equal-length string comparison. Both inputs are first hashed * to fixed-size SHA-256 digests so unequal lengths cannot leak via early-exit * timing. Mirrors `api/_crypto.js::timingSafeIncludes` for a single candidate. */ async function timingSafeStringEqual(a: string, b: string): Promise { const enc = new TextEncoder(); const aHash = new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(a))); const bHash = new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(b))); let diff = 0; for (let i = 0; i < aHash.length; i++) diff |= aHash[i]! ^ bHash[i]!; return diff === 0; } /** * Parse the `X-WM-MCP-Internal` header value into `{ts, sigB64u}`. * Returns null on any structural malformation. Single dot only — `.`. */ function parseSignatureHeader(value: string | null): { ts: number; sigB64u: string } | null { if (!value) return null; const dotIdx = value.indexOf('.'); // Reject missing dot, leading dot, trailing dot, multiple dots. if (dotIdx <= 0 || dotIdx === value.length - 1) return null; if (value.indexOf('.', dotIdx + 1) !== -1) return null; const tsStr = value.slice(0, dotIdx); const sigB64u = value.slice(dotIdx + 1); // F11 (U7+U8 review pass): bound the numeric width. `^[0-9]+$` would // accept arbitrarily long inputs; future ms-precision timestamps could // silently truncate through `Number()` to lose precision. 1-15 digits // covers Unix seconds (10 digits today) and ms epoch (13 digits) with // margin for the year-9999 boundary; rejects pathological lengths. if (!/^[0-9]{1,15}$/.test(tsStr)) return null; // base64url charset only — `+` `/` `=` not allowed. if (!/^[A-Za-z0-9_-]+$/.test(sigB64u)) return null; const ts = Number(tsStr); if (!Number.isFinite(ts) || ts < 0) return null; return { ts, sigB64u }; } /** * Verify an inbound internal-MCP request signed by U7's sign helpers. * * Reads `X-WM-MCP-Internal` (`.`), `X-WM-MCP-User-Id`, * and `X-WM-MCP-Nonce` from the request. Re-canonicalises the inbound request * the SAME way the signer did — exporting/importing canonicalQueryString, * sha256Hex, buildHmacPayload from this module is the single-source-of-truth * invariant. * * Body handling: `req.clone().bytes()` reads the body as raw bytes; cloning * preserves the original body for the downstream handler. Body-less requests * (GET) hash to SHA-256("") consistently between sign and verify. * * Returns `{userId, nonce}` on success, `null` on ANY verification failure * (missing headers, malformed signature, timestamp out of window, signature * mismatch). The gateway treats null as 401 `invalid_internal_mcp_signature` * and MUST NOT fall through to other auth paths. * * @param req The inbound `Request` (edge runtime). * @param secret `MCP_INTERNAL_HMAC_SECRET`. Caller provides explicitly so * tests can inject without env mutation. * @param now Override Unix-seconds (test injection); defaults to * `Math.floor(Date.now()/1000)`. */ export async function verifyInternalMcpRequest( req: Request, secret: string, now?: number, ): Promise { if (!secret) return null; const sigHeader = req.headers.get(INTERNAL_MCP_SIG_HEADER); const userId = req.headers.get(INTERNAL_MCP_USER_ID_HEADER); const nonce = req.headers.get(INTERNAL_MCP_NONCE_HEADER); if (!sigHeader || !userId || !nonce || !isValidInternalMcpNonce(nonce)) return null; const parsed = parseSignatureHeader(sigHeader); if (!parsed) return null; const { ts, sigB64u } = parsed; const nowSec = Math.floor(now ?? Date.now() / 1000); if (Math.abs(nowSec - ts) > INTERNAL_MCP_TIMESTAMP_WINDOW_SECONDS) return null; let url: URL; try { url = new URL(req.url); } catch { return null; } // Vercel's filesystem router serves every gateway domain through a dynamic // route file (api//v1/[rpc].ts) and injects the matched segment into // the function's request URL as a query parameter (?rpc=) — see // "named parameters are automatically passed through in the query string" // in Vercel's routing docs. The signer (api/mcp/auth.ts buildAuthHeaders) // signs the OUTBOUND url, which never carries that param, so hashing the // inbound query verbatim 401s EVERY legitimate Pro tool fetch // (WORLDMONITOR-R1 / WORLDMONITOR-T8 — deterministic since U7 shipped, // confirmed live: signing WITH the injected param verifies, without fails). // Strip `rpc` entries ONLY when every value exactly equals the final path // segment — i.e. only the router-injected echo. A caller-appended // ?rpc= still participates in the hash and breaks the // signature, so this is not a bypass vector: stripping the exact echo is // semantically identical to the router not having injected it. const pathSegments = url.pathname.split('/').filter(Boolean); const lastSegment = pathSegments[pathSegments.length - 1] ?? ''; const rpcParams = url.searchParams.getAll('rpc'); if (rpcParams.length > 0 && rpcParams.every((v) => v === lastSegment)) { url.searchParams.delete('rpc'); } // Body must be cloned BEFORE reading so the downstream handler can still // read it — Web Fetch API contract: a body can only be consumed once on // any given Request/Response. let bodyBytes: Uint8Array; try { const buf = await req.clone().arrayBuffer(); bodyBytes = new Uint8Array(buf); } catch { return null; } // sha256Hex takes a string; coerce raw bytes via TextDecoder. Empty body → // empty string → SHA-256(""), matching the signer's `coerceBodyToString`. const bodyAsString = bodyBytes.length === 0 ? '' : new TextDecoder().decode(bodyBytes); const queryHash = await sha256Hex(canonicalQueryString(url)); const bodyHash = await sha256Hex(bodyAsString); const expectedPayload = buildHmacPayload({ ts, method: req.method, pathname: url.pathname, queryHash, bodyHash, userId, nonce, }); const expectedSig = await hmacSha256Base64Url(secret, expectedPayload); const ok = await timingSafeStringEqual(expectedSig, sigB64u); if (!ok) return null; return { userId, nonce }; }