Skip to content

Commit 31284d4

Browse files
w3: lock pairing protocol contract + threat model
Wave-1 planning artifacts from the W3 sprint kickoff. Four parallel agents (Plan, cryptographer-reviewer, security-reviewer, Explore) produced the inputs; this commit is the integrated foundation that Wave-2 (backend + Android + dashboard) builds against. ADR-0009 — QR-mediated proof pairing for desktop login. Adopts the cryptographer's Option B' (fold the session nonce into didHash on the phone, server re-derives) instead of the naive 'nonce into identityBinding' which fails the circuit's hardcoded 2-input Poseidon constraint. Pins TTL (5 min), single-use semantics, session_bind cookie, SSE back-channel, and the 10 required server-side checks that every /submit must perform. ADR-0010 — Android WebView snarkjs bundling. snarkjs ships as a SHA-256-pinned APK asset (never loaded over the network), in a process-isolated WebView with connect-src 'none' CSP, build-time hash verification, and a Play Integrity field reserved in the submit payload for W4 server-side enforcement. threat_model.md — 16 new rows A-11..A-26 covering nonce replay, cross-tenant claim, session fixation, race conditions, camera spoofing, MITM, WebView supply-chain, rooted devices, coerced biometric, /submit DoS, audit tampering, PII leakage, shoulder-surf, side-channel, ID enumeration, and timing-side-channel between failure modes. Each row carries the concrete mitigation, the test requirement, and the audit-event verb to emit. api_contract.md + error_codes.md — public surface for the four new /v1/proof-pairing/* endpoints and the 12 pairing-specific machine error codes (pairing_session_not_found, pairing_nonce_mismatch, pairing_session_already_bound, pairing_session_bind_mismatch, etc.). No code yet — these are pure docs. The next wave commits the backend service, Android workspace scaffold, dashboard route, and the proof_pairing_sessions schema migration.
1 parent 0944897 commit 31284d4

5 files changed

Lines changed: 699 additions & 3 deletions

File tree

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# ADR-0009 — QR-mediated proof pairing for desktop login
2+
3+
## Status
4+
5+
Proposed (cryptographer pre-review applied; awaits final sign-off after
6+
implementation lands)
7+
8+
## Context
9+
10+
W3 of the 7-week plan ([`docs/operations/central-api-delivery-plan.md`](../docs/operations/central-api-delivery-plan.md))
11+
calls for "one non-IoT wrapper demo built on the same API." The chosen
12+
wrapper is desktop sign-in via a native Android phone: the user proves
13+
on their phone (biometric → Groth16 proof), the proof crosses the air
14+
gap as a QR code, the desktop's webcam scans it, the desktop submits to
15+
the hosted backend, the backend mints a desktop session.
16+
17+
Two physical devices, two QR scans, one Groth16 proof, no new
18+
cryptographic primitives. The W2 verifier ([`@zeroauth/verifier`](../verifier/))
19+
is reused unchanged; the W2 circuit (`identity_proof.circom`) is reused
20+
unchanged.
21+
22+
The interesting design problem is **how the desktop's session nonce
23+
binds to the proof so a proof for session X cannot be replayed against
24+
session Y**. Three candidate strategies were considered:
25+
26+
- **A — Cut a new circuit with `nonce` as a fourth public input.**
27+
Cleanest cryptographically. Costs a Phase-2 trusted-setup ceremony,
28+
a new `.zkey`, a new `verification_key.json` shipped to every
29+
verifier instance, and either a versioned verifier or a breaking
30+
change for the W2 IoT bridge. Rejected for W3 timeline.
31+
- **B (naïve) — Phone produces `identityBinding = Poseidon(3)([secret, didHash, nonce])`.**
32+
Looks attractive — no circuit change in the obvious sense — but
33+
**does not actually work**: `circuits/identity_proof.circom:41-44`
34+
hard-codes `identityBinding = Poseidon(2)([biometricSecret, didHash])`.
35+
If the phone supplies a 3-input Poseidon as the public signal, the
36+
R1CS constraint is unsatisfied and `snarkjs.groth16.fullProve` throws
37+
before producing a proof. Caught in cryptographer pre-review.
38+
- **B′ — Fold the nonce into `didHash` _before_ the circuit consumes
39+
it.** Phone computes `didHashSession = Poseidon(2)([didHash, nonce])`
40+
and uses `didHashSession` as the `didHash` public input to the
41+
circuit. The unchanged circuit still enforces
42+
`identityBinding === Poseidon(2)([biometricSecret, didHash])`; from
43+
the circuit's perspective `didHash` is just the value the verifier
44+
supplied. The server re-derives the expected
45+
`Poseidon(2)([storedDidHash, sessionNonce])` from its own records
46+
and rejects the proof unless `publicSignals[1]` matches. No circuit
47+
change. No new ceremony. Adopted.
48+
49+
## Decision
50+
51+
### Protocol (Option B′)
52+
53+
```
54+
Phone side:
55+
didHashSession = Poseidon(2)([storedDidHash, sessionNonce_F])
56+
identityBinding = Poseidon(2)([biometricSecret, didHashSession])
57+
publicSignals = [commitment, didHashSession, identityBinding]
58+
proof = groth16.fullProve(witness, identity_proof.wasm, circuit_final.zkey)
59+
60+
Server side (POST /v1/proof-pairing/sessions/:id/submit):
61+
ctx = getTenantContext(req) // never trust body for tenant
62+
session = SELECT ... FROM proof_pairing_sessions
63+
WHERE id = $1 AND tenant_id = $2 AND environment = $3
64+
AND state = 'issued' AND expires_at > now()
65+
user = lookup by (tenant_id, did) from submit body
66+
expected = Poseidon(2)([user.didHash, session.nonce])
67+
REJECT if publicSignals[0] != user.commitment → pairing_did_unknown
68+
REJECT if !timingSafeEqual(publicSignals[1], expected) → pairing_nonce_mismatch
69+
POST { proof, publicSignals } to verifier (loopback)
70+
REJECT if !verified → pairing_proof_invalid
71+
UPDATE proof_pairing_sessions SET state='consumed', consumed_at=NOW()
72+
WHERE id = $1 AND state = 'issued' RETURNING *
73+
REJECT if zero rows → pairing_session_already_bound
74+
MINT desktop session JWT
75+
AWAIT recordAuditEvent('pairing_session.claimed', ...) // critical-path, not fire-and-forget
76+
```
77+
78+
### Pinned parameters
79+
80+
- **Nonce**: 31 bytes (248 bits) of `crypto.randomBytes`, decoded as a
81+
field element. 31 not 32 to avoid modular-reduction bias against
82+
BN128's scalar prime (≈ 2^254), matching the convention in
83+
[`iot/src/crypto.ts`'s `toFieldElement`](../iot/src/crypto.ts).
84+
- **Session TTL**: 5 minutes from issuance.
85+
- **Single-use**: enforced via an atomic `UPDATE … WHERE state='issued'
86+
RETURNING *` — race-safe under concurrent submits.
87+
- **Session-bind cookie**: a `Secure; HttpOnly; SameSite=Strict;
88+
Path=/v1/proof-pairing/`-scoped cookie set on the issuing browser at
89+
`POST /v1/proof-pairing/sessions` time. The cookie value is **not**
90+
carried in the QR. Subsequent reads (`GET .../stream`, `GET
91+
.../{id}`) require the cookie. Closes A-13 (session-fixation
92+
phishing).
93+
- **Back-channel**: Server-Sent Events on `GET .../{id}/stream`. SSE
94+
was chosen over WebSocket (one-way, easier to proxy through Caddy)
95+
and over polling (UX latency). Polling is documented as the
96+
fallback for clients without `EventSource`.
97+
- **Phone is air-gapped from the backend.** The phone scans the
98+
desktop's challenge QR, generates the proof locally, and renders its
99+
own QR. It never POSTs to `api.zeroauth.dev`. The desktop owns the
100+
submission. This keeps the tenant API key on a controlled surface
101+
and removes phone-side network attack surface from the demo's threat
102+
model.
103+
104+
### Public surface (full contract in `docs/api_contract.md`)
105+
106+
| Method | Path | Scope |
107+
|---|---|---|
108+
| `POST` | `/v1/proof-pairing/sessions` | `proof_pairing:create` |
109+
| `POST` | `/v1/proof-pairing/sessions/:id/submit` | `proof_pairing:claim` |
110+
| `GET` | `/v1/proof-pairing/sessions/:id/stream` | `proof_pairing:create` (cookie) |
111+
| `GET` | `/v1/proof-pairing/sessions/:id` | `proof_pairing:create` (cookie) |
112+
113+
New scopes added to `src/types/index.ts`. New error codes registered in
114+
`docs/error_codes.md` under a "Proof pairing" section.
115+
116+
### Schema
117+
118+
New table `proof_pairing_sessions` bootstrapped from
119+
`src/services/db.ts` (same `CREATE TABLE IF NOT EXISTS` pattern as
120+
`attendance_events`). Columns:
121+
122+
```
123+
id UUID PRIMARY KEY,
124+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
125+
environment VARCHAR(10) CHECK (environment IN ('live','test')),
126+
api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
127+
nonce_hex VARCHAR(62), -- 31 bytes = 62 hex chars
128+
session_bind_token_hash VARCHAR(64), -- sha256 of the bind cookie value
129+
state VARCHAR(20) DEFAULT 'issued'
130+
CHECK (state IN ('issued','consumed','expired','failed')),
131+
consumed_user_id UUID REFERENCES tenant_users(id) ON DELETE SET NULL,
132+
consumed_verification_id UUID REFERENCES verification_events(id),
133+
proof_hash VARCHAR(64), -- audit, never the proof itself
134+
last_error_code VARCHAR(50),
135+
desktop_ip INET,
136+
desktop_user_agent VARCHAR(512),
137+
expires_at TIMESTAMPTZ NOT NULL,
138+
consumed_at TIMESTAMPTZ,
139+
created_at TIMESTAMPTZ DEFAULT NOW(),
140+
UNIQUE(tenant_id, nonce_hex)
141+
```
142+
143+
Indexes: `(tenant_id, environment, created_at DESC)` for console
144+
listings, partial index on `(state, expires_at) WHERE state='issued'`
145+
for the cleanup sweep.
146+
147+
### QR payload (both directions)
148+
149+
- **Desktop → phone (challenge QR, ≤ 200 B)** — compact ASCII:
150+
`za:pair:1:<sessionId>:<nonceHex>:<tenantDomain>:<integrityTag>`
151+
where `integrityTag` is the first 4 hex chars of
152+
`sha256(sessionId|nonceHex|tenantDomain)`. Operator-typeable in a
153+
pinch.
154+
- **Phone → desktop (proof QR, target ≤ 1500 B)** — gzip-then-base64url
155+
of CBOR-encoded `{ s: sessionId, p: groth16Proof, ps: publicSignals,
156+
d: did, m: clientMeta }`, prefixed with `za:proof:1:`. Empirical size
157+
on the W2 fixture proofs: ~990 B. CI test asserts ≤ 1500 B and warns
158+
at ≥ 1300 B.
159+
160+
### Non-goals (explicitly deferred)
161+
162+
- **Production rapidsnark on Android.** W3 ships snarkjs-in-WebView.
163+
rapidsnark via JNI is ADR-0011, scheduled for W5+.
164+
- **iOS app.** Android-only for W3.
165+
- **Play Integrity attestation gate.** Recommended as a tenant-policy
166+
knob; the request payload reserves `clientMeta.playIntegrityVerdict`
167+
for the field. Server-side enforcement is W4 work tracked under the
168+
security punch list.
169+
- **Per-tenant rate limit on `/submit`.** Existing tenant rate limiter
170+
applies; a tighter `/submit`-specific limit (30/min/tenant + 5/min/
171+
session-id) is recommended in [A-20] but lands as a follow-up.
172+
- **Cross-circuit-version verifier.** When we cut a v2 circuit, the
173+
verifier needs `circuitVersion` routing. Out of scope for W3.
174+
175+
## Consequences
176+
177+
### Positive
178+
179+
- No new trusted-setup ceremony. Reuse the existing W2 `.zkey` + `.wasm`
180+
on both phone and server.
181+
- Verifier path is unchanged from W2 — same audit log, same latency
182+
profile, same rate-limit accounting.
183+
- Phone is network-isolated; tenant API key never leaves the desktop.
184+
- 5-min single-use TTL + nonce binding (Option B′) closes the replay
185+
window to "scan happens inside this window with this specific
186+
desktop's nonce."
187+
188+
### Negative
189+
190+
- **The proof is generated by snarkjs in a WebView.** This is the
191+
W3 demo's single biggest live risk: a compromised WebView (supply
192+
chain on snarkjs, OS-level malware, debugger attached) reads the
193+
`biometricSecret` straight out of in-process memory. Android Keystore
194+
does not save you here — it protects key material, not arbitrary app
195+
memory. ADR-0010 documents the bundling + CSP + Play Integrity
196+
mitigations; the residual risk is accepted for the demo and flagged
197+
as a P0 blocker for any BFSI pilot.
198+
- **The desktop holds a tenant API key.** Same posture as the IoT
199+
bridge. Scoped to `proof_pairing:{create,claim}` only — no
200+
`users:write`, no `devices:write`. **[GATE: security]** — confirm
201+
scope minimization for the demo console.
202+
- **WebView snarkjs latency.** Empirical mid-range Android: 3–8 s.
203+
Tracked in `clientMeta.proofMs` so we get production p50/p95/p99
204+
immediately.
205+
206+
### Neutral
207+
208+
- The desktop QR carries a 4-char integrity tag (not security-bearing)
209+
so the operator can spot a hand-typed typo during the documented
210+
paste-fallback recovery path.
211+
212+
## Cryptographer's required server-side checks (every `/submit`)
213+
214+
These are not options. Implementation tests (`tests/proof-pairing.test.ts`)
215+
must cover each negatively:
216+
217+
1. `authenticateTenantApiKey(['proof_pairing:claim'])` resolves the
218+
tenant context. **Tenant id never read from the request body.**
219+
2. Session lookup keyed on `(id, tenant_id, environment)` with `state =
220+
'issued'` and `expires_at > now()`. No row → 404 (indistinguishable
221+
from "exists in a different tenant" to defeat enumeration — see
222+
A-25).
223+
3. `session_bind` cookie value, sha256-hashed, must match the row's
224+
`session_bind_token_hash`.
225+
4. User lookup keyed on `(tenant_id, did)` returning `commitment` and
226+
`did_hash`.
227+
5. Constant-time compare `publicSignals[0]` against `user.commitment`.
228+
6. Recompute `expectedDidHashSession = poseidon2([user.did_hash,
229+
session.nonce])` using `poseidon-lite@^0.3.0` (pinned in the
230+
`iot/` and server packages — see ADR-0008).
231+
7. Constant-time compare `publicSignals[1]` against
232+
`expectedDidHashSession`.
233+
8. Forward `{proof, publicSignals, tenantId, environment, circuitVersion:
234+
'v1', correlationId}` to the loopback verifier. Require
235+
`verified: true && structuralFallback: false`.
236+
9. Atomic `UPDATE proof_pairing_sessions SET state='consumed', ...
237+
WHERE id=$1 AND state='issued' RETURNING *`. Zero rows → 409
238+
`pairing_session_already_bound`.
239+
10. `await recordAuditEvent('pairing_session.claimed', ...)`. **Not**
240+
fire-and-forget on this path — failure to write audit returns 500.
241+
242+
## Alternatives considered
243+
244+
- **Option A (new circuit with nonce input).** Best assurance,
245+
rejected for the W3 timeline. Filed as the production migration
246+
path; revisit in W6+.
247+
- **Naïve Option B (`Poseidon(3)([secret, didHash, nonce])`).** Caught
248+
in cryptographer pre-review: the current circuit hardcodes a 2-input
249+
Poseidon and rejects the witness.
250+
- **Option C (EdDSA over the nonce alongside the proof).** Doubles the
251+
primitive count, gains a separable trust-on-attestation story.
252+
Useful as belt+braces alongside Option A in production; overkill
253+
for the W3 demo.
254+
- **WebSocket instead of SSE.** One-way back-channel; SSE survives
255+
Caddy without sticky sessions; trivially polyfillable on the
256+
desktop.
257+
- **Phone POSTs the proof directly to `api.zeroauth.dev`.** Inverts
258+
the tenant trust model (API keys distributed to user devices).
259+
Rejected.
260+
261+
## References
262+
263+
- W2 verifier: [ADR-0006](0006-verifier-typescript-not-rust.md)
264+
- snarkjs / poseidon-lite pin: [ADR-0008](0008-iot-snarkjs-poseidon-lite.md)
265+
- Circuit: [`circuits/identity_proof.circom`](../circuits/identity_proof.circom)
266+
- W2 signal derivation: [`iot/src/crypto.ts`](../iot/src/crypto.ts)
267+
- W2 prover: [`iot/src/proof.ts`](../iot/src/proof.ts)
268+
- Verifier server: [`verifier/src/server.ts`](../verifier/src/server.ts)
269+
- Threat model rows A-11..A-26 (this sprint): [`docs/threat_model.md`](../docs/threat_model.md)
270+
- Android WebView snarkjs bundling: [ADR-0010](0010-android-webview-snarkjs-bundling.md)
271+
272+
---
273+
LAST_UPDATED: 2026-05-22
274+
OWNER: Pulkit Pareek

0 commit comments

Comments
 (0)