Skip to content

Commit 7ff0755

Browse files
Merge backend: w3 proof-pairing service + routes + 23 tests
Brings in the backend implementation that was developed in parallel against the same protocol contract (ADR-0009) the dashboard branch also targeted. No conflicts expected — backend touches src/{services,routes/v1}/proof-pairing.* + src/services/db.ts + src/types/index.ts + tests/; dashboard touches src/routes/console.ts + dashboard/. Both add to disjoint sections of those shared files.
2 parents 4e8397e + b2fb2f7 commit 7ff0755

9 files changed

Lines changed: 1753 additions & 9 deletions

File tree

‎package-lock.json‎

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"jsonwebtoken": "^9.0.2",
4545
"nodemailer": "^8.0.7",
4646
"pg": "^8.20.0",
47+
"poseidon-lite": "^0.3.0",
4748
"snarkjs": "^0.7.6",
4849
"uuid": "^9.0.0",
4950
"winston": "^3.11.0"

‎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;

‎src/services/api-keys.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export async function createApiKey(
5353
'attendance:read',
5454
'attendance:write',
5555
'audit:read',
56+
'proof_pairing:create',
57+
'proof_pairing:claim',
5658
],
5759
): Promise<ApiKeyCreateResult> {
5860
const pool = getPool();

‎src/services/db.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,39 @@ const SCHEMA = `
213213
CREATE INDEX IF NOT EXISTS idx_attendance_events_tenant ON attendance_events(tenant_id, environment, occurred_at DESC);
214214
CREATE INDEX IF NOT EXISTS idx_attendance_events_user ON attendance_events(tenant_id, environment, user_id, occurred_at DESC);
215215
216+
-- ─── Proof Pairing Sessions (W3, ADR-0009) ───────────────
217+
-- Desktop-issued, single-use, 5-min TTL. The session_bind cookie's
218+
-- sha256 is the second factor that prevents an attacker-issued QR
219+
-- from delivering the minted JWT to the wrong browser (A-13).
220+
-- Single-use enforced by atomic UPDATE ... WHERE state='issued'
221+
-- RETURNING * in the consume path (A-14).
222+
CREATE TABLE IF NOT EXISTS proof_pairing_sessions (
223+
id UUID PRIMARY KEY,
224+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
225+
environment VARCHAR(10) NOT NULL
226+
CHECK (environment IN ('live', 'test')),
227+
api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
228+
nonce_hex VARCHAR(62) NOT NULL, -- 31 bytes = 62 hex chars
229+
session_bind_token_hash VARCHAR(64) NOT NULL, -- sha256 of cookie value
230+
state VARCHAR(20) NOT NULL DEFAULT 'issued'
231+
CHECK (state IN ('issued', 'consumed', 'expired', 'failed')),
232+
consumed_user_id UUID REFERENCES tenant_users(id) ON DELETE SET NULL,
233+
consumed_verification_id UUID REFERENCES verification_events(id),
234+
proof_hash VARCHAR(64),
235+
last_error_code VARCHAR(50),
236+
desktop_ip INET,
237+
desktop_user_agent VARCHAR(512),
238+
failure_count SMALLINT NOT NULL DEFAULT 0,
239+
expires_at TIMESTAMPTZ NOT NULL,
240+
consumed_at TIMESTAMPTZ,
241+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
242+
UNIQUE (tenant_id, nonce_hex)
243+
);
244+
CREATE INDEX IF NOT EXISTS idx_pps_tenant_created
245+
ON proof_pairing_sessions(tenant_id, environment, created_at DESC);
246+
CREATE INDEX IF NOT EXISTS idx_pps_state_expires
247+
ON proof_pairing_sessions(state, expires_at) WHERE state = 'issued';
248+
216249
-- ─── Audit Events ────────────────────────────────────────
217250
CREATE TABLE IF NOT EXISTS audit_events (
218251
id BIGSERIAL PRIMARY KEY,

0 commit comments

Comments
 (0)