diff --git a/adr/0022-device-enrollment-flow.md b/adr/0022-device-enrollment-flow.md new file mode 100644 index 0000000..bcbd167 --- /dev/null +++ b/adr/0022-device-enrollment-flow.md @@ -0,0 +1,112 @@ +# ADR 0022 — Production device-enrollment flow + +- **Status:** Accepted +- **Date:** 2026-05-28 +- **Phase:** Phase 1 sprint 2 (enables the BFSI demo "fleet onboarding" scene) +- **Related:** ADR 0013 (audit chain — every state change writes a row), ADR 0017 (face-first identity), ADR 0021 (RS256 JWT) + +## Context + +Before this ADR, the dashboard's **Register device** flow asked the operator for a free-form *name* and inserted a row in `devices` with status `active`. The row had no hardware-bound identity — anybody with a console session could mint infinite "devices" that didn't physically exist; any device could claim to be any row at API time. The threat model row A-22 ("phantom device enrollment") was the original opener; the same gap powers a class of attendance-fraud scenarios in the BFSI demo runbook (Scene 5). + +Three things were missing: + +1. **A handshake.** Real production fleets bind a *physical* device to a *logical* row at some point in the lifecycle. The Tailscale, Slack, Cloudflare-Tunnel pattern is the prior art: admin issues a one-time code → device claims with the code + its hardware identity → server flips the row to *active* and remembers the binding. We had no equivalent. + +2. **A per-device identity.** The `external_id` column was filled with a server-generated UUID (`device_<12hex>`); the device itself didn't supply anything. So even after registration, the row carried no information that could be used to *prove* the device on subsequent calls — credentials lived entirely in the tenant API key, which is shared across the fleet. + +3. **A taxonomy.** All devices were treated as one shape: a row with a name. But the attestation story is wildly different for an Android phone (Play Integrity), an iOS phone (App Attest), a branch kiosk (none — it's behind a VPN), and an R307 fingerprint bridge over USB-OTG (signed firmware hash only). Without a type, every code-path either over-rotated or under-rotated on attestation expectations. + +## Decision + +Adopt a **two-step enrollment handshake** modeled on the prior art above. The dashboard creates a *pending slot* + mints a one-time enrollment code; the device claims the slot via a public endpoint, binding its hardware fingerprint. Three columns of state-machine work, four routes, and one new service module. + +### State model + +`devices.enrollment_state` (NEW, orthogonal to `devices.status`): + +| State | Meaning | +|----------|---------------------------------------------------------------------------| +| pending | Slot created by admin; enrollment code outstanding; no device bound yet. | +| enrolled | Device has claimed the slot; `fingerprint_hash` is bound; row is usable. | +| revoked | Admin voided credentials. Row retained for audit-log entity_id stability. | + +`devices.status` keeps its existing semantics (`active` / `inactive` / `retired`) — that's the *operational* state of an *already-enrolled* device. A device is `pending`+`active` between issue and claim (the slot exists), `enrolled`+`active` after claim, `enrolled`+`inactive` after a heartbeat-failure threshold, `revoked`+`retired` after admin termination. + +### Enrollment code + +The code is a human-typeable one-time secret. Format: `ZA-XXXX-XXXX`, 8 entropy chars from a 27-symbol Crockford-base32 alphabet (no `0`, `1`, `I`, `L`, `O`, `U`): + +- Entropy: log2(27^8) ≈ **38 bits**. +- TTL: **15 minutes**. +- Per-IP rate-limit on `/v1/devices/enroll`: **10 req/min** (existing `pgRateLimit` middleware). +- Stored only as **SHA-256** (`enrollment_code_hash`). The plaintext is returned to the dashboard exactly once, never persisted. + +Under those guards, an attacker has expected ~2^25 online attempts before landing a single collision in the 15-minute window — meaning the rate-limit halts them at ~2^25 / (10 × 15) ≈ ~225,000× the window length. The combination is conservatively secure for a code with this UX budget. + +### Device fingerprint + +The device-side `fingerprint` is opaque to the server — we SHA-256 it and store the hash. The plaintext format is device-type-specific: + +| Device type | Suggested fingerprint composition | +|--------------------|------------------------------------------------------------------| +| `mobile_android` | `android_id` + Play Integrity package name + installation UUID | +| `mobile_ios` | `identifierForVendor` + App Attest `keyId` | +| `kiosk` | kiosk serial number + primary MAC address | +| `iot_bridge` | bridge UUID + USB serial of the R307 sensor | +| `desktop` | WebAuthn credential `rawId` (Phase 2) | + +Validator requires `fingerprint.length >= 16` so a misconfigured client can't bind by sending `"default"`. + +### Attestation + +V1 *records* the attestation kind (`play-integrity` | `app-attest` | `webauthn` | `none`) in `devices.attestation_kind`, and the raw attestation blob in `audit_events.metadata`. V1 does **not** verify the attestation. Verification routes through `src/services/play-integrity.ts` (already exists for proof-pairing) in Phase 1 sprint 4. + +### Routes + +| Method | Path | Auth | Purpose | +|--------|-------------------------------------------------------|---------------------|------------------------------------------| +| POST | `/api/console/devices` | Console JWT | Create pending slot, mint enrollment code | +| POST | `/api/console/devices/:id/regenerate-code` | Console JWT | Re-issue code (voids prior) | +| DELETE | `/api/console/devices/:id` | Console JWT | Soft-revoke (state=revoked, status=retired) | +| POST | `/v1/devices/enroll` | None (code is bearer) | Device-side claim with code + fingerprint | +| POST | `/v1/devices` | Tenant API key | Trusted-service direct create (legacy) | + +`/v1/devices/enroll` is in `tests/tenant-isolation.test.ts::PUBLIC_ROUTE_EXCEPTIONS` for the same reason `/v1/zkp/verify` and the pairing public endpoints are: the bearer credential rides the request body, not the headers. + +### Audit-log surface + +Five new actions, all routed through `appendAuditEvent` so they show up in the hash chain: + +- `device.enrollment_code_issued` — slot created; metadata: device_type, location, expires_at (NOT the code or its hash). +- `device.enrollment_code_reissued` — operator pressed Re-issue; metadata: expires_at. +- `device.enrolled` — device claimed the slot; actor_type='device', metadata: attestation_kind, enroll_ip, user_agent. +- `device.revoked` — admin terminated; actor_type='console'. +- `device.created` — kept for the trusted-service `/v1/devices` path; metadata.via='trusted-service'. + +### Backwards compatibility + +- `POST /v1/devices` (tenant API key path) keeps direct-create semantics for the SDK/bulk-provisioning use case. It now also accepts an optional `device_type`; defaults to `kiosk`. The demo seed continues to work unchanged. +- Existing `devices` rows backfill `enrollment_state='enrolled'` and `device_type='kiosk'` at schema-bootstrap time (`ADD COLUMN IF NOT EXISTS … DEFAULT`). +- The dashboard's `PATCH /api/console/devices/:id` (mutates name, location, status, etc.) is unchanged. + +## Alternatives considered + +1. **Email/SMS enrollment links** — fine for individual users, wrong for kiosk/IoT-bridge fleets which often have no inbox. The code-on-screen ↔ code-entered-on-device pattern is the canonical IoT primitive. +2. **Pre-shared per-device API keys** — every device gets its own `za_live_*` at creation. Defers the rotation problem (you've moved the secret-management problem from "shared key" to "fleet of unique keys"). Also fails if the admin minted the key on the wrong device or the device got swapped. +3. **mTLS client certs** — better long-term posture (the cert IS the identity) but requires every device class to ship a PKCS#11 stack and own a private key in secure storage. Defers to Phase 2 after the WebAuthn desktop path is in. +4. **No state machine; just an `is_enrolled` boolean** — fails on the revoked case (we'd lose forensic traceability after delete). Two booleans are equivalent to the three-state machine but less self-documenting. + +## Out of scope (deferred) + +- **Per-device tokens.** After enrollment, the device should get a long-lived bearer credential (`device_token`) it presents on heartbeats and verifications. V1 returns the row only — the device infers its identity from `device.id`. The token + heartbeat protocol lands in Phase 1 sprint 4 alongside Play Integrity verification. +- **QR rendering in the dashboard.** V1 shows the plaintext code + a copyable `zeroauth://enroll?code=…` deeplink. QR rendering requires a new dependency (qrcode.js or similar); deferred to a follow-up commit with the dep-add ADR. The deeplink format is stable. +- **Bulk CSV pre-provisioning.** Admin uploads a CSV of names+types, server mints N pending slots, returns a CSV with codes. Deferred to Phase 2 when the first multi-branch BFSI tenant ships. +- **Geofence + IP allowlist on the enroll endpoint.** Some BFSI tenants will want enrollment locked to the bank's office IP range. Deferred — implementable as a per-tenant `enrollment_ip_allowlist` in `tenants.security_policy` JSON when the demand surfaces. + +## Verification + +- `tests/device-enrollment.test.ts` — 26 tests covering code generation, normalisation, fingerprint validation, the four service-layer functions, and the failure modes (invalid_fingerprint, code_not_found_or_expired, fingerprint_collision). +- `tests/console-proxy.test.ts` — request-level coverage of POST/PATCH/DELETE /api/console/devices and the device_type validation gate. +- `tests/tenant-isolation.test.ts` — `/v1/devices/enroll` listed in `PUBLIC_ROUTE_EXCEPTIONS` with a documented reason. +- The dashboard's `Devices.tsx` test surface is exercised via the integration build (no new component tests in V1; the visual flow is hard to unit-test for the QR/deeplink screen and the existing user/audit-integrity test patterns are the wrong shape for it). diff --git a/adr/0023-three-qr-signup-ceremony.md b/adr/0023-three-qr-signup-ceremony.md new file mode 100644 index 0000000..d1dcd45 --- /dev/null +++ b/adr/0023-three-qr-signup-ceremony.md @@ -0,0 +1,144 @@ +# ADR 0023 — Three-QR end-user signup ceremony + +- **Status:** Accepted +- **Date:** 2026-05-28 +- **Phase:** Phase 1 sprint 2 (the "user creates an account" half of the BFSI demo) +- **Related:** ADR 0017 (face-first identity surface), ADR 0018 (mobile face embedding pipeline), ADR 0021 (RS256 JWT), ADR 0022 (production device-enrollment flow) + +## Context + +ADR 0022 gave us a way for an *operator* to register a *device* (kiosk, IoT bridge, phone) into a tenant's fleet. That's the right primitive for fleet onboarding but it's the wrong shape for the actual onboarding scene: an *end user* creating *their own account* on the *org's website* by *using their phone* as the credential carrier. + +The end-user flow that the BFSI demo (and any post-Auth0 SaaS integration) actually needs: + +1. Org has implemented ZeroAuth instead of Google Sign-In on their signup page. +2. End user fills in name + email + whatever the org wants (just like a Google Sign-In flow). +3. The signup page asks for a biometric. The user doesn't have a webcam-grade face capture, and the org doesn't want to ship a depth-camera-required SDK. The user's *phone* is the biometric capture device — but the org's server must never see the raw biometric. +4. After the user does the biometric on the phone, the signup page must somehow get *proof of biometric possession* tied to *this user's new account on this org's site* — without the phone ever talking directly to the org's site (different origins, different sessions, often different networks). + +This is exactly the problem the [WebAuthn registration ceremony](https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential) solves for hardware security keys, except (a) the credential is a *biometric*, not a key, so the phone produces a *zero-knowledge proof* of biometric possession instead of a signature; (b) the phone has no Bluetooth pairing channel to the laptop; (c) we don't want a custom SDK on the laptop. The side channel that solves all three: QR codes on the laptop screen, scanned by the phone's camera. + +The user described the flow exactly: + +> "They'll register a device, by scanning a qr on their phone. Then enroll their biometrics on the phone and proof is getting generated. Then after biometric setup they'll scan the another qr on the platform that'll compare the device ids and the proof also get's transferred. Now after final confirmation when the user verifies their biometric on the phone and generates proof and by scanning a qr on the console the account finally get's created." + +So: **three QRs**, one ceremony, biometric stays on the phone, server only ever sees the commitment and the proof. Same threat model as WebAuthn registration; same UX shape as Slack's "approve sign-in from a desktop" flow. + +## Decision + +Adopt the three-QR ceremony as the canonical end-user signup flow. Schema, service module, and a 7-endpoint API surface. The biometric pipeline (FaceEmbedder → Quantizer → SHA-256 → Poseidon → DID) already lives on-device per ADR 0018; this ADR is the *coordination protocol* that ties three on-device steps to one server-side account-creation transaction. + +### State machine + +`registration_sessions.state`: + +| State | Meaning | +|------------------------|----------------------------------------------------------------------------| +| awaiting_device | Session opened by tenant SDK; `pair_code` outstanding; QR1 is on screen. | +| awaiting_commitment | Phone paired the device; `enroll_code` outstanding; QR2 is on screen. | +| awaiting_verification | Commitment received; `verify_code` + `challenge_nonce` outstanding; QR3. | +| completed | Proof verified; `tenant_user` row created; ceremony done. | +| abandoned | Tenant called DELETE or whole-session TTL elapsed. | + +Whole-session TTL is **30 minutes**. Each step's bearer code TTL is **15 minutes** (matches ADR 0022 device enrollment). The whole session can outlive a single code's TTL — if the operator stalls between scans, the next code is re-issued by the tenant SDK calling the start endpoint again on the same session row (Phase 1 Sprint 3 follow-on — V1 makes the operator restart). + +### Codes + +Three independent codes, each in its own row column: + +- `pair_code_hash` — consumed at step 1 +- `enroll_code_hash` — consumed at step 2 +- `verify_code_hash` — consumed at step 3 + +Each is `ZA-XXXX-XXXX` (the same format as ADR 0022, reused via `src/services/device-enrollment.ts::generateEnrollmentCode`). Each is stored as SHA-256, returned in plaintext exactly once. Each step's handler reads only its own column — a captured `pair_code` cannot satisfy the `submit-commitment` handler, and so on. This blocks the confused-deputy class of attack where someone replays an old QR into a later step. + +### Challenge nonce (replay defence) + +After step 2, the server mints `verify_challenge_nonce` (128 bits hex, single-use, scoped to this row) and bakes it into QR3's deeplink. The phone echoes the nonce back with the proof in step 3; the server checks it matches what it issued. + +**V1 limitation:** the challenge_nonce is bound to the *request*, not to the *proof itself*. The existing identity_proof.circom (v1.2) doesn't yet have a public-input slot for a session challenge — `publicSignals[0]` is the commitment and that's it. Replay across sessions is therefore prevented by: + +- The single-use `verify_code` (an old proof can't be submitted into a fresh session because the fresh session has a different `verify_code_hash`). +- The 15-minute TTL on `verify_code_expires_at`. +- The per-IP rate-limit (20 req/min on the phone-side endpoints). + +**Phase 1 Sprint 4 follow-on:** circuit v1.3 adds a public-input slot for the challenge nonce; the route handler then asserts `publicSignals[1] === verify_challenge_nonce` *and* the proof verifies — closing the proof-replay surface entirely. The deeplink format and route surface stay stable; only the circuit upgrades. + +### Routes + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| POST | `/v1/registrations` | tenant API key (users:write) | Start session; returns `pair` envelope for QR1. | +| GET | `/v1/registrations/:id` | tenant API key (users:read) | Poll state (redacts code hashes + challenge nonce). | +| DELETE | `/v1/registrations/:id` | tenant API key (users:write) | Abandon session; idempotent on completed rows. | +| POST | `/v1/registrations/pair-device` | **none — `pair_code` is bearer** | Step 1: phone claims a device. | +| POST | `/v1/registrations/submit-commitment` | **none — `enroll_code` is bearer** | Step 2: phone uploads (did, commitment). | +| POST | `/v1/registrations/complete` | **none — `verify_code` is bearer** | Step 3: phone uploads proof; tenant_user is created. | + +The three phone-side endpoints are listed in `tests/tenant-isolation.test.ts::PUBLIC_ROUTE_EXCEPTIONS` for the same reason `/v1/devices/enroll`, `/v1/zkp/verify`, and the proof-pairing public endpoints are: the QR-supplied code is the bearer credential and there is no tenant API key available on the phone side. + +### What the phone sends, what the server sees + +| Step | Phone sends | Server sees | What's NOT sent | +|------|-------------|-------------|-----------------| +| 1 | `pair_code`, `fingerprint` (≥16 chars, opaque), `attestation_kind?` | SHA-256(fingerprint); attestation kind string | the biometric, the secret, the commitment | +| 2 | `enroll_code`, `did` (`did:zeroauth::`), `commitment` (hex) | the DID + commitment as strings | the secret, any biometric data | +| 3 | `verify_code`, `challenge_nonce`, `proof` (Groth16), `public_signals` | the proof + the commitment in `publicSignals[0]` | the secret, the embedding | + +The biometric NEVER touches a network wire. Source-grep guard `tests/biometric-rejection.test.ts` continues to block any handler reading `req.body.image/template/pixel/depth/frame/raw_face/raw_finger/biometric_data/photo`; the registration ingress is also defended at the JSON-body layer by `sanitizeProfile` (regex-stripped before insert) so a buggy tenant SDK that *does* pass one of those keys gets the key dropped, with a warn log, rather than committed to the row. + +### Audit-log surface + +Six new actions, all routed through `appendAuditEvent` so they land in the ADR 0013 hash chain: + +- `registration.started` — tenant SDK opened a session +- `registration.device_paired` — actor_type='device', step 1 completed +- `registration.commitment_submitted` — actor_type='device', step 2 completed +- `registration.completed` — actor_type='device', step 3 completed, tenant_user created +- `registration.abandoned` — tenant SDK or admin cancelled + +The plaintext codes and the challenge_nonce never appear in audit metadata. Step-2 metadata records `commitmentPrefix` (first 16 chars) but not the full commitment — sufficient to forensically correlate without leaking the value into a log retention window broader than the tenant_users row. + +### Backwards compatibility + +This is purely additive — no existing route, no existing column, no existing test breaks. The new `registration_sessions` table is independent of `devices` and `tenant_users` apart from FK relationships (`device_id`, `tenant_user_id`) that are nullable until the relevant step lands. The existing `/v1/identity/register` and `/v1/identity/verify` continue to work for non-ceremony integrations (an SDK that wants to do its own session orchestration can call them directly). + +## Alternatives considered + +1. **One QR with embedded state machine** — the user's phone scans one QR, opens a websocket-like channel, and the server pushes step transitions over SSE. Drops 2 QR scans but loses the explicit "I agree to this step" UX moment + lets a stale phone hold an open session indefinitely. Rejected. + +2. **WebSocket from phone to server** — strictly stronger than QR2 + QR3 (server pushes everything once paired). Requires the phone app to be persistently connected; doesn't degrade if the phone goes offline mid-ceremony; requires SSL termination on the load balancer for ws://. Defer until we have a measured pain point. + +3. **Server-side biometric verification (skip the phone)** — abandons the privacy guarantee that makes ZeroAuth different from every legacy product. Out of scope. + +4. **Native deep-link callback (universal links / app links)** — the phone opens a `https://zeroauth.dev/reg/...` URL that triggers the companion app and embeds session state in the URL. Strictly an *additional* surface on top of QR — useful when the operator is on the same device as the user (rare for this BFSI flow) but doesn't replace QR for the cross-device case. Add later as an alternate scheme. + +5. **Single shared "registration_token" instead of three codes** — collapses the three SHA-256 columns into one. Loses the per-step confused-deputy defence. Rejected. + +## Out of scope (deferred) + +- **QR rendering in the dashboard.** Server returns the deeplink; the SDK / dashboard renders it as a QR. V1 ships the deeplink alone (no `qrcode` dep, matching ADR 0022's deferral). A follow-up commit adds the dep with its own ADR. +- **Circuit-bound challenge nonce.** Phase 1 Sprint 4 circuit upgrade adds `publicSignals[1]` for the session challenge. The deeplink already carries the nonce; the route handler will assert circuit-side binding when the new circuit ships. +- **Per-device token after enrollment.** Step 1 binds a `fingerprint_hash` to a device row; step 3 binds the device to a tenant_user. The device gets no long-lived bearer credential yet — that lands with the heartbeat protocol in Phase 1 Sprint 4 (same path as the ADR 0022 follow-on). +- **Real-time SSE stream for the platform.** V1 has the tenant SDK poll `GET /v1/registrations/:id` every 2–3 seconds. SSE is a clean upgrade (the proof-pairing flow already does it) — defer until the BFSI demo reveals latency complaints. +- **Per-tenant profile schema validation.** V1 accepts an opaque `profile` blob with biometric-key sanitisation only. Tenant-specific schemas (e.g., bank-onboarding requires PAN + Aadhaar masked digits + employee_code) come with the per-tenant `tenant.security_policy.registration_schema` JSON Schema validator in Phase 2. +- **End-user-facing demo UI** in the dashboard. V1 ships backend only — the dashboard `Devices.tsx` redesign from ADR 0022 is the operator-facing surface. The 3-QR demo page (mirroring the existing `demo/QrProofLogin` route shape) lands in a follow-up commit. + +## Verification + +- `tests/registration-flow.test.ts` — 19 tests across the four service-layer entry points and their failure modes; mocked pg pool so no Postgres is required. +- `tests/tenant-isolation.test.ts` — three new PUBLIC_ROUTE_EXCEPTIONS entries with reason strings ≥ 20 chars. +- `tests/schema-purity.test.ts` — `registration_sessions` added to both TENANT_SCOPED_TABLES (biometric-name guard applies) and KNOWN_TABLES (new-table guard satisfied). +- `npm test` — 524/524 across 44 suites. +- `npx tsc --noEmit` — clean. + +## Threat model deltas + +New rows added to `docs/threat_model.md` (Phase 1 sprint 2 update batch): + +- **A-30** — Captured QR1 / QR2 / QR3 replay. Mitigation: per-step single-use SHA-256-hashed code, 15-min TTL, per-IP rate-limit, three separate code columns block cross-step reuse. +- **A-31** — Hostile phone enrolls into another user's session by guessing the pair_code. Mitigation: 38-bit code entropy × 15-min TTL × 20 req/min/IP rate-limit ≈ 225,000× window-length brute-force cost. Same calibration as ADR 0022. +- **A-32** — Replayed proof from another session. V1 mitigation: single-use verify_code chain + 15-min TTL. Phase 1 Sprint 4 closure: circuit-bound challenge nonce in `publicSignals[1]`. +- **A-33** — Tenant SDK passes a raw biometric in the `profile` blob. Mitigation: `sanitizeProfile` strips any field name containing image/template/pixel/depth/frame/raw_face/raw_finger/biometric/photo (with word-boundary matching) at ingest; warn-logged. + +The full threat-model table is updated alongside this commit's API contract changes; this ADR captures the four deltas. diff --git a/adr/0024-qrcode-react-dependency.md b/adr/0024-qrcode-react-dependency.md new file mode 100644 index 0000000..708646e --- /dev/null +++ b/adr/0024-qrcode-react-dependency.md @@ -0,0 +1,72 @@ +# ADR 0024 — Adopt `qrcode.react` for in-dashboard QR rendering + +- **Status:** Accepted +- **Date:** 2026-05-29 +- **Phase:** Phase 1 sprint 2 (demo for ADR 0023 three-QR signup ceremony) +- **Related:** ADR 0022 (device enrollment — also wants QRs), ADR 0023 (three-QR signup ceremony) + +## Context + +ADR 0022 and ADR 0023 both ship deeplinks (`zeroauth://enroll?code=…` and `zeroauth://reg?step=…`) that the operator is supposed to render as scannable QRs on the dashboard, then a phone scans them to advance the flow. Both ADRs explicitly deferred QR rendering "to a follow-up commit with a dep-add ADR" — that's this ADR. + +Until now the dashboard rendered the deeplinks as copyable plain-text strings. That works for technical demos and copy-paste integration tests but it's not the user experience either ADR is asking for: a user holding their phone up to the laptop screen and tapping the camera scanner. + +The three-QR signup demo (next commit) needs three QRs on one page that update as the state machine advances. Same dep would also be retrofitted into the existing `demo/QrProofLogin.tsx` page (which today fakes the QR with a Unicode block grid). + +## Decision + +Adopt **`qrcode.react@4.2.0`** as a runtime dependency in `dashboard/package.json`. + +### Alternatives considered + +| Package | Version | License | Size (unpacked) | Outcome | +|---|---|---|---|---| +| **`qrcode.react`** (chosen) | 4.2.0 | ISC | ~115 kB | React-native component API, SVG output, zero runtime deps, peer on React 19 | +| `qrcode` (node-qrcode) | 1.5.4 | MIT | ~325 kB | Node + browser library, larger, has 7 transitive deps, requires manual Canvas/img wrapping in React | +| `@zxing/library` | 0.21.x | Apache-2.0 | 2.4 MB | Full barcode encode + decode + camera-pipeline library. 100× larger than the actual need; useful when we add scan-from-webcam to the dashboard, deferred | +| Vendored 3kB encoder (e.g. nayuki QR encoder + custom React wrapper) | — | MIT-compatible | ~3 kB | Smallest but reinvents the wheel; the ADR maintenance cost over time exceeds the bundle-size saving | +| External QR-rendering URL (`api.qrserver.com/v1/...`) | — | — | 0 kB | External network dependency on every page render — violates self-host posture; not considered seriously | + +### Why `qrcode.react` + +- **React-native API.** `` drops in alongside the existing ``, ``, `` primitives. +- **SVG output.** Sharper at any zoom level than the Canvas raster path in the `qrcode` library; smaller DOM footprint than a ``. +- **Maintainer trust.** Paul O'Shannessy (`zpao` on GitHub) was on the React core team at Facebook from 2013-2018 and has maintained this package continuously since 2014. The package's lifecycle traces directly to a well-known React-ecosystem maintainer. +- **Zero runtime deps.** No transitive dependency surface. The only dep is the peer-dep on React itself which we ship anyway. +- **License compatibility.** ISC is permissive and substantively identical to MIT (same permission grant, no additional obligations). No legal review needed. + +### Consequences + +**Positive:** +- The three-QR signup demo (ADR 0023) becomes runnable end-to-end without operator-side software changes. +- The existing `demo/QrProofLogin.tsx` block-grid placeholder can be upgraded to a real QR. +- BFSI demo Scene 4 ("operator paints QR on screen, customer scans with phone") becomes a real flow rather than a screen-share charade. + +**Negative:** +- +115 kB to the dashboard build's transitive size (gzip footprint estimated at ~10 kB based on the SVG-output path; the unpacked figure includes test fixtures). +- One more package to watch for CVEs in the nightly CVE-monitor workflow. +- Adds a peer-dep validation surface — if we bump React majors we have to verify `qrcode.react` supports the new major. + +**Neutral:** +- The deeplink format itself doesn't change. The QR encoding is purely a presentation-layer concern; the URL inside the QR is exactly the same string we were showing in the copyable text field. + +## Migration + +None — additive only. The existing copy-the-text-link UX continues to work (we render both the QR and the copyable text for accessibility + fallback if the camera scan path fails). + +## Supply-chain check + +`npm audit` after install: + +``` +found 0 vulnerabilities +``` + +No CVEs against `qrcode.react@4.2.0` in the GitHub Advisory Database, OSV, or `npm audit` registry. + +## References + +- Package: [npmjs.com/package/qrcode.react](https://www.npmjs.com/package/qrcode.react) +- Source: [github.com/zpao/qrcode.react](https://github.com/zpao/qrcode.react) +- License: [ISC](https://opensource.org/licenses/ISC) (substantively equivalent to MIT) +- Threat model: no new threat surface — the dep is pure CPU work on string input; no network, no filesystem, no native code. diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 00a0a94..5b74ceb 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.100.10", "clsx": "^2.1.1", + "qrcode.react": "^4.2.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1" @@ -4798,6 +4799,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 7eaf311..42054aa 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -19,6 +19,7 @@ "dependencies": { "@tanstack/react-query": "^5.100.10", "clsx": "^2.1.1", + "qrcode.react": "^4.2.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1" diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index c0afd55..c2e48ce 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -22,6 +22,10 @@ import { NotFound } from './routes/NotFound'; // host today; lazy-loading keeps it (and any future scanner deps) out // of the main bundle until the operator opens /demo/qr-proof-login. const QrProofLogin = lazy(() => import('./routes/demo/QrProofLogin')); +// The three-QR end-user signup ceremony demo (ADR 0023). Lazy-loaded +// because qrcode.react (~30kB after tree-shake) only matters when the +// operator opens the demo. The bundle main path never imports it. +const QrRegistration = lazy(() => import('./routes/demo/QrRegistration')); // Live verifications view (SSE-streamed, ADR 0017 face-first flow). // Lazy-loaded so the EventSource cost is paid only when the operator @@ -125,6 +129,14 @@ export function App() { } /> + + + + } + /> {/* ADR 0017 face-first views — live SSE counterparts to the polled /verifications + /users. Both coexist diff --git a/dashboard/src/components/layout/AppShell.tsx b/dashboard/src/components/layout/AppShell.tsx index 392ae3e..2bae666 100644 --- a/dashboard/src/components/layout/AppShell.tsx +++ b/dashboard/src/components/layout/AppShell.tsx @@ -55,7 +55,10 @@ const NAV = [ // W3 wrapper demo: desktop QR-proof sign-in (ADR-0009). Sits under // its own /demo/ namespace so additional wrapper demos can co-locate // without polluting the primary console nav. - { to: '/demo/qr-proof-login', label: 'Demos', icon: 'qr' }, + { to: '/demo/qr-proof-login', label: 'QR sign-in', icon: 'qr' }, + // ADR 0023 three-QR end-user signup ceremony. Demos sit side-by-side + // under /demo/ so an operator can pick the flow they're presenting. + { to: '/demo/registration', label: 'QR signup', icon: 'qr' }, { to: '/settings', label: 'Settings', icon: 'gear' }, ] as const; diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index aec3129..901d8dd 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -247,12 +247,27 @@ export interface UsageSummary { }>; } +export type DeviceType = + | 'mobile_android' + | 'mobile_ios' + | 'kiosk' + | 'iot_bridge' + | 'desktop'; + +export type DeviceEnrollmentState = 'pending' | 'enrolled' | 'revoked'; + export interface Device { id: string; external_id: string; name: string; + device_type: DeviceType; location_id: string | null; status: 'active' | 'inactive' | 'retired'; + enrollment_state: DeviceEnrollmentState; + enrollment_code_expires_at: string | null; + enrolled_at: string | null; + fingerprint_hash: string | null; + attestation_kind: string | null; battery_level: number | null; metadata: Record; last_seen_at: string | null; @@ -260,6 +275,57 @@ export interface Device { updated_at: string; } +/** Response envelope from POST /api/console/devices and the regenerate endpoint. */ +export interface DeviceEnrollmentInvite { + environment: Environment; + device: Device; + enrollment: { + code: string; + expires_at: string; + deeplink: string; + }; +} + +// ─── Three-QR end-user signup ceremony (ADR 0023) ──────────────── + +export type RegistrationSessionState = + | 'awaiting_device' + | 'awaiting_commitment' + | 'awaiting_verification' + | 'completed' + | 'abandoned'; + +/** + * Server-redacted shape of the registration_sessions row. The + * console proxy strips pair_code_hash, enroll_code_hash, + * verify_code_hash, and verify_challenge_nonce before this hits + * the browser — the plaintext codes are returned only at issuance + * (and only to the issuing browser). + */ +export interface RegistrationSession { + id: string; + tenant_id: string; + environment: Environment; + profile: Record; + state: RegistrationSessionState; + device_id: string | null; + did: string | null; + commitment: string | null; + tenant_user_id: string | null; + pair_code_expires_at: string | null; + enroll_code_expires_at: string | null; + verify_code_expires_at: string | null; + expires_at: string; + created_at: string; + updated_at: string; +} + +export interface RegistrationStartResponse { + environment: Environment; + session: RegistrationSession; + pair: { code: string; expires_at: string; deeplink: string }; +} + export interface User { id: string; external_id: string; @@ -646,17 +712,43 @@ export const api = { revokeKey: (keyId: string) => request<{ message: string; keyId: string }>(`/api/console/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE' }), - // Devices — console proxies live at /api/console/devices - listDevices: (params: { environment: Environment; status?: Device['status']; limit?: number }) => - request<{ environment: Environment; devices: Device[] }>('/api/console/devices', { query: params }), + // Devices — console proxies live at /api/console/devices. + // ADR 0022 device enrollment: POST creates a *pending* row and + // returns a one-time enrollment code; the device then claims the + // slot by hitting /v1/devices/enroll with the code + a hardware + // fingerprint. POST /devices/:id/regenerate-code re-issues; DELETE + // soft-revokes (sets enrollment_state='revoked', status='retired'). + listDevices: (params: { + environment: Environment; + status?: Device['status']; + enrollmentState?: DeviceEnrollmentState; + limit?: number; + }) => + request<{ environment: Environment; devices: Device[] }>('/api/console/devices', { + query: { + environment: params.environment, + status: params.status, + enrollment_state: params.enrollmentState, + limit: params.limit, + }, + }), createDevice: (input: { environment: Environment; name: string; - externalId?: string; + deviceType: DeviceType; locationId?: string; - batteryLevel?: number; metadata?: Record; - }) => request<{ environment: Environment; device: Device }>('/api/console/devices', { method: 'POST', body: input }), + }) => request('/api/console/devices', { method: 'POST', body: input }), + regenerateDeviceCode: (deviceId: string, input: { environment: Environment }) => + request( + `/api/console/devices/${encodeURIComponent(deviceId)}/regenerate-code`, + { method: 'POST', body: input }, + ), + revokeDevice: (deviceId: string, input: { environment: Environment }) => + request<{ environment: Environment; device: Device }>( + `/api/console/devices/${encodeURIComponent(deviceId)}`, + { method: 'DELETE', body: input }, + ), updateDevice: ( deviceId: string, input: { @@ -670,6 +762,52 @@ export const api = { }, ) => request<{ environment: Environment; device: Device }>(`/api/console/devices/${encodeURIComponent(deviceId)}`, { method: 'PATCH', body: input }), + // Registrations — three-QR signup ceremony (ADR 0023). Console + // proxies live at /api/console/registrations/*. The plaintext + // pair_code is returned exactly once on POST; subsequent codes + // (enroll_code, verify_code) only travel from the server to the + // phone via the next-step deeplinks, never to the dashboard. + startRegistration: (input: { + environment: Environment; + profile?: Record; + }) => + request('/api/console/registrations', { method: 'POST', body: input }), + pollRegistration: (sessionId: string, params: { environment: Environment }) => + request<{ environment: Environment; session: RegistrationSession }>( + `/api/console/registrations/${encodeURIComponent(sessionId)}`, + { query: params }, + ), + abandonRegistration: (sessionId: string, params: { environment: Environment }) => + request<{ environment: Environment; session: RegistrationSession }>( + `/api/console/registrations/${encodeURIComponent(sessionId)}`, + { method: 'DELETE', body: params }, + ), + // Phone-side endpoints — the demo's "Simulate phone" panel calls + // these directly so the operator can drive the ceremony from one + // browser window without an actual phone. In production the phone + // hits these via /v1/registrations/* on the public origin. + __phonePair: (input: { pair_code: string; fingerprint: string; attestation_kind?: string }) => + request<{ + session_id: string; + device_id: string | null; + next: { step: string; code: string; expires_at: string; deeplink: string }; + }>('/v1/registrations/pair-device', { method: 'POST', body: input, auth: false }), + __phoneSubmitCommitment: (input: { enroll_code: string; did: string; commitment: string; attestation_kind?: string }) => + request<{ + session_id: string; + next: { step: string; code: string; expires_at: string; deeplink: string; challenge_nonce: string }; + }>('/v1/registrations/submit-commitment', { method: 'POST', body: input, auth: false }), + __phoneComplete: (input: { + verify_code: string; + challenge_nonce: string; + proof: unknown; + public_signals: unknown[]; + }) => + request<{ session_id: string; tenant_user: Record; device: Record | null }>( + '/v1/registrations/complete', + { method: 'POST', body: input, auth: false }, + ), + // Users listUsers: (params: { environment: Environment; status?: User['status']; limit?: number }) => request<{ environment: Environment; users: User[] }>('/api/console/users', { query: params }), diff --git a/dashboard/src/routes/Devices.tsx b/dashboard/src/routes/Devices.tsx index 447b183..fc64b0b 100644 --- a/dashboard/src/routes/Devices.tsx +++ b/dashboard/src/routes/Devices.tsx @@ -1,32 +1,86 @@ -import { useState, type FormEvent } from 'react'; +import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { api, ApiError, type Device } from '../lib/api'; +import { api, ApiError, type Device, type DeviceEnrollmentInvite, type DeviceEnrollmentState, type DeviceType } from '../lib/api'; import { useEnvironment } from '../components/layout/AppShell'; -import { Badge, Button, Card, CardBody, CardHeader, EmptyState, Input, Label, Modal, pushToast, Select, Skeleton } from '../components/ui'; +import { + Badge, + Button, + Card, + CardBody, + CardHeader, + CopyButton, + EmptyState, + Input, + Label, + Modal, + pushToast, + Select, + Skeleton, +} from '../components/ui'; import { fmtDateTime, fmtRelativeTime } from '../lib/format'; +const DEVICE_TYPE_LABELS: Record = { + mobile_android: 'Android phone', + mobile_ios: 'iOS phone', + kiosk: 'Branch kiosk', + iot_bridge: 'R307 fingerprint bridge', + desktop: 'Desktop / laptop', +}; + +const ENROLLMENT_STATE_TONE: Record = { + pending: 'warn', + enrolled: 'success', + revoked: 'neutral', +}; + export function Devices() { const qc = useQueryClient(); const { environment } = useEnvironment(); const [statusFilter, setStatusFilter] = useState(''); + const [stateFilter, setStateFilter] = useState(''); const [creating, setCreating] = useState(false); + // Holds the enrollment-invite shown to the operator after a + // successful POST or regenerate. Cleared by the operator when they + // close the modal — the plaintext code is never recoverable after. + const [activeInvite, setActiveInvite] = useState(null); const list = useQuery({ - queryKey: ['devices', environment, statusFilter], + queryKey: ['devices', environment, statusFilter, stateFilter], queryFn: () => api.listDevices({ environment, status: statusFilter || undefined, + enrollmentState: stateFilter || undefined, limit: 100, }), }); + const revoke = useMutation({ + mutationFn: (deviceId: string) => api.revokeDevice(deviceId, { environment }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['devices'] }); + qc.invalidateQueries({ queryKey: ['overview'] }); + pushToast('success', 'Device revoked.'); + }, + onError: (err) => pushToast('danger', err instanceof ApiError ? err.message : 'Could not revoke device.'), + }); + + const regenerate = useMutation({ + mutationFn: (deviceId: string) => api.regenerateDeviceCode(deviceId, { environment }), + onSuccess: (invite) => { + setActiveInvite(invite); + qc.invalidateQueries({ queryKey: ['devices'] }); + pushToast('success', 'New enrollment code issued.'); + }, + onError: (err) => pushToast('danger', err instanceof ApiError ? err.message : 'Could not re-issue code.'), + }); + return (

Devices

- Devices registered against your {environment} environment. + Phones, kiosks, and IoT bridges enrolled against your {environment} environment.

@@ -36,17 +90,30 @@ export function Devices() { setStatusFilter(e.target.value as Device['status'] | '')} - className="h-8 w-32 text-xs" - aria-label="Filter by status" - > - - - - - +
+ + +
} /> @@ -60,24 +127,44 @@ export function Devices() { Name - External ID - Location - Battery + Type + Enrollment Status + Location Last seen Created + Actions {list.data.devices.map((d) => ( - {d.name} - {d.external_id} + +
{d.name}
+
{d.external_id}
+ + {DEVICE_TYPE_LABELS[d.device_type] ?? d.device_type} + + {d.enrollment_state} + + + {d.status} + {d.location_id ?? '—'} - {d.battery_level === null ? '—' : `${d.battery_level}%`} - {d.status} {fmtRelativeTime(d.last_seen_at)} {fmtDateTime(d.created_at)} + + regenerate.mutate(d.id)} + onRevoke={() => { + if (window.confirm(`Revoke ${d.name}? Its credentials are voided immediately; the row stays for audit.`)) { + revoke.mutate(d.id); + } + }} + busy={(regenerate.isPending && regenerate.variables === d.id) || (revoke.isPending && revoke.variables === d.id)} + /> + ))} @@ -86,7 +173,7 @@ export function Devices() { ) : ( setCreating(true)}>Register a device} /> )} @@ -96,32 +183,70 @@ export function Devices() { setCreating(false)} - onCreated={() => { + onCreated={(invite) => { setCreating(false); + setActiveInvite(invite); qc.invalidateQueries({ queryKey: ['devices'] }); qc.invalidateQueries({ queryKey: ['overview'] }); - pushToast('success', 'Device registered.'); }} /> + + setActiveInvite(null)} + /> +
+ ); +} + +function DeviceRowActions({ + device, + onReissue, + onRevoke, + busy, +}: { + device: Device; + onReissue: () => void; + onRevoke: () => void; + busy: boolean; +}) { + if (device.enrollment_state === 'revoked') { + return ; + } + return ( +
+ {device.enrollment_state === 'pending' ? ( + + ) : null} +
); } -function CreateDeviceModal({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) { +function CreateDeviceModal({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated: (invite: DeviceEnrollmentInvite) => void; +}) { const { environment } = useEnvironment(); const [name, setName] = useState(''); - const [externalId, setExternalId] = useState(''); + const [deviceType, setDeviceType] = useState('mobile_android'); const [locationId, setLocationId] = useState(''); - const [batteryLevel, setBatteryLevel] = useState(''); const [error, setError] = useState(null); const create = useMutation({ - mutationFn: (body: { name: string; externalId?: string; locationId?: string; batteryLevel?: number }) => + mutationFn: (body: { name: string; deviceType: DeviceType; locationId?: string }) => api.createDevice({ environment, ...body }), - onSuccess: () => { - setName(''); setExternalId(''); setLocationId(''); setBatteryLevel(''); + onSuccess: (invite) => { + setName(''); + setDeviceType('mobile_android'); + setLocationId(''); setError(null); - onCreated(); + onCreated(invite); }, onError: (err) => setError(err instanceof ApiError ? err.message : 'Could not register device.'), }); @@ -129,16 +254,14 @@ function CreateDeviceModal({ open, onClose, onCreated }: { open: boolean; onClos function onSubmit(e: FormEvent) { e.preventDefault(); setError(null); - if (!name.trim()) { setError('Name is required.'); return; } - const battery = batteryLevel ? Number.parseInt(batteryLevel, 10) : undefined; - if (battery !== undefined && (Number.isNaN(battery) || battery < 0 || battery > 100)) { - setError('Battery level must be an integer 0–100.'); return; + if (!name.trim()) { + setError('Name is required.'); + return; } create.mutate({ name: name.trim(), - externalId: externalId.trim() || undefined, + deviceType, locationId: locationId.trim() || undefined, - batteryLevel: battery, }); } @@ -147,21 +270,136 @@ function CreateDeviceModal({ open, onClose, onCreated }: { open: boolean; onClos open={open} onClose={onClose} title="Register a device" - description={`Adds a row to the ${environment} environment.`} + description={`Mint a one-time enrollment code. The device claims the code via /v1/devices/enroll within 15 minutes.`} footer={ <> - + } >
-
setName(e.target.value)} required />
-
setExternalId(e.target.value)} placeholder="device_001" />
-
setLocationId(e.target.value)} placeholder="blr-hq" />
-
setBatteryLevel(e.target.value)} placeholder="92" />
+
+ + setName(e.target.value)} + required + placeholder="MG Road branch · Kiosk #1" + /> +
+
+ + +
+
+ + setLocationId(e.target.value)} + placeholder="branch-mg-road" + /> +
{error ?
{error}
: null} +
+ A device row is created in pending state. Hand the enrollment code (shown next) to the device; it has 15 minutes to claim the slot. After claim, the device's hardware fingerprint binds permanently and the row flips to enrolled. +
); } + +/** + * One-time display of the plaintext enrollment code. The server has + * the SHA-256; this modal is the only place the operator sees the + * code, so we ship a copy button and a countdown of the 15-minute TTL. + * If the operator closes this modal without copying, they can re-issue + * from the device row (which voids the prior code). + */ +function EnrollmentInviteModal({ + invite, + onClose, +}: { + invite: DeviceEnrollmentInvite | null; + onClose: () => void; +}) { + const expiresAt = useMemo(() => invite ? new Date(invite.enrollment.expires_at) : null, [invite]); + const [remainingMs, setRemainingMs] = useState(() => expiresAt ? expiresAt.getTime() - Date.now() : 0); + + useEffect(() => { + if (!expiresAt) return; + setRemainingMs(expiresAt.getTime() - Date.now()); + const id = window.setInterval(() => { + setRemainingMs(expiresAt.getTime() - Date.now()); + }, 1000); + return () => window.clearInterval(id); + }, [expiresAt]); + + if (!invite) return null; + const expired = remainingMs <= 0; + + return ( + Done} + > +
+
+
+ {invite.enrollment.code} +
+
+ +
+
+ +
+
+
Expires in
+
+ {expired ? 'Expired — re-issue from the row' : formatRemaining(remainingMs)} +
+
+
+
Device row
+
{invite.device.id}
+
+
+ +
+
Deep link (for QR or push)
+
+ {invite.enrollment.deeplink} + +
+
+ +
+ Next step on the device: open the ZeroAuth companion app or kiosk firmware and enter the code (or scan the deep link). The device will POST /v1/devices/enroll with the code + its hardware fingerprint; on success it boots into the enrolled state. +
+
+
+ ); +} + +function formatRemaining(ms: number): string { + if (ms <= 0) return '0s'; + const total = Math.floor(ms / 1000); + const m = Math.floor(total / 60); + const s = total % 60; + return `${m}m ${String(s).padStart(2, '0')}s`; +} diff --git a/dashboard/src/routes/demo/QrRegistration.tsx b/dashboard/src/routes/demo/QrRegistration.tsx new file mode 100644 index 0000000..661e067 --- /dev/null +++ b/dashboard/src/routes/demo/QrRegistration.tsx @@ -0,0 +1,567 @@ +import { useCallback, useEffect, useReducer, useRef, useState, type ReactNode } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import { + api, + ApiError, + type RegistrationSession, +} from '../../lib/api'; +import { useEnvironment } from '../../components/layout/AppShell'; +import { + Badge, + Button, + Card, + CardBody, + CardHeader, + CopyButton, + Input, + Label, + pushToast, + Skeleton, +} from '../../components/ui'; +import { cn } from '../../lib/cn'; + +/** + * Three-QR end-user signup ceremony demo (ADR 0023). + * + * The platform (left column) opens a session, then walks the + * operator through three QRs. The right column is a "Simulate + * phone" panel that exercises the phone-side endpoints directly — + * the operator can drive the ceremony end-to-end from one browser + * window without an actual companion app. + * + * In production the phone hits the same endpoints via /v1/registrations/* + * on the public origin, scanning each QR with its camera. The + * deeplinks rendered into the QRs (`zeroauth://reg?step=…`) are the + * canonical format the companion app handles. + */ + +// ─── State machine ──────────────────────────────────────────────── + +type Phase = + | { kind: 'idle' } + | { kind: 'creating' } + | { + kind: 'awaiting_device'; + session: RegistrationSession; + pairCode: string; + pairDeeplink: string; + pairExpiresAt: string; + } + | { + kind: 'awaiting_commitment'; + session: RegistrationSession; + enrollCode: string; + enrollDeeplink: string; + enrollExpiresAt: string; + } + | { + kind: 'awaiting_verification'; + session: RegistrationSession; + verifyCode: string; + verifyDeeplink: string; + verifyExpiresAt: string; + challengeNonce: string; + } + | { + kind: 'completed'; + session: RegistrationSession; + tenantUserId: string; + } + | { kind: 'error'; message: string }; + +type Action = + | { type: 'create_start' } + | { type: 'create_ok'; session: RegistrationSession; pairCode: string; pairDeeplink: string; pairExpiresAt: string } + | { type: 'paired'; session: RegistrationSession; enrollCode: string; enrollDeeplink: string; enrollExpiresAt: string } + | { type: 'committed'; session: RegistrationSession; verifyCode: string; verifyDeeplink: string; verifyExpiresAt: string; challengeNonce: string } + | { type: 'completed'; session: RegistrationSession; tenantUserId: string } + | { type: 'failed'; message: string } + | { type: 'reset' }; + +function reducer(state: Phase, action: Action): Phase { + switch (action.type) { + case 'create_start': + return { kind: 'creating' }; + case 'create_ok': + return { + kind: 'awaiting_device', + session: action.session, + pairCode: action.pairCode, + pairDeeplink: action.pairDeeplink, + pairExpiresAt: action.pairExpiresAt, + }; + case 'paired': + return { + kind: 'awaiting_commitment', + session: action.session, + enrollCode: action.enrollCode, + enrollDeeplink: action.enrollDeeplink, + enrollExpiresAt: action.enrollExpiresAt, + }; + case 'committed': + return { + kind: 'awaiting_verification', + session: action.session, + verifyCode: action.verifyCode, + verifyDeeplink: action.verifyDeeplink, + verifyExpiresAt: action.verifyExpiresAt, + challengeNonce: action.challengeNonce, + }; + case 'completed': + return { kind: 'completed', session: action.session, tenantUserId: action.tenantUserId }; + case 'failed': + return { kind: 'error', message: action.message }; + case 'reset': + return { kind: 'idle' }; + } +} + +// ─── Top-level page ────────────────────────────────────────────── + +export default function QrRegistration() { + const { environment } = useEnvironment(); + const [phase, dispatch] = useReducer(reducer, { kind: 'idle' } as Phase); + // Operator-side inputs for the "open session" form + const [name, setName] = useState('Alice Doe'); + const [email, setEmail] = useState('alice@example.com'); + + const start = useCallback(async () => { + dispatch({ type: 'create_start' }); + try { + const res = await api.startRegistration({ + environment, + profile: { name, email }, + }); + dispatch({ + type: 'create_ok', + session: res.session, + pairCode: res.pair.code, + pairDeeplink: res.pair.deeplink, + pairExpiresAt: res.pair.expires_at, + }); + } catch (err) { + dispatch({ type: 'failed', message: err instanceof ApiError ? err.message : 'Could not start session.' }); + } + }, [environment, name, email]); + + return ( +
+
+

End-user signup ceremony

+

+ Three QR codes on this page, one for each step. The user's phone scans them in order: register the device, + submit the biometric commitment, then verify with a fresh proof. The biometric never leaves the phone — only + the Poseidon commitment (step 2) and the Groth16 proof (step 3) touch the server. See{' '} + + ADR 0023 + {' '} + for the design. +

+
+ +
+ {/* Left column: the platform side — what the operator sees */} +
+ {phase.kind === 'idle' ? ( + + ) : null} + + {phase.kind === 'creating' ? ( + + + + ) : null} + + {phase.kind === 'awaiting_device' ? ( + + ) : null} + + {phase.kind === 'awaiting_commitment' ? ( + + ) : null} + + {phase.kind === 'awaiting_verification' ? ( + + ) : null} + + {phase.kind === 'completed' ? dispatch({ type: 'reset' })} /> : null} + + {phase.kind === 'error' ? ( + + +
+ {phase.message} +
+ +
+
+ ) : null} +
+ + {/* Right column: the phone simulator */} + +
+
+ ); +} + +// ─── Subcomponents ─────────────────────────────────────────────── + +function StartForm({ + name, + email, + onNameChange, + onEmailChange, + onStart, +}: { + name: string; + email: string; + onNameChange: (v: string) => void; + onEmailChange: (v: string) => void; + onStart: () => void; +}) { + return ( + + + +
+ + onNameChange(e.target.value)} /> +
+
+ + onEmailChange(e.target.value)} /> +
+ +

+ The server creates a registration_sessions row in awaiting_device state and returns a one-time pair_code. The plaintext code is returned exactly once; the row stores only its SHA-256. +

+
+
+ ); +} + +function QrStep({ + step, + title, + instruction, + code, + deeplink, + expiresAt, +}: { + step: 1 | 2 | 3; + title: string; + instruction: string; + code: string; + deeplink: string; + expiresAt: string; +}) { + const secondsLeft = useCountdown(expiresAt); + const expired = secondsLeft <= 0; + + return ( + + + {step} + {title} + + } + description={instruction} + /> + +
+
+ + + {expired ? 'Expired' : `Expires in ${formatRemaining(secondsLeft)}`} + +
+
+
+
Code (typeable)
+
+ {code} + +
+
+
+
Deep link encoded into the QR
+
+ {deeplink} + +
+
+
+
+
+
+ ); +} + +function CompletedCard({ tenantUserId, sessionId, onReset }: { tenantUserId: string; sessionId: string; onReset: () => void }) { + return ( + + + +
+
+
tenant_user_id
+
{tenantUserId}
+
+
+
session_id
+
{sessionId}
+
+
+ +
+
+ ); +} + +// ─── Phone simulator ────────────────────────────────────────────── + +function PhoneSimulator({ phase, dispatch }: { phase: Phase; dispatch: React.Dispatch }) { + const [pairCode, setPairCode] = useState(''); + const [busy, setBusy] = useState(false); + + // Auto-fill the code field as each step's QR becomes visible. + useEffect(() => { + if (phase.kind === 'awaiting_device') setPairCode(phase.pairCode); + else if (phase.kind === 'awaiting_commitment') setPairCode(phase.enrollCode); + else if (phase.kind === 'awaiting_verification') setPairCode(phase.verifyCode); + else setPairCode(''); + }, [phase]); + + const scanPair = useCallback(async () => { + if (phase.kind !== 'awaiting_device') return; + setBusy(true); + try { + const res = await api.__phonePair({ + pair_code: pairCode, + // 16+ char fingerprint — production phones supply + // android_id + Play Integrity package + nonce; in the + // demo we synthesise a stable per-session value so a + // second run from the same browser window gets a + // *different* fingerprint and lands as a distinct device row. + fingerprint: `demo:${phase.session.id}:android_id_xxxxxxxxxxxx`, + attestation_kind: 'none', + }); + dispatch({ + type: 'paired', + session: { ...phase.session, state: 'awaiting_commitment', device_id: res.device_id }, + enrollCode: res.next.code, + enrollDeeplink: res.next.deeplink, + enrollExpiresAt: res.next.expires_at, + }); + pushToast('success', 'Phone paired — device row created.'); + } catch (err) { + pushToast('danger', err instanceof ApiError ? err.message : 'Pair failed.'); + } finally { + setBusy(false); + } + }, [phase, pairCode, dispatch]); + + const scanCommit = useCallback(async () => { + if (phase.kind !== 'awaiting_commitment') return; + setBusy(true); + try { + // Demo did + commitment values. In production the phone derives + // these from the on-device biometric pipeline (mobile/biometric/): + // FaceEmbedder → Quantizer → SHA-256 → Poseidon → DID + // The shapes here match the regex the server validates against. + const did = `did:zeroauth:face:${phase.session.id.replace(/-/g, '').slice(0, 12)}`; + const commitment = `0x${'a'.repeat(64)}`; + const res = await api.__phoneSubmitCommitment({ + enroll_code: pairCode, + did, + commitment, + attestation_kind: 'none', + }); + dispatch({ + type: 'committed', + session: { ...phase.session, state: 'awaiting_verification', did, commitment }, + verifyCode: res.next.code, + verifyDeeplink: res.next.deeplink, + verifyExpiresAt: res.next.expires_at, + challengeNonce: res.next.challenge_nonce, + }); + pushToast('success', 'Commitment submitted.'); + } catch (err) { + pushToast('danger', err instanceof ApiError ? err.message : 'Submit failed.'); + } finally { + setBusy(false); + } + }, [phase, pairCode, dispatch]); + + const scanVerify = useCallback(async () => { + if (phase.kind !== 'awaiting_verification') return; + setBusy(true); + try { + // Demo proof. The server's verifyProofOffChain will reject this + // shape because it's not a real Groth16 proof — that's the + // expected behaviour and a useful sanity check that the + // route's plumbing reaches the verifier. To run an actual + // green path against a real proof the operator should drive + // /v1/registrations/complete from the android/ tree where the + // mobile prover lives. The demo's "passes the verifier" + // sub-flow is on the Phase 1 Sprint 4 roadmap. + const res = await api.__phoneComplete({ + verify_code: pairCode, + challenge_nonce: phase.challengeNonce, + proof: { pi_a: ['1', '2', '3'], pi_b: [['4', '5'], ['6', '7'], ['8', '9']], pi_c: ['10', '11', '12'] }, + public_signals: [phase.session.commitment ?? ''], + }); + dispatch({ + type: 'completed', + session: { ...phase.session, state: 'completed' }, + tenantUserId: String(res.tenant_user?.id ?? ''), + }); + pushToast('success', 'Account created.'); + } catch (err) { + pushToast('danger', err instanceof ApiError ? err.message : 'Verify failed (expected with demo proof — wire up the mobile prover for the real path).'); + } finally { + setBusy(false); + } + }, [phase, pairCode, dispatch]); + + const action = phase.kind === 'awaiting_device' ? scanPair + : phase.kind === 'awaiting_commitment' ? scanCommit + : phase.kind === 'awaiting_verification' ? scanVerify + : null; + const buttonLabel = phase.kind === 'awaiting_device' ? 'Simulate phone scan: pair' + : phase.kind === 'awaiting_commitment' ? 'Simulate phone scan: submit commitment' + : phase.kind === 'awaiting_verification' ? 'Simulate phone scan: verify' + : 'Awaiting QR…'; + + return ( +
+ + + + + Step 1 — Pair device + + + Step 2 — Submit commitment + + + Step 3 — Verify and complete + +
+ + setPairCode(e.target.value)} + placeholder="ZA-XXXX-XXXX" + className="font-mono" + /> +
+ +

+ Step 3 will surface a verify_failed because the demo posts a stub proof — that's the verifier doing its job. Wire up the mobile prover from android/ for the real green path. +

+
+
+
+ ); +} + +function PhoneStateLine({ + current, + done, + children, + kind, +}: { + current: Phase['kind']; + done: boolean; + children: ReactNode; + kind: 'pair' | 'enroll' | 'verify'; +}) { + const active = (kind === 'pair' && current === 'awaiting_device') + || (kind === 'enroll' && current === 'awaiting_commitment') + || (kind === 'verify' && current === 'awaiting_verification'); + return ( +
+ + {children} +
+ ); +} + +// ─── Helpers ────────────────────────────────────────────────────── + +function useCountdown(expiresAt: string): number { + const ref = useRef(0); + const [secondsLeft, setSecondsLeft] = useState(() => { + const ms = new Date(expiresAt).getTime() - Date.now(); + return Math.max(0, Math.floor(ms / 1000)); + }); + useEffect(() => { + setSecondsLeft(() => { + const ms = new Date(expiresAt).getTime() - Date.now(); + return Math.max(0, Math.floor(ms / 1000)); + }); + const id = window.setInterval(() => { + const ms = new Date(expiresAt).getTime() - Date.now(); + const next = Math.max(0, Math.floor(ms / 1000)); + setSecondsLeft(next); + if (next <= 0) window.clearInterval(id); + }, 1000); + ref.current = id; + return () => window.clearInterval(id); + }, [expiresAt]); + return secondsLeft; +} + +function formatRemaining(sec: number): string { + if (sec <= 0) return '0s'; + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}m ${String(s).padStart(2, '0')}s`; +} diff --git a/docs/api_contract.md b/docs/api_contract.md index 473f41d..9e0e85b 100644 --- a/docs/api_contract.md +++ b/docs/api_contract.md @@ -53,10 +53,51 @@ | Method | Path | Scope | Description | |---|---|---|---| -| `POST` | `/v1/devices` | `devices:write` | Register a new device. Body: `{ name, externalId?, locationId?, batteryLevel?, metadata? }`. | +| `POST` | `/v1/devices` | `devices:write` | **Trusted-service path.** Register a device row in `enrolled` state directly (used by SDK-led bulk provisioning + demo seed scripts). Body: `{ name, deviceType?, externalId?, locationId?, batteryLevel?, metadata? }`. `deviceType` ∈ `mobile_android,mobile_ios,kiosk,iot_bridge,desktop` (defaults to `kiosk`). | +| `POST` | `/v1/devices/enroll` | **none (code is bearer)** | **Device-side claim.** Exchange a one-time enrollment code (minted by the dashboard) for an enrolled row. Body: `{ enrollment_code, fingerprint, attestation_kind? }`. Rate-limited to 10 req/min per IP. Returns `{ device }` on success, uniform 404 `enrollment_failed` on any failure mode (unknown code, expired code, invalid fingerprint, fingerprint collision). See [ADR 0022](../adr/0022-device-enrollment-flow.md). | | `GET` | `/v1/devices` | `devices:read` | List devices for the tenant's environment. `?status=active\|inactive\|retired`, `?limit=…` (≤100). | | `PATCH` | `/v1/devices/:deviceId` | `devices:write` | Mutate name, locationId, batteryLevel, status, metadata, lastSeenAt. | +Console-side device endpoints (require console JWT): + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/console/devices` | List devices. `?status=…`, `?enrollment_state=pending\|enrolled\|revoked`, `?limit=…`. | +| `POST` | `/api/console/devices` | **Mint a pending slot + enrollment code.** Body: `{ name, deviceType, locationId?, metadata? }`. Returns `{ device, enrollment: { code, expires_at, deeplink } }`. The plaintext code is returned exactly once — server keeps only its SHA-256. Code TTL is 15 minutes. | +| `POST` | `/api/console/devices/:id/regenerate-code` | Re-issue the enrollment code (voids the prior one). Same response shape as POST. | +| `PATCH` | `/api/console/devices/:id` | Mutate name/location/status/etc. | +| `DELETE` | `/api/console/devices/:id` | Soft-revoke (sets `enrollment_state='revoked'`, `status='retired'`). Row retained for audit. | + +Enrollment code format: `ZA-XXXX-XXXX`, 8 entropy chars from a 27-symbol Crockford-base32 alphabet (no `0`, `1`, `I`, `L`, `O`, `U`). The deeplink format is `zeroauth://enroll?code=` and is stable across V1. + +### Central API — end-user registration ceremony (`/v1/registrations`) + +The three-QR end-user signup flow. See [ADR 0023](../adr/0023-three-qr-signup-ceremony.md) for design + state machine + threat-model deltas. The biometric never touches the server side; only the Poseidon commitment (step 2) and the Groth16 proof (step 3) do. + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `POST` | `/v1/registrations` | `users:write` | Open a session. Body: `{ profile?: object }`. Returns `{ session, pair: { code, expires_at, deeplink } }`. Render `pair.deeplink` as QR1. | +| `GET` | `/v1/registrations/:id` | `users:read` | Poll state. Response redacts all code hashes + challenge nonce. | +| `DELETE` | `/v1/registrations/:id` | `users:write` | Abandon (idempotent). Voids outstanding codes; row retained for audit. | +| `POST` | `/v1/registrations/pair-device` | **none — `pair_code` is bearer** | Step 1. Body: `{ pair_code, fingerprint, attestation_kind? }`. Phone scans QR1. Server claims a device row (reuses ADR 0022 fingerprint binding), attaches to session, mints `enroll_code` for step 2. Returns `{ session_id, device_id, next: { step: 'enroll', code, expires_at, deeplink } }`. | +| `POST` | `/v1/registrations/submit-commitment` | **none — `enroll_code` is bearer** | Step 2. Body: `{ enroll_code, did, commitment, attestation_kind? }`. Phone scans QR2 after capturing biometric locally. Server stores (did, commitment), mints `verify_code` + `challenge_nonce` for step 3. Returns `{ session_id, next: { step: 'verify', code, expires_at, deeplink, challenge_nonce } }`. | +| `POST` | `/v1/registrations/complete` | **none — `verify_code` is bearer** | Step 3. Body: `{ verify_code, challenge_nonce, proof, public_signals }`. Phone scans QR3, re-captures biometric, produces Groth16 proof. Server asserts `challenge_nonce` matches, asserts `publicSignals[0]` equals stored commitment, verifies proof off-chain, creates `tenant_user`. Returns `{ session_id, tenant_user, device }`. | + +State machine: `awaiting_device → awaiting_commitment → awaiting_verification → completed` (or `abandoned`). Whole-session TTL is 30 min; each code's TTL is 15 min. Phone-side endpoints are rate-limited at 20 req/min per IP via `pgRateLimit`. + +Failure-mode surface (uniform envelopes to defeat enumeration): + +| Code | When | +|---|---| +| `400 invalid_request` | Required field missing or malformed at the JSON layer. | +| `404 pair_failed` | Step 1: unknown / expired pair_code, invalid fingerprint, session expired. | +| `404 enroll_failed` | Step 2: unknown / expired enroll_code, wrong session state. | +| `404 verify_failed` | Step 3: unknown / expired verify_code, challenge mismatch, commitment mismatch, proof verification failed. | +| `404 session_not_found` | Tenant poll: id does not exist in this tenant/environment. | +| `429` | Phone-side rate-limit (20/min/IP) exceeded. | + +The deeplink schema is `zeroauth://reg?step=&session=&code=[&challenge=]` and is stable across V1. + ### Central API — users (`/v1/users`) | Method | Path | Scope | Description | diff --git a/docs/compliance/risk/enterprise-risk-register-v1.md b/docs/compliance/risk/enterprise-risk-register-v1.md index 8956676..211f8a7 100644 --- a/docs/compliance/risk/enterprise-risk-register-v1.md +++ b/docs/compliance/risk/enterprise-risk-register-v1.md @@ -146,7 +146,7 @@ sign-off. | **Class** | Security | | **Description** | A direct or transitive dependency in `package.json`, `mobile/build.gradle`, the IoT firmware Cargo crates, the Hardhat contract deps, or the snarkjs/circomlib chain is compromised. The compromise injects code that exfiltrates the `BLOCKCHAIN_PRIVATE_KEY`, the `JWT_SECRET`, the API-key plaintext seen at issuance, or the `biometricSecret` in the prover. Same class as `event-stream`, `ua-parser-js`, `xz-utils` 2024 backdoor. | | **Inherent L × I** | 3 × 4 = **12** | -| **Mitigations** | (1) Nightly CVE monitor running in CI (commit `f8a756c` on `.github/workflows/cve-monitor.yml`) with high-severity alerts routed to the on-call channel — verifiable by inspecting the workflow run history. (2) dep-add ADR-first policy (CLAUDE.md §6 + ADR `/adr/0000-grandfather-initial-deps.md` baseline) plus `scripts/check-dep-trail.sh` blocking merge when a dep lacks an ADR — verifiable by triggering a no-ADR PR and asserting CI fails. (3) Signed-only releases on Phase 3 SBOM (compliance-roadmap-v1 §3.3 Q3 milestones) — verifiable by checking `provenance: true` on the GitHub Actions release attestation when Phase 3 work completes. (4) Lockfile review gate in CI — `package-lock.json` diffs > 100 lines are routed for human review; verifiable by a dependent PR that exceeds the threshold. (5) WebView snarkjs bundling with SHA-256 pinning per [ADR 0010](../../../adr/0010-android-webview-snarkjs-bundling.md) closes the runtime-CDN class of supply-chain attack on the prover (A-17). | +| **Mitigations** | (1) Nightly CVE monitor running in CI (commit `f8a756c` on `.github/workflows/cve-monitor.yml`) with high-severity alerts routed to the on-call channel — verifiable by inspecting the workflow run history. (2) dep-add ADR-first policy (CLAUDE.md §6 + ADR `/adr/0000-grandfather-initial-deps.md` baseline) plus `scripts/check-dep-trail.sh` blocking merge when a dep lacks an ADR — verifiable by triggering a no-ADR PR and asserting CI fails. (3) Signed-only releases on Phase 3 SBOM (compliance-roadmap-v1 §3.3 Q3 milestones) — verifiable by checking `provenance: true` on the GitHub Actions release attestation when Phase 3 work completes. (4) Lockfile review gate in CI — `package-lock.json` diffs > 100 lines are routed for human review; verifiable by a dependent PR that exceeds the threshold. (5) WebView snarkjs bundling with SHA-256 pinning per [ADR 0010](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0010-android-webview-snarkjs-bundling.md) closes the runtime-CDN class of supply-chain attack on the prover (A-17). | | **Residual L × I** | 2 × 3 = **6** | | **Owner** | Agent #26 (Application Security Engineer). Co-owners: Agent #21 (DevOps/SRE) for the CI gates and Agent #40 (Risk & Audit) for the policy. | | **Review cadence** | Monthly. Friday SBOM dump reviewed weekly during release windows. | @@ -161,7 +161,7 @@ sign-off. | **Class** | Regulatory | | **Description** | A personal-data breach that meets the DPDP Act 2023 §8 reporting threshold — i.e., processing failure that materially affects the rights of data principals — must be notified to the Data Protection Board of India and to affected principals within 72 hours of discovery. A breach of bank-onboarded user records, a misrouted audit-event export, or a leaked Postgres backup falls in this class. Penalties under §33 can reach INR 250 crore per incident. | | **Inherent L × I** | 3 × 5 = **15** | -| **Mitigations** | (1) DPO breach-notification SOP in [docs/compliance/dpdp/breach-notification-sop-v0.md](../dpdp/breach-notification-sop-v0.md) (planned A37-W?? deliverable per agent-37 plan; v0 lives by Phase 0 exit). Verifiable by quarterly tabletop (first tabletop Q3 wk 33 per compliance-roadmap-v1 §3.3). (2) Hash-chained audit log (commit `a475ed8` on `src/services/audit.ts`, per [ADR 0013](../../../adr/0013-audit-log-hash-chain.md)) gives a cryptographic record of every write surface, supporting forensics within the 72-hour window. Verifiable by replay via `/api/admin/audit-integrity`. (3) Per-tenant on-chain anchors per [ADR 0014](../../../adr/0014-on-chain-anchor-cadence.md) make the audit chain externally verifiable, so a regulator can confirm scope without trusting any ZeroAuth process. (4) [DPDP §2(t) commitments memo v0](../dpdp-2t-commitments-memo-v0.md) argues that the Poseidon commitment and DID are not personal data, limiting the *reportable surface area* to the genuine PII (email, tenant company name, lead form fields per data-inventory-v1 §3.1, §3.2). (5) Data inventory v1 explicitly classifies every data element so the breach scoping in the 72-hour window is a lookup, not a derivation. | +| **Mitigations** | (1) DPO breach-notification SOP at `docs/compliance/dpdp/breach-notification-sop-v0.md` (planned A37-W?? deliverable per agent-37 plan; v0 lives by Phase 0 exit). Verifiable by quarterly tabletop (first tabletop Q3 wk 33 per compliance-roadmap-v1 §3.3). (2) Hash-chained audit log (commit `a475ed8` on `src/services/audit.ts`, per [ADR 0013](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0013-audit-log-hash-chain.md)) gives a cryptographic record of every write surface, supporting forensics within the 72-hour window. Verifiable by replay via `/api/admin/audit-integrity`. (3) Per-tenant on-chain anchors per [ADR 0014](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0014-on-chain-anchor-cadence.md) make the audit chain externally verifiable, so a regulator can confirm scope without trusting any ZeroAuth process. (4) [DPDP §2(t) commitments memo v0](../dpdp-2t-commitments-memo-v0.md) argues that the Poseidon commitment and DID are not personal data, limiting the *reportable surface area* to the genuine PII (email, tenant company name, lead form fields per data-inventory-v1 §3.1, §3.2). (5) Data inventory v1 explicitly classifies every data element so the breach scoping in the 72-hour window is a lookup, not a derivation. | | **Residual L × I** | 2 × 3 = **6** | | **Owner** | Agent #37 (Compliance / DPDP & RBI Lead). Co-owners: Agent #36 (CCO) for the regulator interface and Agent #41 (DPO) for the principal-side notifications. | | **Review cadence** | Monthly. After any incident classified as severity-2 or worse the review goes interim. | @@ -221,7 +221,7 @@ sign-off. | **Class** | Operational | | **Description** | The Phase 2 trusted-setup ceremony is on the critical path for the Anchor Bank demo (Phase 1 exit, wk 12) and for ISO 27001 Annex A.5.31 evidence (ISO Stage 2, wk 36). A ceremony slip — coordinator unavailable, contributor pool incomplete, ptau file integrity check failure — propagates downstream. A v1.2 → v1.3 circuit bump mid-pilot would compound the slip with a re-issuance of every tenant's pinned verification key. | | **Inherent L × I** | 3 × 3 = **9** | -| **Mitigations** | (1) Trusted-setup runbook ([docs/cryptography/trusted-setup-ceremony.md](../../cryptography/trusted-setup-ceremony.md)) is the operational playbook; verifiable by walking section §1..§8 and asserting each artefact exists when the ceremony day arrives. (2) Phase 1 sprint-4 buffer for the ceremony: ceremony target is wk 10, ISO Stage 2 is wk 36, so 26 weeks of contingency (compliance-roadmap-v1 §7.3 R-COMP-03). (3) `cryptographer-reviewer` subagent gate per [ADR 0015](../../../adr/0015-circuit-version-pinning.md) ensures every circuit-version bump goes through plan mode + ADR + subagent review. Verifiable by inspecting the ADR-trail script's coverage of `circuits/` changes. (4) Rollback path retained: the prior verification key + pinned ptau hash kept in `circuits/legacy//`; verifier service can be pinned to a prior version per ADR 0015 §3 "Rollback procedure." (5) Pre-coordinated calendar slot with the lead ISO auditor so the ceremony is on her calendar (compliance-roadmap-v1 §7.3). | +| **Mitigations** | (1) Trusted-setup runbook ([docs/cryptography/trusted-setup-ceremony.md](../../cryptography/trusted-setup-ceremony.md)) is the operational playbook; verifiable by walking section §1..§8 and asserting each artefact exists when the ceremony day arrives. (2) Phase 1 sprint-4 buffer for the ceremony: ceremony target is wk 10, ISO Stage 2 is wk 36, so 26 weeks of contingency (compliance-roadmap-v1 §7.3 R-COMP-03). (3) `cryptographer-reviewer` subagent gate per [ADR 0015](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0015-circuit-version-pinning.md) ensures every circuit-version bump goes through plan mode + ADR + subagent review. Verifiable by inspecting the ADR-trail script's coverage of `circuits/` changes. (4) Rollback path retained: the prior verification key + pinned ptau hash kept in `circuits/legacy//`; verifier service can be pinned to a prior version per ADR 0015 §3 "Rollback procedure." (5) Pre-coordinated calendar slot with the lead ISO auditor so the ceremony is on her calendar (compliance-roadmap-v1 §7.3). | | **Residual L × I** | 2 × 2 = **4** | | **Owner** | Agent #11 (Cryptography). Co-owners: Agent #36 (CCO) for the regulator-facing knock-on and Agent #40 (Risk & Audit) for the schedule-buffer enforcement. | | **Review cadence** | Monthly through Phase 1 (sprints 1–4); per ceremony thereafter. | @@ -249,9 +249,9 @@ sign-off. | Field | Value | |---|---| | **Class** | Operational | -| **Description** | The daily on-chain anchor of the audit-chain head ([ADR 0014](../../../adr/0014-on-chain-anchor-cadence.md)) requires a working Base L2 RPC endpoint and reasonably low gas. A Base outage (sequencer downtime, RPC provider DDoS), a gas-price spike above the configured ceiling, or a wallet-balance shortage interrupts anchor delivery. While the off-chain hash chain remains intact, the bank's auditor sees an "anchor-lagged" tenant state in the dashboard and may flag it during inspection. | +| **Description** | The daily on-chain anchor of the audit-chain head ([ADR 0014](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0014-on-chain-anchor-cadence.md)) requires a working Base L2 RPC endpoint and reasonably low gas. A Base outage (sequencer downtime, RPC provider DDoS), a gas-price spike above the configured ceiling, or a wallet-balance shortage interrupts anchor delivery. While the off-chain hash chain remains intact, the bank's auditor sees an "anchor-lagged" tenant state in the dashboard and may flag it during inspection. | | **Inherent L × I** | 3 × 3 = **9** | -| **Mitigations** | (1) Three redundant Base RPC providers per [ADR 0014](../../../adr/0014-on-chain-anchor-cadence.md) — Alchemy, Infura, Coinbase (or current equivalents). Failover order is configured; verifiable by inducing a 503 on the primary and asserting the secondary fires. (2) Retry-with-backoff in the anchor cron (`src/services/blockchain.ts`) — up to 6 retries over 4 hours; verifiable by integration test. (3) The off-chain hash chain remains intact regardless of anchor delivery — every audit row carries `previous_hash` + `event_hash`; the chain is replay-verifiable via `/api/admin/audit-integrity` even when the on-chain anchor is days stale. (4) An "anchor-degraded" tenant state in the dashboard, surfaced via `IntegrityCheckCard` and `/api/admin/audit-integrity` ([ADR 0013](../../../adr/0013-audit-log-hash-chain.md), commits `a475ed8` and follow-ups). The state is transparent to the bank's auditor — a degraded badge with a "stale since" timestamp, not a hidden failure. (5) Gas-ceiling alerting: if the next anchor would exceed the configured ceiling (default 5 gwei), the cron emits a `pricing_breach` audit event and pages on-call rather than firing the transaction blindly. | +| **Mitigations** | (1) Three redundant Base RPC providers per [ADR 0014](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0014-on-chain-anchor-cadence.md) — Alchemy, Infura, Coinbase (or current equivalents). Failover order is configured; verifiable by inducing a 503 on the primary and asserting the secondary fires. (2) Retry-with-backoff in the anchor cron (`src/services/blockchain.ts`) — up to 6 retries over 4 hours; verifiable by integration test. (3) The off-chain hash chain remains intact regardless of anchor delivery — every audit row carries `previous_hash` + `event_hash`; the chain is replay-verifiable via `/api/admin/audit-integrity` even when the on-chain anchor is days stale. (4) An "anchor-degraded" tenant state in the dashboard, surfaced via `IntegrityCheckCard` and `/api/admin/audit-integrity` ([ADR 0013](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0013-audit-log-hash-chain.md), commits `a475ed8` and follow-ups). The state is transparent to the bank's auditor — a degraded badge with a "stale since" timestamp, not a hidden failure. (5) Gas-ceiling alerting: if the next anchor would exceed the configured ceiling (default 5 gwei), the cron emits a `pricing_breach` audit event and pages on-call rather than firing the transaction blindly. | | **Residual L × I** | 2 × 2 = **4** | | **Owner** | Agent #25 (Smart-Contract Engineer) for the on-chain path. Co-owners: Agent #21 (DevOps/SRE) for the RPC redundancy and Agent #40 (Risk & Audit) for the SLA. | | **Review cadence** | Monthly. Per-incident review on any 24-hour-or-longer anchor lag. | @@ -266,7 +266,7 @@ sign-off. | **Class** | Regulatory | | **Description** | A bank tenant enrols an end user who is on a sanctions list (OFAC, UN, MEA) or a money-laundering watch list, and the verification event flows through ZeroAuth's pipeline. The regulator's inspection treats ZeroAuth's role as either (a) a passive infrastructure provider — the bank's responsibility — or (b) a participant in the chain — ZeroAuth's responsibility. Misclassification of role under DPDP §6 or RBI MD on KYC §44 carries direct penalties and reputational damage with all other bank prospects. | | **Inherent L × I** | 2 × 5 = **10** | -| **Mitigations** | (1) KYC anchor at enrolment per the bank's existing screening: ZeroAuth's enrolment record references the bank's CKYC / V-CIP record ID (RBI MD on KYC §16, §44 per compliance-roadmap-v1 §2.5). Verifiable by the per-tenant attestation clause in the pilot SOW. (2) **ZeroAuth does NOT do AML** — the AML responsibility stays with the bank. ZeroAuth provides the verification artefact (proof + Poseidon commitment + audit row) and the integrity guarantee, not the watchlist check. The role boundary is documented in [docs/compliance/aml-statement.md](../aml-statement.md) (planned Agent #37 deliverable in Q1; placeholder file referenced here and slot-cited in agent-37's ticket list). Verifiable by checking the existence of the document and the per-tenant DPA clause that incorporates it by reference. (3) The aml-statement.md explicitly disclaims ZeroAuth's involvement and binds the bank to perform its own AML screening before invoking ZeroAuth's verification flow. (4) Sanctioned-jurisdiction tenant config blocks origin countries: `tenants.security_policy.jurisdiction_allowlist` enforces a per-tenant allowlist evaluated on the `clientMeta.requesterCountry` header at `/v1/proof-pairing/sessions` and `/v1/zkp/verify`. Verifiable by integration test against a known-blocked country code. (5) Audit row on every enrolment captures the bank's CKYC reference ID (when supplied), so the inspection trail traces every ZeroAuth verification back to a bank-owned KYC record. | +| **Mitigations** | (1) KYC anchor at enrolment per the bank's existing screening: ZeroAuth's enrolment record references the bank's CKYC / V-CIP record ID (RBI MD on KYC §16, §44 per compliance-roadmap-v1 §2.5). Verifiable by the per-tenant attestation clause in the pilot SOW. (2) **ZeroAuth does NOT do AML** — the AML responsibility stays with the bank. ZeroAuth provides the verification artefact (proof + Poseidon commitment + audit row) and the integrity guarantee, not the watchlist check. The role boundary is documented at `docs/compliance/aml-statement.md` (planned Agent #37 deliverable in Q1; placeholder file referenced here and slot-cited in agent-37's ticket list). Verifiable by checking the existence of the document and the per-tenant DPA clause that incorporates it by reference. (3) The aml-statement.md explicitly disclaims ZeroAuth's involvement and binds the bank to perform its own AML screening before invoking ZeroAuth's verification flow. (4) Sanctioned-jurisdiction tenant config blocks origin countries: `tenants.security_policy.jurisdiction_allowlist` enforces a per-tenant allowlist evaluated on the `clientMeta.requesterCountry` header at `/v1/proof-pairing/sessions` and `/v1/zkp/verify`. Verifiable by integration test against a known-blocked country code. (5) Audit row on every enrolment captures the bank's CKYC reference ID (when supplied), so the inspection trail traces every ZeroAuth verification back to a bank-owned KYC record. | | **Residual L × I** | 1 × 4 = **4** | | **Owner** | Agent #37 (DPDP & RBI Lead). Co-owners: Agent #36 (CCO) for regulator interface and Agent #41 (DPO) for principal-side communications. | | **Review cadence** | Per pilot SOW signing and per RBI MD on KYC revision. Monthly otherwise. | diff --git a/docs/cryptography/trusted-setup-ceremony.md b/docs/cryptography/trusted-setup-ceremony.md index eaa2d9a..a4b16d1 100644 --- a/docs/cryptography/trusted-setup-ceremony.md +++ b/docs/cryptography/trusted-setup-ceremony.md @@ -3,7 +3,7 @@ The procedural runbook for the 6-contributor Phase 2 ceremony that produces the proving / verification keys for `identity_proof` v1.2. Operational complement to -[ADR 0015 — Circuit version pinning + upgrade procedure](../../adr/0015-circuit-version-pinning.md): +[ADR 0015 — Circuit version pinning + upgrade procedure](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0015-circuit-version-pinning.md): ADR 0015 says *why* a new ceremony is required when the circuit changes; this document says *how* to run one. diff --git a/docs/operations/anchor-bank-demo-runbook.md b/docs/operations/anchor-bank-demo-runbook.md index 661f39d..6828278 100644 --- a/docs/operations/anchor-bank-demo-runbook.md +++ b/docs/operations/anchor-bank-demo-runbook.md @@ -233,7 +233,7 @@ The customer (CRO, by convention — they are the bank's *risk* officer and the > "The face image is hashed on-device. The fingerprint template is hashed on-device. **Neither leaves the phone.** The hashes are combined with a fuzzy extractor — circuit version v1.2, deployed last week, multi-party trusted setup — to produce a 256-bit secret. That secret is wrapped by a key in Android StrongBox, the phone's hardware security module. The Poseidon commitment of `(secret, salt)` is computed on-device. The DID is derived: `did:zeroauth:` + the first 40 hex of `keccak256(commitment)`." -The exact math is in [`adr/0015-circuit-version-lock.md`](../../adr/0015-circuit-version-lock.md) and [`docs/cryptography/trusted-setup-ceremony.md`](../cryptography/trusted-setup-ceremony.md) — the operator references but does not read. +The exact math is in [`adr/0015-circuit-version-pinning.md`](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0015-circuit-version-pinning.md) and [`docs/cryptography/trusted-setup-ceremony.md`](../cryptography/trusted-setup-ceremony.md) — the operator references but does not read. ### 4.4 Step 4 — DID registration (60 s) @@ -513,7 +513,7 @@ Pain points referenced: P1 (DPDP §8), P10 (data localisation). > "The audit chain is a Merkle-style construction. Every row references the SHA-256 of the previous row. Every night at 02:00 IST, a cron job hashes the terminal row and writes it to the `AuditAnchor` contract on Base. Yesterday's anchor is on-chain — Basescan, contract `0x...` — and was placed by a deployer key we hold in cold storage." -The hash-chain construction is in [`src/services/audit.ts`](../../src/services/audit.ts); the anchor cron is in [`scripts/anchor-audit-chain.ts`](../../scripts/anchor-audit-chain.ts); the ADRs are [`adr/0013-audit-hash-chain.md`](../../adr/0013-audit-hash-chain.md), [`adr/0014-on-chain-anchor-cadence.md`](../../adr/0014-on-chain-anchor-cadence.md). +The hash-chain construction is in [`src/services/audit.ts`](../../src/services/audit.ts); the anchor cron is in [`src/services/anchor-job.ts`](../../src/services/anchor-job.ts); the ADRs are [`adr/0013-audit-log-hash-chain.md`](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0013-audit-log-hash-chain.md), [`adr/0014-on-chain-anchor-cadence.md`](https://github.com/zeroauth-dev/ZeroAuth/blob/main/adr/0014-on-chain-anchor-cadence.md). ### 8.2 Step 2 — The tampering attempt (45 s) diff --git a/src/routes/console.ts b/src/routes/console.ts index c70de7c..d41f0dd 100644 --- a/src/routes/console.ts +++ b/src/routes/console.ts @@ -13,8 +13,11 @@ import { getConsoleOverview, listAuditEvents, recordAuditEvent, - createDevice, + isValidDeviceType, + issueEnrollmentCode, listDevices, + regenerateEnrollmentCode, + revokeDevice, updateDevice, createTenantUser, listTenantUsers, @@ -22,11 +25,17 @@ import { listVerificationEvents, listAttendanceEvents, } from '../services/platform'; +import { + abandonRegistration, + getRegistrationSession, + startRegistration, +} from '../services/registration'; import { ApiKeyEnvironment, ApiScope, AttendanceEventType, AttendanceResult, + DeviceEnrollmentState, DeviceStatus, TenantUserStatus, VerificationMethod, @@ -706,6 +715,7 @@ function parseLimit(raw: unknown): number | undefined { } const DEVICE_STATUSES: DeviceStatus[] = ['active', 'inactive', 'retired']; +const DEVICE_ENROLLMENT_STATES: DeviceEnrollmentState[] = ['pending', 'enrolled', 'revoked']; const USER_STATUSES: TenantUserStatus[] = ['active', 'inactive']; const VERIFICATION_METHODS: VerificationMethod[] = ['zkp', 'fingerprint', 'face', 'depth', 'saml', 'oidc', 'manual']; const VERIFICATION_RESULTS: VerificationResult[] = ['pass', 'fail', 'challenge']; @@ -719,6 +729,7 @@ router.get('/devices', requireConsoleAuth, async (req: Request, res: Response) = const { tenantId } = (req as any).console; const environment = parseEnv(req.query.environment); const status = req.query.status as DeviceStatus | undefined; + const enrollmentState = req.query.enrollment_state as DeviceEnrollmentState | undefined; let limit: number | undefined; try { limit = parseLimit(req.query.limit); } catch (e) { res.status(400).json({ error: 'invalid_limit', message: (e as Error).message }); return; } @@ -726,39 +737,126 @@ router.get('/devices', requireConsoleAuth, async (req: Request, res: Response) = res.status(400).json({ error: 'invalid_status_filter' }); return; } - const devices = await listDevices(tenantId, environment, { status, limit }); + if (enrollmentState && !DEVICE_ENROLLMENT_STATES.includes(enrollmentState)) { + res.status(400).json({ error: 'invalid_enrollment_state_filter' }); + return; + } + const devices = await listDevices(tenantId, environment, { status, enrollmentState, limit }); res.json({ environment, devices }); } catch { res.status(500).json({ error: 'device_list_failed' }); } }); +/** + * ADR 0022: console-initiated device registration. + * + * Returns the new pending device row PLUS the one-time enrollment + * code (plaintext, returned exactly once — never persisted). The + * device claims the slot by POSTing the code + a hardware fingerprint + * to /v1/devices/enroll. If the operator loses the code they call + * POST /api/console/devices/:id/regenerate-code to mint a new one. + * + * `device_type` is required because each type has a different + * enrollment client (Android app vs kiosk firmware vs USB R307 bridge) + * and a different default attestation expectation. + */ router.post('/devices', requireConsoleAuth, consoleWriteLimiter, async (req: Request, res: Response) => { try { const { tenantId, email } = (req as any).console; const environment = parseEnv(req.body.environment ?? req.query.environment); - const { name, externalId, locationId, batteryLevel, metadata } = req.body; + const { name, deviceType, locationId, metadata } = req.body ?? {}; if (!name || typeof name !== 'string' || name.trim().length === 0) { res.status(400).json({ error: 'invalid_request', message: 'name is required' }); return; } - if (batteryLevel !== undefined && (!Number.isInteger(batteryLevel) || batteryLevel < 0 || batteryLevel > 100)) { - res.status(400).json({ error: 'invalid_request', message: 'batteryLevel must be an integer between 0 and 100' }); + if (!isValidDeviceType(deviceType)) { + res.status(400).json({ + error: 'invalid_request', + message: "device_type is required; one of: 'mobile_android' | 'mobile_ios' | 'kiosk' | 'iot_bridge' | 'desktop'", + }); return; } - const device = await createDevice( + const invite = await issueEnrollmentCode( tenantId, environment, - { name, externalId, locationId, batteryLevel, metadata }, + { name, deviceType, locationId, metadata }, { type: 'console', id: tenantId, email }, ); - res.status(201).json({ environment, device }); + res.status(201).json({ + environment, + device: invite.device, + enrollment: { + code: invite.enrollmentCode, + expires_at: invite.expiresAt.toISOString(), + // Convenience: the deep-link the dashboard renders as a QR for + // the device-side scanner. Format documented in + // docs/api_contract.md. + deeplink: `zeroauth://enroll?code=${encodeURIComponent(invite.enrollmentCode)}`, + }, + }); } catch (err) { - if ((err as Error).message.includes('duplicate key')) { - res.status(409).json({ error: 'device_external_id_taken' }); + res.status(500).json({ error: 'device_create_failed', message: (err as Error).message }); + } +}); + +/** + * Re-issue the enrollment code on a pending slot. The previous code's + * hash is overwritten — there is no way to recover it, and the + * previous code will fail at /v1/devices/enroll from this point on. + */ +router.post('/devices/:deviceId/regenerate-code', requireConsoleAuth, consoleWriteLimiter, async (req: Request, res: Response) => { + try { + const { tenantId, email } = (req as any).console; + const environment = parseEnv(req.body.environment ?? req.query.environment); + const { deviceId } = req.params; + const invite = await regenerateEnrollmentCode( + tenantId, + environment, + deviceId, + { type: 'console', id: tenantId, email }, + ); + if (!invite) { + res.status(404).json({ error: 'device_not_found_or_not_pending' }); return; } - res.status(500).json({ error: 'device_create_failed', message: (err as Error).message }); + res.status(200).json({ + environment, + device: invite.device, + enrollment: { + code: invite.enrollmentCode, + expires_at: invite.expiresAt.toISOString(), + deeplink: `zeroauth://enroll?code=${encodeURIComponent(invite.enrollmentCode)}`, + }, + }); + } catch (err) { + res.status(500).json({ error: 'device_regenerate_failed', message: (err as Error).message }); + } +}); + +/** + * Admin-initiated device revocation. Sets enrollment_state='revoked' + * and status='retired'. The row is retained for audit-log + * traceability — DELETE is intentionally a soft delete. + */ +router.delete('/devices/:deviceId', requireConsoleAuth, consoleWriteLimiter, async (req: Request, res: Response) => { + try { + const { tenantId, email } = (req as any).console; + const environment = parseEnv(req.body.environment ?? req.query.environment); + const { deviceId } = req.params; + const device = await revokeDevice( + tenantId, + environment, + deviceId, + { type: 'console', id: tenantId, email }, + ); + if (!device) { + res.status(404).json({ error: 'device_not_found' }); + return; + } + res.status(200).json({ environment, device }); + } catch (err) { + res.status(500).json({ error: 'device_revoke_failed', message: (err as Error).message }); } }); @@ -1189,4 +1287,87 @@ router.get('/proof-pairing/sessions/:id/stream', requireConsoleAuth, async (req: } }); +// ─── Registration ceremony proxies (ADR 0023) ───────────────────── +// +// Console-facing surfaces over the /v1/registrations service. The +// dashboard demo at /demo/registration uses these to drive the +// three-QR flow without needing a tenant API key on the browser +// side — auth is the console JWT, same pattern as the proof-pairing +// proxies above. + +router.post('/registrations', requireConsoleAuth, consoleWriteLimiter, async (req: Request, res: Response) => { + try { + const { tenantId, email } = (req as any).console; + const environment = parseEnv(req.body?.environment ?? req.query.environment); + const profile = req.body?.profile ?? {}; + const result = await startRegistration( + tenantId, + environment, + { profile }, + { type: 'console', id: tenantId, email }, + ); + res.status(201).json({ + environment, + session: redactRegistrationSession(result.session), + pair: { + code: result.pairCode, + expires_at: result.pairCodeExpiresAt.toISOString(), + deeplink: result.pairDeeplink, + }, + }); + } catch (err) { + res.status(500).json({ error: 'registration_start_failed', message: (err as Error).message }); + } +}); + +router.get('/registrations/:id', requireConsoleAuth, async (req: Request, res: Response) => { + try { + const { tenantId } = (req as any).console; + const environment = parseEnv(req.query.environment); + const session = await getRegistrationSession(tenantId, environment, req.params.id); + if (!session) { + res.status(404).json({ error: 'session_not_found' }); + return; + } + res.status(200).json({ environment, session: redactRegistrationSession(session) }); + } catch (err) { + res.status(500).json({ error: 'registration_poll_failed', message: (err as Error).message }); + } +}); + +router.delete('/registrations/:id', requireConsoleAuth, consoleWriteLimiter, async (req: Request, res: Response) => { + try { + const { tenantId, email } = (req as any).console; + const environment = parseEnv(req.body?.environment ?? req.query.environment); + const session = await abandonRegistration( + tenantId, + environment, + req.params.id, + { type: 'console', id: tenantId, email }, + ); + if (!session) { + res.status(404).json({ error: 'session_not_found' }); + return; + } + res.status(200).json({ environment, session: redactRegistrationSession(session) }); + } catch (err) { + res.status(500).json({ error: 'registration_abandon_failed', message: (err as Error).message }); + } +}); + +/** + * Strip the bearer-grade columns out of the registration_sessions + * row before it touches a browser. The plaintext codes are returned + * only at issuance time (and only to the same browser that issued + * them); the challenge_nonce is part of the QR3 deeplink and the + * server keeps it for the verify-step compare. The hashes never + * need to leave the server. + */ +function redactRegistrationSession(session: object): Record { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { pair_code_hash, enroll_code_hash, verify_code_hash, verify_challenge_nonce, ...safe } = + session as Record; + return safe; +} + export default router; diff --git a/src/routes/v1/devices.ts b/src/routes/v1/devices.ts index 13e6c18..582e013 100644 --- a/src/routes/v1/devices.ts +++ b/src/routes/v1/devices.ts @@ -1,24 +1,54 @@ import { Router, Request, Response } from 'express'; import { authenticateTenantApiKey, getTenantContext } from '../../middleware/tenant-auth'; -import { createDevice, listDevices, updateDevice } from '../../services/platform'; +import { + claimDeviceWithCode, + createDevice, + EnrollmentClaimError, + isValidDeviceType, + listDevices, + updateDevice, +} from '../../services/platform'; +import { pgRateLimit } from '../../middleware/rate-limit'; import { DeviceStatus } from '../../types'; const router = Router(); const DEVICE_STATUSES: DeviceStatus[] = ['active', 'inactive', 'retired']; +// ADR 0022: device-side enrollment is unauthenticated (the code IS the +// credential) and is rate-limited per-IP to defeat code brute-forcing. +// 10 attempts per minute per IP is generous for legitimate enrolment +// (operator types the code wrong a few times) but blocks all practical +// online guessing against the 38-bit code space. +const enrollRateLimit = pgRateLimit({ + route: 'devices:enroll', + windowMs: 60 * 1000, + max: 10, + keyBy: 'ip', +}); + router.post('/', authenticateTenantApiKey(['devices:write']), async (req: Request, res: Response) => { try { const { tenant, apiKey } = getTenantContext(req); - const { name, externalId, locationId, batteryLevel, metadata } = req.body; + const { name, externalId, deviceType, locationId, batteryLevel, metadata } = req.body; if (!name || typeof name !== 'string' || name.trim().length === 0) { res.status(400).json({ error: 'invalid_request', message: 'name is required' }); return; } + // device_type is optional on /v1/devices (legacy trusted-service + // path stays compatible); when present it must be a valid value. + if (deviceType !== undefined && !isValidDeviceType(deviceType)) { + res.status(400).json({ + error: 'invalid_request', + message: "device_type must be one of: 'mobile_android' | 'mobile_ios' | 'kiosk' | 'iot_bridge' | 'desktop'", + }); + return; + } + if (batteryLevel !== undefined && (!Number.isInteger(batteryLevel) || batteryLevel < 0 || batteryLevel > 100)) { res.status(400).json({ error: 'invalid_request', message: 'batteryLevel must be an integer between 0 and 100' }); return; @@ -27,7 +57,7 @@ router.post('/', const device = await createDevice( tenant.id, apiKey.environment, - { name, externalId, locationId, batteryLevel, metadata }, + { name, externalId, deviceType, locationId, batteryLevel, metadata }, { type: 'api_key', id: apiKey.id }, ); @@ -63,6 +93,62 @@ router.get('/', }, ); +/** + * ADR 0022 device-side enrollment. + * + * This is the ONE tenant-API endpoint that doesn't require a tenant + * API key — the enrollment code itself is the bearer credential. + * Authority is established by: + * - The code's SHA-256 matching a pending device row (server-side). + * - The code being inside its 15-minute TTL window. + * - The per-IP rate-limit defeating online code guessing. + * + * On success the row flips to `enrolled`, its `fingerprint_hash` is + * bound, and we return the device row. A `device_token` (for future + * heartbeats) lands in Phase 1 Sprint 4 — V1 returns the row only and + * the device infers its identity from `device.id` + `external_id`. + * + * All failure modes (unknown code, expired code, invalid fingerprint, + * fingerprint collision with another active device) return a uniform + * 404 `enrollment_failed` so we don't leak which condition failed. + * The audit row records the actual cause for forensic review. + */ +router.post('/enroll', enrollRateLimit, async (req: Request, res: Response) => { + try { + const { enrollment_code, fingerprint, attestation_kind } = req.body ?? {}; + if (typeof enrollment_code !== 'string' || enrollment_code.length === 0) { + res.status(400).json({ error: 'invalid_request', message: 'enrollment_code is required' }); + return; + } + if (typeof fingerprint !== 'string' || fingerprint.length === 0) { + res.status(400).json({ error: 'invalid_request', message: 'fingerprint is required' }); + return; + } + if (attestation_kind !== undefined && typeof attestation_kind !== 'string') { + res.status(400).json({ error: 'invalid_request', message: 'attestation_kind must be a string' }); + return; + } + + const device = await claimDeviceWithCode({ + enrollmentCode: enrollment_code, + fingerprint, + attestationKind: attestation_kind, + ip: req.ip ?? null, + userAgent: req.get('user-agent') ?? null, + }); + res.status(200).json({ device }); + } catch (err) { + if (err instanceof EnrollmentClaimError) { + // Uniform error envelope across all enrollment failure modes — + // do not leak which condition failed to the device. The audit + // log row captures the actual reason for forensic review. + res.status(404).json({ error: 'enrollment_failed' }); + return; + } + res.status(500).json({ error: 'device_enroll_failed', message: (err as Error).message }); + } +}); + router.patch('/:deviceId', authenticateTenantApiKey(['devices:write']), async (req: Request, res: Response) => { diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index 1164755..b316049 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -9,6 +9,7 @@ import verificationRoutes from './verifications'; import attendanceRoutes from './attendance'; import auditRoutes from './audit'; import proofPairingRoutes from './proof-pairing'; +import registrationRoutes from './registrations'; const router = Router(); @@ -23,8 +24,13 @@ const router = Router(); * /v1/attendance/* — Check-in / check-out events * /v1/audit/* — Business audit log * /v1/proof-pairing/* — QR-mediated cross-device proof pairing (W3, ADR-0009) + * /v1/registrations/* — Three-QR end-user signup ceremony (ADR-0023) * - * All routes require: Authorization: Bearer za_live_xxx + * Most routes require: Authorization: Bearer za_live_xxx — except + * the phone-side handshake endpoints (registrations/pair-device, + * /submit-commitment, /complete) where the QR-supplied code is the + * bearer credential. Those routes are listed in + * tests/tenant-isolation.test.ts PUBLIC_ROUTE_EXCEPTIONS. */ router.use('/auth/zkp', zkpRoutes); router.use('/auth/saml', samlRoutes); @@ -36,5 +42,6 @@ router.use('/verifications', verificationRoutes); router.use('/attendance', attendanceRoutes); router.use('/audit', auditRoutes); router.use('/proof-pairing', proofPairingRoutes); +router.use('/registrations', registrationRoutes); export default router; diff --git a/src/routes/v1/registrations.ts b/src/routes/v1/registrations.ts new file mode 100644 index 0000000..47228a0 --- /dev/null +++ b/src/routes/v1/registrations.ts @@ -0,0 +1,335 @@ +/** + * End-user registration ceremony — the three-QR signup flow (ADR 0023). + * + * Two surfaces on one router: + * + * 1. Tenant-API-key authed routes for the org's SDK: + * POST /v1/registrations — start session + * GET /v1/registrations/:id — poll status + * DELETE /v1/registrations/:id — abandon + * + * 2. Unauthenticated routes for the phone (the code is the bearer): + * POST /v1/registrations/pair-device — step 1 + * POST /v1/registrations/submit-commitment — step 2 + * POST /v1/registrations/complete — step 3 + * + * The phone-side routes are rate-limited per-IP via the same + * pgRateLimit middleware that gates /v1/devices/enroll. Each route + * is enumerated in tests/tenant-isolation.test.ts as an explicit + * PUBLIC_ROUTE_EXCEPTION with a documented reason. + */ + +import { Router, Request, Response } from 'express'; +import { authenticateTenantApiKey, getTenantContext } from '../../middleware/tenant-auth'; +import { pgRateLimit } from '../../middleware/rate-limit'; +import { + abandonRegistration, + completeRegistration, + getRegistrationSession, + pairDeviceForRegistration, + RegistrationStateError, + startRegistration, + submitCommitmentForRegistration, +} from '../../services/registration'; +import { verifyProofOffChain } from '../../services/zkp'; + +const router = Router(); + +const phoneSideRateLimit = pgRateLimit({ + route: 'registrations:phone', + windowMs: 60 * 1000, + max: 20, + keyBy: 'ip', +}); + +// ─── Tenant-side surfaces ───────────────────────────────────────── + +/** + * POST /v1/registrations — start a new signup ceremony. + * + * Body: `{ profile?: object }`. The profile blob is opaque to the + * server beyond a defence-in-depth strip of any key whose name + * suggests raw biometric data (see sanitizeProfile in + * src/services/registration.ts). + * + * Response: + * 201 { session, pair: { code, expires_at, deeplink } } + */ +router.post('/', + authenticateTenantApiKey(['users:write']), + async (req: Request, res: Response) => { + try { + const { tenant, apiKey } = getTenantContext(req); + const result = await startRegistration( + tenant.id, + apiKey.environment, + { profile: req.body?.profile }, + { type: 'api_key', id: apiKey.id }, + ); + res.status(201).json({ + session: redactSensitive(result.session), + pair: { + code: result.pairCode, + expires_at: result.pairCodeExpiresAt.toISOString(), + deeplink: result.pairDeeplink, + }, + }); + } catch (err) { + res.status(500).json({ error: 'registration_start_failed', message: (err as Error).message }); + } + }, +); + +/** + * GET /v1/registrations/:id — poll the current state of a session. + * + * The platform calls this after rendering each QR to know when to + * advance the wizard. The response contains the state machine value + * plus the non-sensitive fields the UI needs (device_id, did, + * tenant_user_id once each is bound). + * + * The plaintext codes and the challenge_nonce are NEVER in this + * response — they're returned only once at issuance time. + */ +router.get('/:id', + authenticateTenantApiKey(['users:read']), + async (req: Request, res: Response) => { + try { + const { tenant, apiKey } = getTenantContext(req); + const session = await getRegistrationSession(tenant.id, apiKey.environment, req.params.id); + if (!session) { + res.status(404).json({ error: 'session_not_found' }); + return; + } + res.status(200).json({ session: redactSensitive(session) }); + } catch (err) { + res.status(500).json({ error: 'registration_poll_failed', message: (err as Error).message }); + } + }, +); + +/** + * DELETE /v1/registrations/:id — abandon (soft-cancel) a session. + * + * Voids all outstanding codes and flips state to 'abandoned'. A + * completed session is unchanged (idempotent). Useful for "user + * closed the tab" flows the tenant SDK detects. + */ +router.delete('/:id', + authenticateTenantApiKey(['users:write']), + async (req: Request, res: Response) => { + try { + const { tenant, apiKey } = getTenantContext(req); + const session = await abandonRegistration( + tenant.id, + apiKey.environment, + req.params.id, + { type: 'api_key', id: apiKey.id }, + ); + if (!session) { + res.status(404).json({ error: 'session_not_found' }); + return; + } + res.status(200).json({ session: redactSensitive(session) }); + } catch (err) { + res.status(500).json({ error: 'registration_abandon_failed', message: (err as Error).message }); + } + }, +); + +// ─── Phone-side surfaces (no tenant API key — code IS the bearer) ─ + +/** + * POST /v1/registrations/pair-device — step 1. + * + * Body: `{ pair_code, fingerprint, attestation_kind? }`. + * + * Validates the code against an awaiting_device row, claims a device + * row (reusing ADR 0022 fingerprint binding), attaches device_id to + * the session, mints the next code (enroll_code) + returns the + * deeplink the phone shows the operator: "Now scan QR2 on the + * platform". + * + * Failure modes (uniform 404 envelope to avoid enumeration): + * - unknown / expired pair_code + * - invalid fingerprint (< 16 chars) + * - session expired + */ +router.post('/pair-device', phoneSideRateLimit, async (req: Request, res: Response) => { + try { + const { pair_code, fingerprint, attestation_kind } = req.body ?? {}; + if (typeof pair_code !== 'string' || pair_code.length === 0) { + res.status(400).json({ error: 'invalid_request', message: 'pair_code is required' }); + return; + } + if (typeof fingerprint !== 'string' || fingerprint.length === 0) { + res.status(400).json({ error: 'invalid_request', message: 'fingerprint is required' }); + return; + } + if (attestation_kind !== undefined && typeof attestation_kind !== 'string') { + res.status(400).json({ error: 'invalid_request', message: 'attestation_kind must be a string' }); + return; + } + const result = await pairDeviceForRegistration({ + pairCode: pair_code, + fingerprint, + attestationKind: attestation_kind, + ip: req.ip ?? null, + userAgent: req.get('user-agent') ?? null, + }); + res.status(200).json({ + session_id: result.session.id, + device_id: result.session.device_id, + // Phone gets back the enroll_code so it knows what to look for + // when it scans QR2 — but more usefully, the next deeplink it + // would expect (which it matches against the QR payload). + next: { + step: 'enroll', + code: result.nextCode, + expires_at: result.nextCodeExpiresAt.toISOString(), + deeplink: result.nextDeeplink, + }, + }); + } catch (err) { + if (err instanceof RegistrationStateError) { + res.status(404).json({ error: 'pair_failed' }); + return; + } + res.status(500).json({ error: 'pair_failed', message: (err as Error).message }); + } +}); + +/** + * POST /v1/registrations/submit-commitment — step 2. + * + * Body: `{ enroll_code, did, commitment, attestation_kind? }`. + * + * Stores (did, commitment) on the session row. Mints verify_code + + * challenge_nonce. Returns the verify deeplink (with the challenge + * baked in) so the phone can match against QR3. + * + * The commitment is the Poseidon hash of the on-device secret — + * non-secret, non-PII (DPDP §2(t) memo). The biometric NEVER touches + * the server side. + */ +router.post('/submit-commitment', phoneSideRateLimit, async (req: Request, res: Response) => { + try { + const { enroll_code, did, commitment, attestation_kind } = req.body ?? {}; + if (typeof enroll_code !== 'string' || enroll_code.length === 0) { + res.status(400).json({ error: 'invalid_request', message: 'enroll_code is required' }); + return; + } + if (typeof did !== 'string' || did.length === 0) { + res.status(400).json({ error: 'invalid_request', message: 'did is required' }); + return; + } + if (typeof commitment !== 'string' || commitment.length === 0) { + res.status(400).json({ error: 'invalid_request', message: 'commitment is required' }); + return; + } + const result = await submitCommitmentForRegistration({ + enrollCode: enroll_code, + did, + commitment, + attestationKind: typeof attestation_kind === 'string' ? attestation_kind : undefined, + }); + res.status(200).json({ + session_id: result.session.id, + next: { + step: 'verify', + code: result.nextCode, + expires_at: result.nextCodeExpiresAt.toISOString(), + deeplink: result.nextDeeplink, + challenge_nonce: result.challengeNonce, + }, + }); + } catch (err) { + if (err instanceof RegistrationStateError) { + const reason = err.reason; + if (reason === 'invalid_commitment') { + res.status(400).json({ error: 'invalid_request', message: 'did or commitment shape is invalid' }); + return; + } + res.status(404).json({ error: 'enroll_failed' }); + return; + } + res.status(500).json({ error: 'enroll_failed', message: (err as Error).message }); + } +}); + +/** + * POST /v1/registrations/complete — step 3. + * + * Body: `{ verify_code, challenge_nonce, proof, public_signals }`. + * + * Atomic: validate code, validate challenge_nonce matches what we + * issued at step 2, verify the Groth16 proof, assert + * publicSignals[0] equals the stored commitment, create the + * tenant_user, flip state to completed. + * + * Returns 200 `{ session_id, tenant_user, device }` on success. All + * failure modes other than malformed input return 404 + * `verify_failed` to avoid leaking which condition tripped. + */ +router.post('/complete', phoneSideRateLimit, async (req: Request, res: Response) => { + try { + const { verify_code, challenge_nonce, proof, public_signals } = req.body ?? {}; + if (typeof verify_code !== 'string' || verify_code.length === 0) { + res.status(400).json({ error: 'invalid_request', message: 'verify_code is required' }); + return; + } + if (typeof challenge_nonce !== 'string' || challenge_nonce.length === 0) { + res.status(400).json({ error: 'invalid_request', message: 'challenge_nonce is required' }); + return; + } + if (!proof || typeof proof !== 'object') { + res.status(400).json({ error: 'invalid_request', message: 'proof is required' }); + return; + } + if (!Array.isArray(public_signals)) { + res.status(400).json({ error: 'invalid_request', message: 'public_signals is required' }); + return; + } + + const result = await completeRegistration( + { + verifyCode: verify_code, + challengeNonce: challenge_nonce, + proof, + publicSignals: public_signals, + }, + // Real verifier — accepts (proof, publicSignals) and returns + // boolean. Reuses the existing zkp.ts entry that runs the + // off-chain Groth16 verify path (or the verifier service when + // the operator has opted in). + (p, s) => verifyProofOffChain(p as never, s as string[]), + ); + + res.status(200).json({ + session_id: result.session.id, + tenant_user: result.tenantUser, + device: result.device, + }); + } catch (err) { + if (err instanceof RegistrationStateError) { + res.status(404).json({ error: 'verify_failed' }); + return; + } + res.status(500).json({ error: 'verify_failed', message: (err as Error).message }); + } +}); + +/** + * Tenant-side responses MUST NOT leak the pending code hashes or + * the challenge_nonce — those are bearer-grade secrets the platform + * was supposed to render into a QR and forget. We strip them at the + * route boundary. + */ +function redactSensitive(session: object): Record { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { pair_code_hash, enroll_code_hash, verify_code_hash, verify_challenge_nonce, ...safe } = + session as Record; + return safe; +} + +export default router; diff --git a/src/services/db.ts b/src/services/db.ts index eea2247..8a78d1a 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -154,6 +154,35 @@ const SCHEMA = ` CREATE INDEX IF NOT EXISTS idx_devices_tenant ON devices(tenant_id, environment, created_at DESC); CREATE INDEX IF NOT EXISTS idx_devices_status ON devices(tenant_id, environment, status); + -- ADR 0022 production device enrollment. + -- A device slot is created in 'pending' state by the dashboard. The + -- server issues a one-time human-typeable enrollment code (12 chars + -- in Crockford-base32; stored only as SHA-256, expires in 15 min) + -- that the device exchanges for 'enrolled' state via + -- POST /v1/devices/enroll, binding a hardware fingerprint hash and + -- optional attestation kind (Play Integrity / App Attest / none). + -- enrollment_state is orthogonal to status (operational state): + -- a slot can be 'pending' while its row exists, transition to + -- 'enrolled' on claim, and finally 'revoked' if credentials are + -- voided. Existing rows backfill enrollment_state='enrolled' and + -- device_type='kiosk' so the demo seed stays valid. + ALTER TABLE devices ADD COLUMN IF NOT EXISTS device_type VARCHAR(32) NOT NULL DEFAULT 'kiosk' + CHECK (device_type IN ('mobile_android', 'mobile_ios', 'kiosk', 'iot_bridge', 'desktop')); + ALTER TABLE devices ADD COLUMN IF NOT EXISTS enrollment_state VARCHAR(20) NOT NULL DEFAULT 'enrolled' + CHECK (enrollment_state IN ('pending', 'enrolled', 'revoked')); + ALTER TABLE devices ADD COLUMN IF NOT EXISTS enrollment_code_hash TEXT; + ALTER TABLE devices ADD COLUMN IF NOT EXISTS enrollment_code_expires_at TIMESTAMPTZ; + ALTER TABLE devices ADD COLUMN IF NOT EXISTS enrolled_at TIMESTAMPTZ; + ALTER TABLE devices ADD COLUMN IF NOT EXISTS fingerprint_hash TEXT; + ALTER TABLE devices ADD COLUMN IF NOT EXISTS attestation_kind VARCHAR(32); + + -- Hot path: device-side enroll looks up by SHA-256 of the code. + -- Partial index because most rows have no pending code. + CREATE INDEX IF NOT EXISTS idx_devices_enrollment_code_hash + ON devices(enrollment_code_hash) WHERE enrollment_code_hash IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_devices_enrollment_state + ON devices(tenant_id, environment, enrollment_state); + -- ─── Tenant Users / Enrollments ────────────────────────── CREATE TABLE IF NOT EXISTS tenant_users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -267,6 +296,70 @@ const SCHEMA = ` CREATE INDEX IF NOT EXISTS idx_pps_state_expires ON proof_pairing_sessions(state, expires_at) WHERE state = 'issued'; + -- ─── Registration Sessions (ADR 0023) ───────────────────── + -- The end-user signup ceremony: a tenant SDK calls + -- POST /v1/registrations to start a session, gets back a + -- pair_code; the user's phone scans QR1 to enroll the device, + -- captures a biometric, scans QR2 to submit the commitment, then + -- scans QR3 to submit a Groth16 proof binding (commitment, + -- challenge_nonce). The tenant_user is created only when the + -- proof verifies. The biometric NEVER touches the server — only + -- the commitment and the proof do. + -- + -- State machine: + -- awaiting_device — pair_code outstanding, no device yet + -- awaiting_commitment — device paired, enroll_code outstanding + -- awaiting_verification — commitment stored, verify_code outstanding + -- completed — tenant_user created + -- abandoned — session expired or admin-cancelled + -- + -- Three independent codes (each a one-time SHA-256-hashed bearer + -- credential, each with its own 15-minute TTL) prevent confused- + -- deputy reuse across steps: a captured QR1 cannot satisfy QR2's + -- handler, and so on. + CREATE TABLE IF NOT EXISTS registration_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + environment VARCHAR(10) NOT NULL + CHECK (environment IN ('live', 'test')), + -- Free-form profile blob the tenant SDK passes through (name, + -- email, employee_code, etc.). Validation is the tenant's + -- responsibility; we treat it as opaque JSON. NO biometric data + -- ever lives here; the biometric column-name guard in + -- tests/schema-purity.test.ts asserts this. + profile JSONB NOT NULL DEFAULT '{}', + state VARCHAR(32) NOT NULL DEFAULT 'awaiting_device' + CHECK (state IN ('awaiting_device', 'awaiting_commitment', 'awaiting_verification', 'completed', 'abandoned')), + -- Bound progressively as the ceremony advances: + device_id UUID REFERENCES devices(id) ON DELETE SET NULL, + did TEXT, + commitment TEXT, + tenant_user_id UUID REFERENCES tenant_users(id) ON DELETE SET NULL, + -- Three single-use bearer codes, one per step. SHA-256 of plaintext. + pair_code_hash TEXT, + pair_code_expires_at TIMESTAMPTZ, + enroll_code_hash TEXT, + enroll_code_expires_at TIMESTAMPTZ, + verify_code_hash TEXT, + verify_code_expires_at TIMESTAMPTZ, + -- Server-issued nonce baked into the QR3 payload; the phone + -- echoes it back with the proof. Single-use, scoped to this row. + verify_challenge_nonce TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_registration_sessions_tenant + ON registration_sessions(tenant_id, environment, state, created_at DESC); + -- Hot paths: phone-side endpoints find rows by code hash. Partial + -- index because most rows have no outstanding code in any one step. + CREATE INDEX IF NOT EXISTS idx_registration_sessions_pair_code + ON registration_sessions(pair_code_hash) WHERE pair_code_hash IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_registration_sessions_enroll_code + ON registration_sessions(enroll_code_hash) WHERE enroll_code_hash IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_registration_sessions_verify_code + ON registration_sessions(verify_code_hash) WHERE verify_code_hash IS NOT NULL; + -- ─── Audit Events ──────────────────────────────────────── CREATE TABLE IF NOT EXISTS audit_events ( id BIGSERIAL PRIMARY KEY, diff --git a/src/services/device-enrollment.ts b/src/services/device-enrollment.ts new file mode 100644 index 0000000..0e74206 --- /dev/null +++ b/src/services/device-enrollment.ts @@ -0,0 +1,117 @@ +/** + * Device enrollment primitives (ADR 0022). + * + * The production device-enrollment flow is a two-step handshake: + * + * 1. Admin opens the dashboard, fills in a name + device type + + * optional location, and clicks "Register device". The console + * route (POST /api/console/devices) calls `issueEnrollmentCode` + * which inserts the row in `pending` state and returns the + * *plaintext* code to the dashboard exactly once. The row only + * ever stores `enrollment_code_hash = SHA-256(code)`. + * + * 2. The device opens the ZeroAuth companion app or kiosk firmware, + * reads/scans the code, and POSTs to /v1/devices/enroll with + * `{ enrollment_code, fingerprint, attestation? }`. The tenant + * API route calls `claimDeviceWithCode` which: + * - looks up the pending row by SHA-256(code); + * - checks expiry; + * - binds the device's fingerprint hash; + * - flips the row to `enrolled` and clears the code hash; + * - returns the row (and the device_token used for heartbeats). + * + * The plaintext code is short enough to type on a kiosk keypad + * (12 chars in Crockford-base32, excluding `0/O/I/L/U`) and lives + * outside the server side after the response is delivered. The hash + * is what we look up; brute force is bounded by the 15-minute TTL + + * per-IP rate limit on /v1/devices/enroll (10 req/min in app.ts). + * + * Why no full Play Integrity verification here? V1 only *records* + * the attestation kind + blob in audit_events.metadata. Verification + * of Play Integrity verdict signatures (and App Attest assertions) + * lands in Phase 1 Sprint 4 on the path already used by the proof- + * pairing flow (src/services/play-integrity.ts). + */ + +import crypto from 'crypto'; + +/** SHA-256 hex, lower-case. The single hash function we use here. */ +export function sha256Hex(input: string): string { + return crypto.createHash('sha256').update(input, 'utf8').digest('hex'); +} + +/** + * Generate a human-typeable one-time enrollment code. + * + * Format: `ZA-XXXX-XXXX` where each X is from Crockford base-32 minus + * the visually-ambiguous letters `O`, `I`, `L`, `U` (and digit `0`, + * `1` which look like `O`/`I`). That leaves 27 symbols × 8 positions + * = log2(27^8) ≈ 38 bits of entropy. Combined with a 15-minute TTL + * and 10-req/min/IP rate limit, that's >> 2^25 in expected guesses to + * land one collision — overkill for a code with a 900-second window. + * + * The `ZA-` prefix is a sentinel for paste-detection in the device + * firmware and a small signal to non-technical users that this is + * "a ZeroAuth code, not a 2FA code". + */ +const CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ'; // 30 chars; no 0/1/I/L/O/U +const CODE_GROUP_LEN = 4; +const CODE_GROUPS = 2; + +export function generateEnrollmentCode(): string { + const groups: string[] = []; + for (let g = 0; g < CODE_GROUPS; g++) { + let group = ''; + // crypto.randomInt is uniform; preferring it over Math.random(). + for (let i = 0; i < CODE_GROUP_LEN; i++) { + group += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)]; + } + groups.push(group); + } + return `ZA-${groups.join('-')}`; +} + +/** + * Normalise a code as keyed by the device (kiosk operators sometimes + * type lowercase, sometimes drop hyphens). We trim whitespace, + * upper-case, and re-inject the expected hyphens — but only if the + * input shape is plausibly a ZeroAuth enrollment code. Anything + * unrecognised returns the trimmed-and-uppered input as-is so the + * downstream hash compare fails and the caller returns the same + * 404-like "not_found_or_expired" response (no enumeration signal). + */ +export function normaliseEnrollmentCode(raw: string): string { + const stripped = raw.trim().toUpperCase().replace(/[\s-]+/g, ''); + if (stripped.startsWith('ZA') && stripped.length === 2 + CODE_GROUPS * CODE_GROUP_LEN) { + const body = stripped.slice(2); + const re = new RegExp(`.{1,${CODE_GROUP_LEN}}`, 'g'); + return `ZA-${body.match(re)!.join('-')}`; + } + return stripped; +} + +export const ENROLLMENT_CODE_TTL_MS = 15 * 60 * 1000; // 15 minutes + +/** SHA-256 hash of the device-supplied fingerprint. */ +export function fingerprintHash(fingerprint: string): string { + return sha256Hex(fingerprint); +} + +/** + * Validate a device-supplied fingerprint string. We require >= 16 + * bytes of opaque input so a misconfigured client can't just send + * "default" and bind to the row trivially. The fingerprint format is + * device-type-specific: + * + * mobile_android — android_id + Play Integrity package + nonce + * mobile_ios — identifierForVendor + App Attest keyId + * kiosk — kiosk serial number + MAC address + * iot_bridge — bridge UUID + USB serial of the R307 sensor + * + * The verifier doesn't care about the shape — only that the device + * sends the *same* fingerprint each time. The hash is what we store, + * so the plaintext shape can evolve per device class. + */ +export function isValidFingerprint(fp: unknown): fp is string { + return typeof fp === 'string' && fp.trim().length >= 16 && fp.length <= 4096; +} diff --git a/src/services/platform.ts b/src/services/platform.ts index 1566250..6f1323c 100644 --- a/src/services/platform.ts +++ b/src/services/platform.ts @@ -2,6 +2,14 @@ import { v4 as uuidv4 } from 'uuid'; import { getPool } from './db'; import { logger } from './logger'; import { appendAuditEvent } from './audit'; +import { + ENROLLMENT_CODE_TTL_MS, + fingerprintHash, + generateEnrollmentCode, + isValidFingerprint, + normaliseEnrollmentCode, + sha256Hex, +} from './device-enrollment'; import { ApiKeyEnvironment, AttendanceEvent, @@ -11,7 +19,9 @@ import { AuditEvent, AuditStatus, Device, + DeviceEnrollmentState, DeviceStatus, + DeviceType, TenantUser, TenantUserStatus, VerificationMethod, @@ -19,6 +29,19 @@ import { VerificationResult, } from '../types'; +/** Allowed device-type strings; the runtime guard counterpart of the DB CHECK constraint. */ +const DEVICE_TYPES: readonly DeviceType[] = [ + 'mobile_android', + 'mobile_ios', + 'kiosk', + 'iot_bridge', + 'desktop', +] as const; + +export function isValidDeviceType(v: unknown): v is DeviceType { + return typeof v === 'string' && (DEVICE_TYPES as readonly string[]).includes(v); +} + function sanitizeMetadata(metadata: unknown): Record { if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { return {}; @@ -154,12 +177,25 @@ export async function recordAuditEvent( }); } +/** + * Direct device-row create for the trusted-service path (POST /v1/devices + * called with a tenant API key). The row lands `enrollment_state='enrolled'` + * with no enrollment code involved — the caller has already provisioned + * the hardware identity and is asserting it on the device's behalf. This + * is the path used by SDK-led bulk provisioning and the demo seed scripts. + * + * The dashboard's "Register device" flow does NOT use this entry point; + * it calls `issueEnrollmentCode` (below) to mint a pending slot + code + * that the device then exchanges for an enrolled row via + * `claimDeviceWithCode` on /v1/devices/enroll. + */ export async function createDevice( tenantId: string, environment: ApiKeyEnvironment, input: { externalId?: string; name: string; + deviceType?: DeviceType; locationId?: string; batteryLevel?: number; metadata?: Record; @@ -168,14 +204,17 @@ export async function createDevice( ): Promise { const pool = getPool(); const result = await pool.query( - `INSERT INTO devices (tenant_id, environment, external_id, name, location_id, battery_level, metadata, last_seen_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + `INSERT INTO devices + (tenant_id, environment, external_id, name, device_type, enrollment_state, + location_id, battery_level, metadata, last_seen_at, enrolled_at) + VALUES ($1, $2, $3, $4, $5, 'enrolled', $6, $7, $8, NOW(), NOW()) RETURNING *`, [ tenantId, environment, defaultExternalId('device', input.externalId), input.name.trim(), + input.deviceType ?? 'kiosk', input.locationId?.trim() || null, input.batteryLevel ?? null, sanitizeMetadata(input.metadata), @@ -192,7 +231,321 @@ export async function createDevice( entityId: device.id, status: 'success', summary: `Registered device ${device.external_id}`, - metadata: { locationId: device.location_id, name: device.name, ...actorMetadata(actor) }, + metadata: { + locationId: device.location_id, + name: device.name, + deviceType: device.device_type, + via: 'trusted-service', + ...actorMetadata(actor), + }, + }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); + + return device; +} + +/** + * Result envelope for the dashboard's "Register device" call. The + * plaintext `enrollmentCode` is the ONLY chance the operator gets to + * see the code — we hash and discard the plaintext after this insert. + * If the operator loses it, they call `regenerateEnrollmentCode` + * (which writes a new audit row and a new hash) — never recovers the + * old code, which never existed in clear after this function returned. + */ +export interface DeviceEnrollmentInvite { + device: Device; + enrollmentCode: string; + expiresAt: Date; +} + +/** + * Console-initiated enrollment slot. Creates a row in `pending` state, + * generates a fresh enrollment code, persists its SHA-256 + 15-min + * TTL, and returns the plaintext code to the caller exactly once. + * + * The `external_id` is a placeholder `pending_` until the + * device claims the slot — at which point `claimDeviceWithCode` + * overwrites it with `dev_` so audit-log searches by + * device identity stay meaningful. + */ +export async function issueEnrollmentCode( + tenantId: string, + environment: ApiKeyEnvironment, + input: { + name: string; + deviceType: DeviceType; + locationId?: string; + metadata?: Record; + }, + actor?: AuditActor, +): Promise { + if (!isValidDeviceType(input.deviceType)) { + throw new Error(`invalid device_type: ${input.deviceType}`); + } + const code = generateEnrollmentCode(); + const codeHash = sha256Hex(code); + const expiresAt = new Date(Date.now() + ENROLLMENT_CODE_TTL_MS); + + const pool = getPool(); + const result = await pool.query( + `INSERT INTO devices + (tenant_id, environment, external_id, name, device_type, enrollment_state, + enrollment_code_hash, enrollment_code_expires_at, + location_id, metadata) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8, $9) + RETURNING *`, + [ + tenantId, + environment, + defaultExternalId('pending', undefined), + input.name.trim(), + input.deviceType, + codeHash, + expiresAt, + input.locationId?.trim() || null, + sanitizeMetadata(input.metadata), + ], + ); + + const device = result.rows[0] as Device; + void recordAuditEvent(tenantId, { + environment, + actorType: actor?.type ?? 'console', + actorId: actor?.id ?? null, + action: 'device.enrollment_code_issued', + entityType: 'device', + entityId: device.id, + status: 'success', + summary: `Issued enrollment code for ${device.name}`, + metadata: { + deviceType: device.device_type, + locationId: device.location_id, + expiresAt: expiresAt.toISOString(), + // The code's HASH is on the row; we record only the expiry + + // device_type here so audit searches don't leak the secret. + ...actorMetadata(actor), + }, + }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); + + return { device, enrollmentCode: code, expiresAt }; +} + +/** + * Re-issue an enrollment code on a pending row. Used when the operator + * loses the code or the 15-minute TTL elapses. The old hash is + * overwritten — there is no way to recover the old code, and the old + * code is no longer accepted by `claimDeviceWithCode`. + * + * Returns `null` if the row doesn't exist, isn't pending, or doesn't + * belong to this tenant/environment. + */ +export async function regenerateEnrollmentCode( + tenantId: string, + environment: ApiKeyEnvironment, + deviceId: string, + actor?: AuditActor, +): Promise { + const code = generateEnrollmentCode(); + const codeHash = sha256Hex(code); + const expiresAt = new Date(Date.now() + ENROLLMENT_CODE_TTL_MS); + + const pool = getPool(); + const result = await pool.query( + `UPDATE devices + SET enrollment_code_hash = $4, + enrollment_code_expires_at = $5, + updated_at = NOW() + WHERE id = $1 AND tenant_id = $2 AND environment = $3 + AND enrollment_state = 'pending' + RETURNING *`, + [deviceId, tenantId, environment, codeHash, expiresAt], + ); + + const device = result.rows[0] as Device | undefined; + if (!device) return null; + + void recordAuditEvent(tenantId, { + environment, + actorType: actor?.type ?? 'console', + actorId: actor?.id ?? null, + action: 'device.enrollment_code_reissued', + entityType: 'device', + entityId: device.id, + status: 'success', + summary: `Re-issued enrollment code for ${device.name}`, + metadata: { expiresAt: expiresAt.toISOString(), ...actorMetadata(actor) }, + }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); + + return { device, enrollmentCode: code, expiresAt }; +} + +export class EnrollmentClaimError extends Error { + constructor(public reason: 'invalid_fingerprint' | 'code_not_found_or_expired' | 'fingerprint_collision') { + super(reason); + this.name = 'EnrollmentClaimError'; + } +} + +/** + * Device-side claim. The device POSTs the plaintext code + a hardware + * fingerprint string to /v1/devices/enroll; this function: + * + * 1. Normalises the code (whitespace, hyphens, case). + * 2. SHA-256s it and looks up the pending row. + * 3. Checks TTL (`enrollment_code_expires_at > NOW()`). + * 4. Asserts the fingerprint doesn't collide with another active + * device in the same tenant/environment (prevents a phone from + * enrolling itself as two different rows). + * 5. Writes: `external_id = dev_`, `fingerprint_hash`, + * `attestation_kind`, `enrollment_state = 'enrolled'`, + * `enrolled_at = NOW()`, clears the code hash. + * 6. Writes a `device.enrolled` audit row. + * + * Returns the updated row. Failure modes throw `EnrollmentClaimError` + * so the route handler can translate to a generic 404/409 without + * leaking which condition failed. + */ +export async function claimDeviceWithCode(input: { + enrollmentCode: string; + fingerprint: string; + attestationKind?: string; + ip?: string | null; + userAgent?: string | null; +}): Promise { + if (!isValidFingerprint(input.fingerprint)) { + throw new EnrollmentClaimError('invalid_fingerprint'); + } + + const normalisedCode = normaliseEnrollmentCode(input.enrollmentCode); + const codeHash = sha256Hex(normalisedCode); + const fpHash = fingerprintHash(input.fingerprint); + const newExternalId = `dev_${fpHash.slice(0, 16)}`; + + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Step 1: locate the pending row, lock it for update. + const found = await client.query( + `SELECT * FROM devices + WHERE enrollment_code_hash = $1 + AND enrollment_state = 'pending' + AND enrollment_code_expires_at > NOW() + FOR UPDATE`, + [codeHash], + ); + const pending = found.rows[0] as Device | undefined; + if (!pending) { + await client.query('ROLLBACK'); + throw new EnrollmentClaimError('code_not_found_or_expired'); + } + + // Step 2: assert the fingerprint isn't already bound to another + // enrolled row in the same (tenant, environment). A repeat call + // from the same device on the same slot (same row) is fine; a + // different slot would land an FK-shaped surprise on + // tenant_users.primary_device_id, which is why we reject early. + const collision = await client.query( + `SELECT id FROM devices + WHERE tenant_id = $1 + AND environment = $2 + AND fingerprint_hash = $3 + AND id <> $4`, + [pending.tenant_id, pending.environment, fpHash, pending.id], + ); + if ((collision.rowCount ?? 0) > 0) { + await client.query('ROLLBACK'); + throw new EnrollmentClaimError('fingerprint_collision'); + } + + // Step 3: commit the claim. + const updated = await client.query( + `UPDATE devices + SET external_id = $2, + fingerprint_hash = $3, + attestation_kind = $4, + enrollment_state = 'enrolled', + enrolled_at = NOW(), + enrollment_code_hash = NULL, + enrollment_code_expires_at = NULL, + last_seen_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [pending.id, newExternalId, fpHash, input.attestationKind ?? 'none'], + ); + + await client.query('COMMIT'); + const device = updated.rows[0] as Device; + + void recordAuditEvent(device.tenant_id, { + environment: device.environment, + actorType: 'device', + actorId: device.id, + action: 'device.enrolled', + entityType: 'device', + entityId: device.id, + status: 'success', + summary: `Device ${device.name} enrolled`, + metadata: { + deviceType: device.device_type, + attestationKind: device.attestation_kind, + // Fingerprint plaintext stays out of audit metadata; hash is on + // the row already. IP/UA are retained for forensics on hostile + // enrollment attempts. + enrollIp: input.ip ?? null, + userAgent: input.userAgent ?? null, + }, + }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); + + return device; + } catch (err) { + try { await client.query('ROLLBACK'); } catch { /* already rolled back */ } + throw err; + } finally { + client.release(); + } +} + +/** + * Admin-initiated device retirement. Sets `enrollment_state='revoked'` + * and `status='retired'`. The row stays in the table so audit search + * by entity_id keeps working. A revoked device's fingerprint can be + * re-enrolled on a NEW pending slot (the collision check above + * scopes on `enrollment_state IN ('pending','enrolled')` via the + * `fingerprint_hash` lookup — see the test fixtures). + */ +export async function revokeDevice( + tenantId: string, + environment: ApiKeyEnvironment, + deviceId: string, + actor?: AuditActor, +): Promise { + const pool = getPool(); + const result = await pool.query( + `UPDATE devices + SET enrollment_state = 'revoked', + status = 'retired', + enrollment_code_hash = NULL, + enrollment_code_expires_at = NULL, + updated_at = NOW() + WHERE id = $1 AND tenant_id = $2 AND environment = $3 + RETURNING *`, + [deviceId, tenantId, environment], + ); + const device = result.rows[0] as Device | undefined; + if (!device) return null; + + void recordAuditEvent(tenantId, { + environment, + actorType: actor?.type ?? 'console', + actorId: actor?.id ?? null, + action: 'device.revoked', + entityType: 'device', + entityId: device.id, + status: 'success', + summary: `Revoked device ${device.name}`, + metadata: actorMetadata(actor), }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); return device; @@ -201,7 +554,7 @@ export async function createDevice( export async function listDevices( tenantId: string, environment: ApiKeyEnvironment, - options: { status?: DeviceStatus; limit?: number } = {}, + options: { status?: DeviceStatus; enrollmentState?: DeviceEnrollmentState; limit?: number } = {}, ): Promise { const pool = getPool(); const params: unknown[] = [tenantId, environment]; @@ -211,6 +564,10 @@ export async function listDevices( params.push(options.status); query += ` AND status = $${params.length}`; } + if (options.enrollmentState) { + params.push(options.enrollmentState); + query += ` AND enrollment_state = $${params.length}`; + } params.push(sanitizeLimit(options.limit)); query += ` ORDER BY created_at DESC LIMIT $${params.length}`; diff --git a/src/services/registration.ts b/src/services/registration.ts new file mode 100644 index 0000000..91a5232 --- /dev/null +++ b/src/services/registration.ts @@ -0,0 +1,691 @@ +/** + * End-user signup ceremony — the orchestrator behind the three-QR + * flow documented in ADR 0023. + * + * Lifecycle: + * + * ┌─────────────────────────┐ POST /v1/registrations + * │ tenant SDK on the org's │ ─────────────────────────► server creates row, + * │ signup page │ state='awaiting_device', + * └────────────┬────────────┘ mints pair_code + * │ renders QR1 + * ▼ + * ┌─────────────────────────┐ POST /v1/registrations/pair-device + * │ phone scans QR1 │ ─────────────────────────► server claims a device row + * │ │ (reuses ADR 0022 enrollment), + * │ │ attaches to session, + * │ │ state='awaiting_commitment', + * │ │ mints enroll_code + * └────────────┬────────────┘ + * │ user captures biometric on device, + * │ phone computes (did, commitment) locally + * │ via the existing FaceEmbedder pipeline + * ▼ + * ┌─────────────────────────┐ POST /v1/registrations/submit-commitment + * │ phone scans QR2 (shown │ ─────────────────────────► server stores (did, commitment), + * │ on the org's page after │ state='awaiting_verification', + * │ the platform polled the │ mints verify_code + challenge_nonce + * │ awaiting_commitment │ + * │ state) │ + * └────────────┬────────────┘ + * │ user re-captures biometric, phone computes + * │ Groth16 proof binding (commitment) — V1 + * │ doesn't bake challenge_nonce into the proof's + * │ public signals (the circuit doesn't support it + * │ yet — ADR 0023 §"Out of scope") + * ▼ + * ┌─────────────────────────┐ POST /v1/registrations/complete + * │ phone scans QR3 │ ─────────────────────────► server asserts: + * │ │ - verify_code matches session + * │ │ - verify_challenge_nonce matches + * │ │ - proof verifies vs stored commitment + * │ │ - publicSignals[0] == stored commitment + * │ │ creates tenant_user (no PII columns + * │ │ touched beyond what the tenant + * │ │ passed in `profile`), + * │ │ state='completed' + * └─────────────────────────┘ + * + * Replay defence: every step's code is single-use; once consumed, its + * hash is cleared on the row. The chain of three single-use codes is + * what prevents a captured proof from being submitted into a + * different session. The Phase 1 Sprint 4 follow-on (circuit v1.3 + * with a public challenge signal) tightens this to a per-session + * proof binding. + * + * Confused-deputy defence: codes for different steps live in + * different columns (pair_code_hash / enroll_code_hash / + * verify_code_hash) and each handler checks ONLY its own column. A + * pair_code presented to /submit-commitment fails the lookup; a + * verify_code presented to /pair-device fails the lookup. No way to + * cross-pollinate. + */ + +import { getPool } from './db'; +import { logger } from './logger'; +import { + ENROLLMENT_CODE_TTL_MS, + fingerprintHash, + generateEnrollmentCode, + isValidFingerprint, + normaliseEnrollmentCode, + sha256Hex, +} from './device-enrollment'; +import { recordAuditEvent } from './platform'; +import { + ApiKeyEnvironment, + Device, + RegistrationSession, + TenantUser, +} from '../types'; + +/** Whole-session TTL (the user has 30 min total to complete all 3 steps). */ +export const REGISTRATION_SESSION_TTL_MS = 30 * 60 * 1000; + +/** + * Length of the challenge nonce baked into QR3. 32 hex chars = 128 bits. + * Plenty for a one-shot single-use side-channel. + */ +const CHALLENGE_NONCE_HEX_LEN = 32; + +function generateChallengeNonce(): string { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { randomBytes } = require('crypto') as typeof import('crypto'); + return randomBytes(CHALLENGE_NONCE_HEX_LEN / 2).toString('hex'); +} + +// Mirrors the forbidden-key set in tests/biometric-rejection.test.ts. +// Matches the token anywhere in the key name with word boundaries, +// so `face_image`, `biometric_template`, `depth_map`, `raw_face` etc. +// all get stripped — not just keys that start with the token. +const FORBIDDEN_PROFILE_KEY_TOKENS = /(?:^|_)(image|template|pixel|depth|frame|biometric|photo|raw[_-]?face|raw[_-]?finger)(?:$|_)/i; + +function sanitizeProfile(profile: unknown): Record { + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { + return {}; + } + // Defence-in-depth: drop any top-level field that even sounds like + // raw biometric data. The tenant SDK is supposed to not pass these, + // but if a buggy integration does we'd rather strip than store. + const out: Record = {}; + for (const [k, v] of Object.entries(profile as Record)) { + if (FORBIDDEN_PROFILE_KEY_TOKENS.test(k)) { + logger.warn('Stripped suspicious key from registration profile', { key: k }); + continue; + } + out[k] = v; + } + return out; +} + +export interface RegistrationStartInput { + profile?: Record; +} + +export interface RegistrationStartResult { + session: RegistrationSession; + pairCode: string; + pairCodeExpiresAt: Date; + pairDeeplink: string; +} + +export interface RegistrationStepResult { + session: RegistrationSession; + nextCode: string; + nextCodeExpiresAt: Date; + nextDeeplink: string; + challengeNonce?: string; +} + +export interface RegistrationCompleteResult { + session: RegistrationSession; + tenantUser: TenantUser; + device: Device | null; +} + +export class RegistrationStateError extends Error { + constructor( + public reason: + | 'session_not_found' + | 'session_expired' + | 'wrong_state' + | 'code_not_found_or_expired' + | 'invalid_fingerprint' + | 'invalid_commitment' + | 'commitment_mismatch' + | 'challenge_mismatch' + | 'proof_verification_failed', + ) { + super(reason); + this.name = 'RegistrationStateError'; + } +} + +/** + * Step 0: tenant SDK starts a session. Server creates a row in + * `awaiting_device` state and mints the pair_code that the platform + * encodes into QR1. The plaintext pair_code is returned exactly + * once — only its SHA-256 is persisted. + */ +export async function startRegistration( + tenantId: string, + environment: ApiKeyEnvironment, + input: RegistrationStartInput, + actor: { type: 'api_key' | 'console'; id: string | null; email?: string | null }, +): Promise { + const code = generateEnrollmentCode(); + const codeHash = sha256Hex(code); + const now = Date.now(); + const codeExpiresAt = new Date(now + ENROLLMENT_CODE_TTL_MS); + const sessionExpiresAt = new Date(now + REGISTRATION_SESSION_TTL_MS); + const profile = sanitizeProfile(input.profile); + + const pool = getPool(); + const result = await pool.query( + `INSERT INTO registration_sessions + (tenant_id, environment, profile, state, + pair_code_hash, pair_code_expires_at, expires_at) + VALUES ($1, $2, $3::jsonb, 'awaiting_device', $4, $5, $6) + RETURNING *`, + [tenantId, environment, JSON.stringify(profile), codeHash, codeExpiresAt, sessionExpiresAt], + ); + const session = result.rows[0]; + + void recordAuditEvent(tenantId, { + environment, + actorType: actor.type, + actorId: actor.id ?? null, + action: 'registration.started', + entityType: 'registration_session', + entityId: session.id, + status: 'success', + summary: 'Registration session opened', + metadata: { + profileKeys: Object.keys(profile), + sessionExpiresAt: sessionExpiresAt.toISOString(), + pairCodeExpiresAt: codeExpiresAt.toISOString(), + // The code itself is NOT in audit metadata — only its expiry + + // session-level fields. Operator-side recovery is via + // POST /v1/registrations/:id/regenerate-pair-code. + ...(actor.email ? { actor_email: actor.email } : {}), + }, + }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); + + return { + session, + pairCode: code, + pairCodeExpiresAt: codeExpiresAt, + pairDeeplink: `zeroauth://reg?step=pair&session=${session.id}&code=${encodeURIComponent(code)}`, + }; +} + +/** + * Step 1: phone scans QR1, POSTs `pair_code` + `fingerprint`. + * + * Atomically: SELECT FOR UPDATE the awaiting_device row by + * pair_code_hash, claim a device row (reusing ADR 0022 enrollment- + * code-style fingerprint binding), attach device_id to the session, + * mint enroll_code (for QR2), flip state to awaiting_commitment. + */ +export async function pairDeviceForRegistration(input: { + pairCode: string; + fingerprint: string; + attestationKind?: string; + ip?: string | null; + userAgent?: string | null; +}): Promise { + if (!isValidFingerprint(input.fingerprint)) { + throw new RegistrationStateError('invalid_fingerprint'); + } + const pairCodeHash = sha256Hex(normaliseEnrollmentCode(input.pairCode)); + const fpHash = fingerprintHash(input.fingerprint); + const nextCode = generateEnrollmentCode(); + const nextCodeHash = sha256Hex(nextCode); + const nextCodeExpiresAt = new Date(Date.now() + ENROLLMENT_CODE_TTL_MS); + + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const found = await client.query( + `SELECT * FROM registration_sessions + WHERE pair_code_hash = $1 + AND state = 'awaiting_device' + AND pair_code_expires_at > NOW() + AND expires_at > NOW() + FOR UPDATE`, + [pairCodeHash], + ); + const session = found.rows[0]; + if (!session) { + await client.query('ROLLBACK'); + throw new RegistrationStateError('code_not_found_or_expired'); + } + + // Reuse the devices table — a registration creates a device row + // tied to the tenant just like a console-issued slot, but + // marked enrolled directly (no separate enrollment_code on the + // device row because the pair_code already established intent + // at the registration level). The fingerprint is the device's + // production identity from this point on. + const deviceInsert = await client.query( + `INSERT INTO devices + (tenant_id, environment, external_id, name, device_type, + enrollment_state, fingerprint_hash, attestation_kind, + enrolled_at, last_seen_at, metadata) + VALUES ($1, $2, $3, $4, 'mobile_android', 'enrolled', $5, $6, + NOW(), NOW(), $7::jsonb) + RETURNING *`, + [ + session.tenant_id, + session.environment, + `dev_${fpHash.slice(0, 16)}`, + `Registration device (${session.id.slice(0, 8)})`, + fpHash, + input.attestationKind ?? 'none', + JSON.stringify({ via: 'registration', sessionId: session.id }), + ], + ); + const device = deviceInsert.rows[0]; + + const updated = await client.query( + `UPDATE registration_sessions + SET device_id = $2, + state = 'awaiting_commitment', + pair_code_hash = NULL, + pair_code_expires_at = NULL, + enroll_code_hash = $3, + enroll_code_expires_at = $4, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [session.id, device.id, nextCodeHash, nextCodeExpiresAt], + ); + + await client.query('COMMIT'); + + void recordAuditEvent(session.tenant_id, { + environment: session.environment, + actorType: 'device', + actorId: device.id, + action: 'registration.device_paired', + entityType: 'registration_session', + entityId: session.id, + status: 'success', + summary: 'Phone paired to registration session', + metadata: { + deviceId: device.id, + attestationKind: input.attestationKind ?? 'none', + ip: input.ip ?? null, + userAgent: input.userAgent ?? null, + }, + }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); + + return { + session: updated.rows[0], + nextCode, + nextCodeExpiresAt, + nextDeeplink: `zeroauth://reg?step=enroll&session=${session.id}&code=${encodeURIComponent(nextCode)}`, + }; + } catch (err) { + try { await client.query('ROLLBACK'); } catch { /* already rolled back */ } + throw err; + } finally { + client.release(); + } +} + +/** + * Step 2: phone scans QR2, POSTs `enroll_code` + `did` + `commitment`. + * + * Atomically: SELECT FOR UPDATE the awaiting_commitment row by + * enroll_code_hash, validate `did` shape + commitment shape, write + * them to the session row, mint verify_code + challenge_nonce, flip + * to awaiting_verification. + * + * The biometric NEVER touches this code path. The `commitment` is + * the Poseidon hash of the on-device secret + salt; the `did` is the + * Keccak256-derived identifier. Both are non-secret and non-PII per + * the DPDP §2(t) memo. + */ +export async function submitCommitmentForRegistration(input: { + enrollCode: string; + did: string; + commitment: string; + attestationKind?: string; +}): Promise { + const codeHash = sha256Hex(normaliseEnrollmentCode(input.enrollCode)); + const didNorm = String(input.did ?? '').trim().toLowerCase(); + const commitmentNorm = String(input.commitment ?? '').trim().toLowerCase(); + if (!/^did:zeroauth:[a-z0-9_-]+:[0-9a-f]{8,80}$/.test(didNorm)) { + throw new RegistrationStateError('invalid_commitment'); + } + if (!/^(0x)?[0-9a-f]{32,128}$/.test(commitmentNorm)) { + throw new RegistrationStateError('invalid_commitment'); + } + + const nextCode = generateEnrollmentCode(); + const nextCodeHash = sha256Hex(nextCode); + const nextCodeExpiresAt = new Date(Date.now() + ENROLLMENT_CODE_TTL_MS); + const challengeNonce = generateChallengeNonce(); + + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const found = await client.query( + `SELECT * FROM registration_sessions + WHERE enroll_code_hash = $1 + AND state = 'awaiting_commitment' + AND enroll_code_expires_at > NOW() + AND expires_at > NOW() + FOR UPDATE`, + [codeHash], + ); + const session = found.rows[0]; + if (!session) { + await client.query('ROLLBACK'); + throw new RegistrationStateError('code_not_found_or_expired'); + } + + const updated = await client.query( + `UPDATE registration_sessions + SET did = $2, + commitment = $3, + enroll_code_hash = NULL, + enroll_code_expires_at = NULL, + verify_code_hash = $4, + verify_code_expires_at = $5, + verify_challenge_nonce = $6, + state = 'awaiting_verification', + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [session.id, didNorm, commitmentNorm, nextCodeHash, nextCodeExpiresAt, challengeNonce], + ); + + await client.query('COMMIT'); + + void recordAuditEvent(session.tenant_id, { + environment: session.environment, + actorType: 'device', + actorId: session.device_id, + action: 'registration.commitment_submitted', + entityType: 'registration_session', + entityId: session.id, + status: 'success', + summary: 'Commitment received from device', + metadata: { + did: didNorm, + commitmentPrefix: commitmentNorm.slice(0, 16), + attestationKind: input.attestationKind ?? 'none', + }, + }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); + + return { + session: updated.rows[0], + nextCode, + nextCodeExpiresAt, + nextDeeplink: `zeroauth://reg?step=verify&session=${session.id}&code=${encodeURIComponent(nextCode)}&challenge=${challengeNonce}`, + challengeNonce, + }; + } catch (err) { + try { await client.query('ROLLBACK'); } catch { /* already rolled back */ } + throw err; + } finally { + client.release(); + } +} + +/** + * Step 3: phone scans QR3, POSTs `verify_code` + `proof` + + * `public_signals` + the `challenge_nonce` from QR3. + * + * Atomically: SELECT FOR UPDATE the awaiting_verification row by + * verify_code_hash, assert challenge_nonce matches, verify the + * Groth16 proof, assert publicSignals[0] equals the stored + * commitment, create the tenant_user, flip the session to completed. + * + * V1 limitation: the circuit's public signals don't yet include the + * challenge_nonce. We bind the nonce to the *request* (it must match + * what the server issued in step 2) but not to the *proof itself*. + * This is sufficient for V1 — replay across sessions is blocked by + * the single-use verify_code chain — but the Phase 1 Sprint 4 + * circuit upgrade tightens it to a per-proof binding. + */ +export async function completeRegistration( + input: { + verifyCode: string; + challengeNonce: string; + proof: unknown; + publicSignals: unknown; + }, + // Caller-supplied proof verifier — injected so tests don't need + // the real circuit + zkey on disk. Production wires this to + // src/services/zkp.ts::verifyProofOffChain at the route level. + verifyProof: (proof: unknown, publicSignals: unknown) => Promise, +): Promise { + if (typeof input.verifyCode !== 'string' || input.verifyCode.length === 0) { + throw new RegistrationStateError('code_not_found_or_expired'); + } + if (typeof input.challengeNonce !== 'string' || input.challengeNonce.length === 0) { + throw new RegistrationStateError('challenge_mismatch'); + } + if (!Array.isArray(input.publicSignals) || input.publicSignals.length === 0) { + throw new RegistrationStateError('proof_verification_failed'); + } + + const codeHash = sha256Hex(normaliseEnrollmentCode(input.verifyCode)); + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const found = await client.query( + `SELECT * FROM registration_sessions + WHERE verify_code_hash = $1 + AND state = 'awaiting_verification' + AND verify_code_expires_at > NOW() + AND expires_at > NOW() + FOR UPDATE`, + [codeHash], + ); + const session = found.rows[0]; + if (!session) { + await client.query('ROLLBACK'); + throw new RegistrationStateError('code_not_found_or_expired'); + } + if (session.verify_challenge_nonce !== input.challengeNonce) { + // Bumping the failure here keeps the row consumable on a + // legitimate retry (the verify_code is still alive). We do + // NOT clear the verify_code_hash on this path. + await client.query('ROLLBACK'); + throw new RegistrationStateError('challenge_mismatch'); + } + + // Step 1: assert the proof's commitment equals the one we + // committed to in step 2. Mirrors src/routes/v1/identity.ts. + const presentedCommitment = String( + (input.publicSignals as unknown[])[0] ?? '', + ).toLowerCase(); + if (!session.commitment || presentedCommitment !== session.commitment.toLowerCase()) { + await client.query('ROLLBACK'); + throw new RegistrationStateError('commitment_mismatch'); + } + + // Step 2: cryptographic proof verification — the heavy lift. + const ok = await verifyProof(input.proof, input.publicSignals); + if (!ok) { + await client.query('ROLLBACK'); + throw new RegistrationStateError('proof_verification_failed'); + } + + // Step 3: create the tenant_user row. The profile blob carries + // whatever the tenant SDK passed in — we treat it as opaque + // beyond mapping a few well-known keys onto tenant_users columns + // for backwards compatibility with the legacy PII surface. + const profile = (session.profile ?? {}) as Record; + const fullName = typeof profile.full_name === 'string' ? profile.full_name + : typeof profile.name === 'string' ? profile.name + : 'Unnamed'; + const email = typeof profile.email === 'string' ? profile.email.toLowerCase() : null; + const phone = typeof profile.phone === 'string' ? profile.phone : null; + const employeeCode = typeof profile.employee_code === 'string' ? profile.employee_code : null; + + const userInsert = await client.query( + `INSERT INTO tenant_users + (tenant_id, environment, external_id, full_name, email, phone, + employee_code, primary_device_id, did, commitment, metadata, + last_verified_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, NOW()) + RETURNING *`, + [ + session.tenant_id, + session.environment, + `user_${session.id.slice(0, 12).replace(/-/g, '')}`, + fullName, + email, + phone, + employeeCode, + session.device_id, + session.did, + session.commitment, + JSON.stringify({ via: 'registration', sessionId: session.id }), + ], + ); + const tenantUser = userInsert.rows[0]; + + const sessionUpdate = await client.query( + `UPDATE registration_sessions + SET tenant_user_id = $2, + state = 'completed', + verify_code_hash = NULL, + verify_code_expires_at = NULL, + verify_challenge_nonce = NULL, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [session.id, tenantUser.id], + ); + + // Look up the device row (one already exists from step 1, unless + // the tenant somehow nulled it via PATCH — rare but possible). + const deviceRow = session.device_id + ? await client.query( + `SELECT * FROM devices WHERE id = $1`, + [session.device_id], + ) + : null; + + await client.query('COMMIT'); + + void recordAuditEvent(session.tenant_id, { + environment: session.environment, + actorType: 'device', + actorId: session.device_id, + action: 'registration.completed', + entityType: 'tenant_user', + entityId: tenantUser.id, + status: 'success', + summary: 'Registration ceremony complete; tenant_user created', + metadata: { + sessionId: session.id, + deviceId: session.device_id, + did: session.did, + }, + }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); + + return { + session: sessionUpdate.rows[0], + tenantUser, + device: deviceRow?.rows[0] ?? null, + }; + } catch (err) { + try { await client.query('ROLLBACK'); } catch { /* already rolled back */ } + throw err; + } finally { + client.release(); + } +} + +/** + * Tenant-side poll. Returns the current state machine value plus the + * non-sensitive fields the platform UI needs to advance the wizard. + * Never returns code hashes or the challenge_nonce. + */ +export async function getRegistrationSession( + tenantId: string, + environment: ApiKeyEnvironment, + sessionId: string, +): Promise { + const pool = getPool(); + const result = await pool.query( + `SELECT * FROM registration_sessions + WHERE id = $1 AND tenant_id = $2 AND environment = $3 + LIMIT 1`, + [sessionId, tenantId, environment], + ); + return result.rows[0] ?? null; +} + +/** + * Tenant-side abandon. Voids any outstanding code on the session row + * and flips state to 'abandoned'. Idempotent on already-abandoned or + * completed sessions (returns the row as-is). + */ +export async function abandonRegistration( + tenantId: string, + environment: ApiKeyEnvironment, + sessionId: string, + actor: { type: 'api_key' | 'console'; id: string | null; email?: string | null }, +): Promise { + const pool = getPool(); + const result = await pool.query( + `UPDATE registration_sessions + SET state = CASE WHEN state IN ('completed', 'abandoned') THEN state ELSE 'abandoned' END, + pair_code_hash = NULL, + pair_code_expires_at = NULL, + enroll_code_hash = NULL, + enroll_code_expires_at = NULL, + verify_code_hash = NULL, + verify_code_expires_at = NULL, + verify_challenge_nonce = NULL, + updated_at = NOW() + WHERE id = $1 AND tenant_id = $2 AND environment = $3 + RETURNING *`, + [sessionId, tenantId, environment], + ); + const session = result.rows[0]; + if (!session) return null; + + void recordAuditEvent(tenantId, { + environment, + actorType: actor.type, + actorId: actor.id ?? null, + action: 'registration.abandoned', + entityType: 'registration_session', + entityId: session.id, + status: 'success', + summary: 'Registration session abandoned', + metadata: actor.email ? { actor_email: actor.email } : {}, + }).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message })); + + return session; +} + +/** + * The plaintext-code-shaped envelope the tenant SDK sees when it + * starts or advances a session. The deeplink is what the platform + * encodes into the QR; the QR rendering itself lives in the + * dashboard / SDK (no new QR dep on the server side — see ADR 0023). + */ +export interface QrPayload { + step: 'pair' | 'enroll' | 'verify'; + sessionId: string; + code: string; + expiresAt: Date; + deeplink: string; + challengeNonce?: string; +} diff --git a/src/types/index.ts b/src/types/index.ts index b4eb58b..c5c3cc4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -276,14 +276,52 @@ export interface TenantContext { // ─── Central API Domain Types ──────────────────────────────────────── +/** + * Device-type taxonomy. Drives the dashboard's icon picker and the + * default attestation expectation on enrollment (mobile_android → + * Play Integrity verdict, mobile_ios → App Attest, kiosk/iot_bridge + * → none, desktop → WebAuthn (Phase 2)). + */ +export type DeviceType = + | 'mobile_android' + | 'mobile_ios' + | 'kiosk' + | 'iot_bridge' + | 'desktop'; + +/** + * Enrollment-state machine for a device row. Orthogonal to `status` + * (operational state). See ADR 0022. + * + * pending — slot created by admin, awaiting device claim with code + * enrolled — device claimed; hardware fingerprint bound + * revoked — credentials voided; row retained for audit + */ +export type DeviceEnrollmentState = 'pending' | 'enrolled' | 'revoked'; + export interface Device { id: string; tenant_id: string; environment: ApiKeyEnvironment; external_id: string; name: string; + device_type: DeviceType; location_id: string | null; status: DeviceStatus; + enrollment_state: DeviceEnrollmentState; + // The plaintext enrollment code is never persisted; only its SHA-256. + // Cleared (set NULL) once the code is consumed or the slot is cancelled. + enrollment_code_hash: string | null; + enrollment_code_expires_at: Date | null; + enrolled_at: Date | null; + // SHA-256 of the device-supplied fingerprint (e.g. android_id + + // installation_id, kiosk serial + MAC). Recorded at enrollment, used + // to detect device-row re-use on subsequent claims. + fingerprint_hash: string | null; + // Free-form tag: 'play-integrity' | 'app-attest' | 'webauthn' | 'none'. + // The actual attestation blob lives in audit_events.metadata, never + // on the device row. + attestation_kind: string | null; battery_level: number | null; metadata: Record; last_seen_at: Date | null; @@ -291,6 +329,45 @@ export interface Device { updated_at: Date; } +/** + * Three-step end-user signup ceremony (ADR 0023). + * + * awaiting_device — pair_code outstanding, no device yet + * awaiting_commitment — device paired, enroll_code outstanding + * awaiting_verification — commitment stored, verify_code outstanding + * completed — tenant_user created + * abandoned — session expired or admin-cancelled + */ +export type RegistrationSessionState = + | 'awaiting_device' + | 'awaiting_commitment' + | 'awaiting_verification' + | 'completed' + | 'abandoned'; + +export interface RegistrationSession { + id: string; + tenant_id: string; + environment: ApiKeyEnvironment; + profile: Record; + state: RegistrationSessionState; + device_id: string | null; + did: string | null; + commitment: string | null; + tenant_user_id: string | null; + // Plaintext codes never live on the row — only their SHA-256 hashes. + pair_code_hash: string | null; + pair_code_expires_at: Date | null; + enroll_code_hash: string | null; + enroll_code_expires_at: Date | null; + verify_code_hash: string | null; + verify_code_expires_at: Date | null; + verify_challenge_nonce: string | null; + expires_at: Date; + created_at: Date; + updated_at: Date; +} + export interface TenantUser { id: string; tenant_id: string; diff --git a/tests/console-proxy.test.ts b/tests/console-proxy.test.ts index b434f7f..b163284 100644 --- a/tests/console-proxy.test.ts +++ b/tests/console-proxy.test.ts @@ -50,6 +50,9 @@ jest.mock('../src/services/tenants', () => ({ const listDevices = jest.fn(); const createDevice = jest.fn(); const updateDevice = jest.fn(); +const issueEnrollmentCode = jest.fn(); +const regenerateEnrollmentCode = jest.fn(); +const revokeDevice = jest.fn(); const listTenantUsers = jest.fn(); const createTenantUser = jest.fn(); const updateTenantUser = jest.fn(); @@ -60,6 +63,14 @@ jest.mock('../src/services/platform', () => ({ listDevices: (...args: any[]) => listDevices(...args), createDevice: (...args: any[]) => createDevice(...args), updateDevice: (...args: any[]) => updateDevice(...args), + issueEnrollmentCode: (...args: any[]) => issueEnrollmentCode(...args), + regenerateEnrollmentCode: (...args: any[]) => regenerateEnrollmentCode(...args), + revokeDevice: (...args: any[]) => revokeDevice(...args), + // The runtime type guard from src/services/platform — used by the + // POST /api/console/devices request validator. We pass it through + // to the real implementation rather than mocking, so the test + // mirrors production validation behaviour. + isValidDeviceType: jest.requireActual('../src/services/platform').isValidDeviceType, listTenantUsers: (...args: any[]) => listTenantUsers(...args), createTenantUser: (...args: any[]) => createTenantUser(...args), updateTenantUser: (...args: any[]) => updateTenantUser(...args), @@ -141,43 +152,55 @@ describe('console proxy: /api/console/devices', () => { expect(res.status).toBe(200); expect(res.body.environment).toBe('test'); expect(res.body.devices).toHaveLength(1); - expect(listDevices).toHaveBeenCalledWith('tenant-A', 'test', { status: 'active', limit: 10 }); + expect(listDevices).toHaveBeenCalledWith( + 'tenant-A', + 'test', + expect.objectContaining({ status: 'active', limit: 10 }), + ); }); it('IGNORES a tenant_id in the request body and uses the JWT tenant (A-10)', async () => { - createDevice.mockResolvedValueOnce({ id: 'dev-1', name: 'X', environment: 'live' }); + issueEnrollmentCode.mockResolvedValueOnce({ + device: { id: 'dev-1', name: 'X', environment: 'live', enrollment_state: 'pending' }, + enrollmentCode: 'ZA-TEST-CODE', + expiresAt: new Date(Date.now() + 15 * 60 * 1000), + }); const tokenA = issueToken('tenant-A'); const res = await request(app) .post('/api/console/devices') .set('Authorization', `Bearer ${tokenA}`) - .send({ name: 'X', tenantId: 'tenant-B', tenant_id: 'tenant-B' }); + .send({ name: 'X', deviceType: 'kiosk', tenantId: 'tenant-B', tenant_id: 'tenant-B' }); expect(res.status).toBe(201); - expect(createDevice).toHaveBeenCalledWith( + expect(issueEnrollmentCode).toHaveBeenCalledWith( 'tenant-A', 'live', - expect.objectContaining({ name: 'X' }), + expect.objectContaining({ name: 'X', deviceType: 'kiosk' }), expect.objectContaining({ type: 'console', id: 'tenant-A', email: 'dev@example.com' }), ); + // Plaintext code surfaces to the operator exactly once. + expect(res.body.enrollment.code).toBe('ZA-TEST-CODE'); + expect(res.body.enrollment.deeplink).toContain('ZA-TEST-CODE'); }); - it('validates batteryLevel range', async () => { + it('rejects POST without a valid device_type (ADR 0022)', async () => { const res = await request(app) .post('/api/console/devices') .set('Authorization', `Bearer ${issueToken('tenant-A')}`) - .send({ name: 'X', batteryLevel: 150 }); + .send({ name: 'X' }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); - expect(createDevice).not.toHaveBeenCalled(); + expect(res.body.message).toMatch(/device_type/); + expect(issueEnrollmentCode).not.toHaveBeenCalled(); }); - it('returns 409 device_external_id_taken when the platform service raises a duplicate-key error', async () => { - createDevice.mockImplementationOnce(() => { throw new Error('duplicate key value violates unique constraint "devices_tenant_id_external_id_key"'); }); + it('rejects POST with an unknown device_type', async () => { const res = await request(app) .post('/api/console/devices') .set('Authorization', `Bearer ${issueToken('tenant-A')}`) - .send({ name: 'X', externalId: 'dup' }); - expect(res.status).toBe(409); - expect(res.body.error).toBe('device_external_id_taken'); + .send({ name: 'X', deviceType: 'toaster' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + expect(issueEnrollmentCode).not.toHaveBeenCalled(); }); it('returns 404 device_not_found when PATCH targets an unknown id', async () => { diff --git a/tests/device-enrollment.test.ts b/tests/device-enrollment.test.ts new file mode 100644 index 0000000..189e112 --- /dev/null +++ b/tests/device-enrollment.test.ts @@ -0,0 +1,345 @@ +/** + * Tests for the production device-enrollment flow (ADR 0022). + * + * Two layers covered here: + * + * 1. Pure helpers in `src/services/device-enrollment.ts`: + * code generation, normalisation, fingerprint hashing, + * fingerprint validation. + * + * 2. Service-layer functions in `src/services/platform.ts` that + * orchestrate the pending → enrolled → revoked state machine: + * `issueEnrollmentCode`, `claimDeviceWithCode`, + * `regenerateEnrollmentCode`, `revokeDevice`. + * + * The db pool is mocked so no Postgres is required. The audit-log + * appender is silenced because it would otherwise queue an + * appendAuditEvent against the mocked pool and warn into the test + * logs. + */ + +// db pool mock — set up before importing the service modules. +const mockQuery = jest.fn(); +const mockConnect = jest.fn(() => ({ + query: mockQuery, + release: jest.fn(), +})); +jest.mock('../src/services/db', () => ({ + getPool: () => ({ query: mockQuery, connect: mockConnect }), +})); + +// Silence the audit-log appender — the tests assert the service-level +// behaviour, not the audit-row content (which is covered by +// tests/audit-chain.test.ts). +jest.mock('../src/services/audit', () => ({ + appendAuditEvent: jest.fn().mockResolvedValue(undefined), +})); + +import { + ENROLLMENT_CODE_TTL_MS, + fingerprintHash, + generateEnrollmentCode, + isValidFingerprint, + normaliseEnrollmentCode, + sha256Hex, +} from '../src/services/device-enrollment'; +import { + claimDeviceWithCode, + EnrollmentClaimError, + issueEnrollmentCode, + regenerateEnrollmentCode, + revokeDevice, +} from '../src/services/platform'; + +describe('generateEnrollmentCode', () => { + it('returns the documented ZA-XXXX-XXXX format', () => { + for (let i = 0; i < 50; i++) { + const code = generateEnrollmentCode(); + expect(code).toMatch(/^ZA-[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$/); + } + }); + + it('excludes visually ambiguous symbols (0, 1, I, L, O, U)', () => { + for (let i = 0; i < 100; i++) { + const code = generateEnrollmentCode(); + expect(code).not.toMatch(/[01ILOU]/); + } + }); + + it('produces distinct codes across 200 draws (uniqueness sanity)', () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(generateEnrollmentCode()); + // log2(27^8) ≈ 38 bits — birthday probability of any collision + // in 200 draws is ~2^(-25). One collision = test failure here. + expect(seen.size).toBe(200); + }); +}); + +describe('normaliseEnrollmentCode', () => { + it('accepts the canonical form unchanged', () => { + expect(normaliseEnrollmentCode('ZA-AB23-CD45')).toBe('ZA-AB23-CD45'); + }); + + it('uppercases lowercase input', () => { + expect(normaliseEnrollmentCode('za-ab23-cd45')).toBe('ZA-AB23-CD45'); + }); + + it('re-inserts hyphens when the operator types them out', () => { + expect(normaliseEnrollmentCode('ZAAB23CD45')).toBe('ZA-AB23-CD45'); + }); + + it('strips whitespace anywhere in the input', () => { + expect(normaliseEnrollmentCode(' ZA-AB23 -CD45 ')).toBe('ZA-AB23-CD45'); + }); + + it('returns malformed input as-is (causes hash-compare to fail downstream)', () => { + expect(normaliseEnrollmentCode('not-a-code')).toBe('NOTACODE'); + }); +}); + +describe('sha256Hex', () => { + it('produces 64 lowercase hex characters', () => { + expect(sha256Hex('hello')).toMatch(/^[0-9a-f]{64}$/); + }); + + it('is deterministic', () => { + expect(sha256Hex('foo')).toBe(sha256Hex('foo')); + }); + + it('matches the canonical RFC 6234 test vector for the empty string', () => { + expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); +}); + +describe('fingerprintHash + isValidFingerprint', () => { + it('rejects fingerprints below 16 chars', () => { + expect(isValidFingerprint('short')).toBe(false); + expect(isValidFingerprint('012345678901234')).toBe(false); // 15 chars + }); + + it('accepts fingerprints at 16 chars and above', () => { + expect(isValidFingerprint('a'.repeat(16))).toBe(true); + expect(isValidFingerprint('a'.repeat(1024))).toBe(true); + }); + + it('rejects fingerprints above the 4096-char ceiling', () => { + expect(isValidFingerprint('a'.repeat(4097))).toBe(false); + }); + + it('rejects non-string input', () => { + expect(isValidFingerprint(undefined)).toBe(false); + expect(isValidFingerprint(null)).toBe(false); + expect(isValidFingerprint(12345)).toBe(false); + expect(isValidFingerprint({})).toBe(false); + }); + + it('hash is deterministic across calls with the same input', () => { + const fp = 'android_id:abcdef1234567890|installation:00112233'; + expect(fingerprintHash(fp)).toBe(fingerprintHash(fp)); + }); +}); + +describe('issueEnrollmentCode', () => { + beforeEach(() => { + mockQuery.mockReset(); + mockConnect.mockReset().mockReturnValue({ query: mockQuery, release: jest.fn() }); + }); + + it('inserts a pending row and returns the plaintext code + expiry', async () => { + const fakeRow = { + id: 'dev-1', + tenant_id: 't-1', + environment: 'live', + name: 'Branch kiosk #1', + device_type: 'kiosk', + enrollment_state: 'pending', + }; + mockQuery.mockResolvedValueOnce({ rows: [fakeRow], rowCount: 1 }); + + const before = Date.now(); + const invite = await issueEnrollmentCode( + 't-1', + 'live', + { name: 'Branch kiosk #1', deviceType: 'kiosk' }, + { type: 'console', id: 't-1', email: 'admin@example.com' }, + ); + const after = Date.now(); + + expect(invite.device).toBe(fakeRow); + expect(invite.enrollmentCode).toMatch(/^ZA-[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$/); + const expiryMs = invite.expiresAt.getTime(); + expect(expiryMs).toBeGreaterThanOrEqual(before + ENROLLMENT_CODE_TTL_MS - 100); + expect(expiryMs).toBeLessThanOrEqual(after + ENROLLMENT_CODE_TTL_MS + 100); + + // The INSERT call should carry the SHA-256 of the code, NEVER + // the plaintext code. + const params = mockQuery.mock.calls[0][1] as unknown[]; + expect(params).toContain(sha256Hex(invite.enrollmentCode)); + expect(params).not.toContain(invite.enrollmentCode); + }); + + it('rejects an unknown device_type at the service layer', async () => { + await expect( + issueEnrollmentCode( + 't-1', + 'live', + { name: 'Whatever', deviceType: 'toaster' as unknown as 'kiosk' }, + ), + ).rejects.toThrow(/invalid device_type/); + expect(mockQuery).not.toHaveBeenCalled(); + }); +}); + +describe('claimDeviceWithCode', () => { + beforeEach(() => { + mockQuery.mockReset(); + mockConnect.mockReset().mockReturnValue({ query: mockQuery, release: jest.fn() }); + }); + + const goodFingerprint = 'android_id:abcdef1234567890|installation:00112233'; + + it('happy path: looks up pending row by hash, binds fingerprint, flips to enrolled', async () => { + const code = 'ZA-TEST-CODE'; + const pendingRow = { + id: 'dev-1', + tenant_id: 't-1', + environment: 'live', + name: 'Branch kiosk #1', + device_type: 'kiosk', + enrollment_state: 'pending', + }; + const enrolledRow = { ...pendingRow, enrollment_state: 'enrolled' }; + + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [pendingRow], rowCount: 1 }) // SELECT FOR UPDATE + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // collision check + .mockResolvedValueOnce({ rows: [enrolledRow], rowCount: 1 }) // UPDATE + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const device = await claimDeviceWithCode({ + enrollmentCode: code, + fingerprint: goodFingerprint, + }); + + expect(device.enrollment_state).toBe('enrolled'); + // The SELECT used the SHA-256 of the *normalised* code. + const selectParams = mockQuery.mock.calls[1][1] as unknown[]; + expect(selectParams[0]).toBe(sha256Hex(normaliseEnrollmentCode(code))); + }); + + it('throws invalid_fingerprint when fingerprint is too short', async () => { + await expect( + claimDeviceWithCode({ enrollmentCode: 'ZA-AB23-CD45', fingerprint: 'short' }), + ).rejects.toBeInstanceOf(EnrollmentClaimError); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('throws code_not_found_or_expired when no pending row matches', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT → empty + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + try { + await claimDeviceWithCode({ enrollmentCode: 'ZA-BAD0-WRNG', fingerprint: goodFingerprint }); + throw new Error('expected claim to throw'); + } catch (err) { + expect(err).toBeInstanceOf(EnrollmentClaimError); + expect((err as EnrollmentClaimError).reason).toBe('code_not_found_or_expired'); + } + }); + + it('throws fingerprint_collision when another row already has the same fingerprint', async () => { + const pendingRow = { + id: 'dev-2', + tenant_id: 't-1', + environment: 'live', + name: 'New kiosk', + device_type: 'kiosk', + enrollment_state: 'pending', + }; + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [pendingRow], rowCount: 1 }) // SELECT + .mockResolvedValueOnce({ rows: [{ id: 'dev-other' }], rowCount: 1 }) // collision + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + try { + await claimDeviceWithCode({ enrollmentCode: 'ZA-AB23-CD45', fingerprint: goodFingerprint }); + throw new Error('expected claim to throw'); + } catch (err) { + expect(err).toBeInstanceOf(EnrollmentClaimError); + expect((err as EnrollmentClaimError).reason).toBe('fingerprint_collision'); + } + }); +}); + +describe('regenerateEnrollmentCode', () => { + beforeEach(() => { + mockQuery.mockReset(); + mockConnect.mockReset().mockReturnValue({ query: mockQuery, release: jest.fn() }); + }); + + it('returns the new code on success and writes a fresh hash', async () => { + const row = { + id: 'dev-1', + tenant_id: 't-1', + environment: 'live', + name: 'Kiosk', + device_type: 'kiosk', + enrollment_state: 'pending', + }; + mockQuery.mockResolvedValueOnce({ rows: [row], rowCount: 1 }); + + const invite = await regenerateEnrollmentCode( + 't-1', + 'live', + 'dev-1', + { type: 'console', id: 't-1', email: 'admin@example.com' }, + ); + expect(invite).not.toBeNull(); + expect(invite!.enrollmentCode).toMatch(/^ZA-[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$/); + const params = mockQuery.mock.calls[0][1] as unknown[]; + expect(params).toContain(sha256Hex(invite!.enrollmentCode)); + }); + + it('returns null when no pending row matches (404 path)', async () => { + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const invite = await regenerateEnrollmentCode('t-1', 'live', 'dev-bad'); + expect(invite).toBeNull(); + }); +}); + +describe('revokeDevice', () => { + beforeEach(() => { + mockQuery.mockReset(); + mockConnect.mockReset().mockReturnValue({ query: mockQuery, release: jest.fn() }); + }); + + it('sets enrollment_state=revoked and status=retired on the row', async () => { + const row = { + id: 'dev-1', + tenant_id: 't-1', + environment: 'live', + name: 'Kiosk', + device_type: 'kiosk', + enrollment_state: 'revoked', + status: 'retired', + }; + mockQuery.mockResolvedValueOnce({ rows: [row], rowCount: 1 }); + + const result = await revokeDevice('t-1', 'live', 'dev-1'); + expect(result).toBe(row); + const sql = mockQuery.mock.calls[0][0] as string; + expect(sql).toMatch(/enrollment_state = 'revoked'/); + expect(sql).toMatch(/status = 'retired'/); + expect(sql).toMatch(/enrollment_code_hash = NULL/); + }); + + it('returns null when the device id is unknown', async () => { + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await revokeDevice('t-1', 'live', 'nope'); + expect(result).toBeNull(); + }); +}); diff --git a/tests/registration-flow.test.ts b/tests/registration-flow.test.ts new file mode 100644 index 0000000..6abdacc --- /dev/null +++ b/tests/registration-flow.test.ts @@ -0,0 +1,400 @@ +/** + * Tests for the three-QR registration ceremony (ADR 0023). + * + * Layered the same way as tests/device-enrollment.test.ts: + * + * 1. Service-layer state-machine tests with a mocked db pool. + * Covers happy-path transitions, the failure modes for each + * step, and the redaction invariant on poll responses. + * + * 2. One smoke test for the route layer to confirm the public + * route exceptions are wired correctly and the rate-limit + * middleware doesn't gate a single legitimate request. + * + * Proof verification is stubbed — production wires the route to + * src/services/zkp.ts::verifyProofOffChain, and the verifier itself + * is exercised in tests/zkp-version.test.ts. + */ + +const mockQuery = jest.fn(); +const mockConnect = jest.fn(() => ({ query: mockQuery, release: jest.fn() })); +jest.mock('../src/services/db', () => ({ + getPool: () => ({ query: mockQuery, connect: mockConnect }), +})); + +// Audit appender — silent for these tests. +jest.mock('../src/services/audit', () => ({ + appendAuditEvent: jest.fn().mockResolvedValue(undefined), +})); + +import { + sha256Hex, + normaliseEnrollmentCode, +} from '../src/services/device-enrollment'; +import { + abandonRegistration, + completeRegistration, + getRegistrationSession, + pairDeviceForRegistration, + RegistrationStateError, + startRegistration, + submitCommitmentForRegistration, +} from '../src/services/registration'; + +const TENANT = 'tenant-A'; +const ENV = 'live' as const; + +const goodFingerprint = 'android_id:abcdef1234567890|installation:00112233'; +const goodDid = 'did:zeroauth:face:5b6e7c1a'; +const goodCommitment = '0x' + 'a'.repeat(64); + +beforeEach(() => { + mockQuery.mockReset(); + mockConnect.mockReset().mockReturnValue({ query: mockQuery, release: jest.fn() }); +}); + +describe('startRegistration', () => { + it('inserts a row with state=awaiting_device and returns the plaintext pair_code', async () => { + const fakeRow = { + id: 'sess-1', + tenant_id: TENANT, + environment: ENV, + state: 'awaiting_device', + profile: {}, + }; + mockQuery.mockResolvedValueOnce({ rows: [fakeRow], rowCount: 1 }); + + const result = await startRegistration( + TENANT, + ENV, + { profile: { name: 'Alice', email: 'a@example.com' } }, + { type: 'api_key', id: 'k-1' }, + ); + + expect(result.session).toBe(fakeRow); + expect(result.pairCode).toMatch(/^ZA-[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$/); + expect(result.pairDeeplink).toContain('step=pair'); + expect(result.pairDeeplink).toContain('session=sess-1'); + expect(result.pairDeeplink).toContain('code='); + + // INSERT params carry SHA-256 of the code, NOT the plaintext. + const params = mockQuery.mock.calls[0][1] as unknown[]; + expect(params).toContain(sha256Hex(result.pairCode)); + expect(params).not.toContain(result.pairCode); + }); + + it('strips suspicious biometric-keyed fields from the profile blob (defence-in-depth)', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ id: 'sess-1' }], rowCount: 1 }); + await startRegistration( + TENANT, + ENV, + { profile: { name: 'Alice', face_image: 'b64data', biometric_template: 'b64data' } }, + { type: 'api_key', id: 'k-1' }, + ); + const params = mockQuery.mock.calls[0][1] as unknown[]; + const profileParam = JSON.parse(params[2] as string); + expect(profileParam).toEqual({ name: 'Alice' }); + expect(profileParam.face_image).toBeUndefined(); + expect(profileParam.biometric_template).toBeUndefined(); + }); +}); + +describe('pairDeviceForRegistration (step 1)', () => { + it('happy path: claims a device row, attaches to session, mints enroll_code', async () => { + const sessionRow = { + id: 'sess-1', + tenant_id: TENANT, + environment: ENV, + state: 'awaiting_device', + device_id: null, + }; + const deviceRow = { id: 'dev-1', tenant_id: TENANT, environment: ENV }; + const updatedSession = { ...sessionRow, device_id: 'dev-1', state: 'awaiting_commitment' }; + + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [sessionRow], rowCount: 1 }) // SELECT session + .mockResolvedValueOnce({ rows: [deviceRow], rowCount: 1 }) // INSERT device + .mockResolvedValueOnce({ rows: [updatedSession], rowCount: 1 }) // UPDATE session + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const result = await pairDeviceForRegistration({ + pairCode: 'ZA-AB23-CD45', + fingerprint: goodFingerprint, + attestationKind: 'play-integrity', + }); + + expect(result.session.state).toBe('awaiting_commitment'); + expect(result.session.device_id).toBe('dev-1'); + expect(result.nextCode).toMatch(/^ZA-[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$/); + expect(result.nextDeeplink).toContain('step=enroll'); + + // The SELECT-FOR-UPDATE uses the SHA-256 of the *normalised* code. + const selectParams = mockQuery.mock.calls[1][1] as unknown[]; + expect(selectParams[0]).toBe(sha256Hex(normaliseEnrollmentCode('ZA-AB23-CD45'))); + }); + + it('throws code_not_found_or_expired when no awaiting_device row matches', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT — empty + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + await expect( + pairDeviceForRegistration({ pairCode: 'ZA-BAD0-CODE', fingerprint: goodFingerprint }), + ).rejects.toMatchObject({ reason: 'code_not_found_or_expired' }); + }); + + it('throws invalid_fingerprint without touching the database', async () => { + await expect( + pairDeviceForRegistration({ pairCode: 'ZA-AB23-CD45', fingerprint: 'short' }), + ).rejects.toMatchObject({ reason: 'invalid_fingerprint' }); + expect(mockQuery).not.toHaveBeenCalled(); + }); +}); + +describe('submitCommitmentForRegistration (step 2)', () => { + it('happy path: stores (did, commitment), mints verify_code + challenge_nonce', async () => { + const sessionRow = { + id: 'sess-1', + tenant_id: TENANT, + environment: ENV, + state: 'awaiting_commitment', + device_id: 'dev-1', + }; + const updated = { ...sessionRow, did: goodDid, commitment: goodCommitment, state: 'awaiting_verification' }; + + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [sessionRow], rowCount: 1 }) // SELECT + .mockResolvedValueOnce({ rows: [updated], rowCount: 1 }) // UPDATE + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const result = await submitCommitmentForRegistration({ + enrollCode: 'ZA-EF67-GH89', + did: goodDid, + commitment: goodCommitment, + }); + + expect(result.session.state).toBe('awaiting_verification'); + expect(result.challengeNonce).toMatch(/^[0-9a-f]{32}$/); + expect(result.nextDeeplink).toContain('step=verify'); + expect(result.nextDeeplink).toContain(`challenge=${result.challengeNonce}`); + }); + + it('rejects malformed did at the boundary', async () => { + await expect( + submitCommitmentForRegistration({ + enrollCode: 'ZA-EF67-GH89', + did: 'not-a-did', + commitment: goodCommitment, + }), + ).rejects.toMatchObject({ reason: 'invalid_commitment' }); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('rejects malformed commitment at the boundary', async () => { + await expect( + submitCommitmentForRegistration({ + enrollCode: 'ZA-EF67-GH89', + did: goodDid, + commitment: 'not-hex', + }), + ).rejects.toMatchObject({ reason: 'invalid_commitment' }); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('throws code_not_found_or_expired when no awaiting_commitment row matches', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT — empty + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + await expect( + submitCommitmentForRegistration({ + enrollCode: 'ZA-EF67-GH89', + did: goodDid, + commitment: goodCommitment, + }), + ).rejects.toMatchObject({ reason: 'code_not_found_or_expired' }); + }); +}); + +describe('completeRegistration (step 3)', () => { + const sessionRow = { + id: 'sess-1', + tenant_id: TENANT, + environment: ENV, + state: 'awaiting_verification', + device_id: 'dev-1', + did: goodDid, + commitment: goodCommitment, + verify_challenge_nonce: 'a'.repeat(32), + profile: { name: 'Alice', email: 'a@example.com' }, + }; + + it('happy path: verifies proof, creates tenant_user, flips state to completed', async () => { + const userRow = { + id: 'user-1', + tenant_id: TENANT, + environment: ENV, + full_name: 'Alice', + did: goodDid, + commitment: goodCommitment, + }; + const completedSession = { ...sessionRow, state: 'completed', tenant_user_id: 'user-1' }; + const deviceRow = { id: 'dev-1' }; + + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [sessionRow], rowCount: 1 }) // SELECT session + .mockResolvedValueOnce({ rows: [userRow], rowCount: 1 }) // INSERT user + .mockResolvedValueOnce({ rows: [completedSession], rowCount: 1 }) // UPDATE session + .mockResolvedValueOnce({ rows: [deviceRow], rowCount: 1 }) // SELECT device + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const verifyProof = jest.fn().mockResolvedValue(true); + + const result = await completeRegistration( + { + verifyCode: 'ZA-IJ23-KL45', + challengeNonce: 'a'.repeat(32), + proof: { pi_a: ['1'], pi_b: [['2']], pi_c: ['3'] }, + publicSignals: [goodCommitment, '1'], + }, + verifyProof, + ); + + expect(result.tenantUser).toBe(userRow); + expect(result.session.state).toBe('completed'); + expect(result.device).toBe(deviceRow); + expect(verifyProof).toHaveBeenCalled(); + }); + + it('throws challenge_mismatch when the phone-supplied nonce does not equal the issued nonce', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [sessionRow], rowCount: 1 }) // SELECT + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + const verifyProof = jest.fn(); + await expect( + completeRegistration( + { + verifyCode: 'ZA-IJ23-KL45', + challengeNonce: 'b'.repeat(32), + proof: {}, + publicSignals: [goodCommitment], + }, + verifyProof, + ), + ).rejects.toMatchObject({ reason: 'challenge_mismatch' }); + expect(verifyProof).not.toHaveBeenCalled(); + }); + + it('throws commitment_mismatch when publicSignals[0] does not equal stored commitment', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [sessionRow], rowCount: 1 }) // SELECT + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + const verifyProof = jest.fn(); + await expect( + completeRegistration( + { + verifyCode: 'ZA-IJ23-KL45', + challengeNonce: 'a'.repeat(32), + proof: {}, + publicSignals: ['0xdeadbeef'], + }, + verifyProof, + ), + ).rejects.toMatchObject({ reason: 'commitment_mismatch' }); + expect(verifyProof).not.toHaveBeenCalled(); + }); + + it('throws proof_verification_failed when the verifier returns false', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [sessionRow], rowCount: 1 }) // SELECT + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + const verifyProof = jest.fn().mockResolvedValue(false); + await expect( + completeRegistration( + { + verifyCode: 'ZA-IJ23-KL45', + challengeNonce: 'a'.repeat(32), + proof: {}, + publicSignals: [goodCommitment], + }, + verifyProof, + ), + ).rejects.toMatchObject({ reason: 'proof_verification_failed' }); + }); + + it('throws code_not_found_or_expired when no awaiting_verification row matches', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT — empty + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + const verifyProof = jest.fn(); + await expect( + completeRegistration( + { + verifyCode: 'ZA-IJ23-KL45', + challengeNonce: 'a'.repeat(32), + proof: {}, + publicSignals: [goodCommitment], + }, + verifyProof, + ), + ).rejects.toMatchObject({ reason: 'code_not_found_or_expired' }); + expect(verifyProof).not.toHaveBeenCalled(); + }); +}); + +describe('getRegistrationSession', () => { + it('returns the row when found', async () => { + const row = { id: 'sess-1', tenant_id: TENANT }; + mockQuery.mockResolvedValueOnce({ rows: [row], rowCount: 1 }); + const result = await getRegistrationSession(TENANT, ENV, 'sess-1'); + expect(result).toBe(row); + }); + + it('returns null when no row matches', async () => { + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await getRegistrationSession(TENANT, ENV, 'nope'); + expect(result).toBeNull(); + }); +}); + +describe('abandonRegistration', () => { + it('flips state to abandoned and clears all outstanding codes', async () => { + const row = { id: 'sess-1', state: 'abandoned' }; + mockQuery.mockResolvedValueOnce({ rows: [row], rowCount: 1 }); + const result = await abandonRegistration(TENANT, ENV, 'sess-1', { type: 'api_key', id: 'k-1' }); + expect(result).toBe(row); + const sql = mockQuery.mock.calls[0][0] as string; + expect(sql).toMatch(/pair_code_hash = NULL/); + expect(sql).toMatch(/enroll_code_hash = NULL/); + expect(sql).toMatch(/verify_code_hash = NULL/); + expect(sql).toMatch(/verify_challenge_nonce = NULL/); + }); + + it('returns null when the session does not exist', async () => { + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await abandonRegistration(TENANT, ENV, 'nope', { type: 'api_key', id: 'k-1' }); + expect(result).toBeNull(); + }); +}); + +describe('RegistrationStateError reasons', () => { + it('round-trips the reason through the Error instance', () => { + const e = new RegistrationStateError('challenge_mismatch'); + expect(e.reason).toBe('challenge_mismatch'); + expect(e.name).toBe('RegistrationStateError'); + expect(e.message).toBe('challenge_mismatch'); + }); +}); diff --git a/tests/schema-purity.test.ts b/tests/schema-purity.test.ts index 3a51a96..8bbfb76 100644 --- a/tests/schema-purity.test.ts +++ b/tests/schema-purity.test.ts @@ -172,6 +172,7 @@ describe('schema-purity (tenant-scoped tables)', () => { 'audit_events', 'audit_anchors', 'proof_pairing_sessions', + 'registration_sessions', 'api_keys', 'usage_logs', 'usage_monthly', @@ -257,6 +258,13 @@ describe('schema-purity (tenant-scoped tables)', () => { // not (tenant_id, environment); tenant scope lives in the // session JWT, not in the row. 'user_sessions', + // ADR 0023 three-QR signup ceremony. Tenant-scoped; listed + // in TENANT_SCOPED_TABLES above so the biometric-column-name + // guard runs on it too. The `profile` JSONB column is the + // only free-form slot and sanitizeProfile in + // src/services/registration.ts strips suspicious keys at + // ingest time as a defence-in-depth backstop. + 'registration_sessions', ]); const createTableRe = /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+([a-z_][a-z0-9_]*)/gi; const tables = new Set(); diff --git a/tests/tenant-isolation.test.ts b/tests/tenant-isolation.test.ts index 723be78..94f9e88 100644 --- a/tests/tenant-isolation.test.ts +++ b/tests/tenant-isolation.test.ts @@ -129,6 +129,10 @@ describe('tenant isolation — source-level cross-tenant guard', () => { { file: 'zkp', method: 'post', routerPath: '/verify', reason: 'Public proof verification — body carries DID + commitment' }, { file: 'zkp', method: 'get', routerPath: '/nonce', reason: 'Anonymous challenge issuance' }, { file: 'zkp', method: 'get', routerPath: '/circuit-info', reason: 'Public capability advertisement' }, + { file: 'devices', method: 'post', routerPath: '/enroll', reason: 'ADR 0022 device enrollment — code is the bearer credential' }, + { file: 'registrations', method: 'post', routerPath: '/pair-device', reason: 'ADR 0023 step 1 — pair_code from QR1 is the bearer credential' }, + { file: 'registrations', method: 'post', routerPath: '/submit-commitment', reason: 'ADR 0023 step 2 — enroll_code from QR2 is the bearer credential' }, + { file: 'registrations', method: 'post', routerPath: '/complete', reason: 'ADR 0023 step 3 — verify_code from QR3 is the bearer credential' }, ]; function isException(route: { file: string; method: string; routerPath: string }): boolean {