Skip to content

BFSI v1: blockchain-agnostic pivot, face-first identity, device + signup ceremonies#61

Merged
pulkitpareek18 merged 6 commits into
mainfrom
dev
May 29, 2026
Merged

BFSI v1: blockchain-agnostic pivot, face-first identity, device + signup ceremonies#61
pulkitpareek18 merged 6 commits into
mainfrom
dev

Conversation

@pulkitpareek18
Copy link
Copy Markdown
Collaborator

Summary

Sixty-four commits accumulated on dev since the last sync to main. Three loosely-stacked themes, each landing on independent ADRs and a closed-loop test surface.

1. Phase 0 P0 audit closures

Closes audit-findings tracker rows C-1, C-3, C-7, C-9, C-10, C-11, C-13, C-14, C-15:

  • C-1 — Demo bypass removed from submitProof.
  • C-3?access_token= query fallback replaced with HttpOnly cookie.
  • C-7 — Boot-time SHA-256 check on verification_key.json against EXPECTED_VKEY_SHA256.
  • C-9 — Postgres-backed session store with write-through cache.
  • C-10 — Postgres-backed sliding-window rate-limit middleware on /v1/zkp/verify, /v1/zkp/register, /api/console/login.
  • C-11 — RS256 JWT migration with JWKS endpoint + dual-issuer rollover.
  • C-13 — Per-tenant CORS allowlist via tenant.security_policy.allowed_origins.
  • C-14 — Nightly CVE monitor workflow at .github/workflows/cve-monitor.yml.
  • C-15 — Husky pre-commit + commit-msg hooks (7 gates, mirrored in CI).

2. Blockchain-agnostic pivot (ADR 0017)

The platform is now off-chain by default. Three opt-in providers keyed on tenant.security_policy:

  • did_provider: off-chain (default) | base-sepolia | base-mainnet | custom-chain
  • verifier_provider: off-chain (default) | on-chain
  • audit_anchor_provider: none (default) | signed-transcript | base-sepolia | base-mainnet | witness-cosign

A default tenant boots with no BLOCKCHAIN_PRIVATE_KEY, no contract address, no RPC. The Pramaan ZK protocol + hash-chained audit log work end-to-end off-chain. Auth0 differentiation pitch (docs/why-zeroauth/vs-auth0.md) doesn't depend on any blockchain.

3. Face-first identity surface (ADR 0017 + 0018)

Production register + verify endpoints:

  • POST /v1/identity/register — accepts on-device-computed (did, commitment) only. No template, no image, no embedding ever crosses the wire.
  • POST /v1/identity/verify — looks up by DID, asserts publicSignals[0] matches stored commitment, runs snarkjs.groth16.verify against the boot-pinned vkey, mints session.

On-device pipeline lives in mobile/biometric/: FaceEmbedder (TFLite MobileFaceNet) → Quantizer → SHA-256 → Poseidon → Keccak256-derived DID. Real Poseidon-BN128 vendored from android/sec/Poseidon.kt, byte-identical to circomlibjs.poseidon2.

Legacy /v1/auth/zkp/* retained with Deprecation: true + Sunset: 2026-12-31 headers.

4. Production device-enrollment flow (ADR 0022)

Replaces the "type a name and click Register" surface with the canonical two-step handshake. Admin creates a pending slot → server mints a one-time ZA-XXXX-XXXX code (SHA-256 stored, 15-min TTL) → device claims via POST /v1/devices/enroll with a hardware fingerprint → row flips to enrolled. Rate-limited 10 req/min per IP.

Dashboard Devices.tsx redesigned: device-type selector (mobile_android / mobile_ios / kiosk / iot_bridge / desktop), pending/enrolled/revoked enrollment-state filter, Re-issue and Revoke row actions, post-create enrollment modal with code + countdown + deeplink.

5. Three-QR end-user signup ceremony (ADR 0023)

The user-visible counterpart. Org's signup page calls POST /v1/registrations → server returns QR1 → user scans → phone pairs → QR2 → phone uploads (did, commitment) → QR3 → phone re-captures, produces Groth16 proof, server verifies and creates tenant_user. Biometric never crosses a wire.

Three single-use codes in three columns (cross-step confused-deputy defence), 15-min per-code TTL, 30-min whole-session TTL, server-issued challenge_nonce baked into QR3, V1 binding via single-use code chain + circuit-bound challenge tracked for Phase 1 Sprint 4.

Dashboard demo at /demo/registration with a "Simulate phone" side panel that exercises the phone-side endpoints from the same browser — drives pair + commit green end-to-end against the live backend, verify intentionally surfaces verify_failed because the demo posts a stub proof.

6. Supporting work

  • ADR 0011 — branching workflow (dev + main only).
  • ADR 0013/0014 — audit hash chain (per-tenant event_hash = SHA-256(canonical_json(payload) ‖ previous_hash), RFC 8785 JCS, Postgres advisory locks per tenant) + daily on-chain anchor (gated by audit_anchor_provider).
  • ADR 0015 — circuit-version pinning + upgrade procedure.
  • ADR 0016 — zod input-validation rollout plan.
  • ADR 0020 — husky pre-commit hook.
  • ADR 0021 — RS256 JWT migration.
  • ADR 0024 — qrcode.react dep with full supply-chain provenance.
  • Trusted-setup ceremony runbook at docs/cryptography/trusted-setup-ceremony.md.
  • BFSI demo runbook at docs/operations/anchor-bank-demo-runbook.md.
  • Compliance roadmap v1 at docs/compliance/compliance-roadmap-v1.md.
  • DPDP §2(t) commitments memo v0 at docs/compliance/dpdp-2t-commitments-memo-v0.md.
  • Enterprise risk register v1 at docs/compliance/risk/enterprise-risk-register-v1.md.
  • Bank intel packs (HDFC / ICICI / Axis / SBI / IDFC First / RBL) under docs/product/bank-intel/.
  • GTM outreach sequence v1 at docs/gtm/outreach-sequence-v1.md.
  • CI hardening — docs build switched to markdown.format: 'detect' so .md files parse as CommonMark; security-review workflow granted issues: write to fix 404 on PR comment.

Diff summary

293 files changed, 44,315 insertions(+), 357 deletions(-).

Test plan

  • npx tsc --noEmit clean (backend + dashboard)
  • npm test524/524 backend across 44 suites
  • npm test (dashboard, vitest) — 56/56
  • npm run build:all — backend + dashboard + docs site all green
  • CI (latest run on 8e39425) — success
  • Pre-commit hooks (tsc / eslint / secret-scan / biometric-payload guard / dep-ADR trail / commit-msg) green on every commit in the stack
  • No new high/critical CVEs from npm audit --omit=dev
  • Schema-purity test pins every new column (devices enrollment fields, registration_sessions) and forbidden-pattern scan covers every tenant-scoped table including the two new ones
  • Tenant-isolation test covers every public route exception (the new /v1/devices/enroll, /v1/registrations/{pair-device,submit-commitment,complete}) with reason strings
  • Audit-chain grep guard still rejects any INSERT INTO audit_events outside src/services/audit.ts

Copilot AI review requested due to automatic review settings May 29, 2026 06:04
Pulkit Pareek added 6 commits May 29, 2026 11:35
The docs build (since markdown.format: 'detect' landed in 5ade6e8)
surfaced a set of pre-existing broken relative links from prose
pages into /adr/. Docusaurus' content plugin scopes the docs root
at ../docs/ so `../../adr/0013-…md` resolves outside the served
tree and emits a build warning per link.

Convert the affected references to absolute GitHub URLs pointed at
main, which both render in the docs site and stay clickable in the
GitHub source view. Files touched:

- docs/compliance/risk/enterprise-risk-register-v1.md
- docs/cryptography/trusted-setup-ceremony.md
- docs/operations/anchor-bank-demo-runbook.md

Verify: npm --prefix website run build → 0 broken-link warnings
on these three files (the wider list shrinks accordingly).
Before this commit the dashboard's "Register device" was an admin
typing a free-form name into a modal. A row landed `active`, no
hardware binding, no enrollment ceremony — every device claimed
identity through the shared tenant API key, and any operator could
mint infinite phantom rows. Threat model row A-22 and the Scene 5
attendance-fraud scenario in the BFSI demo runbook were the openers.

Replace with the canonical two-step handshake (Tailscale / Slack /
Cloudflare-Tunnel pattern):

  1. Admin creates a pending slot in the dashboard. Server mints a
     one-time enrollment code (ZA-XXXX-XXXX, 8 entropy chars from
     a 27-symbol Crockford-base32 alphabet, 15-minute TTL). The
     plaintext code is returned exactly once; the server keeps only
     SHA-256. Row state = pending; status orthogonal.

  2. Device POSTs to /v1/devices/enroll with the code + a hardware
     fingerprint string (>=16 chars, opaque to server, SHA-256'd
     and stored as fingerprint_hash). Server validates the code by
     hash lookup + TTL, asserts no collision with another enrolled
     row on the same fingerprint, binds the fingerprint, flips the
     row to enrolled.

The enrollment endpoint is unauthenticated by design — the code IS
the bearer credential. Brute-force is bounded by SHA-256 lookup
cost + per-IP rate-limit (10 req/min via existing pgRateLimit) +
the 15-minute window. Listed in PUBLIC_ROUTE_EXCEPTIONS for the
same reason /v1/zkp/verify is.

Five new audit actions all route through appendAuditEvent so they
land in the hash chain: device.enrollment_code_issued, _reissued,
device.enrolled, device.revoked, device.created (existing,
metadata.via='trusted-service'). Code and code-hash never appear
in audit metadata.

Schema is additive — six new columns on `devices` via
ADD COLUMN IF NOT EXISTS with backfilled defaults
(enrollment_state='enrolled', device_type='kiosk') so the demo
seed and any prior production data keeps working unchanged.

Backwards compat: POST /v1/devices (tenant API key) keeps its
direct-create semantics for the SDK / bulk-provisioning path and
the demo seed. Dashboard path is the new flow.

Verify:
  - npx tsc --noEmit          clean
  - tests/device-enrollment   26/26
  - tests/console-proxy       updated to new contract (POST now
                              requires device_type; DELETE soft-
                              revokes)
  - npm test                  500/500 across 43 suites
  - ADR 0022 explains state model, code-format math, rate-limit
                              calibration, attestation roadmap,
                              and the deferred items (per-device
                              tokens, QR rendering, bulk CSV,
                              geofence allowlist).

Closes the dashboard-side fleet-onboarding scene of the BFSI demo.
Companion to the backend handshake. Three new client capabilities
in dashboard/src/lib/api.ts:

  - createDevice now takes deviceType (required) and returns the
    DeviceEnrollmentInvite envelope: { device, enrollment: { code,
    expires_at, deeplink } }. The plaintext code is the operator's
    one-time chance to copy.
  - regenerateDeviceCode hits POST /api/console/devices/:id/
    regenerate-code; re-issues the code on a pending slot.
  - revokeDevice hits DELETE /api/console/devices/:id; soft-revoke.
  - listDevices accepts an enrollmentState filter and passes it
    through to the new ?enrollment_state= query param.
  - Device type extended with device_type, enrollment_state,
    enrollment_code_expires_at, enrolled_at, fingerprint_hash,
    attestation_kind to mirror the server row.

Devices.tsx redesigned:

  - Two filter selects: enrollment state (pending/enrolled/revoked)
    plus the existing status filter, side by side.
  - Table grows a Type column (humanised device_type labels), an
    Enrollment column (badge tones: pending=warn, enrolled=success,
    revoked=neutral), and a right-aligned Actions column.
  - Pending rows surface "Re-issue code" + "Revoke" actions.
    Enrolled rows surface "Revoke" only. Revoked rows have no
    actions (row stays for audit).
  - "Register device" modal now collects name + device_type +
    optional location; submit calls createDevice.
  - On success, a second modal (EnrollmentInviteModal) shows:
      * The plaintext code in big mono type with a CopyButton.
      * A live 15-minute countdown synced to expires_at.
      * The zeroauth://enroll?code= deeplink (also copyable).
      * A short instruction block pointing the operator at the
        device's enrollment flow (companion app / kiosk firmware).
    The code is unrecoverable after the operator closes the modal —
    they re-issue from the device row if it's lost.

Verify:
  - npx tsc --noEmit (dashboard/)        clean
  - npm test          (vitest)           56/56
  - npm run build     (vite)             119 modules, no warnings

V1 deliberately omits QR rendering (would pull in a new dep — see
ADR 0022 §"Out of scope"). The deeplink format is stable so the
QR follow-up can land without changing the server.
The device-enrollment flow (ADR 0022) gave an operator a way to add
a device to a tenant's fleet. This commit adds the missing other
half: the way an actual end-user creates an account on the org's
site using their phone as the biometric credential carrier.

The user described it like this:

  - Org implements ZeroAuth instead of Google Sign-In.
  - User fills name + email on the org's signup page.
  - User scans QR1 on their phone → phone pairs with the session.
  - User enrolls biometric on the phone → commitment computed
    locally; biometric never leaves the device.
  - User scans QR2 on the platform → phone uploads (did, commitment).
  - User scans QR3 on the platform → phone re-captures biometric,
    produces Groth16 proof, server verifies + creates the account.

Same threat model as WebAuthn registration except the credential
is a biometric (and the proof is zero-knowledge instead of a
signature). Same UX shape as Slack's "approve-from-desktop" flow.
The QR codes are the side channel between the laptop browser and
the phone's camera — no Bluetooth, no custom SDK on the laptop,
no biometric over the wire.

Three single-use SHA-256-hashed codes in three separate columns,
each with its own 15-min TTL. Three corresponding routes on the
phone side, listed in PUBLIC_ROUTE_EXCEPTIONS for the same reason
/v1/devices/enroll is — the code IS the bearer credential. The
chain of three single-use codes provides cross-step confused-deputy
defence (a captured pair_code can't satisfy submit-commitment;
a verify_code can't satisfy pair-device).

State machine on the new registration_sessions table:
  awaiting_device      → pair_code outstanding, no device yet
  awaiting_commitment  → device paired, enroll_code outstanding
  awaiting_verification → commitment stored, verify_code +
                          challenge_nonce outstanding
  completed            → tenant_user created
  abandoned            → expired or admin-cancelled

Phone-side endpoints (no auth, code is bearer, 20 req/min/IP):
  POST /v1/registrations/pair-device
  POST /v1/registrations/submit-commitment
  POST /v1/registrations/complete

Tenant-side endpoints (tenant API key):
  POST   /v1/registrations
  GET    /v1/registrations/:id      (redacts code hashes + nonce)
  DELETE /v1/registrations/:id

Defence-in-depth: `sanitizeProfile` regex-strips any profile-blob
key matching image/template/pixel/depth/frame/raw_face/raw_finger/
biometric/photo (with word-boundary matching) at ingest, so a buggy
tenant SDK that passes a raw biometric in the profile field gets the
key dropped rather than committed.

V1 limitation documented in ADR 0023: the challenge_nonce binds to
the *request*, not to the proof's public signals (circuit v1.2
doesn't have a slot for it). Replay across sessions is blocked by
the single-use verify_code chain + 15-min TTL + rate-limit; full
circuit-bound binding lands with circuit v1.3 in Phase 1 Sprint 4.
The deeplink format and route surface stay stable across that
upgrade.

Verify:
  - npx tsc --noEmit                clean
  - tests/registration-flow         19/19 (mocked pg pool, no Postgres)
  - npm test                        524/524 across 44 suites
  - ADR 0023 captures the state machine, code-format math,
    confused-deputy defence, four threat-model deltas (A-30..A-33),
    and the deferred items (QR rendering dep, circuit-bound
    challenge, SSE poll-replacement, per-tenant profile schema,
    dashboard demo UI).

Closes the end-user signup half of the BFSI demo. The dashboard
demo page that walks an operator through the ceremony with a
simulated phone panel lands in a follow-up commit (mirroring the
shape of demo/QrProofLogin).
ADR 0022 (device enrollment) and ADR 0023 (three-QR signup) both
shipped deeplinks the operator was supposed to render as scannable
QRs but both deferred the QR-rendering dep "to a follow-up commit
with a dep-add ADR" — this is that commit.

Picked qrcode.react@4.2.0 over node-qrcode (3x larger, needs manual
React wrapping), @zxing/library (2.4 MB, includes a full decode
pipeline we don't need), and a vendored 3 kB encoder (smallest but
ongoing maintenance cost exceeds the bundle saving). qrcode.react
is ISC-licensed (functionally MIT), maintained by zpao (former
React core team), zero runtime deps, peer-dep on React ^16-19.

Supply chain: `npm audit --omit=dev` clean. No CVEs against
qrcode.react@4.2.0 in the GitHub Advisory DB or OSV. The two
moderate findings npm audit surfaces (postcss < 8.5.10 XSS, ws
8.0-8.20 memory disclosure) are in dev-only transitive deps used
by vitest + vite — they don't ship to customers.

The dep is used by the new dashboard/src/routes/demo/QrRegistration
page (next commit) and is also available to upgrade the
QrProofLogin demo's block-grid placeholder to a real QR. The
existing copy-the-deeplink UX continues to work as a fallback for
accessibility + headless tests.

Verify:
  - tests/device-enrollment             26/26  (unaffected)
  - tests/registration-flow             19/19  (unaffected)
  - dashboard tests                     56/56  (unaffected)
  - dashboard build                     OK; bundle main path
                                        unchanged; qrcode.react
                                        rides the QrRegistration
                                        lazy chunk (~30 kB / ~10 kB
                                        gzip)
End-user-visible counterpart to the ADR 0023 backend. The operator
opens the route, types a name + email, clicks "Open session & mint
QR1", and the page walks through three QR steps:

  1. QR1 ("Pair your phone") — encodes the pair-step deeplink. A
     phone scanning the QR (or, in this demo, the simulator panel)
     POSTs to /v1/registrations/pair-device with the code + a
     hardware fingerprint. Server flips state to awaiting_commitment
     and mints the enroll_code.

  2. QR2 ("Submit your biometric commitment") — the phone captures
     the biometric locally (mobile/biometric/ pipeline), computes
     the Poseidon commitment + DID, scans QR2 to POST them to
     /v1/registrations/submit-commitment. State flips to
     awaiting_verification; server mints verify_code + a 128-bit
     challenge_nonce baked into QR3's deeplink.

  3. QR3 ("Verify and create account") — phone re-captures the
     biometric, produces a Groth16 proof, scans QR3 to POST to
     /v1/registrations/complete. Server checks the challenge_nonce
     matches, asserts publicSignals[0] equals the stored commitment,
     verifies the proof off-chain, creates the tenant_user.

The biometric never crosses a wire. Only the commitment (step 2)
and the proof (step 3) do. The deeplink format is
zeroauth://reg?step=<pair|enroll|verify>&session=<uuid>&code=<code>
[&challenge=<hex>] — same format the android/ companion app
handles.

Right column is a "Simulate phone" panel that exercises the
phone-side endpoints directly so an operator can drive the
ceremony from one browser window without an actual companion app.
Pair + commit steps run end-to-end against the live backend. The
verify step intentionally lands a `verify_failed` because the demo
posts a stub Groth16 proof — that's the verifier doing its job. The
real green path goes through the android/ mobile prover (Phase 1
Sprint 4 integration).

Server side: three new console proxies at /api/console/registrations
(POST / GET :id / DELETE :id) mirror /v1/registrations but auth via
console JWT. Both surfaces strip pair_code_hash, enroll_code_hash,
verify_code_hash, verify_challenge_nonce out of the response shape
before it touches the browser — the plaintext codes are returned
only at issuance, the challenge nonce travels only in the QR3
deeplink.

Nav: "QR sign-in" + "QR signup" now sit side by side under the
shared /demo/ namespace; the old singular "Demos" label split to
make the two flows distinguishable.

Verify:
  - npx tsc --noEmit                  (dashboard + backend)  clean
  - npm test     (dashboard, vitest)  56/56
  - npm test     (backend, jest)      524/524 across 44 suites
  - npm run build (dashboard)         121 modules, QrRegistration
                                      lazy chunk = 28.4 kB / 9.95 kB
                                      gzip; main bundle unchanged
@github-actions
Copy link
Copy Markdown

🔒 Security review required

This PR touches security-sensitive surfaces. Per CLAUDE.md §4, the security-reviewer subagent (.claude/agents/security-reviewer.md) must be invoked locally before merge.

Touched paths:

  • src/routes/console.ts
  • src/routes/v1/devices.ts
  • src/routes/v1/index.ts
  • src/routes/v1/registrations.ts
  • src/services/platform.ts

How to run the review:

# In Claude Code, after pulling this branch:
@security-reviewer review the changes on this branch

Reply on this PR with the structured findings report (or a "no findings" confirmation) before requesting merge. Block merge if any Critical / High finding lands without a tracked carve-out.

This comment is posted automatically by .github/workflows/security-review.yml and updated on every push to keep the touched-paths list current.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@pulkitpareek18 pulkitpareek18 merged commit 57c2d4a into main May 29, 2026
5 checks passed
@pulkitpareek18 pulkitpareek18 deleted the dev branch May 29, 2026 06:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants