diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts index e397b8103..d5d79e7d9 100644 --- a/apps/api/src/routes/api-keys.ts +++ b/apps/api/src/routes/api-keys.ts @@ -69,6 +69,23 @@ export function apiKeyRoutes(c: Container): Hono<{ Variables: AuthVariables }> { ); } + // Prevent privilege escalation: a key may only mint keys whose scopes are a + // subset of its own. Session-authenticated sellers are the authority the + // keys derive from, so they may still request any scope. + if (ctx.get("authKind") === "api_key") { + const callerScopes = ctx.get("scopes") ?? []; + const denied = scopes.filter((s) => !callerScopes.includes(s)); + if (denied.length > 0) { + return ctx.json( + { + error: "forbidden", + issues: [{ message: `Requested scope(s) not held by calling key: ${denied.join(", ")}` }], + }, + 403, + ); + } + } + const { plaintext, prefix } = generateApiKey(parsed.data.env as KeyEnvironment); const hash = await hashApiKey(plaintext); diff --git a/apps/api/src/services/api-keys.ts b/apps/api/src/services/api-keys.ts index fdf258918..53e646157 100644 --- a/apps/api/src/services/api-keys.ts +++ b/apps/api/src/services/api-keys.ts @@ -53,13 +53,19 @@ export function isValidScope(s: string): s is ApiKeyScope { return (ALL_SCOPES as readonly string[]).includes(s); } -export function parseScopes(raw: string): ApiKeyScope[] { - if (!raw.trim()) return [...DEFAULT_SCOPES]; +export function parseScopes(raw: string, allowedScopes?: ApiKeyScope[]): ApiKeyScope[] { + if (!raw.trim()) { + const parsed = [...DEFAULT_SCOPES]; + if (allowedScopes) assertScopesSubset(parsed, allowedScopes); + return parsed; + } const parts = raw.split(",").map((p) => p.trim()).filter(Boolean); for (const p of parts) { if (!isValidScope(p)) throw new Error(`Unknown scope: "${p}"`); } - return parts as ApiKeyScope[]; + const parsed = parts as ApiKeyScope[]; + if (allowedScopes) assertScopesSubset(parsed, allowedScopes); + return parsed; } export function encodeScopesForDb(scopes: ApiKeyScope[]): string { @@ -73,6 +79,17 @@ export function decodeScopesFromDb(raw: string): ApiKeyScope[] { .filter(isValidScope) as ApiKeyScope[]; } +/** + * Throws if `requested` contains a scope not present in `allowed`. + * Used to prevent an API key from minting another key with elevated scopes. + */ +export function assertScopesSubset(requested: ApiKeyScope[], allowed: ApiKeyScope[]): void { + const denied = requested.filter((scope) => !allowed.includes(scope)); + if (denied.length > 0) { + throw new Error(`Requested scopes not held by caller: ${denied.join(", ")}`); + } +} + // ── Key generation ──────────────────────────────────────────────────────── const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; diff --git a/apps/api/test/routes/api-keys.test.ts b/apps/api/test/routes/api-keys.test.ts index 9cabdcb64..8dcd443aa 100644 --- a/apps/api/test/routes/api-keys.test.ts +++ b/apps/api/test/routes/api-keys.test.ts @@ -202,3 +202,72 @@ describe("api-keys:manage gating", () => { expect(((await revoked.json()) as Record).error).toBe("invalid_api_key"); }); }); + +describe("scope-subset rule on create (6.6 — a key cannot mint a more powerful key)", () => { + async function mintKey(name: string, scopes: string[]): Promise { + const { generateApiKey, hashApiKey } = await import("../../src/services/api-keys"); + const { plaintext, prefix } = generateApiKey("live"); + const hash = await hashApiKey(plaintext); + await container.apiKeys.create({ + sellerId, + name, + prefix, + hash, + scopes: scopes as never, + }); + return plaintext; + } + + async function createAs(plaintext: string, body: Record): Promise { + return app.request("/api-keys", { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${plaintext}` }, + body: JSON.stringify(body), + }); + } + + it("a key without offramp:initiate cannot mint a key that has it — 403, offending scope named", async () => { + const narrow = await mintKey("narrow-rotator", ["api-keys:manage"]); + const res = await createAs(narrow, { name: "escalated", scopes: "api-keys:manage,offramp:initiate" }); + + expect(res.status).toBe(403); + const body = await res.json() as { error: string; issues: Array<{ message: string }> }; + expect(body.error).toBe("forbidden"); + const message = body.issues[0]?.message ?? ""; + expect(message).toContain("offramp:initiate"); + expect(message).not.toContain("api-keys:manage"); + }); + + it("a key may mint an equal-or-narrower key — 201", async () => { + const caller = await mintKey("rotator", ["api-keys:manage", "links:read"]); + const res = await createAs(caller, { name: "narrower", scopes: "links:read" }); + + expect(res.status).toBe(201); + expect(((await res.json()) as { scopes: string[] }).scopes).toEqual(["links:read"]); + }); + + it("the default scopes are still subject to the rule — 403 when the caller lacks them", async () => { + // DEFAULT_SCOPES (links:read,links:write,webhooks:manage) apply when the + // request omits `scopes`; a caller holding only api-keys:manage holds none + // of them, so the implicit request must be rejected too. + const narrow = await mintKey("narrow-default", ["api-keys:manage"]); + const res = await createAs(narrow, { name: "implicit-defaults" }); + + expect(res.status).toBe(403); + expect(((await res.json()) as { error: string }).error).toBe("forbidden"); + }); + + it("a wallet-session caller can still issue any scope — 201", async () => { + const res = await req("/api-keys", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "session-issued", scopes: "offramp:initiate,api-keys:manage" }), + }); + + expect(res.status).toBe(201); + expect(((await res.json()) as { scopes: string[] }).scopes).toEqual([ + "offramp:initiate", + "api-keys:manage", + ]); + }); +});