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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,18 @@ The local evaluator path is intentionally constrained. Local development default

Authentication is `x-api-key` with scoped access. Revocation additionally requires issuer authorization headers: `x-issuer-id`, `x-signature-timestamp`, and `x-issuer-signature`.

For the public TrustSignal verification surface, the simplest production setup is now:

- `TRUSTSIGNAL_API_KEY=<live-key>`
- optional `TRUSTSIGNAL_API_KEY_SCOPES=verify|read`

Multi-key deployments can still use:

- `API_KEYS`
- `API_KEY_SCOPES`

When `TRUSTSIGNAL_API_KEY` is set, the API accepts that key even if it is not duplicated in `API_KEYS`. This avoids drift between GitHub Actions secrets and backend allowlists for the common single-key deployment path.

The repository also still includes a legacy JWT-authenticated `/v1/*` surface used by the current JavaScript SDK:

- `POST /v1/verify-bundle`
Expand Down
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ ZK_ORACLE_URL=https://zk-oracle.internal/registry-jobs
API_KEYS=example_local_key_id
API_KEY_SCOPES=example_local_key_id=verify|read|anchor|revoke
API_KEY_DEFAULT_SCOPES=verify,read,anchor,revoke
# Canonical single-key configuration for the public TrustSignal API surface.
# When set, this key is accepted even if it is omitted from API_KEYS.
# Default scopes are verify|read unless TRUSTSIGNAL_API_KEY_SCOPES is set to a non-empty value.
TRUSTSIGNAL_API_KEY=
TRUSTSIGNAL_API_KEY_SCOPES=verify|read
REVOCATION_ISSUERS=issuer-dev=0x0000000000000000000000000000000000000000
REVOCATION_SIGNATURE_MAX_SKEW_MS=300000
CORS_ALLOWLIST=http://localhost:3000
Expand Down
27 changes: 26 additions & 1 deletion apps/api/src/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { type JWK } from 'jose';

const DEFAULT_API_KEY = 'example_local_key_id';
const DEFAULT_SCOPES = ['verify', 'read', 'anchor', 'revoke'];
const DEFAULT_TRUSTSIGNAL_API_KEY_SCOPES = ['verify', 'read'];
const DEFAULT_DEV_CORS_ORIGINS = [
'http://localhost:3000',
'http://127.0.0.1:3000',
Expand Down Expand Up @@ -76,6 +77,18 @@ function parseScopes(value: string | undefined): Set<string> {
return scopes.length ? new Set(scopes) : new Set(DEFAULT_SCOPES);
}

function parsePipeSeparatedScopes(value: string | undefined, fallback: string[]): Set<string> {
const raw = (value || '').trim();
if (!raw) return new Set(fallback);

const scopes = raw
.split('|')
.map((scope) => scope.trim())
.filter(Boolean);

return scopes.length ? new Set(scopes) : new Set(fallback);
}

function parseApiKeyScopeMapping(value: string | undefined): Map<string, Set<string>> {
const result = new Map<string, Set<string>>();
if (!value) return result;
Expand Down Expand Up @@ -230,16 +243,28 @@ export function buildSecurityConfig(env: NodeJS.ProcessEnv = process.env): Secur
const nodeEnv = env.NODE_ENV || 'development';
const defaultScopes = parseScopes(env.API_KEY_DEFAULT_SCOPES);
const scopedMappings = parseApiKeyScopeMapping(env.API_KEY_SCOPES);
const trustSignalApiKey = (env.TRUSTSIGNAL_API_KEY || '').trim();
const trustSignalApiKeyScopes = parsePipeSeparatedScopes(
env.TRUSTSIGNAL_API_KEY_SCOPES,
DEFAULT_TRUSTSIGNAL_API_KEY_SCOPES
);

const apiKeys = parseList(env.API_KEYS);
if (trustSignalApiKey && !apiKeys.includes(trustSignalApiKey)) {
apiKeys.push(trustSignalApiKey);
}
const resolvedApiKeys = apiKeys.length > 0 ? apiKeys : nodeEnv === 'production' ? [] : [DEFAULT_API_KEY];

if (nodeEnv === 'production' && resolvedApiKeys.length === 0) {
throw new Error('API_KEYS is required in production');
throw new Error('API_KEYS or TRUSTSIGNAL_API_KEY is required in production');
}

const keyMap = new Map<string, Set<string>>();
for (const key of resolvedApiKeys) {
if (key === trustSignalApiKey) {
keyMap.set(key, scopedMappings.get(key) ?? trustSignalApiKeyScopes);
continue;
}
keyMap.set(key, scopedMappings.get(key) ?? new Set(defaultScopes));
}

Expand Down
89 changes: 89 additions & 0 deletions tests/api/security-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { generateKeyPairSync } from 'node:crypto';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { buildSecurityConfig } from '../../apps/api/src/security.js';

type EnvSnapshot = Record<string, string | undefined>;

function snapshotEnv(keys: string[]): EnvSnapshot {
return Object.fromEntries(keys.map((key) => [key, process.env[key]]));
}

function restoreEnv(snapshot: EnvSnapshot) {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}

function applyProductionReceiptSigningEnv() {
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
process.env.TRUSTSIGNAL_RECEIPT_SIGNING_PRIVATE_JWK = JSON.stringify(privateKey.export({ format: 'jwk' }));
process.env.TRUSTSIGNAL_RECEIPT_SIGNING_PUBLIC_JWK = JSON.stringify(publicKey.export({ format: 'jwk' }));
process.env.TRUSTSIGNAL_RECEIPT_SIGNING_KID = 'test-kid';
}

describe('API security config', () => {
let envSnapshot: EnvSnapshot;

beforeEach(() => {
envSnapshot = snapshotEnv([
'NODE_ENV',
'API_KEYS',
'API_KEY_SCOPES',
'API_KEY_DEFAULT_SCOPES',
'TRUSTSIGNAL_API_KEY',
'TRUSTSIGNAL_API_KEY_SCOPES',
'TRUSTSIGNAL_RECEIPT_SIGNING_PRIVATE_JWK',
'TRUSTSIGNAL_RECEIPT_SIGNING_PUBLIC_JWK',
'TRUSTSIGNAL_RECEIPT_SIGNING_KID'
]);
});

afterEach(() => {
restoreEnv(envSnapshot);
});

it('accepts TRUSTSIGNAL_API_KEY without duplicating it in API_KEYS', () => {
process.env.NODE_ENV = 'production';
delete process.env.API_KEYS;
delete process.env.API_KEY_SCOPES;
delete process.env.API_KEY_DEFAULT_SCOPES;
process.env.TRUSTSIGNAL_API_KEY = 'canonical-live-key';
delete process.env.TRUSTSIGNAL_API_KEY_SCOPES;
applyProductionReceiptSigningEnv();

const config = buildSecurityConfig();

expect(config.apiKeys.has('canonical-live-key')).toBe(true);
expect(Array.from(config.apiKeys.get('canonical-live-key') ?? [])).toEqual(['verify', 'read']);
});

it('allows TRUSTSIGNAL_API_KEY_SCOPES to override the canonical key scopes', () => {
process.env.NODE_ENV = 'production';
delete process.env.API_KEYS;
delete process.env.API_KEY_SCOPES;
process.env.TRUSTSIGNAL_API_KEY = 'canonical-live-key';
process.env.TRUSTSIGNAL_API_KEY_SCOPES = 'verify|read|anchor';
applyProductionReceiptSigningEnv();

const config = buildSecurityConfig();

expect(Array.from(config.apiKeys.get('canonical-live-key') ?? [])).toEqual(['verify', 'read', 'anchor']);
});

it('still fails fast in production when no API key source is configured', () => {
process.env.NODE_ENV = 'production';
delete process.env.API_KEYS;
delete process.env.API_KEY_SCOPES;
delete process.env.TRUSTSIGNAL_API_KEY;
delete process.env.TRUSTSIGNAL_API_KEY_SCOPES;
applyProductionReceiptSigningEnv();

expect(() => buildSecurityConfig()).toThrow('API_KEYS or TRUSTSIGNAL_API_KEY is required in production');
});
});
Loading