From e02c09debba584b43ef7b8dff3224bcc74934804 Mon Sep 17 00:00:00 2001 From: SaulBuilds Date: Fri, 25 Sep 2026 09:34:20 -0700 Subject: [PATCH] hardening(docs): mint script uses the resolver's pepper rule The pepper rule moves to lib/auth/mcp-pepper.ts (no server-only or alias imports). lib/auth/mcp-keys.ts re-exports it, and scripts/mint-mcp-key.mjs uses it, so the script refuses any pepper the resolver would refuse and never mints a key that can't resolve. check:mcp-keys loads the shared module. A parity test mints under a table of peppers and asserts that a key is minted exactly when the resolver accepts the pepper, and that it then resolves. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012cD3fDq5vhh2YWZPU2SV6H --- lib/auth/mcp-keys.ts | 20 +++++--------------- lib/auth/mcp-pepper.ts | 25 +++++++++++++++++++++++++ scripts/check-mcp-keys.mjs | 2 ++ scripts/mint-mcp-key.mjs | 7 ++++++- test/mcp-key-floor.test.ts | 24 ++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 lib/auth/mcp-pepper.ts diff --git a/lib/auth/mcp-keys.ts b/lib/auth/mcp-keys.ts index 54d31a0f..dfd8facd 100644 --- a/lib/auth/mcp-keys.ts +++ b/lib/auth/mcp-keys.ts @@ -1,5 +1,6 @@ import "server-only"; import { createHmac } from "node:crypto"; +import { mcpPepperStatus, type McpPepperStatus } from "./mcp-pepper"; import { Tier, normalizeTier } from "@/prototype/fixtures"; /** @@ -44,21 +45,10 @@ interface RawEntry { */ export const MCP_KEY_RE = /^cdk_[A-Za-z0-9_-]{43}$/; -/** Minimum length of the server-side pepper (`MCP_KEY_PEPPER`), after trimming. */ -export const MIN_MCP_KEY_PEPPER_LENGTH = 32; -/** Minimum distinct characters in the pepper (rejects "aaaa…" / whitespace padding). */ -export const MIN_MCP_KEY_PEPPER_DISTINCT = 8; - -export type McpPepperStatus = "ok" | "missing" | "weak"; - -/** Whether `MCP_KEY_PEPPER` is usable: set, >= 32 chars after trimming, >= 8 distinct characters. */ -export function mcpPepperStatus(env: NodeJS.ProcessEnv = process.env): McpPepperStatus { - const raw = env.MCP_KEY_PEPPER ?? ""; - if (!raw.trim()) return "missing"; - const p = raw.trim(); - if (p !== raw || p.length < MIN_MCP_KEY_PEPPER_LENGTH || new Set(p).size < MIN_MCP_KEY_PEPPER_DISTINCT) return "weak"; - return "ok"; -} +// The pepper rule lives in ./mcp-pepper (plain TS, no server-only / alias imports) so the mint +// script applies exactly the same check. +export { MIN_MCP_KEY_PEPPER_LENGTH, MIN_MCP_KEY_PEPPER_DISTINCT, mcpPepperStatus, pepperStatus } from "./mcp-pepper"; +export type { McpPepperStatus } from "./mcp-pepper"; /** True when MCP_API_KEYS holds at least one entry. */ export function mcpStoreConfigured(env: NodeJS.ProcessEnv = process.env): boolean { diff --git a/lib/auth/mcp-pepper.ts b/lib/auth/mcp-pepper.ts new file mode 100644 index 00000000..a2728df3 --- /dev/null +++ b/lib/auth/mcp-pepper.ts @@ -0,0 +1,25 @@ +/** + * MCP key pepper rule, shared by the resolver (lib/auth/mcp-keys.ts) and the mint script + * (scripts/mint-mcp-key.mjs). No server-only or path-alias imports, so plain node can load it. + */ + +/** Minimum length of the server-side pepper (`MCP_KEY_PEPPER`), after trimming. */ +export const MIN_MCP_KEY_PEPPER_LENGTH = 32; +/** Minimum distinct characters in the pepper (rejects "aaaa…" / whitespace padding). */ +export const MIN_MCP_KEY_PEPPER_DISTINCT = 8; + +export type McpPepperStatus = "ok" | "missing" | "weak"; + +/** Status of a pepper value: set, >= 32 chars, >= 8 distinct characters, no surrounding whitespace. */ +export function pepperStatus(raw: string | undefined | null): McpPepperStatus { + const v = typeof raw === "string" ? raw : ""; + if (!v.trim()) return "missing"; + const p = v.trim(); + if (p !== v || p.length < MIN_MCP_KEY_PEPPER_LENGTH || new Set(p).size < MIN_MCP_KEY_PEPPER_DISTINCT) return "weak"; + return "ok"; +} + +/** Whether `MCP_KEY_PEPPER` in `env` is usable. */ +export function mcpPepperStatus(env: NodeJS.ProcessEnv = process.env): McpPepperStatus { + return pepperStatus(env.MCP_KEY_PEPPER); +} diff --git a/scripts/check-mcp-keys.mjs b/scripts/check-mcp-keys.mjs index 02e2a3a9..46cd0bd0 100644 --- a/scripts/check-mcp-keys.mjs +++ b/scripts/check-mcp-keys.mjs @@ -65,6 +65,8 @@ async function loadResolver() { // mcp-keys.ts's only runtime import from the barrel is normalizeTier (Tier is type-only). m = m.replace(/^import\s+\{[^}]*\}\s+from\s+["']@\/prototype\/fixtures["'];?\s*$/m, 'import { normalizeTier } from "./types.ts";'); + m = m.replace(/from\s+["']\.\/mcp-pepper["']/g, 'from "./mcp-pepper.ts"'); + fs.copyFileSync(path.join(ROOT, "lib/auth/mcp-pepper.ts"), path.join(tmp, "mcp-pepper.ts")); fs.writeFileSync(path.join(tmp, "mcp-keys.ts"), m); return import(path.join(tmp, "mcp-keys.ts")); } diff --git a/scripts/mint-mcp-key.mjs b/scripts/mint-mcp-key.mjs index 67f0238a..62ec1a9f 100644 --- a/scripts/mint-mcp-key.mjs +++ b/scripts/mint-mcp-key.mjs @@ -8,12 +8,17 @@ // keyed by HMAC-SHA256(key, MCP_KEY_PEPPER), matching lib/auth/mcp-keys.ts. import { createHmac, randomBytes } from "node:crypto"; import { pathToFileURL } from "node:url"; +import { pepperStatus } from "../lib/auth/mcp-pepper.ts"; export const MCP_KEY_PREFIX = "cdk_"; const TIERS = new Set(["public", "commercial", "academic", "confidential"]); export function mintMcpKey({ pepper, tier, sub, expiresAt = null }) { - if (typeof pepper !== "string" || pepper.length < 32) throw new Error("MCP_KEY_PEPPER must be set (>= 32 chars)"); + // Same rule as the resolver: a key minted under an unusable pepper could never resolve. + const status = pepperStatus(pepper); + if (status !== "ok") { + throw new Error(`MCP_KEY_PEPPER is ${status}: need >= 32 chars, >= 8 distinct, no surrounding whitespace`); + } if (!TIERS.has(tier)) throw new Error(`tier must be one of ${[...TIERS].join(", ")}`); if (typeof sub !== "string" || !sub.trim()) throw new Error("sub is required (e.g. org:acme)"); const key = MCP_KEY_PREFIX + randomBytes(32).toString("base64url"); diff --git a/test/mcp-key-floor.test.ts b/test/mcp-key-floor.test.ts index 502fda1c..40d7db56 100644 --- a/test/mcp-key-floor.test.ts +++ b/test/mcp-key-floor.test.ts @@ -111,3 +111,27 @@ describe("pepper quality and diagnosability (verify2)", () => { } }); }); + +describe("mint script applies the resolver's pepper rule", () => { + const peppers = [ + "a".repeat(40), + " ".repeat(40), + ` ${TEST_PEPPER}`, + `${TEST_PEPPER}\n`, + TEST_PEPPER.slice(0, 31), + "abcdefg".repeat(6), + "abcdefgh".repeat(4), + TEST_PEPPER, + ]; + it.each(peppers)("mint accepts %j iff mcpPepperStatus says ok", (p) => { + const ok = mcpPepperStatus({ MCP_KEY_PEPPER: p } as never) === "ok"; + const mint = () => mintMcpKey({ pepper: p, tier: "academic", sub: "org:p" }); + if (ok) { + const { key, entry } = mint(); + // ...and a key it mints resolves under that pepper. + expect(resolveMcpKeyCap(key, NOW, { MCP_KEY_PEPPER: p, MCP_API_KEYS: JSON.stringify(entry) } as never).tier).toBe("academic"); + } else { + expect(mint).toThrow(/MCP_KEY_PEPPER is (weak|missing)/); + } + }); +});