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
7 changes: 7 additions & 0 deletions spartan/aztec-node/templates/_pod-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions spartan/aztec-node/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions spartan/aztec-validator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ validator:
replicaCount: 1

node:
# Set to false in production to enable API key auth.
noAdminApiKey: true
configMap:
envEnabled: true
secret:
Expand Down
170 changes: 170 additions & 0 deletions yarn-project/aztec/src/cli/admin_api_key_store.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be enough to check stat.mode === 0o600? I don't see why we need to go through the extra step.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0o777 strips the stat mode's upper file bits,

});

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();
});
});
});
128 changes: 128 additions & 0 deletions yarn-project/aztec/src/cli/admin_api_key_store.ts
Original file line number Diff line number Diff line change
@@ -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 `<dataDirectory>/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<AdminApiKeyResolution | undefined> {
// 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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the random source from foundation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now using the foundation's randomBytes

const hash = sha256Hash(rawKey);
return { rawKey, hash };
}
Loading
Loading