diff --git a/README.md b/README.md index 758f2ea..e57ade3 100644 --- a/README.md +++ b/README.md @@ -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=` +- 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` diff --git a/apps/api/.env.example b/apps/api/.env.example index c3692b5..d0d7ac8 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/src/security.ts b/apps/api/src/security.ts index 4574946..fe30c04 100644 --- a/apps/api/src/security.ts +++ b/apps/api/src/security.ts @@ -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', @@ -76,6 +77,18 @@ function parseScopes(value: string | undefined): Set { return scopes.length ? new Set(scopes) : new Set(DEFAULT_SCOPES); } +function parsePipeSeparatedScopes(value: string | undefined, fallback: string[]): Set { + 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> { const result = new Map>(); if (!value) return result; @@ -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>(); 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)); } diff --git a/tests/api/security-config.test.ts b/tests/api/security-config.test.ts new file mode 100644 index 0000000..a2ce14e --- /dev/null +++ b/tests/api/security-config.test.ts @@ -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; + +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'); + }); +});