Skip to content

Commit a654e33

Browse files
w3: integration cleanup after the four-agent wave
Three loose ends the Wave-3 agents flagged: 1. Android net/Api.kt referenced GET /v1/proof-pairing/sessions/:id/public as an optional unauthenticated freshness read, but the endpoint did not exist server-side. Land it. New getSessionPublicMinimal in the service queries by id alone (no tenant filter — safe because the returned shape carries no PII and no tenant identifier), uniformly 404s every rejection class for A-25 enumeration safety, pads to LATENCY_FLOOR_MS for A-26, and is per-IP rate-limited at 30/min. Three new request-level tests added (happy path, 404, no auth required); 260/260 tests pass. 2. clientMeta short-key compression. The Android prover packs the proof into CBOR with short keys (av/pl/md/ms/pi) to fit the 1500-byte QR ceiling, the desktop forwards the raw scan as clientMeta.rawScan, so server-side decoders need the short-key map. Documented in docs/api_contract.md under POST /submit. 3. WebView process isolation. ADR-0010 originally said the WebView runs in android:process=':prover'. The W3 prover (WebViewMobileProver) lives inline in the main process because the IPC plumbing between MainActivity and a separate ProverActivity is its own day of work. Rewrote the manifest comment + the ADR paragraph to reflect what actually ships (inline, in main process, with CSP + asset hash-pinning + Android process sandboxing for defense-in-depth) and what's deferred (the :prover process + Messenger/AIDL binder). No code change — purely truthful docs. Full test sweep: typecheck clean, 260/260 backend tests, Docusaurus build green.
1 parent d18460f commit a654e33

6 files changed

Lines changed: 199 additions & 23 deletions

File tree

‎adr/0010-android-webview-snarkjs-bundling.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,19 @@ The hosting `WebView` is configured with:
9292
- `setDomStorageEnabled(false)` — no localStorage / IndexedDB inside
9393
the prover sandbox
9494

95-
The WebView runs in its own renderer process
96-
(`android:process=":prover"` in the manifest) so a renderer
97-
compromise does not give the attacker the rest of the app.
95+
**Process isolation — DEFERRED to W4+.** The W3 build hosts the
96+
WebView inline in the main activity's process (see
97+
[`WebViewMobileProver.kt`](../android/app/src/main/java/dev/zeroauth/android/prover/WebViewMobileProver.kt)
98+
class doc). Moving the WebView into its own
99+
`android:process=":prover"` activity requires a Messenger or AIDL
100+
binder layer between MainActivity and the prover process, which is
101+
its own day-of-work. The deferred state is documented inline in
102+
[`AndroidManifest.xml`](../android/app/src/main/AndroidManifest.xml)
103+
and acknowledged by threat-model row
104+
[A-17](../docs/threat_model.md). For the W3 demo the WebView's CSP +
105+
asset hash-pinning + Android process sandboxing carry the
106+
defense-in-depth; the dedicated `:prover` process is the next-class
107+
upgrade.
98108

99109
### Build-time integrity gate
100110

‎android/app/src/main/AndroidManifest.xml‎

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
unlock paths (BiometricGate is stubbed for now).
1717
1818
Components:
19-
* .MainActivity — single-activity host, Compose root.
20-
* .prover.ProverActivity (NOT YET PRESENT) — will run in
21-
android:process=":prover" per ADR-0010 once the
22-
WebView snarkjs bundle lands. Manifest entry stubbed
23-
below as a commented block so the next sprint can
24-
uncomment + flesh out.
19+
* .MainActivity — single-activity host, Compose root. Hosts the
20+
WebViewMobileProver inline in the main process today
21+
(see TODO in WebViewMobileProver.kt and the "Deferred:
22+
WebView process isolation" note in ADR-0010 §"WebView
23+
is process-isolated and CSP-locked"). Process
24+
isolation is gated on the IPC plumbing — a Messenger
25+
or AIDL binder between MainActivity and a future
26+
ProverActivity running android:process=":prover".
27+
Scheduled work, not stubbed scaffolding.
2528
2629
No <queries> needed today — no package introspection.
2730
-->
@@ -61,26 +64,30 @@
6164
</activity>
6265

6366
<!--
64-
Prover activity placeholder — uncomment + flesh out in the prover-glue
65-
sprint task. android:process=":prover" puts the WebView renderer
66-
in a dedicated process so a renderer compromise cannot reach
67-
the main process where the Keystore-bound credential lives
68-
(ADR-0010, "WebView is process-isolated and CSP-locked").
67+
DEFERRED: dedicated prover activity in android:process=":prover".
68+
69+
ADR-0010 §"WebView is process-isolated and CSP-locked" calls for
70+
hosting the snarkjs WebView in its own OS process. The W3 prover
71+
(WebViewMobileProver.kt) currently lives inline in the main
72+
activity's process because the IPC plumbing — Messenger / AIDL
73+
binder between MainActivity and a future ProverActivity — is its
74+
own ~day-of-work and we shipped W3 without it. Threat model
75+
A-17 acknowledges the residual risk: a WebView renderer
76+
compromise has line-of-sight into the main process where the
77+
Keystore-unwrapped biometric secret lives during proof
78+
generation.
79+
80+
To uncomment when the IPC layer lands (W4+):
6981
7082
<activity
7183
android:name=".prover.ProverActivity"
7284
android:exported="false"
7385
android:process=":prover"
7486
android:theme="@style/Theme.ZeroAuth.Prover" />
75-
-->
7687
77-
<!--
78-
WebView asset loader provider placeholder — once we ship
79-
assets/prover/* (ADR-0010), the prover activity will host an
80-
androidx.webkit.WebViewAssetLoader that serves the bundled
81-
snarkjs.min.js + .wasm + .zkey over an https://appassets.androidplatform.net
82-
origin. No <provider> registration is needed for that — calling
83-
this out so the next engineer doesn't add one by reflex.
88+
(The asset loader at https://appassets.androidplatform.net is
89+
configured per-WebView by WebViewMobileProver — no manifest
90+
provider needed even after the move.)
8491
-->
8592

8693
</application>

‎docs/api_contract.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ Cross-device verification flow: a desktop opens a session, a phone scans the QR
137137
| `POST` | `/v1/proof-pairing/sessions/:id/submit` | `proof_pairing:claim` | Desktop submits the proof + public signals it scanned from the phone. Body: `{ did, proof, publicSignals, clientMeta? }`. Returns `{ session, tokens }`. |
138138
| `GET` | `/v1/proof-pairing/sessions/:id/stream` | `proof_pairing:create` + `session_bind` cookie | Server-Sent Events. Events: `session_created`, `session_bound`, `session_expired`, `session_error`. Connection closes after a terminal event. |
139139
| `GET` | `/v1/proof-pairing/sessions/:id` | `proof_pairing:create` + `session_bind` cookie | Polling fallback for clients without `EventSource`. |
140+
| `GET` | `/v1/proof-pairing/sessions/:id/public` | none (per-IP rate-limited 30/min) | Unauthenticated freshness read. Returns only `{ id, state, expiresAt }` so the Android app can short-circuit a ceremony after a stale QR was scanned. Uniform `404 pairing_session_not_found` for every rejection class (A-25). Cache-Control: no-store. |
140141

141142
`POST /sessions` response (also sets `Set-Cookie: zeroauth_pair_bind=…; HttpOnly; Secure; SameSite=Strict; Path=/v1/proof-pairing/; Max-Age=300`):
142143
```json
@@ -158,10 +159,22 @@ Cross-device verification flow: a desktop opens a session, a phone scans the QR
158159
"did": "did:zeroauth:demo:7a3c9f5b8e1d2a4c6f0b9e3d5a7c1f8b",
159160
"proof": { "pi_a":[…], "pi_b":[…], "pi_c":[…], "protocol":"groth16", "curve":"bn128" },
160161
"publicSignals": ["<commitment>", "<didHashSession>", "<identityBinding>"],
161-
"clientMeta": { "appVersion": "0.1.0", "platform": "android", "model": "Pixel 7a", "proofMs": 4820, "playIntegrityVerdict": "MEETS_STRONG_INTEGRITY" }
162+
"clientMeta": { "appVersion": "0.1.0", "platform": "android", "model": "Pixel 7a", "proofMs": 4820, "playIntegrityVerdict": "MEETS_STRONG_INTEGRITY", "rawScan": "za:proof:1:<base64url-of-gzip-cbor>" }
162163
}
163164
```
164165

166+
**`clientMeta.rawScan` short-key schema.** The Android app encodes the proof QR as `gzip(cbor({ s, p, ps, d, m }))` to fit under the 1500-byte QR-capacity ceiling (ADR-0009 §5). The desktop relays the raw scan string into `clientMeta.rawScan` unchanged; backend audit + analytics decoders need to know the field-shortening map:
167+
168+
| Short key | Long key in this contract | Type |
169+
|---|---|---|
170+
| `s` | `session.id` | string (UUIDv4) |
171+
| `p` | `proof` | `{ a, b, c, protocol, curve }` (same shape, also short-keyed: `a` = `pi_a`, `b` = `pi_b`, `c` = `pi_c`) |
172+
| `ps` | `publicSignals` | string[3] (`[commitment, didHashSession, identityBinding]`) |
173+
| `d` | `did` | string |
174+
| `m` | `clientMeta` | `{ av, pl, md, ms, pi }` where `av` = `appVersion`, `pl` = `platform`, `md` = `model`, `ms` = `proofMs`, `pi` = `playIntegrityVerdict` |
175+
176+
Decoded forms are byte-equal to the long-keyed JSON above; the desktop's submit handler reconstructs the long-keyed body before posting if it needs to call `/submit` itself, so server-side handlers can rely on the documented long-keyed shape and treat `clientMeta.rawScan` as an opaque audit token.
177+
165178
`POST .../submit` success — `200 OK`:
166179
```json
167180
{

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
*/
1111

1212
import { Router, Request, Response } from 'express';
13+
import rateLimit from 'express-rate-limit';
1314
import { authenticateTenantApiKey, getTenantContext } from '../../middleware/tenant-auth';
1415
import { logger } from '../../services/logger';
1516
import {
1617
createSession,
1718
submitProof,
1819
getSession,
20+
getSessionPublicMinimal,
1921
subscribeStream,
2022
streamHeartbeatMs,
2123
PairingSessionNotFound,
@@ -277,4 +279,46 @@ router.get('/sessions/:id',
277279
},
278280
);
279281

282+
// ─── Public freshness read ─────────────────────────────────────────────
283+
//
284+
// Unauthenticated read returning ONLY { id, state, expiresAt }. The
285+
// Android app uses it to short-circuit a doomed ceremony when the user
286+
// scans a desktop QR after it has already expired — phone gets the
287+
// signal without exposing the desktop's API key. Per-IP rate-limited
288+
// to defeat enumeration at scale; service layer pads latency to the
289+
// LATENCY_FLOOR_MS to defeat A-26.
290+
const publicReadLimiter = rateLimit({
291+
windowMs: 60_000,
292+
max: 30,
293+
standardHeaders: true,
294+
legacyHeaders: false,
295+
message: { error: 'rate_limit_exceeded' },
296+
});
297+
298+
router.get('/sessions/:id/public',
299+
publicReadLimiter,
300+
async (req: Request, res: Response) => {
301+
try {
302+
const sessionId = String(req.params.id);
303+
const session = await getSessionPublicMinimal(sessionId);
304+
// Hint to caches: this is per-session and short-lived; never store.
305+
res.setHeader('Cache-Control', 'no-store');
306+
res.status(200).json({ session });
307+
} catch (err) {
308+
const mapped = mapError(err);
309+
if (mapped.status === 500) {
310+
logger.error('proof-pairing: getSessionPublicMinimal failed', {
311+
error: (err as Error).message,
312+
});
313+
}
314+
// Uniform 404 for every rejection class — A-25 enumeration safety.
315+
// Public callers see only "not found", never "exists but blocked".
316+
res.status(404).json({
317+
error: 'pairing_session_not_found',
318+
message: 'No such session.',
319+
});
320+
}
321+
},
322+
);
323+
280324
export default router;

‎src/services/proof-pairing.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,21 @@ export interface SessionPublicView {
127127
did?: string;
128128
}
129129

130+
/**
131+
* Strictly-public view returned by `GET /sessions/:id/public`. No
132+
* tenant context, no bind cookie, no PII — only the freshness signal
133+
* the Android app needs to decide whether to bother prompting the user
134+
* for biometric. Everything in this shape is information the phone
135+
* already knows from the QR payload or could re-derive locally. The
136+
* endpoint exists purely so the phone can short-circuit a doomed
137+
* ceremony when the user scans a QR after it has already expired.
138+
*/
139+
export interface SessionPublicMinimalView {
140+
id: string;
141+
state: ProofPairingState;
142+
expiresAt: string;
143+
}
144+
130145
export interface SubmitResult {
131146
session: SessionPublicView;
132147
verification: { id: string };
@@ -664,6 +679,49 @@ export async function submitProof(
664679
}
665680
}
666681

682+
/**
683+
* Public read for the Android app — no tenant context, no bind cookie.
684+
* Returns only `{ id, state, expiresAt }` so the phone can decide
685+
* whether the session is worth a biometric prompt. Looks up the row
686+
* by ID alone (no tenant filter), which is safe because:
687+
*
688+
* - the returned shape has no tenant identifier and no PII;
689+
* - session IDs are UUIDv4 (~122 bits of entropy);
690+
* - the route is per-IP rate-limited at 30 req/min/IP (route layer);
691+
* - latency is padded to LATENCY_FLOOR_MS to defeat A-26.
692+
*
693+
* A `PairingSessionNotFound` thrown here maps to a uniform 404 — the
694+
* route layer must not distinguish "session doesn't exist" from any
695+
* other rejection class. See A-25 in `docs/threat_model.md`.
696+
*/
697+
export async function getSessionPublicMinimal(
698+
sessionId: string,
699+
): Promise<SessionPublicMinimalView> {
700+
const startMs = Date.now();
701+
try {
702+
const pool = getPool();
703+
const result = await pool.query<ProofPairingSession>(
704+
`SELECT id, state, expires_at
705+
FROM proof_pairing_sessions
706+
WHERE id = $1`,
707+
[sessionId],
708+
);
709+
const row = result.rows[0];
710+
if (!row) {
711+
throw new PairingSessionNotFound();
712+
}
713+
return {
714+
id: row.id,
715+
state: row.state,
716+
expiresAt: row.expires_at instanceof Date
717+
? row.expires_at.toISOString()
718+
: String(row.expires_at),
719+
};
720+
} finally {
721+
await padToFloor(startMs);
722+
}
723+
}
724+
667725
export async function getSession(
668726
sessionId: string,
669727
tenantId: string,

‎tests/proof-pairing.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ jest.mock('../src/middleware/tenant-auth', () => ({
128128
const createSessionMock = jest.fn();
129129
const submitProofMock = jest.fn();
130130
const getSessionMock = jest.fn();
131+
const getSessionPublicMinimalMock = jest.fn();
131132
const subscribeStreamMock = jest.fn();
132133

133134
jest.mock('../src/services/proof-pairing', () => {
@@ -148,6 +149,7 @@ jest.mock('../src/services/proof-pairing', () => {
148149
createSession: (...args: unknown[]) => createSessionMock(...args),
149150
submitProof: (...args: unknown[]) => submitProofMock(...args),
150151
getSession: (...args: unknown[]) => getSessionMock(...args),
152+
getSessionPublicMinimal: (...args: unknown[]) => getSessionPublicMinimalMock(...args),
151153
subscribeStream: (...args: unknown[]) => subscribeStreamMock(...args),
152154
expireOverdueSessions: jest.fn(),
153155
streamHeartbeatMs: 15000,
@@ -587,3 +589,45 @@ describe('audit', () => {
587589
expect(crossTenantAuditRecorded).toBe(true);
588590
});
589591
});
592+
593+
describe('GET /v1/proof-pairing/sessions/:id/public', () => {
594+
const id = uuid();
595+
596+
it('200 with { session: { id, state, expiresAt } } on a known session — no auth required', async () => {
597+
getSessionPublicMinimalMock.mockResolvedValueOnce({
598+
id,
599+
state: 'issued',
600+
expiresAt: '2026-12-01T00:00:00.000Z',
601+
});
602+
const res = await request(app).get(`/v1/proof-pairing/sessions/${id}/public`);
603+
expect(res.status).toBe(200);
604+
expect(res.body).toEqual({
605+
session: { id, state: 'issued', expiresAt: '2026-12-01T00:00:00.000Z' },
606+
});
607+
expect(res.headers['cache-control']).toBe('no-store');
608+
// Exactly the three public fields — no userId, no boundAt, no did,
609+
// no tenant identifier. Public callers must never receive PII.
610+
expect(res.body.session.userId).toBeUndefined();
611+
expect(res.body.session.boundAt).toBeUndefined();
612+
expect(res.body.session.did).toBeUndefined();
613+
});
614+
615+
it('404 pairing_session_not_found for unknown id (uniform with all other rejections — A-25)', async () => {
616+
getSessionPublicMinimalMock.mockRejectedValueOnce(new PairingSessionNotFound('no such row'));
617+
const res = await request(app).get(`/v1/proof-pairing/sessions/${uuid()}/public`);
618+
expect(res.status).toBe(404);
619+
expect(res.body.error).toBe('pairing_session_not_found');
620+
});
621+
622+
it('does not require Authorization or session_bind cookie', async () => {
623+
// Spy that the route ever consults tenant auth would land here; we
624+
// assert by reaching the service mock without setting either auth
625+
// primitive.
626+
getSessionPublicMinimalMock.mockResolvedValueOnce({
627+
id, state: 'consumed', expiresAt: '2026-12-01T00:00:00.000Z',
628+
});
629+
const res = await request(app).get(`/v1/proof-pairing/sessions/${id}/public`);
630+
expect(res.status).toBe(200);
631+
expect(getSessionPublicMinimalMock).toHaveBeenLastCalledWith(id);
632+
});
633+
});

0 commit comments

Comments
 (0)