Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`; /healthz and /readyz stay open. Each token must
# be at least 32 characters. Generate one with: openssl rand -hex 32
# KEYGATE_API_TOKENS=
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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:
Expand Down
12 changes: 7 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down Expand Up @@ -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'),
});

Expand All @@ -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'],
});

Expand Down
39 changes: 39 additions & 0 deletions src/http/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve, reject) => {
Expand Down
43 changes: 43 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
49 changes: 49 additions & 0 deletions test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function grant(overrides: Partial<Grant> = {}): Grant {
interface ServeOptions {
readonly synced?: boolean;
readonly signingKey?: string;
readonly apiTokens?: readonly string[];
readonly now?: number;
readonly maxBodyBytes?: number;
readonly withSync?: boolean;
Expand Down Expand Up @@ -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 }),
});
Expand Down Expand Up @@ -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 }) => {
Expand Down