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