diff --git a/.env.example b/.env.example index 8b0e99e..4055729 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,8 @@ KEYGATE_LOG_LEVEL=info # in `x-keygate-signature` that callers can verify with keygate-client. # Generate one with: openssl rand -hex 32 # KEYGATE_SIGNING_KEY= + +# Optional. Comma-separated bearer tokens. When set, every /v1/* route requires +# `Authorization: Bearer `; /healthz and /readyz stay open. Each token must +# be at least 32 characters. Generate one with: openssl rand -hex 32 +# KEYGATE_API_TOKENS= diff --git a/README.md b/README.md index bd0c7fd..bba5ade 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,14 @@ is `KEYGATE_MAX_STALENESS_SECONDS` (default `120`): the point of a cache is spee and a stale cache is a security hole rather than an optimisation, so this is deliberately tight. +`KEYGATE_API_TOKENS` (optional) is a comma-separated list of bearer tokens. When +set, every `/v1/*` route requires `Authorization: Bearer ` and answers +`401 {"error":"unauthorized"}` otherwise; `/healthz` and `/readyz` stay open so +load balancers can probe them. Each token must be at least 32 characters, and +empty entries are refused at boot. When unset, `/v1/*` is open, so put the +service on a private network or behind a proxy in that case. Tokens are never +logged. + ## Design Directory by directory, and why each boundary is where it is: diff --git a/SECURITY.md b/SECURITY.md index 8ce6ef9..c1cae5f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,11 +56,13 @@ rough order of how much they have shaped the design: approved for the whole remainder and the contract's `enforce` is what decides the winner. Two instances of this service do not share a counter. Do not treat an allow with an amount as a reservation. -5. **Nothing authenticates the caller.** There is no API key, no mTLS, no rate - limit — `GET /v1/capabilities/:holder` answers for any address, which means - anyone who can reach the port can enumerate who holds grants and when. The - process assumes it sits behind something that does not. Put it on a private - network or behind authentication before putting it on the internet. +5. **Authentication is opt-in.** Set `KEYGATE_API_TOKENS` and every `/v1/*` route + requires a bearer token; `/healthz` and `/readyz` stay open. There is still no + mTLS and no rate limit. When the variable is unset, `GET /v1/capabilities/:holder` + answers for any address, so anyone who can reach the port can enumerate who + holds grants and when. The process assumes that deployment sits behind + something that authenticates. Put it on a private network or behind + authentication before putting it on the internet. 6. **Absence is not proof, and only `ready` says so.** Until the first sync completes, the mirror knows nothing and every decision is `state_unsynced`; `/v1/capabilities/:holder` can answer `[]` while `stateReady` is false. A diff --git a/src/config.ts b/src/config.ts index e04f230..bf34c1c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,19 @@ const signingKey = z .string() .min(16, 'KEYGATE_SIGNING_KEY must be at least 16 bytes of secret material'); +/** Comma-separated bearer tokens. Each one is a credential, so short ones are refused. */ +const apiTokens = z + .string() + .transform((value) => value.split(',')) + .pipe( + z.array( + z + .string() + .min(1, 'KEYGATE_API_TOKENS must not contain empty entries') + .min(32, 'KEYGATE_API_TOKENS entries must be at least 32 characters'), + ), + ); + export const ConfigSchema = z.object({ port: z.coerce.number().int().min(1).max(65535).default(8080), @@ -44,6 +57,9 @@ export const ConfigSchema = z.object({ /** Shared secret used to HMAC responses; unset means responses are unsigned. */ signingKey: signingKey.optional(), + /** Bearer tokens required on every `/v1/*` route; unset means the routes are open. */ + apiTokens: apiTokens.optional(), + logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'), }); @@ -69,6 +85,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { syncIntervalMs: env['KEYGATE_SYNC_INTERVAL_MS'], syncPageSize: env['KEYGATE_SYNC_PAGE_SIZE'], signingKey: env['KEYGATE_SIGNING_KEY'], + apiTokens: env['KEYGATE_API_TOKENS'], logLevel: env['KEYGATE_LOG_LEVEL'], }); diff --git a/src/http/server.ts b/src/http/server.ts index 937c3be..ebc15d6 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -7,6 +7,7 @@ * belongs in `keygate-client`, where the caller's key never leaves their own * process. */ +import { createHash, timingSafeEqual } from 'node:crypto'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { StrKey } from '@stellar/stellar-sdk'; import { z } from 'zod'; @@ -45,6 +46,8 @@ export interface KeygateServerOptions { readonly clock: Clock; readonly sync?: StateSync; readonly signingKey?: string; + /** When non-empty, every `/v1/*` route requires one of these as a bearer token. */ + readonly apiTokens?: readonly string[]; readonly logger?: SyncLogger; readonly maxBodyBytes?: number; /** Decisions older than this are refused as `state_stale` even if fresh locally. */ @@ -109,6 +112,16 @@ async function route( }; } + // Health routes stay open above: load balancers probe them without credentials. + const apiTokens = options.apiTokens ?? []; + if (apiTokens.length > 0 && path.startsWith('/v1/') && !isAuthorised(request, apiTokens)) { + return { + status: 401, + body: { error: 'unauthorized' }, + headers: { 'www-authenticate': 'Bearer' }, + }; + } + if (method === 'GET' && path === '/v1/status') { const snapshot = options.mirror.snapshot(options.clock.now()); return { @@ -161,6 +174,32 @@ async function route( return { status: 404, body: { error: 'not_found' } }; } +/** + * Hashes both sides to a fixed length before `timingSafeEqual`, which requires + * equal lengths, so a short guess is not rejected any faster than a long one. + * Every configured token is compared even after a match, so timing does not + * reveal which token was presented. + */ +function isAuthorised(request: IncomingMessage, tokens: readonly string[]): boolean { + const header = request.headers.authorization ?? ''; + const match = /^Bearer (.+)$/.exec(header); + if (match === null) { + return false; + } + const presented = sha256(match[1] ?? ''); + let authorised = false; + for (const token of tokens) { + if (timingSafeEqual(presented, sha256(token))) { + authorised = true; + } + } + return authorised; +} + +function sha256(value: string): Buffer { + return createHash('sha256').update(value).digest(); +} + async function decide( request: IncomingMessage, options: KeygateServerOptions, diff --git a/src/index.ts b/src/index.ts index b06fa33..ec1a411 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,7 @@ export async function main( logger, maxStalenessSeconds: config.maxStalenessSeconds, ...(config.signingKey === undefined ? {} : { signingKey: config.signingKey }), + ...(config.apiTokens === undefined ? {} : { apiTokens: config.apiTokens }), }); await new Promise((resolve, reject) => { diff --git a/test/config.test.ts b/test/config.test.ts index 305cee6..ae31484 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -94,3 +94,46 @@ describe('loadConfig rejection', () => { ); }); }); + +describe('loadConfig API tokens', () => { + const TOKEN_A = 'a'.repeat(32); + const TOKEN_B = 'b'.repeat(40); + + it('reads a comma-separated list of tokens', () => { + const config = loadConfig(baseEnv({ KEYGATE_API_TOKENS: `${TOKEN_A},${TOKEN_B}` })); + + expect(config.apiTokens).toEqual([TOKEN_A, TOKEN_B]); + }); + + it('leaves the routes open when the variable is unset', () => { + expect(loadConfig(baseEnv()).apiTokens).toBeUndefined(); + }); + + it('refuses an empty entry rather than silently dropping it', () => { + let caught: unknown; + try { + loadConfig(baseEnv({ KEYGATE_API_TOKENS: `${TOKEN_A},,${TOKEN_B}` })); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ConfigError); + expect((caught as ConfigError).issues).toContain( + 'apiTokens.1: KEYGATE_API_TOKENS must not contain empty entries', + ); + }); + + it('refuses a token shorter than 32 characters', () => { + let caught: unknown; + try { + loadConfig(baseEnv({ KEYGATE_API_TOKENS: `${TOKEN_A},hunter2` })); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ConfigError); + expect((caught as ConfigError).issues).toContain( + 'apiTokens.1: KEYGATE_API_TOKENS entries must be at least 32 characters', + ); + }); +}); diff --git a/test/server.test.ts b/test/server.test.ts index 4a5621f..93f97c6 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -35,6 +35,7 @@ function grant(overrides: Partial = {}): Grant { interface ServeOptions { readonly synced?: boolean; readonly signingKey?: string; + readonly apiTokens?: readonly string[]; readonly now?: number; readonly maxBodyBytes?: number; readonly withSync?: boolean; @@ -92,6 +93,7 @@ async function serve( clock, maxStalenessSeconds: MAX_STALENESS, ...(options.signingKey === undefined ? {} : { signingKey: options.signingKey }), + ...(options.apiTokens === undefined ? {} : { apiTokens: options.apiTokens }), ...(options.maxBodyBytes === undefined ? {} : { maxBodyBytes: options.maxBodyBytes }), ...(sync === undefined ? {} : { sync }), }); @@ -360,6 +362,53 @@ describe('read-only projections', () => { }); }); +describe('bearer token authentication', () => { + const TOKEN = 'k'.repeat(32); + const WRONG = 'w'.repeat(32); + const CAPABILITIES = `/v1/capabilities/${HOLDER}`; + + it('rejects a /v1 request with no authorization header', async () => { + await serve({ apiTokens: [TOKEN] }, async ({ base }) => { + const response = await fetch(`${base}${CAPABILITIES}`); + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toBe('Bearer'); + expect(await response.json()).toEqual({ error: 'unauthorized' }); + }); + }); + + it('rejects a wrong token', async () => { + await serve({ apiTokens: [TOKEN] }, async ({ base }) => { + const response = await fetch(`${base}${CAPABILITIES}`, { + headers: { authorization: `Bearer ${WRONG}` }, + }); + expect(response.status).toBe(401); + }); + }); + + it('accepts any configured token', async () => { + await serve({ apiTokens: [WRONG, TOKEN] }, async ({ base }) => { + const response = await fetch(`${base}${CAPABILITIES}`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(response.status).toBe(200); + }); + }); + + it('keeps the health routes open without a token', async () => { + await serve({ apiTokens: [TOKEN] }, async ({ base }) => { + expect((await fetch(`${base}/healthz`)).status).toBe(200); + expect((await fetch(`${base}/readyz`)).status).toBe(200); + }); + }); + + it('leaves /v1 open when no tokens are configured', async () => { + await serve({}, async ({ base }) => { + const response = await fetch(`${base}${CAPABILITIES}`); + expect(response.status).toBe(200); + }); + }); +}); + describe('write paths are not offered', () => { it('refuses to be used as a way to mutate the registry', async () => { await serve({}, async ({ base }) => {