Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions lib/auth/mcp-keys.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions lib/auth/mcp-pepper.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions scripts/check-mcp-keys.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down
7 changes: 6 additions & 1 deletion scripts/mint-mcp-key.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
24 changes: 24 additions & 0 deletions test/mcp-key-floor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)/);
}
});
});
Loading