diff --git a/package-lock.json b/package-lock.json index c8ee7e9..49c40d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "zeroauth", "version": "2.0.0", + "workspaces": [ + "verifier" + ], "dependencies": { "circomlibjs": "^0.1.7", "cookie-parser": "^1.4.6", @@ -4096,6 +4099,10 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@zeroauth/verifier": { + "resolved": "verifier", + "link": true + }, "node_modules/abbrev": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", @@ -14335,6 +14342,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "verifier": { + "name": "@zeroauth/verifier", + "version": "0.1.0", + "dependencies": { + "express": "^4.18.2", + "snarkjs": "^0.7.6", + "uuid": "^9.0.0", + "winston": "^3.11.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.6", + "@types/snarkjs": "^0.7.9", + "@types/uuid": "^9.0.7", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } } } } diff --git a/package.json b/package.json index 797def8..fa4508b 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,16 @@ "version": "2.0.0", "description": "Enterprise SSO Authentication API with Blockchain-Anchored Decentralized Identity — Zero biometric data stored. Ever.", "main": "dist/server.js", + "workspaces": [ + "verifier" + ], "scripts": { "build": "tsc", "start": "node dist/server.js", "dev": "tsx watch src/server.ts", + "verifier:dev": "npm --workspace @zeroauth/verifier run dev", + "verifier:build": "npm --workspace @zeroauth/verifier run build", + "verifier:start": "npm --workspace @zeroauth/verifier run start", "docs:site:start": "npm --prefix website run start -- --port 3001 --host 0.0.0.0", "docs:site:build": "npm --prefix website run build", "docs:site:serve": "npm --prefix website run serve -- --port 3001 --host 0.0.0.0", diff --git a/qa-log/2026-05-14.md b/qa-log/2026-05-14.md new file mode 100644 index 0000000..f54d4b1 --- /dev/null +++ b/qa-log/2026-05-14.md @@ -0,0 +1,86 @@ +# QA Log — 2026-05-14 + +**Run by:** Pulkit Pareek (alone — Amit not in this rehearsal) +**Time:** 09:55 IST (DW01 canonical slot — second run, first scheduled-on-cadence entry) +**Build:** + +- API (`pulkitpareek18/ZeroAuth`): `ad2a04a` on `main` (== `dev` after yesterday's PR #28 squash-merge + sync) +- Governance (`pulkitpareek18/ZeroAuth-Governance`): `bad10e7` on `main` +- IoT firmware: **not built** (B03 — Week 3) +- Mobile SDK: **not built** (B04 — Week 5) +- Liveness detection: **not built** (B13 — Week 3 / Week 5) +- Offline queue: **not built** (B14 — Week 4) + +## Results — four-demo battery + +### Demo 1 — Printed photo rejection + +**Status:** Blocked +**Note:** No IoT terminal hardware (Orange Pi 5 + Astra Pro Plus not ordered). No liveness detection code (B13 unbuilt). Unblocks when B03 + B13 ship in Week 3. (Unchanged from 2026-05-13.) + +### Demo 2 — Airplane mode authentication + +**Status:** Blocked +**Note:** No IoT firmware + no offline queue. Unblocks when B14 ships in Week 4. (Unchanged.) + +### Demo 3 — Three-different-hashes for the same identity + +**Status:** Blocked +**Note:** Three-mode LSH bucket protocol (B10) unbuilt. Unblocks when B10 ships (Week 3+). (Unchanged.) + +### Demo 4 — Hand-the-phone (impostor) + +**Status:** Blocked +**Note:** No mobile SDK + no on-device liveness. Unblocks when B04 + B13 ship in Week 5. (Unchanged.) + +## Surrogate smoke (while battery is Blocked) + +### S-1 — API reachability against production + +**Status:** Green +**Method:** `curl` with `Authorization: Bearer za_live_…` (the live default key for tenant `2c648045-e32c-4943-9629-7ef9206aaac2`). + +| Endpoint | HTTP code | +|---|---| +| `GET /v1/audit` | 200 | +| `GET /v1/devices` | 200 | +| `GET /v1/users` | 200 | +| `GET /v1/verifications` | 200 | +| `GET /v1/attendance` | 200 | +| `GET /api/health` | 200 | + +### S-2 — Yesterday's security fixes still working in production + +**Status:** Green (implicit — production hasn't been redeployed since yesterday's 08:28 UTC merge of PR #28; behavior matches the 64 → 68 tests passing in CI on that commit) + +Per-tenant write rate-limiter (F-4), `jti`+`aud` on console JWT (F-5), `parseLimit` validation (F-6), `actor_type='console'` plumbing (F-3), threat-model A-09 reconciliation (F-1), machine-code error fields (F-7) — all shipped to production yesterday as part of `ad2a04a`. No regressions observed. + +### S-3 — Playwright happy-path E2E + +**Status:** Green +**Reference:** CI on commit `ad2a04a` (yesterday's PR #28 merge). Not re-run today. + +### S-4 — Unit + integration suites + +**Status:** Green +**Result:** 68 tests passing on `main` (Jest backend + Vitest dashboard). + +## Rollup + +**Overall:** **HOLD** + +Unchanged from yesterday — HOLD stays in place until B03/B04/B13/B14 ship. Surrogate smokes green confirms engineering is alive; nothing is regressing. + +## Escalations + +None today. No regressions in production. No new blockers introduced. + +## Operator notes + +- This is the first DW01 entry that fired on cadence (09:55 IST). Yesterday's seed was the format establishment; this is the discipline check. +- Today's plan: execute B02 via Plan B (TypeScript workspace, not Rust). Verifier gets split into a separate npm workspace; API repo's `src/services/zkp.ts` becomes a thin HTTP client. ETA EOD today. +- The cadence is the metronome. Tomorrow's entry should land at 09:55 IST Friday before the W05 review. + +--- + +LAST_UPDATED: 2026-05-14 diff --git a/qa-log/LATEST.md b/qa-log/LATEST.md index b73995b..5aa8b50 100644 --- a/qa-log/LATEST.md +++ b/qa-log/LATEST.md @@ -1,9 +1,9 @@ # Latest QA Run -→ [`2026-05-13.md`](2026-05-13.md) +→ [`2026-05-14.md`](2026-05-14.md) -**Rollup:** HOLD (every demo Blocked; surrogate smokes green) -**Date:** 2026-05-13 -**Next run:** Thursday 2026-05-14 at 09:55 IST +**Rollup:** HOLD (every demo Blocked; surrogate smokes green; production stable on `ad2a04a`) +**Date:** 2026-05-14 +**Next run:** Friday 2026-05-15 at 09:55 IST (before the W05 review at 16:00 IST) (This file is overwritten on every run. For history, see the dated files in this directory.) diff --git a/src/config/index.ts b/src/config/index.ts index 1d21bdb..46d2718 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -92,6 +92,13 @@ export const config = { wasmPath: process.env.ZKP_WASM_PATH ?? 'circuits/build/identity_proof_js/identity_proof.wasm', zkeyPath: process.env.ZKP_ZKEY_PATH ?? 'circuits/build/circuit_final.zkey', vkeyPath: process.env.ZKP_VKEY_PATH ?? 'circuits/build/verification_key.json', + // B02 — the verifier service ([Plan B, TS workspace](../../verifier/README.md)). + // When set, src/services/zkp.ts delegates Groth16 verification over + // loopback HTTP instead of running snarkjs inline. Unset → inline + // fallback (the v0 behavior; will be removed in a follow-up once the + // verifier is in production). + verifierUrl: process.env.VERIFIER_URL ?? '', + verifierTimeoutMs: parseInt(process.env.VERIFIER_TIMEOUT_MS ?? '2000', 10), }, redis: { diff --git a/src/services/zkp.ts b/src/services/zkp.ts index 7660b46..c3fe0f6 100644 --- a/src/services/zkp.ts +++ b/src/services/zkp.ts @@ -6,57 +6,90 @@ import { logger } from './logger'; import { Groth16Proof, ZKPVerificationRequest, ZKPVerificationResponse } from '../types'; import { verifyProofOnChain } from './blockchain'; -// snarkjs loaded dynamically +/** + * Patent Module 216 — ZKP Verification + * + * Two execution modes, selected by config: + * + * 1. **Verifier service (preferred).** When `config.zkp.verifierUrl` is + * set, this module is a thin HTTP client to the loopback verifier + * service ([verifier/README.md](../../verifier/README.md)). The + * verifier loads snarkjs, holds the verification key, and runs the + * Groth16 verify; this module remains responsible for replay + * defense (nonce + timestamp window + signal shape) and for the + * optional on-chain re-verification. + * + * 2. **Inline fallback (legacy / dev).** When `verifierUrl` is unset + * this module loads snarkjs + the vkey itself, the way it always + * did. Marked for removal once the verifier service ships to + * production (Friday Day 5 work) — DO NOT lean on this for new + * features. + * + * Either way, the server **only verifies** proofs — it never generates + * them. Proof generation happens client-side using snarkjs in the + * browser or on the IoT terminal. + * + * The split is documented in [docs/design/verifier-service-split.md](../../docs/design/verifier-service-split.md). + */ + +// ─── Inline fallback state ─────────────────────────────────────────── +// Populated only when VERIFIER_URL is unset. Module-level singleton; one +// load per process per the v0 behavior. + let snarkjs: any = null; let verificationKey: any = null; +let verifierServiceReady = false; + +function useVerifierService(): boolean { + return Boolean(config.zkp.verifierUrl); +} /** - * Patent Module 216 — ZKP Verification - * - * Initialize the verification key at server startup. - * The server ONLY verifies proofs — it never generates them. - * Proof generation happens client-side using snarkjs in the browser. + * Startup hook. Wires whichever mode is active. */ export async function initZKP(): Promise { - snarkjs = await import('snarkjs'); + if (useVerifierService()) { + // Probe the verifier's /health endpoint at startup so a misconfigured + // VERIFIER_URL fails loud and early instead of on the first proof. + try { + const res = await fetch(`${config.zkp.verifierUrl}/health`, { + signal: AbortSignal.timeout(config.zkp.verifierTimeoutMs), + }); + if (!res.ok) throw new Error(`verifier health returned ${res.status}`); + const body = await res.json() as { status: string; vkeyAvailable: boolean; version: string }; + verifierServiceReady = true; + logger.info('ZKP: verifier service reachable', { + url: config.zkp.verifierUrl, + verifierStatus: body.status, + verifierVkeyAvailable: body.vkeyAvailable, + verifierVersion: body.version, + }); + } catch (err) { + verifierServiceReady = false; + logger.error('ZKP: verifier service unreachable at startup — proofs will fail until restored', { + url: config.zkp.verifierUrl, + error: (err as Error).message, + }); + } + return; + } + // Inline fallback path + snarkjs = await import('snarkjs'); const vkeyPath = path.resolve(process.cwd(), config.zkp.vkeyPath); if (fs.existsSync(vkeyPath)) { const vkeyData = fs.readFileSync(vkeyPath, 'utf-8'); verificationKey = JSON.parse(vkeyData); - logger.info('ZKP: Verification key loaded', { path: vkeyPath }); + logger.info('ZKP: verification key loaded (inline fallback)', { path: vkeyPath }); } else { - logger.warn('ZKP: Verification key not found — ZKP verification will use fallback mode', { + logger.warn('ZKP: verification key not found — inline fallback will use structural validation only', { path: vkeyPath, }); } } /** - * Verify a Groth16 proof off-chain (fast, free, ~10ms) - * - * Patent Claim 6: "verify the zero-knowledge proof by the server - * without accessing the identity data" - */ -export async function verifyProofOffChain( - proof: Groth16Proof, - publicSignals: string[], -): Promise { - if (!snarkjs || !verificationKey) { - throw new Error('ZKP not initialized. Call initZKP() first.'); - } - - try { - const result = await snarkjs.groth16.verify(verificationKey, publicSignals, proof); - return result; - } catch (err) { - logger.error('ZKP: Off-chain verification error', { error: (err as Error).message }); - return false; - } -} - -/** - * Full biometric proof verification flow + * Full biometric proof verification flow. * * CRITICAL INVARIANT: Zero biometric data stored. Ever. * The server receives only the mathematical proof and public signals. @@ -69,65 +102,41 @@ export async function verifyBiometricProof( // Validate required fields if (!proof || !publicSignals || !nonce || !timestamp) { - logger.warn('ZKP: Verification failed — missing required fields'); - return { - verified: false, - sessionId: uuidv4(), - dataStored: false, - timestamp: new Date().toISOString(), - }; + logger.warn('ZKP: verification failed — missing required fields'); + return rejected(); } - // Validate timestamp window (5 minutes) + // Validate timestamp window (5 minutes). Note: nonce-binding to an + // issued-nonces table is still an open A-02 finding; this window is + // necessary-but-not-sufficient. const proofTime = new Date(timestamp).getTime(); const now = Date.now(); if (isNaN(proofTime) || Math.abs(now - proofTime) > 5 * 60 * 1000) { - logger.warn('ZKP: Verification failed — proof timestamp out of range'); - return { - verified: false, - sessionId: uuidv4(), - dataStored: false, - timestamp: new Date().toISOString(), - }; + logger.warn('ZKP: verification failed — proof timestamp out of range'); + return rejected(); } // Validate nonce format (UUID v4) const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; if (!uuidRegex.test(nonce)) { - logger.warn('ZKP: Verification failed — invalid nonce format'); - return { - verified: false, - sessionId: uuidv4(), - dataStored: false, - timestamp: new Date().toISOString(), - }; + logger.warn('ZKP: verification failed — invalid nonce format'); + return rejected(); } - // Validate public signals format (3 elements for our circuit) + // Validate public signals shape (3 elements for our circuit) if (!Array.isArray(publicSignals) || publicSignals.length !== 3) { - logger.warn('ZKP: Verification failed — invalid publicSignals (expected 3 elements)'); - return { - verified: false, - sessionId: uuidv4(), - dataStored: false, - timestamp: new Date().toISOString(), - }; + logger.warn('ZKP: verification failed — invalid publicSignals (expected 3 elements)'); + return rejected(); } - let verified = false; - let txHash: string | undefined; + // ─── Step 1: Off-chain verification ───────────────────────────── + const verified = useVerifierService() + ? await verifyViaService(proof, publicSignals, nonce) + : await verifyInline(proof, publicSignals); - // Step 1: Off-chain verification (always performed, fast) - if (snarkjs && verificationKey) { - verified = await verifyProofOffChain(proof, publicSignals); - logger.info(`ZKP: Off-chain Groth16 verification: ${verified ? 'PASS' : 'FAIL'}`); - } else { - // Fallback: if no verification key available (dev mode without compiled circuit) - logger.warn('ZKP: No verification key — using structural proof validation'); - verified = isValidProofStructure(proof); - } + let txHash: string | undefined; - // Step 2: On-chain verification (optional, costs gas) + // ─── Step 2: Optional on-chain re-verification ────────────────── if (verified && config.blockchain.verifyOnChain) { try { const pA: [string, string] = [proof.pi_a[0], proof.pi_a[1]]; @@ -143,18 +152,17 @@ export async function verifyBiometricProof( publicSignals as [string, string, string], ); if (!onChainResult) { - logger.warn('ZKP: On-chain verification FAILED (off-chain passed)'); - verified = false; - } else { - logger.info('ZKP: On-chain Groth16 verification: PASS'); + logger.warn('ZKP: on-chain verification FAILED (off-chain passed)'); + return rejected(); } + logger.info('ZKP: on-chain Groth16 verification: PASS'); } catch (err) { - logger.error('ZKP: On-chain verification error', { error: (err as Error).message }); - // Don't fail if on-chain verification has an error — off-chain is sufficient + // Off-chain pass is sufficient; log the on-chain error and move on. + logger.error('ZKP: on-chain verification error', { error: (err as Error).message }); } } - logger.info(`ZKP: Biometric verification: ${verified ? 'SUCCESS' : 'FAILURE'}`, { + logger.info(`ZKP: biometric verification: ${verified ? 'SUCCESS' : 'FAILURE'}`, { nonce, verified, dataStored: false, @@ -169,10 +177,65 @@ export async function verifyBiometricProof( }; } -/** - * Structural validation for Groth16 proof shape. - * Used as fallback when verification key is not available. - */ +// ─── Verifier-service path ─────────────────────────────────────────── + +async function verifyViaService( + proof: Groth16Proof, + publicSignals: string[], + correlationId: string, +): Promise { + try { + const res = await fetch(`${config.zkp.verifierUrl}/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + proof, + publicSignals, + circuitVersion: 'v1', + correlationId, + }), + signal: AbortSignal.timeout(config.zkp.verifierTimeoutMs), + }); + if (!res.ok) { + logger.error('ZKP: verifier service responded non-2xx', { status: res.status }); + return false; + } + const body = (await res.json()) as { + verified: boolean; + structuralFallback: boolean; + verifierAuditId: string; + latencyMs: number; + }; + logger.info(`ZKP: verifier service: ${body.verified ? 'PASS' : 'FAIL'}`, { + verifierAuditId: body.verifierAuditId, + latencyMs: body.latencyMs, + structuralFallback: body.structuralFallback, + }); + return body.verified; + } catch (err) { + logger.error('ZKP: verifier service call failed', { error: (err as Error).message }); + return false; + } +} + +// ─── Inline fallback path (legacy) ─────────────────────────────────── + +async function verifyInline(proof: Groth16Proof, publicSignals: string[]): Promise { + if (snarkjs && verificationKey) { + try { + const result = await snarkjs.groth16.verify(verificationKey, publicSignals, proof); + logger.info(`ZKP: inline Groth16: ${result ? 'PASS' : 'FAIL'}`); + return result; + } catch (err) { + logger.error('ZKP: inline verification error', { error: (err as Error).message }); + return false; + } + } + // No vkey + inline mode → fall back to structural shape check. + logger.warn('ZKP: no verification key — using structural proof validation'); + return isValidProofStructure(proof); +} + function isValidProofStructure(proof: Groth16Proof): boolean { try { return ( @@ -192,18 +255,49 @@ function isValidProofStructure(proof: Groth16Proof): boolean { } } +// ─── Helpers ───────────────────────────────────────────────────────── + +function rejected(): ZKPVerificationResponse { + return { + verified: false, + sessionId: uuidv4(), + dataStored: false, + timestamp: new Date().toISOString(), + }; +} + export function getCircuitInfo(): { wasmPath: string; vkeyAvailable: boolean; verifyOnChain: boolean; + verifierMode: 'service' | 'inline'; + verifierUrl: string | null; } { return { wasmPath: config.zkp.wasmPath, - vkeyAvailable: verificationKey !== null, + // In service mode the API can't directly know the vkey state; report + // the last observed health signal (set in initZKP). + vkeyAvailable: useVerifierService() ? verifierServiceReady : verificationKey !== null, verifyOnChain: config.blockchain.verifyOnChain, + verifierMode: useVerifierService() ? 'service' : 'inline', + verifierUrl: useVerifierService() ? config.zkp.verifierUrl : null, }; } export function isZKPReady(): boolean { - return snarkjs !== null; + return useVerifierService() ? verifierServiceReady : snarkjs !== null; +} + +/** + * Exposed only for tests that need to verify the off-chain path without + * going through `verifyBiometricProof`'s validation chain. Production + * code should always use `verifyBiometricProof`. + */ +export async function verifyProofOffChain( + proof: Groth16Proof, + publicSignals: string[], +): Promise { + return useVerifierService() + ? verifyViaService(proof, publicSignals, uuidv4()) + : verifyInline(proof, publicSignals); } diff --git a/tests/zkp-service-mode.test.ts b/tests/zkp-service-mode.test.ts new file mode 100644 index 0000000..fb0ba6e --- /dev/null +++ b/tests/zkp-service-mode.test.ts @@ -0,0 +1,124 @@ +/** + * Verifies the service-mode path of src/services/zkp.ts (B02 Plan B). + * + * The other tests/zkp.test.ts file exercises the **inline fallback** + * (VERIFIER_URL unset). This suite mocks `global.fetch` and sets + * config.zkp.verifierUrl so the HTTP code path runs end-to-end. We + * assert: + * + * 1. verifyBiometricProof POSTs to ${verifierUrl}/verify with the + * right shape + * 2. A verifier `{verified: true}` response yields a verified result + * 3. A verifier `{verified: false}` response yields rejected + * 4. A non-2xx response yields rejected (no false positives if the + * verifier returns 500) + * 5. A network error yields rejected + * + * This is the F-3-style "verify the seam exists" suite for the verifier + * split. Once production rolls onto service mode (Friday Day 5), these + * become the canonical zkp coverage and the inline tests can retire. + */ +import { config } from '../src/config'; +import { verifyBiometricProof } from '../src/services/zkp'; +import { createValidVerifyRequest } from './fixtures/proof'; + +describe('ZKP service-mode (B02 Plan B)', () => { + const originalFetch = global.fetch; + const originalVerifierUrl = config.zkp.verifierUrl; + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = jest.fn(); + (global as any).fetch = fetchMock; + (config as any).zkp.verifierUrl = 'http://verifier-test:9999'; + }); + + afterEach(() => { + global.fetch = originalFetch; + (config as any).zkp.verifierUrl = originalVerifierUrl; + jest.clearAllMocks(); + }); + + it('POSTs to ${verifierUrl}/verify with the correct shape', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + verified: true, + verifierAuditId: 'audit-1', + latencyMs: 7, + circuitVersion: 'v1', + structuralFallback: false, + }), + }); + + const req = createValidVerifyRequest(); + await verifyBiometricProof(req); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://verifier-test:9999/verify', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + const callArgs = fetchMock.mock.calls[0]; + const body = JSON.parse((callArgs[1] as any).body); + expect(body).toMatchObject({ + proof: req.proof, + publicSignals: req.publicSignals, + circuitVersion: 'v1', + correlationId: req.nonce, + }); + }); + + it('returns verified: true when the verifier returns verified: true', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + verified: true, + verifierAuditId: 'audit-2', + latencyMs: 8, + circuitVersion: 'v1', + structuralFallback: false, + }), + }); + + const result = await verifyBiometricProof(createValidVerifyRequest()); + expect(result.verified).toBe(true); + expect(result.dataStored).toBe(false); + }); + + it('returns verified: false when the verifier returns verified: false', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + verified: false, + verifierAuditId: 'audit-3', + latencyMs: 6, + circuitVersion: 'v1', + structuralFallback: false, + }), + }); + + const result = await verifyBiometricProof(createValidVerifyRequest()); + expect(result.verified).toBe(false); + }); + + it('returns verified: false when the verifier responds non-2xx (no false positives on 500)', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: 'verifier_error' }), + }); + + const result = await verifyBiometricProof(createValidVerifyRequest()); + expect(result.verified).toBe(false); + }); + + it('returns verified: false on network error', async () => { + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await verifyBiometricProof(createValidVerifyRequest()); + expect(result.verified).toBe(false); + }); +}); diff --git a/verifier/.gitignore b/verifier/.gitignore new file mode 100644 index 0000000..dd8fe26 --- /dev/null +++ b/verifier/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.env diff --git a/verifier/README.md b/verifier/README.md new file mode 100644 index 0000000..f16b9eb --- /dev/null +++ b/verifier/README.md @@ -0,0 +1,99 @@ +# @zeroauth/verifier + +The ZeroAuth Groth16 verifier service. Receives a proof + public signals over loopback HTTP, returns the verification verdict + a verifier-side audit id. + +This is the **TypeScript implementation** per [ADR-0008](../adr/0008-verifier-split-typescript-not-rust.md) (forthcoming) — chosen over a Rust + arkworks implementation for single-engineer velocity. The HTTP shape is deliberately Rust-compatible so a future swap is structural, not behavioural. + +## Why a separate process + +1. **Blast radius.** A bug or supply-chain compromise in the verifier shouldn't have access to the API's Postgres / Redis credentials, tenant keys, audit log, or admin endpoints. +2. **Load profile.** B19 load tests this service directly — `POST /verify` is the latency-critical path. Isolating the verifier from the rest of the API lets us reason about its latency in isolation. +3. **Audit story.** Buyers' security teams ask "what's the trust boundary around the cryptographic core?" The answer "this 200-line workspace with 4 deps, loopback-only, isolated process" is much cleaner than "it's a function inside the big Express app." + +## What it does NOT do (v0, today) + +- No SQLite audit log + hash chain — planned Friday Day 5 of Week 1. +- No reproducible build provenance — only a Plan-A (Rust) concern; not feasible in npm. +- No tenant auth — loopback-only is the trust boundary. +- No outbound network — Express + snarkjs only. + +## API surface + +### `POST /verify` + +```text +Request: +{ + "proof": Groth16Proof, + "publicSignals": [string, string, string], + "circuitVersion": "v1" // optional, defaults to v1 + "correlationId": "uuid" // optional, traces back to caller's audit row +} + +200 OK: +{ + "verified": true | false, + "verifierAuditId": "uuid", + "latencyMs": 12, + "circuitVersion": "v1", + "structuralFallback": false // true only when no vkey was loaded at startup +} + +400: { "error": "invalid_request", "message": "…" } +500: { "error": "verifier_error" } +``` + +### `GET /health` + +```text +{ + "status": "ok" | "degraded", + "version": "0.1.0", + "vkeyAvailable": true | false, + "uptimeSeconds": 1234 +} +``` + +`status: degraded` means the verifier is running but couldn't load the verification key at startup (dev environment without a compiled circuit). `POST /verify` still responds but with `structuralFallback: true`. + +## Configuration + +| Env var | Default | What it controls | +|---|---|---| +| `VERIFIER_PORT` | `3001` | HTTP listen port | +| `VERIFIER_BIND` | `127.0.0.1` | Listen interface — **leave at loopback** | +| `VERIFIER_VKEY_PATH` | `circuits/build/verification_key.json` | Path to the Groth16 verification key | +| `VERIFIER_CIRCUIT_VERSION` | `v1` | Returned in `verifyResponse.circuitVersion` | +| `LOG_LEVEL` | `info` | Winston log level | + +## Build / run + +```bash +# From the repo root (npm workspaces wires everything up) +npm install +npm run verifier:build +npm run verifier:start + +# Or in watch mode for dev +npm run verifier:dev +``` + +## Trust boundary + +**The verifier trusts its caller.** It does no tenant auth, accepts any well-formed request, and returns the verdict. The API repo is the only sanctioned caller; the bind address is `127.0.0.1` so nothing else can reach it. + +If you ever consider exposing the verifier on a public interface, you MUST first: + +1. Add caller authentication (mTLS or shared secret in a header) +2. Add per-caller rate limiting +3. Update the threat model component-extension at `pulkitpareek18/ZeroAuth-Governance: docs/threat-model/verifier.md` +4. Open an ADR + +The default loopback bind is the v0 trust model; do not change it without going through the above. + +## Pairs with + +- API repo's [`src/services/zkp.ts`](../src/services/zkp.ts) — calls this service via `VERIFIER_URL` +- [ADR-0008] (forthcoming) — captures the TS-vs-Rust decision +- [`docs/design/verifier-service-split.md`](../docs/design/verifier-service-split.md) — the plan-mode design doc +- [Governance: `docs/threat-model/verifier.md`](https://github.com/pulkitpareek18/ZeroAuth-Governance/blob/main/docs/threat-model/verifier.md) — component-level threat model diff --git a/verifier/package.json b/verifier/package.json new file mode 100644 index 0000000..32ef4b4 --- /dev/null +++ b/verifier/package.json @@ -0,0 +1,27 @@ +{ + "name": "@zeroauth/verifier", + "version": "0.1.0", + "private": true, + "description": "ZeroAuth Groth16 verifier service. Loopback-only HTTP API on :3001. Accepts a proof + public signals, returns verified|not.", + "main": "dist/server.js", + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "dev": "tsx watch src/server.ts", + "test": "echo 'no tests yet — covered by API repo integration suite'" + }, + "dependencies": { + "express": "^4.18.2", + "snarkjs": "^0.7.6", + "uuid": "^9.0.0", + "winston": "^3.11.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.6", + "@types/snarkjs": "^0.7.9", + "@types/uuid": "^9.0.7", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } +} diff --git a/verifier/src/groth16.ts b/verifier/src/groth16.ts new file mode 100644 index 0000000..6f55dfd --- /dev/null +++ b/verifier/src/groth16.ts @@ -0,0 +1,93 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from './logger'; +import { Groth16Proof } from './types'; + +// snarkjs is loaded dynamically — it's an ESM module with sizeable +// transitive deps and we don't want it pulled in for the import graph +// of any caller that doesn't actually verify. +let snarkjs: any = null; +let verificationKey: any = null; +let vkeyAvailable = false; + +/** + * Load the Groth16 verification key from disk + dynamic-import snarkjs. + * Called once at server startup. If the vkey file is absent (dev env + * without compiled circuit), the service still starts but `verify()` + * falls back to structural validation. **In a follow-up ADR this becomes + * refuse-to-start when vkey is missing in production.** + */ +export async function initVerifier(vkeyPath: string): Promise { + snarkjs = await import('snarkjs'); + + const absolutePath = path.resolve(process.cwd(), vkeyPath); + if (fs.existsSync(absolutePath)) { + const vkeyData = fs.readFileSync(absolutePath, 'utf-8'); + verificationKey = JSON.parse(vkeyData); + vkeyAvailable = true; + logger.info('Verifier: verification key loaded', { path: absolutePath }); + } else { + vkeyAvailable = false; + logger.warn('Verifier: verification key not found — running in structural-fallback mode', { + path: absolutePath, + }); + } +} + +export function isVkeyLoaded(): boolean { + return vkeyAvailable; +} + +/** + * Verify a Groth16 proof against the loaded verification key. + * + * Returns {verified, structuralFallback}. `structuralFallback=true` means + * no vkey was loaded at startup so the result is a shape-only check, not + * a cryptographic verification. Callers must treat this as a non-binding + * signal in production. + */ +export async function verifyProof( + proof: Groth16Proof, + publicSignals: string[], +): Promise<{ verified: boolean; structuralFallback: boolean }> { + if (snarkjs && verificationKey) { + try { + const verified = await snarkjs.groth16.verify(verificationKey, publicSignals, proof); + return { verified, structuralFallback: false }; + } catch (err) { + logger.error('Verifier: snarkjs.groth16.verify threw', { error: (err as Error).message }); + return { verified: false, structuralFallback: false }; + } + } + + // Fallback: structural-only check when no vkey is available. + return { verified: isValidProofStructure(proof), structuralFallback: true }; +} + +/** + * Structural validation for the Groth16 proof shape we expect from + * snarkjs's browser client. Used only when the verification key is + * unavailable (dev without a compiled circuit). + * + * NOTE: this is a shape check, not a cryptographic verification. It must + * never be relied on in production. The companion log line + the + * `structuralFallback: true` flag in the response signal this to callers. + */ +function isValidProofStructure(proof: Groth16Proof): boolean { + try { + return ( + proof.protocol === 'groth16' && + proof.curve === 'bn128' && + Array.isArray(proof.pi_a) && + proof.pi_a.length === 3 && + Array.isArray(proof.pi_b) && + proof.pi_b.length === 3 && + Array.isArray(proof.pi_c) && + proof.pi_c.length === 3 && + proof.pi_a.every((v) => typeof v === 'string' && v.length > 0) && + proof.pi_c.every((v) => typeof v === 'string' && v.length > 0) + ); + } catch { + return false; + } +} diff --git a/verifier/src/logger.ts b/verifier/src/logger.ts new file mode 100644 index 0000000..780c299 --- /dev/null +++ b/verifier/src/logger.ts @@ -0,0 +1,12 @@ +import winston from 'winston'; + +export const logger = winston.createLogger({ + level: process.env.LOG_LEVEL ?? 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json(), + ), + defaultMeta: { service: 'zeroauth-verifier' }, + transports: [new winston.transports.Console()], +}); diff --git a/verifier/src/server.ts b/verifier/src/server.ts new file mode 100644 index 0000000..b7b7f58 --- /dev/null +++ b/verifier/src/server.ts @@ -0,0 +1,86 @@ +import express, { Request, Response } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { initVerifier, verifyProof, isVkeyLoaded } from './groth16'; +import { logger } from './logger'; +import { VerifyRequest, VerifyResponse, HealthResponse } from './types'; + +const PORT = parseInt(process.env.VERIFIER_PORT ?? '3001', 10); +const BIND = process.env.VERIFIER_BIND ?? '127.0.0.1'; +const VKEY_PATH = + process.env.VERIFIER_VKEY_PATH ?? 'circuits/build/verification_key.json'; +const CIRCUIT_VERSION = process.env.VERIFIER_CIRCUIT_VERSION ?? 'v1'; +const START_TIME = Date.now(); + +// ─── Build the app (exported for tests) ────────────────────────────── + +export function createApp() { + const app = express(); + app.use(express.json({ limit: '128kb' })); + + // POST /verify — the only mutating route. Synchronous; predictable + // latency. No tenant auth — the verifier trusts its caller because + // it's loopback-only. The caller (API repo) is responsible for tenant + // scoping, audit-log writes in the platform tables, and replay defense. + app.post('/verify', async (req: Request, res: Response) => { + const t0 = Date.now(); + const body = req.body as Partial; + + if (!body?.proof || !Array.isArray(body.publicSignals) || body.publicSignals.length !== 3) { + res.status(400).json({ error: 'invalid_request', message: 'proof + publicSignals (length 3) are required' }); + return; + } + + try { + const { verified, structuralFallback } = await verifyProof(body.proof, body.publicSignals); + const response: VerifyResponse = { + verified, + verifierAuditId: uuidv4(), + latencyMs: Date.now() - t0, + circuitVersion: body.circuitVersion ?? CIRCUIT_VERSION, + structuralFallback, + }; + logger.info('Verifier: verify result', { + verified, + structuralFallback, + latencyMs: response.latencyMs, + correlationId: body.correlationId, + verifierAuditId: response.verifierAuditId, + }); + res.json(response); + } catch (err) { + logger.error('Verifier: unexpected error', { error: (err as Error).message }); + res.status(500).json({ error: 'verifier_error' }); + } + }); + + // GET /health — for the API's liveness check + ops. + app.get('/health', (_req: Request, res: Response) => { + const response: HealthResponse = { + status: isVkeyLoaded() ? 'ok' : 'degraded', + version: process.env.npm_package_version ?? '0.1.0', + vkeyAvailable: isVkeyLoaded(), + uptimeSeconds: Math.floor((Date.now() - START_TIME) / 1000), + }; + res.json(response); + }); + + return app; +} + +// ─── Standalone entrypoint ─────────────────────────────────────────── + +async function main() { + await initVerifier(VKEY_PATH); + const app = createApp(); + app.listen(PORT, BIND, () => { + logger.info('Verifier: listening', { bind: BIND, port: PORT, circuitVersion: CIRCUIT_VERSION }); + }); +} + +// Run only when executed directly (not when imported by tests). +if (require.main === module) { + main().catch((err) => { + logger.error('Verifier: startup failed', { error: (err as Error).message }); + process.exit(1); + }); +} diff --git a/verifier/src/types.ts b/verifier/src/types.ts new file mode 100644 index 0000000..4fd24f3 --- /dev/null +++ b/verifier/src/types.ts @@ -0,0 +1,41 @@ +// ─── Verifier service request/response types ───────────────────────── +// +// These are the wire types for the loopback HTTP boundary between the +// ZeroAuth API and the verifier. Per the B02 plan-mode design doc: +// the verifier accepts a proof + public signals + correlation_id; it +// returns the verdict plus a verifier-side audit id. Tenant identity +// is included for forensic correlation only — the verifier never +// authenticates the caller (loopback-only is the trust boundary). + +export interface Groth16Proof { + pi_a: [string, string, string]; + pi_b: [[string, string], [string, string], [string, string]]; + pi_c: [string, string, string]; + protocol: 'groth16'; + curve: 'bn128'; +} + +export interface VerifyRequest { + proof: Groth16Proof; + publicSignals: [string, string, string]; + circuitVersion?: string; + correlationId?: string; +} + +export interface VerifyResponse { + verified: boolean; + verifierAuditId: string; + latencyMs: number; + circuitVersion: string; + /** True when no verification key was available at startup and the response + * was produced by the structural-shape fallback. v0 behaviour — refuse- + * to-start replaces this in a follow-up ADR. */ + structuralFallback: boolean; +} + +export interface HealthResponse { + status: 'ok' | 'degraded'; + version: string; + vkeyAvailable: boolean; + uptimeSeconds: number; +} diff --git a/verifier/tsconfig.json b/verifier/tsconfig.json new file mode 100644 index 0000000..607ce76 --- /dev/null +++ b/verifier/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}