diff --git a/README.md b/README.md index 337ee4f..8ac1be6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ API gateway, metering, and billing backend for the AgentPay protocol (machine-to agentpay-backend/ ├── src/ │ ├── index.ts # Thin Express composition root that exports app +│ ├── auth/ # API-key hashing, creation, and constant-time checks │ ├── events.ts # Bounded in-memory audit event log helpers │ ├── middleware/ # CORS, security headers, request id, pause, rate limit │ ├── routes/ # Feature routers for admin, usage, services, keys, webhooks @@ -84,11 +85,39 @@ npm run build npm start ``` -The API is currently open for local development and demos. You do not need an -API key for the metering flow until API-key enforcement lands. Add your own +The API is open by default for local development and demos. Add your own `X-Request-Id` header when you want to correlate client logs with backend responses. The backend echoes the value on success and structured errors. +### Authentication + +Set `REQUIRE_API_KEY=true` to require credentials on state-changing routes. +`GET`, `HEAD`, and `OPTIONS` remain open for dashboards, health checks, and +metadata readers. Non-admin write routes require a valid tenant key in the +`X-API-Key` header: + +```bash +curl -sS -X POST "$BASE_URL/api/v1/usage" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $AGENTPAY_API_KEY" \ + -d '{"agent":"agent-alpha","serviceId":"embedding-v1","requests":3}' +``` + +Tenant keys are returned once from `POST /api/v1/api-keys`. The in-memory store +keeps only a SHA-256 hash plus the public 8-character prefix, and +`GET /api/v1/api-keys` never returns the live secret. + +Set `ADMIN_API_KEY` alongside `REQUIRE_API_KEY=true` for privileged writes. +`POST /api/v1/admin/*` and API-key creation/revocation require this admin key +instead of a tenant key: + +```bash +curl -sS -X POST "$BASE_URL/api/v1/api-keys" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $ADMIN_API_KEY" \ + -d '{"label":"ops"}' +``` + Set a shell variable for the local base URL: ```bash diff --git a/src/apikey-recognition.test.ts b/src/apikey-recognition.test.ts index 607289f..79f814b 100644 --- a/src/apikey-recognition.test.ts +++ b/src/apikey-recognition.test.ts @@ -11,17 +11,22 @@ import { import type { AgentPayRequest } from "./types.js"; // We create a minimal test app that mounts the same middleware stack as the -// real app to act as our "test-only assertion path" for observing req.apiKey. +// real app to act as our test-only path for observing request API-key metadata. const testApp = express(); installPreRouteMiddleware(testApp); installRequestStateMiddleware(testApp); testApp.get("/_test/api-key", (req, res) => { - res.json({ apiKey: (req as AgentPayRequest).apiKey }); + res.json({ + apiKeyHash: (req as AgentPayRequest).apiKeyHash, + apiKeyPrefix: (req as AgentPayRequest).apiKeyPrefix, + }); }); void describe("API Key Recognition Middleware", () => { beforeEach(() => { apiKeyStore.clear(); + delete process.env.REQUIRE_API_KEY; + delete process.env.ADMIN_API_KEY; }); void it("recognises a created key (case-insensitive header, exact value)", async () => { @@ -35,23 +40,27 @@ void describe("API Key Recognition Middleware", () => { // 2. Assert it is recognised via exact case const resExact = await request(testApp).get("/_test/api-key").set("X-API-Key", key); - assert.strictEqual(resExact.body.apiKey, key); + assert.strictEqual(resExact.body.apiKeyPrefix, key.slice(0, 8)); + assert.ok(resExact.body.apiKeyHash); // 3. Assert it is recognised via lowercase header const resLower = await request(testApp).get("/_test/api-key").set("x-api-key", key); - assert.strictEqual(resLower.body.apiKey, key); + assert.strictEqual(resLower.body.apiKeyPrefix, key.slice(0, 8)); + assert.strictEqual(resLower.body.apiKeyHash, resExact.body.apiKeyHash); }); void it("silently ignores an unknown key", async () => { const res = await request(testApp) .get("/_test/api-key") .set("X-API-Key", "apk_unknown123"); - assert.strictEqual(res.body.apiKey, undefined); + assert.strictEqual(res.body.apiKeyHash, undefined); + assert.strictEqual(res.body.apiKeyPrefix, undefined); }); - void it("leaves apiKey undefined if no key is provided", async () => { + void it("leaves API key metadata undefined if no key is provided", async () => { const res = await request(testApp).get("/_test/api-key"); - assert.strictEqual(res.body.apiKey, undefined); + assert.strictEqual(res.body.apiKeyHash, undefined); + assert.strictEqual(res.body.apiKeyPrefix, undefined); }); void it("silently ignores a revoked key", async () => { @@ -67,7 +76,8 @@ void describe("API Key Recognition Middleware", () => { // Try to use the revoked key const res = await request(testApp).get("/_test/api-key").set("X-API-Key", key); - assert.strictEqual(res.body.apiKey, undefined); + assert.strictEqual(res.body.apiKeyHash, undefined); + assert.strictEqual(res.body.apiKeyPrefix, undefined); }); void it("ensures the API remains open: a write with no X-API-Key still succeeds", async () => { diff --git a/src/auth/apiKeys.test.ts b/src/auth/apiKeys.test.ts new file mode 100644 index 0000000..4dfc6d6 --- /dev/null +++ b/src/auth/apiKeys.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert"; +import request from "supertest"; +import { createApp } from "../index.js"; +import { apiKeyStore, pauseState, usageStore } from "../store/state.js"; + +beforeEach(() => { + apiKeyStore.clear(); + usageStore.clear(); + pauseState.paused = false; + delete process.env.REQUIRE_API_KEY; + delete process.env.ADMIN_API_KEY; +}); + +async function createTenantKey(): Promise { + const app = createApp(); + const created = await request(app).post("/api/v1/api-keys").send({ label: "tenant" }); + assert.strictEqual(created.status, 201); + return readCreatedKey(created.body); +} + +function readCreatedKey(body: unknown): string { + const key = + body && typeof body === "object" && "key" in body + ? (body as { key: unknown }).key + : undefined; + if (typeof key !== "string") { + throw new TypeError("expected API-key response to include a string key"); + } + assert.match(key, /^apk_/); + return key; +} + +void describe("API key authentication", () => { + void it("keeps write endpoints open when REQUIRE_API_KEY is disabled", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/usage") + .send({ agent: "agent-open", serviceId: "svc-open", requests: 1 }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.total, 1); + }); + + void it("requires a valid tenant API key for state-changing non-admin routes", async () => { + const key = await createTenantKey(); + process.env.REQUIRE_API_KEY = "true"; + const app = createApp(); + + const read = await request(app).get("/api/v1/usage/agent-auth/svc-auth"); + assert.strictEqual(read.status, 200); + + const missing = await request(app) + .post("/api/v1/usage") + .send({ agent: "agent-auth", serviceId: "svc-auth", requests: 1 }); + assert.strictEqual(missing.status, 401); + assert.strictEqual(missing.body.error, "unauthorized"); + assert.ok(missing.body.requestId); + + const unknown = await request(app) + .post("/api/v1/usage") + .set("X-API-Key", "apk_unknown") + .send({ agent: "agent-auth", serviceId: "svc-auth", requests: 1 }); + assert.strictEqual(unknown.status, 401); + assert.strictEqual(unknown.body.error, "unauthorized"); + assert.ok(unknown.body.requestId); + + const accepted = await request(app) + .post("/api/v1/usage") + .set("X-API-Key", key) + .send({ agent: "agent-auth", serviceId: "svc-auth", requests: 2 }); + assert.strictEqual(accepted.status, 201); + assert.strictEqual(accepted.body.total, 2); + }); + + void it("stores tenant API keys hashed and lists only the public prefix", async () => { + const key = await createTenantKey(); + const prefix = key.slice(0, 8); + + assert.strictEqual(apiKeyStore.has(key), false); + + const listed = await request(createApp()).get("/api/v1/api-keys"); + assert.strictEqual(listed.status, 200); + assert.strictEqual(listed.body.items.length, 1); + assert.strictEqual(listed.body.items[0].prefix, prefix); + assert.strictEqual(listed.body.items[0].key, undefined); + }); + + void it("requires ADMIN_API_KEY for API-key creation when enforcement is enabled", async () => { + process.env.REQUIRE_API_KEY = "true"; + process.env.ADMIN_API_KEY = "admin-secret"; + const app = createApp(); + + const missing = await request(app) + .post("/api/v1/api-keys") + .send({ label: "new-tenant" }); + assert.strictEqual(missing.status, 401); + assert.strictEqual(missing.body.error, "unauthorized"); + + const created = await request(app) + .post("/api/v1/api-keys") + .set("X-API-Key", "admin-secret") + .send({ label: "new-tenant" }); + assert.strictEqual(created.status, 201); + const key = readCreatedKey(created.body); + assert.strictEqual(apiKeyStore.has(key), false); + }); + + void it("requires ADMIN_API_KEY instead of a tenant key for admin writes", async () => { + const tenantKey = await createTenantKey(); + process.env.REQUIRE_API_KEY = "true"; + process.env.ADMIN_API_KEY = "admin-secret"; + const app = createApp(); + + const status = await request(app).get("/api/v1/admin/status"); + assert.strictEqual(status.status, 200); + + const missing = await request(app).post("/api/v1/admin/pause"); + assert.strictEqual(missing.status, 401); + assert.strictEqual(missing.body.error, "unauthorized"); + assert.ok(missing.body.requestId); + + const tenant = await request(app) + .post("/api/v1/admin/pause") + .set("X-API-Key", tenantKey); + assert.strictEqual(tenant.status, 401); + assert.strictEqual(tenant.body.error, "unauthorized"); + + const wrongAdmin = await request(app) + .post("/api/v1/admin/pause") + .set("X-API-Key", "admin-wrong"); + assert.strictEqual(wrongAdmin.status, 401); + assert.strictEqual(wrongAdmin.body.error, "unauthorized"); + + const accepted = await request(app) + .post("/api/v1/admin/pause") + .set("X-API-Key", "admin-secret"); + assert.strictEqual(accepted.status, 200); + assert.strictEqual(accepted.body.paused, true); + }); +}); diff --git a/src/auth/apiKeys.ts b/src/auth/apiKeys.ts new file mode 100644 index 0000000..7097049 --- /dev/null +++ b/src/auth/apiKeys.ts @@ -0,0 +1,68 @@ +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import type { ApiKeyRecord } from "../store/state.js"; + +export type VerifiedApiKey = { + hash: string; + prefix: string; + record: ApiKeyRecord; +}; + +/** Hashes an API key before it is stored or compared. */ +export function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + +/** Creates a tenant API key and the hashed in-memory record for it. */ +export function createApiKeyRecord( + label: string, + now = Date.now() +): { + key: string; + hash: string; + record: ApiKeyRecord; +} { + const key = `apk_${randomUUID().replace(/-/g, "")}`; + return { + key, + hash: hashApiKey(key), + record: { + label, + createdAt: now, + prefix: key.slice(0, 8), + }, + }; +} + +/** Compares fixed-format SHA-256 hex digests without early-exit string checks. */ +export function timingSafeEqualHex(leftHex: string, rightHex: string): boolean { + const left = Buffer.from(leftHex, "hex"); + const right = Buffer.from(rightHex, "hex"); + return left.length === right.length && timingSafeEqual(left, right); +} + +/** Compares two secrets by digest so length differences do not leak directly. */ +export function timingSafeEqualSecret(supplied: string, expected: string): boolean { + return timingSafeEqualHex(hashApiKey(supplied), hashApiKey(expected)); +} + +/** + * Finds a supplied tenant key in the hashed store while comparing every stored + * hash so the matching key does not determine the loop exit timing. + */ +export function verifyApiKey( + supplied: string | undefined, + store: Map +): VerifiedApiKey | undefined { + if (typeof supplied !== "string" || supplied.length === 0) { + return undefined; + } + + const suppliedHash = hashApiKey(supplied); + let matched: VerifiedApiKey | undefined; + for (const [storedHash, record] of store.entries()) { + if (timingSafeEqualHex(suppliedHash, storedHash)) { + matched = { hash: storedHash, prefix: record.prefix, record }; + } + } + return matched; +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 53859a7..e14c7c7 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -5,6 +5,7 @@ import express, { type Request, type Response, } from "express"; +import { timingSafeEqualSecret, verifyApiKey } from "../auth/apiKeys.js"; import { apiKeyStore, pauseState, @@ -23,6 +24,7 @@ export function installPreRouteMiddleware(app: Application): void { app.use(express.json({ limit: "100kb" })); app.use(securityHeadersMiddleware); app.use(requestIdMiddleware); + app.use(apiKeyAuthMiddleware); } /** @@ -30,7 +32,6 @@ export function installPreRouteMiddleware(app: Application): void { * the main API routes. */ export function installRequestStateMiddleware(app: Application): void { - app.use(apiKeyRecognitionMiddleware); app.use(pauseGuardMiddleware); app.use(rateLimitMiddleware); app.use(requestTimerMiddleware); @@ -94,19 +95,70 @@ function requestIdMiddleware(req: Request, res: Response, next: NextFunction): v next(); } -/** Recognizes known API keys without requiring them. */ -function apiKeyRecognitionMiddleware( - req: Request, - _res: Response, - next: NextFunction -): void { +/** Recognizes tenant keys and enforces them on writes when REQUIRE_API_KEY=true. */ +function apiKeyAuthMiddleware(req: Request, res: Response, next: NextFunction): void { const supplied = req.header("x-api-key"); - if (typeof supplied === "string" && apiKeyStore.has(supplied)) { - (req as AgentPayRequest).apiKey = supplied; + const tenantKey = verifyApiKey(supplied, apiKeyStore); + if (tenantKey) { + (req as AgentPayRequest).apiKeyHash = tenantKey.hash; + (req as AgentPayRequest).apiKeyPrefix = tenantKey.prefix; + } + + if (!requiresApiKey()) { + next(); + return; + } + + const method = req.method.toUpperCase(); + if (method === "GET" || method === "HEAD" || method === "OPTIONS") { + next(); + return; + } + + if (req.path.startsWith("/api/v1/admin/") || isApiKeyManagementPath(req.path)) { + if (isValidAdminKey(supplied)) { + (req as AgentPayRequest).adminApiKey = true; + next(); + return; + } + sendUnauthorized(res, req, "valid ADMIN_API_KEY required for privileged writes"); + return; } + + if (!tenantKey) { + sendUnauthorized(res, req, "valid X-API-Key required for write request"); + return; + } + next(); } +function requiresApiKey(): boolean { + return process.env.REQUIRE_API_KEY?.toLowerCase() === "true"; +} + +function isApiKeyManagementPath(path: string): boolean { + return path === "/api/v1/api-keys" || path.startsWith("/api/v1/api-keys/"); +} + +function isValidAdminKey(supplied: string | undefined): boolean { + const adminKey = process.env.ADMIN_API_KEY; + return ( + typeof supplied === "string" && + typeof adminKey === "string" && + adminKey.length > 0 && + timingSafeEqualSecret(supplied, adminKey) + ); +} + +function sendUnauthorized(res: Response, req: Request, message: string): void { + res.status(401).json({ + error: "unauthorized", + message, + requestId: (req as AgentPayRequest).id, + }); +} + /** Blocks state-changing requests while the backend is paused. */ function pauseGuardMiddleware(req: Request, res: Response, next: NextFunction): void { if (!pauseState.paused) return next(); diff --git a/src/routes/apiKeys.ts b/src/routes/apiKeys.ts index ce8bf2a..ef34bbd 100644 --- a/src/routes/apiKeys.ts +++ b/src/routes/apiKeys.ts @@ -1,5 +1,5 @@ -import { randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; +import { createApiKeyRecord } from "../auth/apiKeys.js"; import { apiKeyStore } from "../store/state.js"; import { getRequestId } from "../types.js"; @@ -12,9 +12,9 @@ export function createApiKeysRouter(): Router { router.delete("/api/v1/api-keys/:prefix", (req: Request, res: Response) => { const { prefix } = req.params; let found: string | undefined; - for (const key of apiKeyStore.keys()) { - if (key.slice(0, 8) === prefix) { - found = key; + for (const [hash, meta] of apiKeyStore.entries()) { + if (meta.prefix === prefix) { + found = hash; break; } } @@ -31,8 +31,8 @@ export function createApiKeysRouter(): Router { }); router.get("/api/v1/api-keys", (_req, res: Response) => { - const items = Array.from(apiKeyStore.entries()).map(([key, meta]) => ({ - prefix: key.slice(0, 8), + const items = Array.from(apiKeyStore.values()).map((meta) => ({ + prefix: meta.prefix, label: meta.label, createdAt: meta.createdAt, })); @@ -50,8 +50,8 @@ export function createApiKeysRouter(): Router { }); return; } - const key = `apk_${randomUUID().replace(/-/g, "")}`; - apiKeyStore.set(key, { label, createdAt: Date.now() }); + const { key, hash, record } = createApiKeyRecord(label); + apiKeyStore.set(hash, record); res.status(201).json({ key, label }); }); diff --git a/src/routes/operational.test.ts b/src/routes/operational.test.ts index 16b7ab2..d29dc19 100644 --- a/src/routes/operational.test.ts +++ b/src/routes/operational.test.ts @@ -57,7 +57,11 @@ void describe("operational routes", () => { void it("reports metrics, stats, deep health, changelog, and OpenAPI metadata", async () => { const app = createApp(); servicesStore.set("svc-meta", { priceStroops: 10 }); - apiKeyStore.set("apk_abcdef", { label: "admin", createdAt: 1 }); + apiKeyStore.set("hash-admin", { + label: "admin", + createdAt: 1, + prefix: "apk_abcd", + }); usageStore.set("agent-meta::svc-meta", 3); pauseState.paused = true; diff --git a/src/store/state.ts b/src/store/state.ts index 6c573f4..6cde12f 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -5,7 +5,7 @@ * for the lifetime of the Node process and resets on restart. */ -export type ApiKeyRecord = { label: string; createdAt: number }; +export type ApiKeyRecord = { label: string; createdAt: number; prefix: string }; export type ServiceMetadataDto = { description: string; owner: string }; export type WebhookRecord = { url: string; events: string[]; createdAt: number }; @@ -20,7 +20,7 @@ export const config: Record = { eventLogCap: 10_000, }; -/** Opaque API keys keyed by full secret token. */ +/** Opaque API keys keyed by SHA-256 hash, never by the live secret token. */ export const apiKeyStore = new Map(); /** Outstanding usage counters keyed by `${agent}::${serviceId}`. */ diff --git a/src/types.ts b/src/types.ts index 86f2143..e8801f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,7 +6,9 @@ import type { Request } from "express"; */ export type AgentPayRequest = Request & { id?: string; - apiKey?: string; + apiKeyHash?: string; + apiKeyPrefix?: string; + adminApiKey?: true; }; /**