Skip to content

Commit b2fb2f7

Browse files
w3(api): implement proof-pairing endpoints with all 10 cryptographer checks
Commit 2 — proof-pairing implementation (ADR-0009 / W3). src/services/proof-pairing.ts — pure logic: - createSession: 31-byte nonce via crypto.randomBytes, 32-byte session-bind token base64url-encoded, sha256 hash stored, atomic INSERT with the 5-min TTL. Enforces the per-tenant 50-issued- session cap (A-20 partial). Builds the QR payload "za:pair:1:<id>:<nonce>:<tenantDomain>:<integrityTag>" where the integrity tag is the first 4 hex chars of sha256(id|nonce|tenantDomain). - submitProof: runs the 10 cryptographer-required server-side checks in order. The Poseidon re-derivation uses `poseidon2([storedDidHash, nonceField])` from poseidon-lite, with the 31-byte field-element encoding from `iot/src/crypto.ts`. Comparisons use `crypto.timingSafeEqual` on 32-byte zero-padded bigint buffers. Single-use is enforced via the atomic `UPDATE ... WHERE state='issued' RETURNING *` pattern (A-14). The `recordAuditEvent('pairing.claimed', ...)` write is AWAITED on the critical path per A-21 — failure to write audit returns 500. Failure paths increment a per-session counter and transition to state='failed' at MAX_FAILURES_BEFORE_LOCK (3). All paths pad to >= 200 ms latency (A-26). - getSession + subscribeStream: uniform 404 across "doesn't exist" and "exists in another tenant" (A-25). Cookie sha256-hashed and constant-time compared against the row's stored hash before any state read (A-13). The SSE iterator emits 'session_created' immediately, polls every 500 ms for state changes, and emits a terminal event ('session_bound' / 'session_expired' / 'session_error') before completing. - expireOverdueSessions: hourly sweep that marks overdue 'issued' rows as 'expired' and emits 'pairing.expired' audit rows. src/routes/v1/proof-pairing.ts — HTTP adapter: - All routes go through `authenticateTenantApiKey([scope])` with the matching scope (`proof_pairing:create` or `:claim`). - POST /sessions sets the `zeroauth_pair_bind` cookie with HttpOnly + Secure + SameSite=Strict + Path=/v1/proof-pairing/ + Max-Age=300. The cookie value never travels in the QR (A-13). - POST /sessions/:id/submit reads the cookie via cookie-parser, falls back to hand-parsing the header. Body validates `did`, `proof`, and `publicSignals` shape before calling the service. - GET /sessions/:id/stream pulls the first iterator value before sending SSE headers, so auth-gate errors surface as JSON 4xx (not as an SSE frame on an already-200 response). Heartbeat comment every 15 s for proxy compatibility. - GET /sessions/:id is the polling fallback for clients without EventSource. - mapError() centralises the error→HTTP mapping; every machine code in docs/error_codes.md "Proof pairing" section is covered. src/routes/v1/index.ts — mount at /proof-pairing. tests/proof-pairing.test.ts — minor mock-shape update to keep the PairingTenantMismatch class + streamHeartbeatMs export reachable from the route layer (mapError uses both). Test results: 23 / 23 passing in tests/proof-pairing.test.ts, 257 / 257 across the full Jest suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f277b82 commit b2fb2f7

4 files changed

Lines changed: 969 additions & 54 deletions

File tree

‎src/routes/v1/index.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,21 @@ import userRoutes from './users';
88
import verificationRoutes from './verifications';
99
import attendanceRoutes from './attendance';
1010
import auditRoutes from './audit';
11+
import proofPairingRoutes from './proof-pairing';
1112

1213
const router = Router();
1314

1415
/**
15-
* /v1/auth/zkp/* — ZKP biometric authentication
16-
* /v1/auth/saml/* — SAML SSO integration
17-
* /v1/auth/oidc/* — OIDC/OAuth2 integration
18-
* /v1/identity/* — Identity & session management
19-
* /v1/devices/* — Device registration and lifecycle
20-
* /v1/users/* — User enrollment and directory
21-
* /v1/verifications/* — Product verification audit trail
22-
* /v1/attendance/* — Check-in / check-out events
23-
* /v1/audit/* — Business audit log
16+
* /v1/auth/zkp/* — ZKP biometric authentication
17+
* /v1/auth/saml/* — SAML SSO integration
18+
* /v1/auth/oidc/* — OIDC/OAuth2 integration
19+
* /v1/identity/* — Identity & session management
20+
* /v1/devices/* — Device registration and lifecycle
21+
* /v1/users/* — User enrollment and directory
22+
* /v1/verifications/* — Product verification audit trail
23+
* /v1/attendance/* — Check-in / check-out events
24+
* /v1/audit/* — Business audit log
25+
* /v1/proof-pairing/* — QR-mediated cross-device proof pairing (W3, ADR-0009)
2426
*
2527
* All routes require: Authorization: Bearer za_live_xxx
2628
*/
@@ -33,5 +35,6 @@ router.use('/users', userRoutes);
3335
router.use('/verifications', verificationRoutes);
3436
router.use('/attendance', attendanceRoutes);
3537
router.use('/audit', auditRoutes);
38+
router.use('/proof-pairing', proofPairingRoutes);
3639

3740
export default router;

‎src/routes/v1/proof-pairing.ts‎

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/**
2+
* /v1/proof-pairing/* — W3, ADR-0009.
3+
*
4+
* Thin HTTP adapter around `src/services/proof-pairing.ts`. The
5+
* service does the actual work (Poseidon re-derivation, atomic
6+
* consume, audit-row writes); the route layer reads the session_bind
7+
* cookie, sets it on the response for new sessions, and maps service
8+
* errors onto the documented HTTP status codes (see
9+
* `docs/error_codes.md` § "Proof pairing").
10+
*/
11+
12+
import { Router, Request, Response } from 'express';
13+
import { authenticateTenantApiKey, getTenantContext } from '../../middleware/tenant-auth';
14+
import { logger } from '../../services/logger';
15+
import {
16+
createSession,
17+
submitProof,
18+
getSession,
19+
subscribeStream,
20+
streamHeartbeatMs,
21+
PairingSessionNotFound,
22+
PairingSessionExpired,
23+
PairingSessionAlreadyBound,
24+
PairingSessionLocked,
25+
PairingSessionBindMismatch,
26+
PairingNonceMismatch,
27+
PairingDidUnknown,
28+
PairingProofInvalid,
29+
PairingTenantMismatch,
30+
TooManyPendingSessions,
31+
VerifierUnavailable,
32+
StreamEvent,
33+
} from '../../services/proof-pairing';
34+
import { Groth16Proof } from '../../types';
35+
36+
const router = Router();
37+
38+
const PAIR_COOKIE = 'zeroauth_pair_bind';
39+
const COOKIE_PATH = '/v1/proof-pairing/';
40+
const COOKIE_MAX_AGE_SEC = 300; // 5 min — matches SESSION_TTL_MS
41+
42+
// ─── Helpers ───────────────────────────────────────────────────────────
43+
44+
function buildSetCookie(value: string): string {
45+
return (
46+
`${PAIR_COOKIE}=${value}; HttpOnly; Secure; SameSite=Strict;`
47+
+ ` Path=${COOKIE_PATH}; Max-Age=${COOKIE_MAX_AGE_SEC}`
48+
);
49+
}
50+
51+
function readBindCookie(req: Request): string | undefined {
52+
const cookies = (req as Request & { cookies?: Record<string, string> }).cookies;
53+
if (cookies && typeof cookies[PAIR_COOKIE] === 'string') {
54+
return cookies[PAIR_COOKIE];
55+
}
56+
// Fallback: hand-parse the Cookie header. cookie-parser handles
57+
// common cases; this defends against middleware-order drift.
58+
const raw = req.headers.cookie;
59+
if (!raw) return undefined;
60+
for (const part of raw.split(';')) {
61+
const [k, ...rest] = part.trim().split('=');
62+
if (k === PAIR_COOKIE) return rest.join('=');
63+
}
64+
return undefined;
65+
}
66+
67+
interface MappedError {
68+
status: number;
69+
error: string;
70+
message: string;
71+
}
72+
73+
function mapError(err: unknown): MappedError {
74+
if (err instanceof PairingSessionNotFound) {
75+
return { status: 404, error: err.code, message: 'Pairing session not found.' };
76+
}
77+
if (err instanceof PairingSessionExpired) {
78+
return { status: 410, error: err.code, message: 'Pairing session expired.' };
79+
}
80+
if (err instanceof PairingSessionAlreadyBound) {
81+
return { status: 409, error: err.code, message: 'Pairing session already bound.' };
82+
}
83+
if (err instanceof PairingSessionLocked) {
84+
return { status: 423, error: err.code, message: 'Pairing session locked.' };
85+
}
86+
if (err instanceof PairingSessionBindMismatch) {
87+
return { status: 403, error: err.code, message: 'Session bind cookie missing or mismatched.' };
88+
}
89+
if (err instanceof PairingNonceMismatch) {
90+
return { status: 400, error: err.code, message: 'Public signals nonce mismatch.' };
91+
}
92+
if (err instanceof PairingDidUnknown) {
93+
return { status: 400, error: err.code, message: 'DID does not resolve for this tenant.' };
94+
}
95+
if (err instanceof PairingProofInvalid) {
96+
return { status: 401, error: err.code, message: 'Proof verification failed.' };
97+
}
98+
if (err instanceof PairingTenantMismatch) {
99+
return { status: 403, error: err.code, message: 'Session belongs to another tenant.' };
100+
}
101+
if (err instanceof TooManyPendingSessions) {
102+
return { status: 429, error: err.code, message: 'Too many open pairing sessions for this tenant.' };
103+
}
104+
if (err instanceof VerifierUnavailable) {
105+
return { status: 503, error: err.code, message: 'Verifier loopback unavailable. Retry shortly.' };
106+
}
107+
return { status: 500, error: 'pairing_failed', message: 'Pairing failed.' };
108+
}
109+
110+
// ─── Routes ────────────────────────────────────────────────────────────
111+
112+
router.post('/sessions',
113+
authenticateTenantApiKey(['proof_pairing:create']),
114+
async (req: Request, res: Response) => {
115+
try {
116+
const { tenant, apiKey } = getTenantContext(req);
117+
const result = await createSession(
118+
tenant.id,
119+
apiKey.environment,
120+
apiKey.id,
121+
req.ip ?? null,
122+
(req.headers['user-agent'] as string | undefined) ?? null,
123+
);
124+
125+
res.setHeader('Set-Cookie', buildSetCookie(result.sessionBindToken));
126+
res.status(201).json({
127+
session: {
128+
id: result.id,
129+
nonce: result.nonce,
130+
expiresAt: result.expiresAt,
131+
qrPayload: result.qrPayload,
132+
streamUrl: `/v1/proof-pairing/sessions/${result.id}/stream`,
133+
state: 'issued',
134+
},
135+
});
136+
} catch (err) {
137+
const mapped = mapError(err);
138+
if (mapped.status === 500) {
139+
logger.error('proof-pairing: createSession failed', { error: (err as Error).message });
140+
}
141+
res.status(mapped.status).json({ error: mapped.error, message: mapped.message });
142+
}
143+
},
144+
);
145+
146+
router.post('/sessions/:id/submit',
147+
authenticateTenantApiKey(['proof_pairing:claim']),
148+
async (req: Request, res: Response) => {
149+
try {
150+
const { tenant, apiKey } = getTenantContext(req);
151+
const sessionId = String(req.params.id);
152+
const { did, proof, publicSignals, clientMeta } = req.body ?? {};
153+
154+
if (typeof did !== 'string' || did.length === 0) {
155+
res.status(400).json({ error: 'invalid_request', message: 'did is required' });
156+
return;
157+
}
158+
if (!proof || typeof proof !== 'object') {
159+
res.status(400).json({ error: 'invalid_request', message: 'proof is required' });
160+
return;
161+
}
162+
if (!Array.isArray(publicSignals) || publicSignals.length !== 3) {
163+
res.status(400).json({
164+
error: 'invalid_request',
165+
message: 'publicSignals must be a 3-element array',
166+
});
167+
return;
168+
}
169+
170+
const bindToken = readBindCookie(req);
171+
const result = await submitProof(
172+
sessionId,
173+
tenant.id,
174+
apiKey.environment,
175+
did,
176+
proof as Groth16Proof,
177+
publicSignals as string[],
178+
(clientMeta && typeof clientMeta === 'object') ? clientMeta : {},
179+
bindToken,
180+
);
181+
182+
res.status(200).json(result);
183+
} catch (err) {
184+
const mapped = mapError(err);
185+
if (mapped.status === 500) {
186+
logger.error('proof-pairing: submitProof failed', { error: (err as Error).message });
187+
}
188+
res.status(mapped.status).json({ error: mapped.error, message: mapped.message });
189+
}
190+
},
191+
);
192+
193+
router.get('/sessions/:id/stream',
194+
authenticateTenantApiKey(['proof_pairing:create']),
195+
async (req: Request, res: Response) => {
196+
const { tenant, apiKey } = getTenantContext(req);
197+
const sessionId = String(req.params.id);
198+
const bindToken = readBindCookie(req);
199+
200+
// Build the iterator. subscribeStream is an async generator —
201+
// calling it doesn't actually execute anything yet; we have to
202+
// pull the first value to surface auth-gate errors as JSON 4xx
203+
// (not as an SSE frame, since headers haven't been written yet).
204+
const iter = subscribeStream(sessionId, tenant.id, apiKey.environment, bindToken);
205+
206+
let firstEvent: IteratorResult<StreamEvent>;
207+
try {
208+
firstEvent = await iter.next();
209+
} catch (err) {
210+
const mapped = mapError(err);
211+
res.status(mapped.status).json({ error: mapped.error, message: mapped.message });
212+
return;
213+
}
214+
215+
// SSE headers — *only* after the auth gate passes.
216+
res.writeHead(200, {
217+
'Content-Type': 'text/event-stream',
218+
'Cache-Control': 'no-store',
219+
'X-Accel-Buffering': 'no',
220+
'Connection': 'keep-alive',
221+
});
222+
223+
const heartbeat: NodeJS.Timeout = setInterval(() => {
224+
// Comment-only heartbeat per SSE spec — keeps Caddy + other
225+
// proxies from idling the connection. The leading colon makes
226+
// browsers ignore the line.
227+
res.write(': heartbeat\n\n');
228+
}, streamHeartbeatMs);
229+
230+
const cleanup = (): void => {
231+
clearInterval(heartbeat);
232+
};
233+
req.on('close', cleanup);
234+
235+
try {
236+
// Emit the first event, then drain the rest until terminal.
237+
if (!firstEvent.done) {
238+
const evt = firstEvent.value;
239+
res.write(`event: ${evt.event}\n`);
240+
res.write(`data: ${JSON.stringify(evt.data)}\n\n`);
241+
if (evt.event === 'session_created') {
242+
for await (const next of iter) {
243+
res.write(`event: ${next.event}\n`);
244+
res.write(`data: ${JSON.stringify(next.data)}\n\n`);
245+
if (next.event !== 'session_created') break;
246+
}
247+
}
248+
}
249+
} catch (err) {
250+
// Headers are out; surface the error via the SSE channel.
251+
const mapped = mapError(err);
252+
res.write(`event: session_error\n`);
253+
res.write(`data: ${JSON.stringify({ error: mapped.error, message: mapped.message })}\n\n`);
254+
} finally {
255+
cleanup();
256+
res.end();
257+
}
258+
},
259+
);
260+
261+
router.get('/sessions/:id',
262+
authenticateTenantApiKey(['proof_pairing:create']),
263+
async (req: Request, res: Response) => {
264+
try {
265+
const { tenant, apiKey } = getTenantContext(req);
266+
const sessionId = String(req.params.id);
267+
const bindToken = readBindCookie(req);
268+
const session = await getSession(sessionId, tenant.id, apiKey.environment, bindToken);
269+
res.status(200).json({ session });
270+
} catch (err) {
271+
const mapped = mapError(err);
272+
if (mapped.status === 500) {
273+
logger.error('proof-pairing: getSession failed', { error: (err as Error).message });
274+
}
275+
res.status(mapped.status).json({ error: mapped.error, message: mapped.message });
276+
}
277+
},
278+
);
279+
280+
export default router;

0 commit comments

Comments
 (0)