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
17 changes: 17 additions & 0 deletions apps/api/src/routes/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
23 changes: 20 additions & 3 deletions apps/api/src/services/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
Expand Down
69 changes: 69 additions & 0 deletions apps/api/test/routes/api-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,72 @@ describe("api-keys:manage gating", () => {
expect(((await revoked.json()) as Record<string, unknown>).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<string> {
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<string, unknown>): Promise<Response> {
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",
]);
});
});