diff --git a/spartan/aztec-node/templates/_pod-template.yaml b/spartan/aztec-node/templates/_pod-template.yaml index 2011f6a609a8..37f5efbb4ee1 100644 --- a/spartan/aztec-node/templates/_pod-template.yaml +++ b/spartan/aztec-node/templates/_pod-template.yaml @@ -190,6 +190,13 @@ spec: value: "{{ .Values.service.rpc.port }}" - name: AZTEC_ADMIN_PORT value: "{{ .Values.service.admin.port }}" + {{- if .Values.node.adminApiKeyHash }} + - name: AZTEC_ADMIN_API_KEY_HASH + value: {{ .Values.node.adminApiKeyHash | quote }} + {{- else if .Values.node.noAdminApiKey }} + - name: AZTEC_NO_ADMIN_API_KEY + value: "true" + {{- end }} - name: LOG_LEVEL value: "{{ .Values.node.logLevel }}" - name: LOG_JSON diff --git a/spartan/aztec-node/values.yaml b/spartan/aztec-node/values.yaml index 8f1cb7af0ce2..734b80e51d9d 100644 --- a/spartan/aztec-node/values.yaml +++ b/spartan/aztec-node/values.yaml @@ -95,6 +95,15 @@ node: envEnabled: false filesEnabled: false + # -- SHA-256 hex hash of a pre-generated admin API key. + # When set, the node uses this hash for authentication instead of auto-generating a key. + # Generate with: echo -n "your-api-key" | sha256sum | cut -d' ' -f1 + adminApiKeyHash: "" + + # -- Disable admin API key authentication. + # Set to false in production to enable API key auth. + noAdminApiKey: true + # the address that will receive block or proof rewards coinbase: diff --git a/spartan/aztec-validator/values.yaml b/spartan/aztec-validator/values.yaml index 9868263c5baa..b5112c561d8a 100644 --- a/spartan/aztec-validator/values.yaml +++ b/spartan/aztec-validator/values.yaml @@ -25,6 +25,8 @@ validator: replicaCount: 1 node: + # Set to false in production to enable API key auth. + noAdminApiKey: true configMap: envEnabled: true secret: diff --git a/yarn-project/aztec/src/cli/admin_api_key_store.test.ts b/yarn-project/aztec/src/cli/admin_api_key_store.test.ts new file mode 100644 index 000000000000..c91e05c44865 --- /dev/null +++ b/yarn-project/aztec/src/cli/admin_api_key_store.test.ts @@ -0,0 +1,170 @@ +import { sha256Hash } from '@aztec/foundation/json-rpc/server'; +import { createLogger } from '@aztec/foundation/log'; + +import { promises as fs } from 'fs'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { type ResolveAdminApiKeyOptions, resolveAdminApiKey } from './admin_api_key_store.js'; + +describe('resolveAdminApiKey', () => { + const log = createLogger('test:admin-api-key'); + let tempDir: string | undefined; + + beforeEach(() => { + tempDir = undefined; + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + describe('opt-out (noAdminApiKey = true)', () => { + it('returns undefined when auth is disabled', async () => { + const result = await resolveAdminApiKey({ noAdminApiKey: true }, log); + expect(result).toBeUndefined(); + }); + }); + + describe('ephemeral mode (no dataDirectory)', () => { + it('returns a key resolution with rawKey and apiKeyHash', async () => { + const result = await resolveAdminApiKey({}, log); + expect(result).toBeDefined(); + expect(result!.rawKey).toBeDefined(); + expect(result!.apiKeyHash).toBeDefined(); + }); + + it('returns rawKey that is a 64-char hex string', async () => { + const result = await resolveAdminApiKey({}, log); + expect(result!.rawKey).toMatch(/^[0-9a-f]{64}$/); + }); + + it('returns apiKeyHash that is SHA-256 of rawKey', async () => { + const result = await resolveAdminApiKey({}, log); + expect(result!.apiKeyHash).toEqual(sha256Hash(result!.rawKey!)); + }); + + it('generates a different key each call', async () => { + const result1 = await resolveAdminApiKey({}, log); + const result2 = await resolveAdminApiKey({}, log); + expect(result1!.rawKey).not.toBe(result2!.rawKey); + }); + }); + + describe('persistent mode (with dataDirectory)', () => { + let opts: ResolveAdminApiKeyOptions; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'aztec-api-key-test-')); + opts = { dataDirectory: tempDir }; + }); + + it('generates a new key on first run', async () => { + const result = await resolveAdminApiKey(opts, log); + expect(result).toBeDefined(); + expect(result!.rawKey).toBeDefined(); + expect(result!.rawKey).toMatch(/^[0-9a-f]{64}$/); + expect(result!.apiKeyHash).toEqual(sha256Hash(result!.rawKey!)); + }); + + it('persists the hash to disk on first run', async () => { + const result = await resolveAdminApiKey(opts, log); + const hashFilePath = join(tempDir!, 'admin', 'api_key_hash'); + const storedHash = (await fs.readFile(hashFilePath, 'utf-8')).trim(); + expect(storedHash).toBe(result!.apiKeyHash.toString('hex')); + }); + + it('sets restrictive permissions on the hash file', async () => { + await resolveAdminApiKey(opts, log); + const hashFilePath = join(tempDir!, 'admin', 'api_key_hash'); + const stat = await fs.stat(hashFilePath); + expect(stat.mode & 0o777).toBe(0o600); + }); + + it('loads the stored hash on subsequent runs (no rawKey)', async () => { + // First run, generates and persists + const firstResult = await resolveAdminApiKey(opts, log); + const firstHash = firstResult!.apiKeyHash; + + // Second run, loads from disk + const secondResult = await resolveAdminApiKey(opts, log); + + expect(secondResult).toBeDefined(); + expect(secondResult!.apiKeyHash).toEqual(firstHash); + expect(secondResult!.rawKey).toBeUndefined(); // Not newly generated + }); + + it('regenerates if stored hash is invalid (wrong length)', async () => { + // Write an invalid hash + const adminDir = join(tempDir!, 'admin'); + await fs.mkdir(adminDir, { recursive: true }); + await fs.writeFile(join(adminDir, 'api_key_hash'), 'tooshort', 'utf-8'); + + const result = await resolveAdminApiKey(opts, log); + expect(result).toBeDefined(); + expect(result!.rawKey).toBeDefined(); // Freshly generated + expect(result!.apiKeyHash).toEqual(sha256Hash(result!.rawKey!)); + }); + + it('creates the admin subdirectory if it does not exist', async () => { + await resolveAdminApiKey(opts, log); + const adminDir = join(tempDir!, 'admin'); + const stat = await fs.stat(adminDir); + expect(stat.isDirectory()).toBe(true); + }); + }); + + describe('reset (resetAdminApiKey = true)', () => { + let opts: ResolveAdminApiKeyOptions; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'aztec-api-key-test-')); + opts = { dataDirectory: tempDir, resetAdminApiKey: true }; + }); + + it('generates a new key even when a valid hash already exists', async () => { + // First run, normal generation + const firstResult = await resolveAdminApiKey({ dataDirectory: tempDir }, log); + const firstHash = firstResult!.apiKeyHash; + + // Second run with reset, should generate a new key + const resetResult = await resolveAdminApiKey(opts, log); + + expect(resetResult).toBeDefined(); + expect(resetResult!.rawKey).toBeDefined(); // New raw key returned + expect(resetResult!.apiKeyHash).not.toEqual(firstHash); // Different hash + expect(resetResult!.apiKeyHash).toEqual(sha256Hash(resetResult!.rawKey!)); + }); + + it('overwrites the persisted hash file', async () => { + // First run — normal generation + await resolveAdminApiKey({ dataDirectory: tempDir }, log); + const hashFilePath = join(tempDir!, 'admin', 'api_key_hash'); + const oldHash = (await fs.readFile(hashFilePath, 'utf-8')).trim(); + + // Reset run + const resetResult = await resolveAdminApiKey(opts, log); + const newHash = (await fs.readFile(hashFilePath, 'utf-8')).trim(); + + expect(newHash).not.toBe(oldHash); + expect(newHash).toBe(resetResult!.apiKeyHash.toString('hex')); + }); + + it('works even when no hash file exists yet (first run with reset)', async () => { + const result = await resolveAdminApiKey(opts, log); + + expect(result).toBeDefined(); + expect(result!.rawKey).toBeDefined(); + expect(result!.apiKeyHash).toEqual(sha256Hash(result!.rawKey!)); + }); + + it('has no effect in ephemeral mode (always generates anyway)', async () => { + const result = await resolveAdminApiKey({ resetAdminApiKey: true }, log); + expect(result).toBeDefined(); + expect(result!.rawKey).toBeDefined(); + }); + }); +}); diff --git a/yarn-project/aztec/src/cli/admin_api_key_store.ts b/yarn-project/aztec/src/cli/admin_api_key_store.ts new file mode 100644 index 000000000000..6d0ea07c9c81 --- /dev/null +++ b/yarn-project/aztec/src/cli/admin_api_key_store.ts @@ -0,0 +1,128 @@ +import { randomBytes } from '@aztec/foundation/crypto/random'; +import { sha256Hash } from '@aztec/foundation/json-rpc/server'; +import type { Logger } from '@aztec/foundation/log'; + +import { promises as fs } from 'fs'; +import { join } from 'path'; + +/** Subdirectory under dataDirectory for admin API key storage. */ +const ADMIN_STORE_DIR = 'admin'; +const HASH_FILE_NAME = 'api_key_hash'; + +/** + * Result of resolving the admin API key. + * Contains the SHA-256 hex hash of the API key to be used by the auth middleware, + * and optionally the raw key when newly generated (so the caller can display it). + */ +export interface AdminApiKeyResolution { + /** The SHA-256 hash of the API key. */ + apiKeyHash: Buffer; + /** + * The raw API key, only present when a new key was generated during this call. + * The caller MUST display this to the operator — it will not be stored or returned again. + */ + rawKey?: string; +} + +export interface ResolveAdminApiKeyOptions { + /** SHA-256 hex hash of a pre-generated API key. When set, the node uses this hash directly. */ + adminApiKeyHash?: string; + /** If true, disable admin API key auth entirely. */ + noAdminApiKey?: boolean; + /** If true, force-generate a new key even if one is already persisted. */ + resetAdminApiKey?: boolean; + /** Root data directory for persistent storage. */ + dataDirectory?: string; +} + +/** + * Resolves the admin API key for the admin RPC endpoint. + * + * Strategy: + * 1. If opt-out flag is set (`noAdminApiKey`), return undefined (no auth). + * 2. If a pre-generated hash is provided (`adminApiKeyHash`), use it directly. + * 3. If a data directory exists, look for a persisted hash file + * at `/admin/api_key_hash`: + * - If `resetAdminApiKey` is set, skip loading and force-generate a new key. + * - Found: use the stored hash (operator already saved the key from first run). + * - Not found: auto-generate a random key, display it once, persist the hash. + * 3. If no data directory: generate a random key + * each run and display it (cannot persist). + * + * @param options - The options for resolving the admin API key. + * @param log - Logger for outputting the key and status messages. + * @returns The resolved API key hash, or undefined if auth is disabled. + */ +export async function resolveAdminApiKey( + options: ResolveAdminApiKeyOptions, + log: Logger, +): Promise { + // Operator explicitly opted out of admin auth + if (options.noAdminApiKey) { + log.warn('Admin API key authentication is DISABLED (--no-admin-api-key / AZTEC_NO_ADMIN_API_KEY)'); + return undefined; + } + + // Operator provided a pre-generated hash (e.g. via AZTEC_ADMIN_API_KEY_HASH env var) + if (options.adminApiKeyHash) { + const hex = options.adminApiKeyHash.trim(); + if (hex.length !== 64 || !/^[0-9a-f]{64}$/.test(hex)) { + throw new Error(`Invalid admin API key hash: expected 64-char hex string, got "${hex}"`); + } + log.info('Admin API key authentication enabled (using pre-configured key hash)'); + return { apiKeyHash: Buffer.from(hex, 'hex') }; + } + + // Persistent storage available, load or generate key + if (options.dataDirectory) { + const adminDir = join(options.dataDirectory, ADMIN_STORE_DIR); + const hashFilePath = join(adminDir, HASH_FILE_NAME); + + // Unless a reset is forced, try to load the existing hash from disk + if (!options.resetAdminApiKey) { + try { + const storedHash = (await fs.readFile(hashFilePath, 'utf-8')).trim(); + if (storedHash.length === 64) { + log.info('Admin API key authentication enabled (loaded stored key hash from disk)'); + return { apiKeyHash: Buffer.from(storedHash, 'hex') }; + } + log.warn(`Invalid stored admin API key hash at ${hashFilePath}, regenerating...`); + } catch (err: any) { + if (err.code !== 'ENOENT') { + log.warn(`Failed to read admin API key hash from ${hashFilePath}: ${err.message}`); + } + // File doesn't exist — fall through to generate + } + } else { + log.warn('Admin API key reset requested — generating a new key'); + } + + // Generate a new key, persist the hash, and return the raw key for the caller to display + const { rawKey, hash } = generateApiKey(); + await fs.mkdir(adminDir, { recursive: true }); + await fs.writeFile(hashFilePath, hash.toString('hex'), 'utf-8'); + // Set restrictive permissions (owner read/write only) + await fs.chmod(hashFilePath, 0o600); + + log.info('Admin API key authentication enabled (new key generated and hash persisted to disk)'); + return { apiKeyHash: hash, rawKey }; + } + + // No data directory, generate a temporary key per session + const { rawKey, hash } = generateApiKey(); + + log.warn('No data directory configured — admin API key cannot be persisted.'); + log.warn('A temporary key has been generated for this session only.'); + + return { apiKeyHash: hash, rawKey }; +} + +/** + * Generates a cryptographically random API key and its SHA-256 hash. + * @returns The raw key (hex string) and its SHA-256 hash as a Buffer. + */ +function generateApiKey(): { rawKey: string; hash: Buffer } { + const rawKey = randomBytes(32).toString('hex'); + const hash = sha256Hash(rawKey); + return { rawKey, hash }; +} diff --git a/yarn-project/aztec/src/cli/aztec_start_action.ts b/yarn-project/aztec/src/cli/aztec_start_action.ts index 8217313dd09c..8d452f21f276 100644 --- a/yarn-project/aztec/src/cli/aztec_start_action.ts +++ b/yarn-project/aztec/src/cli/aztec_start_action.ts @@ -1,6 +1,7 @@ import { type NamespacedApiHandlers, createNamespacedSafeJsonRpcServer, + getApiKeyAuthMiddleware, startHttpRpcServer, } from '@aztec/foundation/json-rpc/server'; import type { LogFn, Logger } from '@aztec/foundation/log'; @@ -11,6 +12,7 @@ import { getOtelJsonRpcPropagationMiddleware } from '@aztec/telemetry-client'; import { createLocalNetwork } from '../local-network/index.js'; import { github, splash } from '../splash.js'; +import { resolveAdminApiKey } from './admin_api_key_store.js'; import { getCliVersion } from './release_version.js'; import { extractNamespacedOptions, installSignalHandlers } from './util.js'; import { getVersions } from './versioning.js'; @@ -99,14 +101,54 @@ export async function aztecStart(options: any, userLog: LogFn, debugLogger: Logg // If there are any admin services, start a separate JSON-RPC server for them if (Object.entries(adminServices).length > 0) { + const adminMiddlewares = [getOtelJsonRpcPropagationMiddleware(), getVersioningMiddleware(versions)]; + + // Resolve the admin API key (auto-generated and persisted, or opt-out) + const apiKeyResolution = await resolveAdminApiKey( + { + adminApiKeyHash: options.adminApiKeyHash, + noAdminApiKey: options.noAdminApiKey, + resetAdminApiKey: options.resetAdminApiKey, + dataDirectory: options.dataDirectory, + }, + debugLogger, + ); + if (apiKeyResolution) { + adminMiddlewares.unshift(getApiKeyAuthMiddleware(apiKeyResolution.apiKeyHash)); + } else { + debugLogger.warn('No admin API key set — admin endpoint is unauthenticated'); + } + const rpcServer = createNamespacedSafeJsonRpcServer(adminServices, { http200OnError: false, log: debugLogger, - middlewares: [getOtelJsonRpcPropagationMiddleware(), getVersioningMiddleware(versions)], + middlewares: adminMiddlewares, maxBatchSize: options.rpcMaxBatchSize, maxBodySizeBytes: options.rpcMaxBodySize, }); const { port } = await startHttpRpcServer(rpcServer, { port: options.adminPort }); debugLogger.info(`Aztec Server admin API listening on port ${port}`, versions); + + // Display the API key after the server has started + // Uses userLog which is never filtered by LOG_LEVEL. + if (apiKeyResolution?.rawKey) { + const separator = '='.repeat(70); + userLog(''); + userLog(separator); + userLog(' ADMIN API KEY (save this — it will NOT be shown again)'); + userLog(''); + userLog(` ${apiKeyResolution.rawKey}`); + userLog(''); + userLog(` Use via header: x-api-key: `); + userLog(` Or via header: Authorization: Bearer `); + if (options.dataDirectory) { + userLog(''); + userLog(' The key hash has been persisted — on next restart, the same key will be used.'); + } + userLog(''); + userLog(' To disable admin auth: --no-admin-api-key or AZTEC_NO_ADMIN_API_KEY=true'); + userLog(separator); + userLog(''); + } } } diff --git a/yarn-project/aztec/src/cli/aztec_start_options.ts b/yarn-project/aztec/src/cli/aztec_start_options.ts index 46ef250c1359..48bec01d75cc 100644 --- a/yarn-project/aztec/src/cli/aztec_start_options.ts +++ b/yarn-project/aztec/src/cli/aztec_start_options.ts @@ -142,6 +142,29 @@ export const aztecStartOptions: { [key: string]: AztecStartOption[] } = { env: 'AZTEC_ADMIN_PORT', parseVal: val => parseInt(val, 10), }, + { + flag: '--admin-api-key-hash ', + description: + 'SHA-256 hex hash of a pre-generated admin API key. When set, the node uses this hash for authentication instead of auto-generating a key.', + defaultValue: undefined, + env: 'AZTEC_ADMIN_API_KEY_HASH', + }, + { + flag: '--no-admin-api-key', + description: + 'Disable API key authentication on the admin RPC endpoint. By default, a key is auto-generated, displayed once, and its hash is persisted.', + defaultValue: false, + env: 'AZTEC_NO_ADMIN_API_KEY', + parseVal: val => val === 'true' || val === '1', + }, + { + flag: '--reset-admin-api-key', + description: + 'Force-generate a new admin API key, replacing any previously persisted key hash. The new key is displayed once at startup.', + defaultValue: false, + env: 'AZTEC_RESET_ADMIN_API_KEY', + parseVal: val => val === 'true' || val === '1', + }, { flag: '--api-prefix ', description: 'Prefix for API routes on any service that is started', diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index fbc42a161bdb..17c7bbcc21a3 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -12,6 +12,9 @@ export type EnvVar = | 'ARCHIVER_VIEM_POLLING_INTERVAL_MS' | 'ARCHIVER_BATCH_SIZE' | 'AZTEC_ADMIN_PORT' + | 'AZTEC_ADMIN_API_KEY_HASH' + | 'AZTEC_NO_ADMIN_API_KEY' + | 'AZTEC_RESET_ADMIN_API_KEY' | 'AZTEC_NODE_ADMIN_URL' | 'AZTEC_NODE_URL' | 'AZTEC_PORT' diff --git a/yarn-project/foundation/src/json-rpc/client/safe_json_rpc_client.ts b/yarn-project/foundation/src/json-rpc/client/safe_json_rpc_client.ts index 926b227b7cdf..b10c6535f8ed 100644 --- a/yarn-project/foundation/src/json-rpc/client/safe_json_rpc_client.ts +++ b/yarn-project/foundation/src/json-rpc/client/safe_json_rpc_client.ts @@ -24,6 +24,7 @@ export type SafeJsonRpcClientOptions = { batchWindowMS?: number; maxBatchSize?: number; maxRequestBodySize?: number; + extraHeaders?: Record; onResponse?: (res: { response: any; headers: { get: (header: string) => string | null | undefined }; @@ -129,6 +130,7 @@ export function createSafeJsonRpcClient( const { response, headers } = await fetch( host, rpcCalls.map(({ request }) => request), + config.extraHeaders, ); if (config.onResponse) { diff --git a/yarn-project/foundation/src/json-rpc/server/api_key_auth.integration.test.ts b/yarn-project/foundation/src/json-rpc/server/api_key_auth.integration.test.ts new file mode 100644 index 000000000000..fb9e6077480e --- /dev/null +++ b/yarn-project/foundation/src/json-rpc/server/api_key_auth.integration.test.ts @@ -0,0 +1,140 @@ +import type http from 'http'; + +import { makeFetch } from '../client/fetch.js'; +import { createSafeJsonRpcClient } from '../client/safe_json_rpc_client.js'; +import { TestNote, TestState, type TestStateApi, TestStateSchema } from '../fixtures/test_state.js'; +import { getApiKeyAuthMiddleware, sha256Hash } from './api_key_auth.js'; +import { createSafeJsonRpcServer, startHttpRpcServer } from './safe_json_rpc_server.js'; + +describe('API key auth integration', () => { + const RAW_API_KEY = 'integration-test-api-key-0123456789abcdef0123456789abcdef'; + const API_KEY_HASH = sha256Hash(RAW_API_KEY); + + let testState: TestState; + let httpServer: http.Server & { port: number }; + let url: string; + + beforeEach(async () => { + testState = new TestState([new TestNote('a'), new TestNote('b')]); + const rpcServer = createSafeJsonRpcServer(testState, TestStateSchema, { + middlewares: [getApiKeyAuthMiddleware(API_KEY_HASH)], + }); + httpServer = await startHttpRpcServer(rpcServer, { host: '127.0.0.1' }); + url = `http://127.0.0.1:${httpServer.port}`; + }); + + afterEach(() => { + httpServer?.close(); + }); + + const noRetryFetch = makeFetch([], true); + + function createClient(apiKey?: string) { + return createSafeJsonRpcClient(url, TestStateSchema, { + fetch: noRetryFetch, + ...(apiKey ? { extraHeaders: { 'x-api-key': apiKey } } : {}), + }); + } + + function createClientWithBearer(apiKey: string) { + return createSafeJsonRpcClient(url, TestStateSchema, { + fetch: noRetryFetch, + extraHeaders: { Authorization: `Bearer ${apiKey}` }, + }); + } + + describe('with valid API key', () => { + it('allows RPC calls via x-api-key header', async () => { + const client = createClient(RAW_API_KEY); + const count = await client.count(); + expect(count).toBe(2); + }); + + it('allows RPC calls via Authorization: Bearer header', async () => { + const client = createClientWithBearer(RAW_API_KEY); + const note = await client.getNote(0); + expect(note?.toString()).toBe('a'); + }); + + it('allows multiple sequential calls', async () => { + const client = createClient(RAW_API_KEY); + const count1 = await client.count(); + await client.addNotes([new TestNote('c')]); + const count2 = await client.count(); + expect(count1).toBe(2); + expect(count2).toBe(3); + }); + }); + + describe('with invalid API key', () => { + it('rejects RPC calls with wrong key', async () => { + const client = createClient('wrong-api-key'); + await expect(client.count()).rejects.toThrow(); + }); + + it('rejects RPC calls with empty key header', async () => { + const client = createClient(''); + await expect(client.count()).rejects.toThrow(); + }); + }); + + describe('with no API key', () => { + it('rejects RPC calls without any auth header', async () => { + const client = createClient(); // no key + await expect(client.count()).rejects.toThrow(); + }); + }); + + describe('health check bypass', () => { + it('allows GET /status without auth', async () => { + const response = await fetch(`${url}/status`); + expect(response.status).toBe(200); + }); + }); + + describe('full flow: generate key, authenticate, reject bad key', () => { + it('simulates the operator flow end-to-end', async () => { + // 1: "Generate" an API key (simulating what resolveAdminApiKey does) + const { randomBytes } = await import('crypto'); + const generatedKey = randomBytes(32).toString('hex'); + const generatedHash = sha256Hash(generatedKey); + + // 2: Start a NEW server with the generated hash + const freshState = new TestState([new TestNote('x'), new TestNote('y')]); + const freshRpcServer = createSafeJsonRpcServer(freshState, TestStateSchema, { + middlewares: [getApiKeyAuthMiddleware(generatedHash)], + }); + const freshHttpServer = await startHttpRpcServer(freshRpcServer, { host: '127.0.0.1' }); + const freshUrl = `http://127.0.0.1:${freshHttpServer.port}`; + + try { + // 3: Make an authenticated request — should succeed + const goodClient = createSafeJsonRpcClient(freshUrl, TestStateSchema, { + fetch: noRetryFetch, + extraHeaders: { 'x-api-key': generatedKey }, + }); + const count = await goodClient.count(); + expect(count).toBe(2); + + // 4: Make a request with a bad key, should fail + const badClient = createSafeJsonRpcClient(freshUrl, TestStateSchema, { + fetch: noRetryFetch, + extraHeaders: { 'x-api-key': 'definitely-not-the-right-key' }, + }); + await expect(badClient.count()).rejects.toThrow(); + + // 5: Make a request with no key, should fail + const noAuthClient = createSafeJsonRpcClient(freshUrl, TestStateSchema, { + fetch: noRetryFetch, + }); + await expect(noAuthClient.count()).rejects.toThrow(); + + // 6: Health check should still work without auth + const statusResp = await fetch(`${freshUrl}/status`); + expect(statusResp.status).toBe(200); + } finally { + freshHttpServer.close(); + } + }); + }); +}); diff --git a/yarn-project/foundation/src/json-rpc/server/api_key_auth.test.ts b/yarn-project/foundation/src/json-rpc/server/api_key_auth.test.ts new file mode 100644 index 000000000000..5d0ad7e826ba --- /dev/null +++ b/yarn-project/foundation/src/json-rpc/server/api_key_auth.test.ts @@ -0,0 +1,91 @@ +import Koa from 'koa'; +import request from 'supertest'; + +import { getApiKeyAuthMiddleware, sha256Hash } from './api_key_auth.js'; + +describe('getApiKeyAuthMiddleware', () => { + const RAW_API_KEY = 'test-api-key-for-unit-tests-1234567890abcdef'; + const API_KEY_HASH = sha256Hash(RAW_API_KEY); + + let app: Koa; + + beforeEach(() => { + app = new Koa(); + app.use(getApiKeyAuthMiddleware(API_KEY_HASH)); + // A simple handler that returns 200 if middleware passes + app.use((ctx: Koa.Context) => { + ctx.status = 200; + ctx.body = { jsonrpc: '2.0', result: 'ok' }; + }); + }); + + const sendPost = (headers: Record = {}) => + request(app.callback()) + .post('/') + .send({ jsonrpc: '2.0', method: 'test', params: [], id: 1 }) + .set({ 'content-type': 'application/json', ...headers }); + + describe('x-api-key header', () => { + it('allows request with valid API key', async () => { + const response = await sendPost({ 'x-api-key': RAW_API_KEY }); + expect(response.status).toBe(200); + expect(response.body.result).toBe('ok'); + }); + + it('rejects request with invalid API key', async () => { + const response = await sendPost({ 'x-api-key': 'wrong-key' }); + expect(response.status).toBe(401); + expect(response.body.error.message).toContain('Unauthorized'); + }); + }); + + describe('Authorization: Bearer header', () => { + it('allows request with valid Bearer token', async () => { + const response = await sendPost({ Authorization: `Bearer ${RAW_API_KEY}` }); + expect(response.status).toBe(200); + expect(response.body.result).toBe('ok'); + }); + + it('allows case-insensitive Bearer prefix', async () => { + const response = await sendPost({ Authorization: `bearer ${RAW_API_KEY}` }); + expect(response.status).toBe(200); + }); + + it('rejects request with invalid Bearer token', async () => { + const response = await sendPost({ Authorization: 'Bearer wrong-key' }); + expect(response.status).toBe(401); + expect(response.body.error.message).toContain('Unauthorized'); + }); + }); + + describe('missing credentials', () => { + it('rejects request with no auth headers', async () => { + const response = await sendPost(); + expect(response.status).toBe(401); + expect(response.body.error.message).toContain('Unauthorized'); + }); + + it('returns a JSON-RPC error envelope', async () => { + const response = await sendPost(); + expect(response.body).toMatchObject({ + jsonrpc: '2.0', + id: null, + error: { code: -32000 }, + }); + }); + }); + + describe('health check bypass', () => { + it('allows GET /status without any auth', async () => { + const response = await request(app.callback()).get('/status'); + // The status endpoint itself isn't handled by our simple handler, + // but the important thing is the middleware does NOT return 401. + expect(response.status).not.toBe(401); + }); + + it('still requires auth for POST /status', async () => { + const response = await request(app.callback()).post('/status').send({}); + expect(response.status).toBe(401); + }); + }); +}); diff --git a/yarn-project/foundation/src/json-rpc/server/api_key_auth.ts b/yarn-project/foundation/src/json-rpc/server/api_key_auth.ts new file mode 100644 index 000000000000..a22d45730f32 --- /dev/null +++ b/yarn-project/foundation/src/json-rpc/server/api_key_auth.ts @@ -0,0 +1,63 @@ +import { timingSafeEqual } from 'crypto'; +import type Koa from 'koa'; + +import { sha256 } from '../../crypto/sha256/index.js'; +import { createLogger } from '../../log/index.js'; + +const log = createLogger('json-rpc:api-key-auth'); + +/** + * Computes the SHA-256 hash of a string and returns it as a Buffer. + * @param input - The input string to hash. + * @returns The SHA-256 hash as a Buffer. + */ +export function sha256Hash(input: string): Buffer { + return sha256(Buffer.from(input)); +} + +/** + * Creates a Koa middleware that enforces API key authentication on all requests + * except the health check endpoint (GET /status). + * + * The API key can be provided via the `x-api-key` header or the `Authorization: Bearer ` header. + * Comparison is done by hashing the provided key with SHA-256 and comparing against the stored hash. + * + * @param apiKeyHash - The SHA-256 hash of the expected API key as a Buffer. + * @returns A Koa middleware that rejects requests without a valid API key. + */ +export function getApiKeyAuthMiddleware( + apiKeyHash: Buffer, +): (ctx: Koa.Context, next: () => Promise) => Promise { + return async (ctx: Koa.Context, next: () => Promise) => { + // Allow health check through without auth + if (ctx.path === '/status' && ctx.method === 'GET') { + return next(); + } + + const providedKey = ctx.get('x-api-key') || ctx.get('authorization')?.replace(/^Bearer\s+/i, ''); + if (!providedKey) { + log.warn(`Rejected admin RPC request from ${ctx.ip}: missing API key`); + ctx.status = 401; + ctx.body = { + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Unauthorized: invalid or missing API key' }, + }; + return; + } + + const providedHashBuf = sha256Hash(providedKey); + if (!timingSafeEqual(apiKeyHash, providedHashBuf)) { + log.warn(`Rejected admin RPC request from ${ctx.ip}: invalid API key`); + ctx.status = 401; + ctx.body = { + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Unauthorized: invalid or missing API key' }, + }; + return; + } + + await next(); + }; +} diff --git a/yarn-project/foundation/src/json-rpc/server/index.ts b/yarn-project/foundation/src/json-rpc/server/index.ts index 048e53af1fcc..2e35821006ed 100644 --- a/yarn-project/foundation/src/json-rpc/server/index.ts +++ b/yarn-project/foundation/src/json-rpc/server/index.ts @@ -1 +1,2 @@ +export * from './api_key_auth.js'; export * from './safe_json_rpc_server.js'; diff --git a/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts b/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts index 1003734261f8..6071b1adc310 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts @@ -94,10 +94,12 @@ export function createAztecNodeAdminClient( url: string, versions: Partial = {}, fetch = defaultFetch, + apiKey?: string, ): AztecNodeAdmin { return createSafeJsonRpcClient(url, AztecNodeAdminApiSchema, { namespaceMethods: 'nodeAdmin', fetch, onResponse: getVersioningResponseHandler(versions), + ...(apiKey ? { extraHeaders: { 'x-api-key': apiKey } } : {}), }); }