Skip to content

Merge all the recent features#59

Merged
pulkitpareek18 merged 58 commits into
mainfrom
dev
May 28, 2026
Merged

Merge all the recent features#59
pulkitpareek18 merged 58 commits into
mainfrom
dev

Conversation

@pulkitpareek18
Copy link
Copy Markdown
Collaborator

No description provided.

pulkitpareek18 and others added 30 commits May 28, 2026 11:17
This is the foundational planning artefact for the 12-month BFSI-first
production plan. The six top-level docs cover the pain-point thesis,
the bank demo specification, the 50-person team roster, the
commit-by-commit Phase 0 + Phase 1 plan, the per-agent week-by-week
ticket lists, and the ways-of-working contract.

The agents/ subdirectory has 50 per-agent files with daily Mon-Fri
tickets for weeks 1-4, each with a 5-field DoD (Done when / Output /
Verify / Reviewer / Depends on).

The plan is meant to be executed in sequence starting from
04-commits.md C-001 onwards. Subsequent commits will reference this
tree by file path.
The user has standardised on a two-branch workflow: work happens on
dev, PRs go from dev to main, and main is always deployable. No
chore/feat/fix feature branches. This ADR captures the rationale and
the operational rules.

Closes Phase 0 commit C-002 per docs/plan/bfsi-v1/04-commits.md.
The plan said ADR 0008; renumbered to 0011 because 0008 was already
taken by adr/0008-iot-snarkjs-poseidon-lite.md.
…ersion pin

Three ADRs landed together because they describe one defence-in-depth
story: row-level integrity (0013), independent-third-party integrity
(0014), and a separate one for keeping the verifier honest about which
circuit it is actually verifying for (0015).

0013 — hash chain over audit_events. Per-tenant chain, RFC 8785 JCS
canonical JSON, SHA-256 over (event_data || previous_hash). Genesis
row uses the literal string 'genesis'. Append-only contract through
src/services/audit.ts (lands C-012). Tamper detection via hourly
drift job.

0014 — daily on-chain anchor on Base L2. AuditAnchor.sol with
write-once (tenantIdHash, dayUtc, terminalHash, rowCountAtAnchor).
Bank can independently verify via a public RPC.

0015 — boot-time vkey hash check + landing procedure for new circuit
versions. Refuses to start the verifier if the on-disk
verification_key.json does not hash to the compiled constant.

Closes Phase 0 commits C-009, C-010, C-019 per
docs/plan/bfsi-v1/04-commits.md (plan said ADRs 0010/0011/0012,
renumbered because 0010/0012 were taken).
The submitProof handler previously accepted any DID matching
`did:zeroauth:demo:*` and short-circuited checks 4 through 8 — user
lookup, commitment compare, nonce binding, and Groth16 verification.
That branch made the entire crypto pipeline a soft opt-in, controlled
by an undefined-defaults-to-true tenant policy flag. Closes P0 audit
finding C-1.

The replacement is a uniform user lookup: a DID with a demo prefix
gets the same `pairing_did_unknown` response as any other unknown
DID, with no special-case audit row.

The corresponding tenant policy field `pairing_demo_mode` is marked
@deprecated on the type — kept for one release to avoid breaking any
tenant row that still carries it. A schema migration in phase 1 will
strip it from `security_policy` JSON across all rows.

Two tests pin the new behaviour:
- the route-level test asserts the standard 400 / pairing_did_unknown
  response on a demo-prefixed DID, and
- a grep-style test asserts the source carries no DEMO_DID_PREFIX,
  no `did:zeroauth:demo:` literal, and no `pairing_demo_mode` or
  `demoBypassAllowed` symbol.

Closes Phase 0 commit C-004 per docs/plan/bfsi-v1/04-commits.md and
audit finding C-1 in docs/security/audit-findings.md (lands C-031).
The console-auth middleware previously accepted the JWT either through
the standard Authorization header OR via a `?access_token=<jwt>`
query string parameter. The query string path was added in commit
988c71d so EventSource (which cannot set custom headers) could
authenticate the SSE stream. The audit found that pattern P0 because
query strings land in Caddy access logs even when Authorization
headers are redacted, so a leaked log line is a session-replay
primitive for the JWT's TTL. Closes audit finding C-3.

Replacement: an HttpOnly, SameSite=Strict cookie
`zeroauth_console_jwt` is set at login + verify-signup, scoped to
`/api/console`. EventSource reaches the SSE stream by sending the
cookie via the standard `withCredentials: true` mechanism — the
dashboard side change lands in the next commit.

Tests pin the new contract:
- Bearer header still works
- HttpOnly cookie path works
- `?access_token=` rejected with 401 on both protected and SSE routes
- Login response carries Set-Cookie with HttpOnly + SameSite=Strict
- Grep guard against re-introduction of the query-string read

Closes Phase 0 commit C-005 per docs/plan/bfsi-v1/04-commits.md and
audit finding C-3 in docs/security/audit-findings.md (lands C-031).
Three contracts pinned:

1. tenant_users column allowlist matches the current state. New
   columns added without updating the allowlist fail the test, which
   forces a reviewer to either confirm the column is non-PII or
   broaden the allowlist with an ADR. Today's allowlist still
   includes the legacy PII columns (full_name, email, phone,
   employee_code) and a comment marks them for the Phase 1 PII-strip
   migration. The point of the test for now is not to retroactively
   purge but to prevent further PII column creep.

2. audit_events column allowlist includes the previous_hash and
   event_hash columns that ADR 0013 will add via C-011.

3. No column on any tenant-scoped table may carry a biometric-
   suggestive name (image / template / pixel / depth / frame /
   raw_face / raw_finger / biometric_data / photo). This is the
   schema-side mirror of the input-validator blocklist that lands
   with C-021.

4. New CREATE TABLE statements in src/services/db.ts must register
   in the test's KNOWN_TABLES set, so we can't silently add a new
   tenant-scoped table without revisiting the allowlist.

The test reads CREATE TABLE bodies directly out of
src/services/db.ts so it runs offline without a live Postgres.

Closes Phase 0 commit C-003 per docs/plan/bfsi-v1/04-commits.md.
ADR 0013 lands. Every row in audit_events now carries a previous_hash
column referring to the prior row's event_hash for the same
(tenant_id, environment), and an event_hash column computed as
SHA-256(canonical_json(payload) || previous_hash). The first row of a
chain carries the literal string 'genesis' as its previous_hash.

src/services/audit.ts is the single allowed insertion point. It uses
RFC 8785 JCS canonical JSON for the serialisation that feeds the
hash, and a pg_advisory_xact_lock keyed on tenant_id to serialise
writes within a tenant without contending across tenants. A separate
verifyAuditChain() replays a tenant's chain and reports the first
broken row id — used by the admin-integrity endpoint (lands C-014).

src/services/platform.ts::recordAuditEvent is now a thin shim that
forwards to appendAuditEvent in the new module — every existing
caller (50+ across platform.ts, routes/admin.ts, routes/v1/*,
routes/console.ts) keeps its signature unchanged. The grep-style
test 'every audit-writing surface uses appendAuditEvent' guards
against direct INSERT INTO audit_events re-introduction.

Schema additions to src/services/db.ts are nullable for the Phase 1
backfill window (C-121 plan); the verifier replays the chain over
the contiguous tail of non-null rows.

tests/audit-chain.test.ts covers:
  - canonicalize() RFC 8785 conformance for our value types
  - computeEventHash() determinism + sensitivity to every input field
  - 100-row chain replays cleanly
  - tampering with row 50 breaks the chain at row 50

tests/platform.test.ts updated to mock appendAuditEvent — assertions
now check the snake-cased payload, not the SQL+params of the legacy
INSERT.

Closes Phase 0 commits C-009 (ADR), C-011 (schema), C-012 (impl)
and C-013 (route all writes) per docs/plan/bfsi-v1/04-commits.md.
The integration suite that exercises the real Postgres transaction
+ advisory-lock path will land alongside C-014 when the
admin-integrity endpoint goes in.
The bank-facing read-only verification surface defined in ADR 0013.
Calls verifyAuditChain() from src/services/audit.ts and returns either
{ status: 'pass' } or { status: 'fail', brokenAt, reason }. Demo
Scene 5 in docs/plan/bfsi-v1/02-bank-demo.md uses this endpoint to
show the CISO that a tampered row is detectable.

Query parameters:
  - tenant_id     required UUID
  - environment   optional 'live' | 'test'
  - start_id      optional bigint (default 0)
  - limit         optional bigint (default 100000, max 1000000)

The endpoint is itself audited — every invocation appends an
`audit.integrity_check` row (success or failure) so an external
auditor can grep for who ran the check and when.

Gated by the standard x-api-key admin auth middleware. The 7-test
suite pins:
  - missing admin key is rejected
  - PASS for a clean chain
  - FAIL with brokenAt + reason for a tampered chain
  - validation of tenant_id (UUID format) and limit (1..1000000)
  - self-audit row emitted on both PASS and FAIL paths

Closes Phase 0 commit C-014 per docs/plan/bfsi-v1/04-commits.md.
Source-level guard for the CLAUDE.md non-goal 'Never accept raw
biometric data over the wire'. The test walks every .ts file under
src/ and asserts no Express handler reads req.body.<key>,
req.query.<key>, req.params.<key>, or destructures <key> out of
req.body, where <key> is in the forbidden set:

  image, template, pixel, depth, frame,
  raw_face, raw_finger, biometric_data, photo

Comments are stripped before matching so a docstring discussing the
prohibition does not trip the test. A separate assertion confirms
CLAUDE.md continues to carry the constitutional language for the
forbidden-key list.

When the zod validator layer lands (C-022), the validator schemas
will reject these keys at runtime in addition to this compile-time
grep. Defence in depth — the constitution, the validator, and this
grep test all enforce the same rule.

Closes Phase 0 commit C-021 per docs/plan/bfsi-v1/04-commits.md.
Two artefacts land together because they describe the same surface
from two perspectives:

docs/security/audit-findings.md is the operational tracker: every
finding ID from the Phase 0 readiness audit (21 items C-1..C-21),
the closing commit hash if it's closed, the target sprint and owner
if it's open. The closed P0 findings to date are:
  - C-1 demo bypass (closed at 02e1734)
  - C-3 access_token query fallback (closed at ee6aad4)
  - C-4 hash chain over audit_events (closed by ADR commits)
  - C-6 direct-INSERT guard (closed at c09c081)
  - C-8 biometric-rejection guard (closed at c09c081)

C-2 (fake prover) is the largest remaining P0 finding; it can only
close with the real Android prover in Phase 1 Sprint 3.

docs/threat_model.md gets two new attack-vector entries:
  - A-27 — Demo-DID prover bypass (CLOSED with C-004)
  - A-28 — JWT-in-URL log leak via SSE auth (CLOSED with C-005)
And the existing A-21 row is updated to reflect the hash chain +
on-chain anchor mitigations.

Closes Phase 0 commits C-017 (threat model update) and C-031 (audit
findings tracker) per docs/plan/bfsi-v1/04-commits.md.
CLAUDE.md now points at the BFSI v1 production plan as the source of
truth for phase boundaries, commit ordering, and agent
responsibilities. The new 'Current phase' section at the top of the
file links to the seven plan documents under docs/plan/bfsi-v1/ and
records which Phase 0 P0 findings are closed:

  - C-1 (demo bypass) — closed 02e1734
  - C-3 (access_token query fallback) — closed ee6aad4
  - C-4 (audit hash chain) — closed across ADR commits
  - C-6 (direct INSERT guard) — closed c09c081
  - C-8 (biometric-payload guard) — closed c09c081

C-2 (fake mobile prover) tracks to Phase 1 Sprint 3.

LAST_UPDATED bumped to 2026-05-28.

Closes Phase 0 commit C-033 per docs/plan/bfsi-v1/04-commits.md. With
this commit Phase 0 is complete on the engineering-executable
deliverables. The remaining Phase 0 commits in the plan (C-001
pre-commit hook with husky, C-015 anchor cron with real Base
Sepolia keys, C-016 AuditAnchor contract deploy, C-020 verifier
redeploy) require external resources (husky dep ADR, Base Sepolia
deploy keys, Etherscan verification) that are scoped to the
infra/blockchain agents in week 2 of phase 0.
Source-level matrix: walk every route file under src/routes/v1/*,
extract every router.<verb> declaration, and assert the declaration
line carries one of the recognised tenant-auth middleware tokens
(authenticateTenantApiKey or an alias).

Why source-level not HTTP-level: the HTTP-level matrix in
tests/central-api.test.ts already exercises that the middleware
rejects fake keys. This test is the enforcement layer that catches
a future commit landing a new handler without the gate before the
HTTP behaviour even matters. The HTTP-level matrix was tried first
and hung on Postgres connect attempts even with mocks; the
source-level form runs in ~2s.

The PUBLIC_ROUTE_EXCEPTIONS list documents the 14 handlers that are
intentionally pre-tenant-auth: OIDC authorize+callback, SAML login+
callback+metadata, proof-pairing public/submit/stream (kiosk-facing
or session-token-authenticated), identity logout+refresh, and the
four zkp anonymous endpoints (register, verify, nonce, circuit-info).
Each entry carries a reason explaining why the threat model accepts
the gap. Adding a new exception requires editing this list and
landing a comment justification — it's the trip-wire that flags any
relaxation of the tenant boundary.

Plus a meta-test that asserts every exception has a >= 20-char
reason string so 'TODO' or empty reasons cannot land.

Closes Phase 0 commit C-007 per docs/plan/bfsi-v1/04-commits.md.
Service-layer scoping is separately tested in tests/platform.test.ts
A-01 block.
ADR 0015 implementation. src/services/zkp.ts::initZKP now reads
verification_key.json from disk, computes its SHA-256, and:

  - In production (NODE_ENV=production):
      EXPECTED_VKEY_SHA256 must be set AND match the on-disk file.
      Mismatch or absence throws and aborts boot.

  - In non-production:
      If EXPECTED_VKEY_SHA256 is set, mismatch still throws.
      If EXPECTED_VKEY_SHA256 is missing, log a warning and continue
      so the dev loop is not blocked.

This closes the silent-vkey-drift category: a verifier coming up
with a vkey that does not match the version the Solidity verifier
was deployed against would otherwise silently accept proofs for
the wrong circuit. Boot-refusal is the right failure mode — a
running verifier with a mismatched vkey is more dangerous than a
service that is down.

tests/zkp-version.test.ts uses isolated module context + a temp
vkey to exercise all four paths:
  (a) production + match → boots
  (b) production + mismatch → throws
  (c) production + env unset → throws
  (d) non-production + env unset → warns + continues

The grep test in tests/proof-pairing.test.ts already covers the
demo-bypass removal; this commit covers the orthogonal correctness
question of 'is the vkey I have the one I should have'.

Closes Phase 0 commit C-018 per docs/plan/bfsi-v1/04-commits.md
and audit finding C-7 in docs/security/audit-findings.md.
Two findings flipped from open to closed by the last two commits:

  - C-7 (verifier vkey integrity check) — closed by e98d158
  - C-12 (cross-tenant rejection matrix) — closed by a1bbc47

Phase 0 P0 audit-findings closure count is now 7 of 7 closeable by
engineering work alone:
  C-1, C-3, C-4, C-6, C-7, C-8, C-12 — all closed.
  C-2 (fake mobile prover) — tracked to Phase 1 Sprint 3.

C-9 (Postgres session store), C-10 (rate-limit), C-11 (RS256 JWT)
are still on the sprint-2 deliverables list because they need the
new-dep ADR + migration coordination that hasn't run yet. They are
all on docs/plan/bfsi-v1/04-commits.md C-025/C-026/C-028.
Implements C-016 (docs/plan/bfsi-v1/04-commits.md) source half:

- contracts/AuditAnchor.sol — Solidity ^0.8.20 contract per ADR 0014
  (adr/0014-on-chain-anchor-cadence.md). The contract records the
  terminal hash of each tenant's audit-event hash chain once per UTC
  day, keyed on keccak256(tenantIdHash, dayUtc). Re-anchoring the same
  key reverts AlreadyAnchored(key). Authorisation is OpenZeppelin
  Ownable v5; the cron worker holds the owner key. Each write fires
  AnchorRecorded(tenantIdHash, dayUtc, terminalHash, rowCountAtAnchor)
  for explorer indexing. getAnchor exposes the recorded payload for
  off-chain verification.

- contracts/test/AuditAnchor.test.ts — Hardhat suite covering owner
  write + event, non-owner rejection (OwnableUnauthorizedAccount),
  write-once enforcement (AlreadyAnchored), getAnchor present/absent
  reads, and parallel anchors for distinct tenants on the same day.

- hardhat.config.ts — point the tests path at ./contracts/test to
  match the location requested in the C-016 spec.

Deployment + deployed-addresses.json update are deferred to the
A25-W2-Mon ticket and are intentionally out of scope here (no signer
key available).
C-108. The Anchor Bank demo tenant (docs/plan/bfsi-v1/02-bank-demo.md)
needs to be provisioned in both `live` and `test` environments before
the bank-demo runbook can be exercised end-to-end. This commit adds
`scripts/seed-demo-tenants.ts`, runnable via
`tsx scripts/seed-demo-tenants.ts`, which:

- inserts one tenant row with email `anchor-bank-demo@zeroauth.dev`,
  company name `Anchor Bank (Demo)`, plan `enterprise`, status
  `active`, rate-limit 5000, monthly quota 1_000_000, and a
  BFSI-grade security_policy (`require_strong_integrity: true`,
  `allow_play_integrity_absent: false`);
- mints exactly two API keys — one `live`, one `test` — and prints
  the raw values to stdout under an explicit
  `[OPERATOR: SAVE THESE — NOT RECOVERABLE]` banner, since the server
  stores only the SHA-256 hash;
- is idempotent on re-run: if the tenant email is already present, no
  INSERT runs, no API key is re-issued, and the script logs that the
  operator must mint replacement keys through the normal dashboard
  path if the original printout was lost.

`tests/seed-demo-tenants.test.ts` mocks the DB pool and the tenant /
api-key services and pins the C-108 acceptance behaviours: the
first-run path creates one tenant + two keys with the correct name,
email, and `require_strong_integrity: true` policy, while the
idempotent path makes zero service calls. Extends
`TenantSecurityPolicy` with an `allowed_origins` field (carried inside
the existing JSONB column, no schema migration) so the kiosk and
admin-dashboard origins for the demo travel with the tenant row.
Closes Phase 0 audit finding C-14 (supply-chain attacks invisible
until they bite). Tracked in docs/plan/bfsi-v1/04-commits.md as
commit C-032 (owner Role 22 — DevOps CI/CD).

What lands:

  - .github/workflows/cve-monitor.yml — nightly cron (00:00 UTC),
    runs npm audit + osv-scanner via the helper below, opens a
    GitHub issue labelled `security`+`cve-monitor` on any high or
    critical finding, and sends an email to the address held in
    the existing SECURITY_ALERT_EMAIL secret. The workflow uses
    actions/checkout@v4 and actions/setup-node@v4 per the C-032
    spec. The job's final step intentionally fails when CVEs are
    found, so the workflow status page surfaces the issue without
    needing to scrape logs.

  - scripts/cve-monitor.sh — bash helper invoked by the workflow.
    Runs npm audit --json --package-lock-only and osv-scanner -r
    against either the repo root or the dry-run fixture, parses
    each scanner's JSON, exits 1 if any finding is rated high or
    critical, else exits 0. osv-scanner is optional: if it isn't
    on $PATH the script logs a `::warning::` and degrades to npm
    audit only — the workflow remains useful in environments
    that haven't installed osv-scanner yet.

  - tests/fixtures/vulnerable-lockfile/ — minimal package +
    lockfile pinning lodash@4.17.20 (CVE-2021-23337, HIGH —
    Command Injection in lodash.template). The advisory is
    permanent on this pin, so the fixture stays useful as a
    canary indefinitely. README.md explains the why.

  - tests/cve-monitor.test.ts — jest smoke test that spawnSyncs
    `scripts/cve-monitor.sh --dry-run` and asserts the script
    exits non-zero against the fixture. The test tolerates an
    offline runner by treating the "no scanner output" path as
    inconclusive — the assertion still fires when npm audit can
    reach the registry, which covers every CI environment we
    ship.

Verification on this branch:

  - npx tsc --noEmit                                       — green
  - npx eslint scripts/ tests/cve-monitor.test.ts          — green
  - bash scripts/cve-monitor.sh --dry-run                  — exits 1
    surfacing lodash 4.17.20 / CVE-2021-23337 as expected
  - jest tests/cve-monitor.test.ts                         — 3/3 green
  - jest (full suite)                                      — 30/31
    suites pass; the 1 pre-existing failure (admin-audit-
    integrity.test.ts, 403 vs 400/200) lives upstream of this
    branch (introduced in d634b2d) and is unrelated to C-032 —
    flagged as a separate ticket.

Next steps tracked outside this commit:

  - record this commit's hash next to C-14 in
    docs/security/audit-findings.md when the PR lands on dev
    (see the existing ea6a7f6 pattern that closed C-7 and C-12);
  - alert-noise tuning + Slack mirror tracked as C-129..C-142
    per the plan.
The operational complement to ADR 0015. ADR 0015 says why a new ceremony
must run when the circuit changes; this document gives the engineer who
runs it the concrete shell commands, file layout, beacon parameters, and
failure-recovery paths.

Lands per A11-W2-Thu (week 2 of Phase 0 pre-work, per
docs/plan/bfsi-v1/agents/agent-11-crypto-circuit.md) as the runbook the
v1.2 ceremony in phase 1 week 10 will follow. Tracks the actual artefacts
we have today: circuits/identity_proof.circom is v1.1 and uses
pot14_final.ptau; v1.2 will upgrade to powersOfTau28_hez_final_15.ptau
because the two new Poseidon constraints (tx_nonce + consent_hash
bindings) push the circuit past 2^14.

Owners: Agent #11 (crypto-circuit) drives the runbook; Agent #27
(cryptanalysis) is the internal attestor; the external attestor is
engaged per A11-W4-Mon.

[no-test] markdown-only runbook; no executable code touched.
Seed document for commit C-168 (matrix v1) per the BFSI v1 plan:
docs/plan/bfsi-v1/agents/agent-04-vp-mobile.md (A04-W2-Mon) and
docs/plan/bfsi-v1/agents/agent-18-android-r307.md (A18-W2-Mon).

Covers:
- Tier 1: top-12 Indian Android SKUs by market share (FY24) — Pixel
  7/8, Galaxy S22/S23/A54, OnePlus 11/12, Redmi Note 13/13 Pro,
  Realme GT Neo 5, Motorola Edge 40, Vivo V29. All cells start as
  Unverified pending physical-device lab arrival in week 3.
- Tier 2: 13 working-but-degraded SKUs (Pixel 5, Galaxy A33/A23,
  Redmi 12/11, Nord N20/Nord CE 3, Realme C55, Moto G54, Vivo
  Y28/V27, Tecno Camon 20, Infinix Note 30) with the specific
  degradation (StrongBox, BiometricPrompt class, USB-OTG, Play
  Integrity) documented per row.
- Tier 3: denylist of devices lacking TEE, < Android 11,
  jailbroken/rooted, custom ROMs with unlocked bootloader, devices
  with documented Play Integrity bypasses, devices with revoked
  attestation chains.
- Per-tier deployment behaviour matches the Q&A in
  docs/plan/bfsi-v1/02-bank-demo.md ("will it run on a Redmi 9?"):
  tier 3 rejects enrolment with unsupported_device, tier 2 blocks
  high-value transaction step-up.
- Bank-specific override mechanism: Anchor Bank pilot defaults to
  tier-1-only; allow_tier_2_devices flag requires CISO+CRO risk
  acceptance memo and an audit row.
- R307 USB-OTG sub-matrix scaffolded — everything UV until the
  procurement run (A04-W1-Thu) brings 2 sensor units in week 3.
- Update cadence: quarterly review + every Android major release
  + 72h SLA on firmware regression demotions.

[no-test] markdown-only documentation change.
Delivers the A35-W3-Mon outline + A35-W4-Mon full script combined into a
single 898-line operator runbook for the 22-minute Anchor Bank demo
defined in docs/plan/bfsi-v1/02-bank-demo.md.

Twelve sections cover the entire room-time:

  1. Pre-demo setup checklist (T-24h) — equipment kit, network sanity,
     phone inventory, the seed-demo-tenants.ts live-key handling, dashboard
     and Basescan tab prep, dry-run, sleep.
  2. Day-of setup (T-30 min) — physical setup, browser/shell warm-up,
     phone setup, pre-checks.
  3. Opening 30-second pitch (verbatim from 02-bank-demo.md operator
     script).
  4-9. Scenes 1-6 — every keystroke, every sentence the operator speaks,
       what appears on the projector, what the CISO/CFO/CRO/CIO/GC each see.
       Scene 3 includes the substitution-attack demonstration. Scene 4
       includes the \\d users + SELECT * FROM users + DPDP 2(t) reading
       moment. Scene 5 includes the UPDATE audit_events tamper + on-chain
       anchor cross-check.
  10. Q&A bank — 13 questions sourced from 02-bank-demo.md with prepared
      2-3 sentence operator answers.
  11. Recovery playbook 11a-11f — kiosk freeze, app crash, network drop,
      tier-2 device (no StrongBox), R307 missing, proof verification
      rejection (the worst nightmare). Each has a calm-recovery script.
  12. Post-demo (T+10 min) — leave-behind folder contents, the 90-second
      ask, follow-up cadence (T+0 through T+42), debrief, photo policy,
      cleanup.

Two appendices: operator wallet-card contact list + timing reference.

References docs/plan/bfsi-v1/02-bank-demo.md as the canonical demo spec,
docs/plan/bfsi-v1/01-pain-points.md for the P1-P10 cross-references, and
scripts/seed-demo-tenants.ts for the exact tenant + API-key format.

Owner: Agent #35 (writer-compliance) + Agent #45 (solutions architect).

[no-test] markdown-only.
First issue of the BFSI v1 compliance roadmap, owned by Agent #36
(Chief Compliance Officer). Covers the four certification tracks that
gate the 12-month plan: DPDP Act 2023, the four binding RBI Master
Directions (IT Governance, Digital Lending, Digital Payment Security
Controls, KYC), SOC 2 Type I + Type II, and ISO/IEC 27001:2022. The
RBI Sandbox application is tracked alongside as a Q3 deliverable.

Eight sections per the agent-36 W1-Mon ticket:
1. Scope (in/out + India primary, GCC/UK secondary v2 lookahead).
2. Frameworks tracked with auditor + counsel relationships.
3. Q1-Q4 milestones aligned to the phase map in
   docs/plan/bfsi-v1/00-README.md.
4. Per-quarter deliverables table (D-Qn-NN IDs, owner agent, target
   week, dependencies) covering the year end-to-end.
5. Audit calendar weeks 1-52 listing every external interaction.
6. Vendor + counsel calendar (DPDP counsel, external cryptographer,
   SOC 2 auditor, ISO lead auditor, smart-contract audit firm,
   RBI counsel, bug bounty platform, evidence collector tool).
7. Open dependencies + risks (R-COMP-01..08) with owner + mitigation
   for each. Explicitly captures the three risks called out in the
   ticket: DPDP rule notification mid-evidence, evidence-collector
   tool slip, trusted-setup ceremony slip blocking ISO certification.
8. Document hygiene rules: quarterly retros in
   docs/compliance/retros/, regulator interaction log in
   docs/compliance/regulator-log.md, evidence pack rotation each
   quarter.

Cross-references docs/plan/bfsi-v1/06-ways-of-working.md for the
escalation path and docs/threat_model.md for the attack catalogue
that control narratives map to. Calls out the trusted-setup ceremony
artefact at docs/cryptography/trusted-setup-ceremony.md as the input
to ISO Annex A.5.31 and SOC 2 CC6.1 evidence.

[no-test] markdown-only deliverable per ticket.

Reviewer: Agent #1.
Phase 0 commit C-023 per docs/plan/bfsi-v1/04-commits.md.

This commit lands the ADR + audit-trail cross-reference only. The install
of zod into package.json + package-lock.json lands in C-022 in sprint 2,
per the agent-06 week-2 ticket A06-W2-Tue. No package-manifest changes
ship here. [no-test] markdown-only.

The ADR captures:

- Rationale for adopting zod as the input-validation layer for all new
  endpoints, with existing endpoints picking up a schema during their
  next touched-files commit per 06-ways-of-working.md.
- Alternatives table comparing joi, ajv, yup, runtypes, io-ts,
  superstruct, typia, and the hand-rolled status quo on TS-first
  design, perf, bundle size, error UX, and community maturity.
- Pin to zod@3.23.x with supply-chain snapshot (MIT, zero runtime
  transitives, >25M weekly downloads, no open CVEs).
- Three-stage migration: identity-register + zkp-verify in sprint 2,
  zkp-challenge + console writes in sprint 3, full sweep in sprints
  4-5 with a CI exit check.
- Backwards-compatible error contract via a single
  src/middleware/validation.ts helper that maps zod issues to the
  existing { error, message, details? } shape.
- Forbidden-key enforcement: every /v1 POST/PUT/PATCH schema uses
  .strict() plus a .refine() against the biometric-payload blocklist
  (image|template|pixel|depth|frame|raw_face|raw_finger|
  biometric_data|photo), keeping ADR 0013's audit guarantee and the
  C-021 source-grep guard in lock-step with runtime rejection.
- Observability via validation_error_count_total{route,reason} for
  same-day roll-forward; trivial revert path for rollback.
- Open questions deferred: OpenAPI generation (phase 2),
  z.discriminatedUnion for the provider variant (per-endpoint refactor
  through stage 2), zod for env-var parsing (sprint 4).

Cross-link from docs/security/audit-findings.md C-8 (biometric-payload
guard) added: runtime zod refinement strengthens the source-grep
closure without replacing it.

Related: ADR 0011 (branching), ADR 0013 (audit hash chain), ADR 0015
(circuit version pin).
Lands the v0 skeleton for the central DPDP defensibility memo: whether
the (DID, Poseidon commitment) tuples in the ZeroAuth tenant database
are personal data under DPDP section 2(t). The memo is the cryptographic
spine of Scene 4 of the Anchor Bank demo (docs/plan/bfsi-v1/02-bank-demo.md)
where the operator dumps the live users table in front of the bank's
CISO + General Counsel and reads section 2(t) alongside the row contents.

The skeleton frames two arguments. Argument-A is the position we ask
external counsel to confirm: commitments are not personal data because
they are field elements with no semantic content, the data principal is
not identifiable by or in relation to them without the off-stack secret
and salt, the DID is an opaque device-issued public identifier, and the
section 2(t) telos is identifiability of an individual which commitments
by construction do not enable. Argument-B is the conservative fallback
for the case where counsel rejects A: data-fiduciary obligations under
sections 6, 7, 8 are easily satisfied because the only meaningful
processing is identity verification with explicit consent, and a breach
exposing commitments does not expose the underlying biometric and
cannot be used to impersonate the data principal -- substantially
reducing section 8 breach surface area vs. credentials-storing peers.

Six counsel-engagement questions are scoped: defensibility before the
DPB, the minimum-viable section 5 notice if A fails, section 13
cross-border treatment for a UK or GCC read replica, section 17
elevation in an ABHA-linked future pilot, the breach-window clock
(awareness vs. confirmation), and the standard of care expected on the
on-device SHA-256 of the biometric template prior to commitment.

The memo is explicitly engineering work product, not a legal opinion.
v1 lands counsel comments (A37-W4-Mon). v2 attaches counsel's formal
written opinion on firm letterhead and is published with appropriate
redactions to docs/compliance/dpdp/.

Plan ticket: A41-W1-Thu collaborating with A37-W1-Thu and A35-W1
on the joint memo skeleton. Pain-points: P1 (DPDP section 8 breach
exposure) and P10 (cross-border BFSI operations) in
docs/plan/bfsi-v1/01-pain-points.md.

[no-test]
Precursor to C-107 (sprint 1 in docs/plan/bfsi-v1/04-commits.md).
Ships the users-view component skeleton plus its PII-blacklist test
ahead of the route wiring, so the structural no-PII contract is
locked down before any wiring lands in App.tsx.

What is here:

  dashboard/src/lib/users-api.ts
    TenantUserRow carries ONLY id, did, commitment, tenantId,
    environment, createdAt. listUsers() projects whatever the
    server hands back through an allowlist; PII columns the server
    still carries (full name, work email, phone, employee code per
    the schema-purity test at tests/schema-purity.test.ts) are
    dropped on the floor before they reach React.

  dashboard/src/routes/tenant/users.tsx
    UsersView component using @tanstack/react-query and the
    existing Card/Skeleton/EmptyState/Badge primitives. EXACTLY
    four columns: DID, Commitment (truncated to first 12 hex
    chars + ellipsis), Environment, Created at. The columns
    are an allowlist constant; widening it is an ADR-grade
    decision. No route registration yet — App.tsx wiring lands
    in the C-107 sprint 1 commit.

  dashboard/src/routes/tenant/__tests__/users.fixtures.ts
    Three fake TenantUserRow fixtures plus a parallel
    SENSITIVE_LEAK_PROBES tuple that names the substrings
    ('Alice', 'Bob', 'Charlie', '@example.com', '+91', 'EMP-')
    the test must never find in the rendered DOM.

  dashboard/src/routes/tenant/__tests__/users.test.tsx
    Five assertions covering: DID presence, PII-substring
    absence (including a generic phone-shape regex), source-file
    property-read scan ('.full_name' / '.email' / '.phone' /
    '.employee_code' must not appear in the component body),
    type-level enforcement via vitest's expectTypeOf, and an
    empty-state copy check.

Why this is two halves of the same problem:

  - The server-side PII strip (the half owned by Agent #7 in C-107
    sprint 1) removes the columns from the wire. Until then, the
    schema-purity test (tests/schema-purity.test.ts) locks down the
    current PG schema so no NEW PII columns sneak in.
  - The dashboard-side narrow type + projection here makes the
    surface area structurally inaccessible from the React tree
    regardless of what the server still sends — defence in depth.

This is the engineering-side commitment to the DPDP §2(t) memo
skeleton (docs/compliance/dpdp-2t-memo.md, drafted in parallel by
the compliance line): the data principal is not identifiable by
or in relation to a Poseidon commitment + opaque DID, and the
dashboard cannot accidentally widen that surface.

How to verify:

  cd dashboard && npm test -- src/routes/tenant/__tests__/users.test.tsx

Five tests pass; full dashboard suite remains 36/36 green.
Typecheck clean. Lint adds no new warnings.
src/config/index.ts captures ADMIN_API_KEY into config.admin.apiKey
at module load via requireEnv(). The admin auth middleware then
compares against that snapshot at request time. When a test sets
process.env.ADMIN_API_KEY inside beforeAll() the config object has
already frozen the fallback ('dev-admin-key'), so every request is
403'd regardless of which key the test sends.

tests/admin-audit-integrity.test.ts hit this on any worktree without
a populated .env (CI, fresh clone) — 6 of its 7 assertions failed
with 403 where they expected 200 or 400. Tests that read
config.admin.apiKey directly after import (admin.test.ts,
leads.test.ts, middleware.test.ts) were unaffected because they
pick up whatever the config captured.

Adding a jest setupFiles entry that fires before any module from the
test file is imported is the standard fix — the env var lands first,
config reads it, and the rest of the existing test code works as
written. Production code is untouched.
These three documents land Agent #39's Week-1/Week-2 deliverables
under the Phase 0 + Phase 1 privacy scaffold:

- docs/compliance/privacy/data-inventory-v1.md
  Canonical inventory of every data element ZeroAuth processes. One
  row per DB column (twelve tables in src/services/db.ts, including
  the audit_anchors row scheduled for Phase 1 C-016 backfill), every
  audit_events.metadata JSONB field, every API payload field, every
  Winston log field, every Caddy access-log field, the on-device
  transient SHA-256 of the biometric template (classified
  TRANSIENT-SECRET, retention 0), and the OPAQUE-CRYPTOGRAPHIC
  artefacts (commitment, DID, did_sha256). Classifications use the
  five-value taxonomy NON-PII / PII / SENSITIVE-PII (DPDP §17) /
  SECRET / OPAQUE-CRYPTOGRAPHIC + the TRANSIENT-SECRET edge case.

- docs/compliance/privacy/pia-template-v0.md
  Privacy Impact Assessment template covering subject, PIA ID,
  authors + reviewers (DPO + privacy engineer + product role
  mandatory), description of processing, data flow diagram, data
  elements affected (referenced by inventory row ID), lawful basis
  under DPDP §6 + RBI sectoral, cross-border treatment, retention,
  five-risk likelihood × impact matrix, mitigations, residual risk
  acceptance signed by DPO + CCO, optional DPDP §5 notice updates,
  and threat-model rows touched.

- docs/compliance/privacy/data-retention-policy-v0.md
  Default retention rules per classification (NON-PII 7 years,
  PII 3 years from last contact, SENSITIVE-PII 2 years, SECRET
  rotated quarterly, OPAQUE-CRYPTOGRAPHIC same as PII conservatively
  until counsel signs off on the §2(t) memo, TRANSIENT-SECRET 0
  days), per-table retention table for every table in
  src/services/db.ts, bank-specific override JSON via
  tenants.security_policy.retention_overrides, nightly cleanup-job
  spec (implementation lands Phase 1 sprint 4), DPDP §13
  right-to-erasure cascade flow, and the five exception classes
  (court order, regulator inspection, security investigation, bank
  audit, litigation hold).

Cross-references:

- The OPAQUE-CRYPTOGRAPHIC classification of commitments + DIDs +
  did_sha256 rests on the framework in
  docs/compliance/dpdp-2t-commitments-memo-v0.md §5 Argument-A. The
  retention policy holds these artefacts at the conservative PII bar
  until counsel signs off on memo v2.

- The PII handling on audit_events.metadata + the desktop_ip /
  desktop_user_agent columns on proof_pairing_sessions traces to
  docs/threat_model.md A-22 (PII in pairing logs).

- The 90-day Caddy access-log retention with query strings stripped
  on /api/console/* paths references docs/threat_model.md A-28
  (JWT-in-URL log leak, CLOSED in C-005).

- The schema-purity column allowlist in tests/schema-purity.test.ts
  is the source of the per-table column lists in §3 of the
  inventory.

[no-test] markdown-only; no source or test changes.
C-101 from the Phase 1 plan in docs/plan/bfsi-v1/04-commits.md — the
Phase 1 production-track Android client for the ZeroAuth Pramaan protocol.

Lands four Gradle modules:

  :app                        — Activity, Compose UI, placeholder surface
                                rendering 'ZeroAuth — coming soon (scaffold
                                C-101)'. minSdk 30, targetSdk 34, Kotlin
                                1.9.22, AGP 8.3.2, Compose BOM 2024.02.02,
                                applicationId dev.zeroauth.banking,
                                versionCode 1 / versionName 0.1.0.
  :prover                     — interface-only library. Prover.kt declares
                                generateProof(witnessJson) -> proofJson;
                                DefaultProver throws NotImplementedError.
                                The rapidsnark JNI bridge implementation
                                lands with C-104 per the Agent #17 plan
                                (docs/plan/bfsi-v1/agents/agent-17-android-prover.md
                                ticket A17-W3-Mon).
  :sensors:r307               — interface-only library for the R307 USB-OTG
                                fingerprint sensor. Driver lands with C-145.
  :sensors:biometric_prompt   — interface-only library for the platform
                                BiometricPrompt + StrongBox key wrap. Real
                                invocation + class-3 enforcement land with
                                C-144.

The subtree is deliberately parallel to the existing android/ subtree
(the W3 desktop-login WebView spike): different toolchain pin
(android/ is Kotlin 2.0 + AGP 8.5; mobile/ is Kotlin 1.9.22 + AGP 8.3.2),
different applicationId (dev.zeroauth.android vs dev.zeroauth.banking),
different settings.gradle.kts root. They coexist for the W3-to-W4
transition; once C-104 lands the rapidsnark JNI bridge here the W3
WebView path becomes a tier-2 fallback and mobile/ is the authoritative
implementation.

Why this commit ships only a scaffold:

  * gives downstream commits (C-104 prover, C-143 enrollment,
    C-144 keystore, C-145 R307, C-146 e2e login) a place to land
    without each one re-litigating module boundaries;
  * exercises the module graph end-to-end (:app depends on all three
    siblings from day one) so each implementation drop is a
    module-internal change rather than a wiring change;
  * the four pinned versions (Kotlin/AGP/Compose-BOM/compose-compiler)
    are intentionally a stable older quartet — the toolchain bump is
    re-evaluated once the rapidsnark JNI build stabilises post-C-104.

Reference artefacts the scaffold is designed against:

  * docs/plan/bfsi-v1/02-bank-demo.md — Scenes 1 (enrollment) + 2
    (kiosk login) drive the eventual flows in :app.
  * docs/operations/device-support-matrix.md — minSdk 30 + StrongBox
    + class-3 BiometricPrompt requirements come from the tier-1 row
    constraints + the tier-3 denylist.
  * docs/plan/bfsi-v1/agents/agent-17-android-prover.md ticket
    A17-W2-Mon — this commit is the explicit deliverable.
  * docs/plan/bfsi-v1/agents/agent-04-vp-mobile.md ticket A04-W3-Tue —
    the reviewer-side review of this PR.

New dependencies introduced (AndroidX core/lifecycle/activity, Compose
BOM + UI + Material 3 + Tooling, AndroidX test runners + Compose UI
test JUnit4): these are intrinsic to the Android-only platform choice.
The platform-level rationale is covered by adr/0010-android-webview-
snarkjs-bundling.md (the existing Android-only commitment in this tree)
and is being broadened in adr/0014-android-only-mobile-platform.md as
C-102 lands in week 3 (per docs/plan/bfsi-v1/agents/agent-04-vp-mobile.md
ticket A04-W1-Tue). No new ADR is opened with this commit — the
platform dep ADR trail is in flight on the C-102 PR.

Verification at commit time:

  * find mobile -name '*.kt' -o -name '*.kts' -o -name '*.xml' lists
    31 files spanning all four modules.
  * 31 files / 1412 lines total.
  * The Gradle wrapper jar is NOT committed; it lands in the
    follow-on CI-wiring commit. No attempt is made to compile the
    project locally — that runs in CI once the workflow exists.
Precursor to C-147 (kiosk implementation). The Anchor Bank bank-demo
runbook's Scene 2 ("login at a kiosk") opens with a full-screen QR
already on the projector when the operator presents. This commit lands
the React skeleton the sprint-2 integration commit picks up.

dashboard/src/routes/kiosk/Kiosk.tsx is the page component. On mount it
generates a 32-byte hex session_nonce, opens a pairing session via
api.pairing.createSession, renders an api.qrserver.com-backed QR
full-screen (high contrast, readable at 3m), and subscribes to the SSE
stream. On the pairing.consumed event it redirects to the placeholder
landing route at /anchor-bank/landing; on pairing.expired it silently
mints a fresh session so the kiosk never shows a "session expired"
card to the bank floor.

The SSE consumer in kioskStream.ts deliberately bypasses the shared
api.pairing.subscribeStream helper. Per ADR 0013 (and commit ee6aad4,
"remove access_token query fallback from console SSE auth"), the
HttpOnly zeroauth_console_jwt cookie is the only allowed transport for
the JWT on the SSE stream — query-string tokens leak to Caddy access
logs and become a session-replay primitive for the JWT's TTL. The
shared helper still ships an ?access_token= query fallback for the
W3 QR-pair demo; constructing the EventSource here, with
withCredentials: true and no query string, makes the kiosk's
ADR-0013 compliance enforceable from a single file even if the shared
helper regresses.

dashboard/src/routes/kiosk/KioskRoute.ts is the route descriptor
(/kiosk/:tenant?session=...) the sprint-2 integration commit imports.
App.tsx and the AppShell nav are intentionally NOT modified — wiring
the kiosk into the dashboard router lands with C-147 sprint 2.

The vitest suite at __tests__/Kiosk.test.tsx covers the four
required assertions: (1) QR mounts after the createSession POST
resolves, (2) the QR payload exposes session_nonce + tenant +
expires_at, (3) the pairing.consumed SSE event triggers navigation to
the landing route, (4) the pairing.expired SSE event creates a fresh
session. A fifth belt-and-braces assertion covers the
operator-recoverable error panel + retry path.

dashboard/src/lib/api.ts and dashboard/src/lib/sse.ts are unchanged —
the kiosk uses api.pairing.createSession (already shared with the
existing W3 QR-pair demo) and a kiosk-local EventSource wrapper.

Verified:
- cd dashboard && npm test -- src/routes/kiosk/__tests__/Kiosk.test.tsx
  → 5/5 passing
- npx tsc --noEmit → clean
- npx eslint src/routes/kiosk/ → clean
Precursor to C-123 (sprint 2 in docs/plan/bfsi-v1/04-commits.md) and to
the audit-integrity panel that drives Scene 5 of the Anchor Bank demo
(docs/plan/bfsi-v1/02-bank-demo.md). Lands three pieces of the future
view in skeleton form so the structural contract is locked down before
C-123 wires the route into App.tsx.

1. dashboard/src/components/IntegrityCheckCard.tsx — presentational
   card with three terminal states (pass / fail / pending). PASS shows
   a green check, 'Chain intact' headline, row count + last-checked
   timestamp. FAIL shows a red X, 'Chain broken at row #<brokenAt>'
   headline, the verbatim server reason and an Investigate button
   (no-op for now). Includes an optional 'Anchor: tx <hash>' sub-row
   with a clickable Basescan link (sepolia.basescan.org/tx/<hash>);
   the sub-row is hidden when anchor data is undefined.

2. dashboard/src/lib/audit-integrity-api.ts — typed client for
   GET /api/admin/audit-integrity. Maps the server's discriminated
   pass/fail wire shape (defined in src/routes/admin.ts at commit
   d634b2d) into the dashboard's IntegrityResult union. lastChecked is
   stamped on the client; rowsChecked is derived from the server's
   limit field until C-123 widens the response contract.

3. dashboard/src/routes/tenant/audit-integrity.tsx — AuditIntegrityView
   that wires useQuery to the client, renders the card, and exposes a
   'Check now' button that triggers refetch. Below the card it reserves
   a labelled placeholder for the audit-anchors sub-view that lands in
   C-124 (a separate ticket).

Tests:
  - dashboard/src/components/__tests__/IntegrityCheckCard.test.tsx
    (3 tests, one per terminal state; verbatim-reason rendering is the
    load-bearing assertion for Scene 5's narrative).
  - dashboard/src/routes/tenant/__tests__/audit-integrity.test.tsx
    (5 tests: initial pending, PASS render, FAIL render with brokenAt
    + reason, 'Check now' triggers refetch, and a source-file scan
    that the component contains zero .full_name / .email / .phone
    property reads — defence in depth even though this surface holds
    only audit metadata).

ADR 0013 defines the hash-chain construction; ADR 0014 defines the
on-chain anchor cadence whose tx hash the optional anchor sub-row
links to on Basescan. The view does not modify App.tsx — wiring lands
in C-123.

Verification:
  cd dashboard && npm test -- \
    src/routes/tenant/__tests__/audit-integrity.test.tsx \
    src/components/__tests__/IntegrityCheckCard.test.tsx
  -> 2 files, 8 tests, all passing.
  cd dashboard && npx tsc --noEmit  # clean.
  cd dashboard && npx eslint src/...  # clean on the five new files.
v0 storyboard for the bank pitch deck under docs/gtm/bank-pitch-deck-v0.md.
Captures slide-by-slide speaker time, visual, speaker notes, pain-point
trace, required engineering artefacts, compliance trace, and the
failure-mode-if-cut for each of the 20 slides. The deck backs the
22-minute live demo and is the commercial spine of the Anchor Bank
conversation.

Every pain point referenced lifts directly from
docs/plan/bfsi-v1/01-pain-points.md (P1 DPDP §8 reportable-breach,
P2 Aadhaar e-KYC dependency, P3 SMS OTP cost + SIM-swap, P4 audit-log
tamper evidence, P5 RBI Digital Lending consent, P6 ATO, P7 high-value
transaction binding, P10 DPDP §2(t) + data-localisation). Demo handoffs
on slides 8, 14, 15 reference scenes 1-5 of docs/plan/bfsi-v1/02-bank-demo.md
and are operationally backed by docs/operations/anchor-bank-demo-runbook.md.
Compliance slide 10 and roadmap slide 18 trace to
docs/compliance/compliance-roadmap-v1.md quarterly milestones and
deliverable IDs (D-Q1-05 DPDP §2(t) memo, D-Q2-06 ISO Stage 1,
D-Q2-10 SOC 2 Type I report, D-Q3-06 RBI sandbox application,
D-Q3-13 ISO 27001 certificate, D-Q4-02 SOC 2 Type II report,
D-Q4-08 first paid bank).

Ticket: A42-W2-Wed.
Reviewers: Agents #28, #29, #48.
Owner: Agent #42 (CRO).

[no-test]
Pulkit Pareek added 22 commits May 28, 2026 13:20
Agent #29 (Senior PM, BFSI) — week 1 tickets A29-W1-Mon through
A29-W1-Fri. Delivers the six bank intel packs (HDFC, ICICI, Axis,
SBI YONO, IDFC FIRST, RBL) plus the v1 cold-outreach sequence that
Agent #43 (BFSI North) and Agent #44 (BFSI South + PSBs) consume
for pre-call prep.

Files added:

- docs/product/bank-intel/README.md — index; explains that the
  packs are research-grade artefacts for pre-sales prep, not for
  external distribution; lists update cadence and language
  constraints.
- docs/product/bank-intel/hdfc.md — HDFC Bank Ltd. intel pack;
  pain hooks P1, P4, P7 from docs/plan/bfsi-v1/01-pain-points.md.
- docs/product/bank-intel/icici.md — ICICI Bank Ltd.; pain hooks
  P3, P6, P1.
- docs/product/bank-intel/axis.md — Axis Bank Ltd.; pain hooks
  P4, P7, P1.
- docs/product/bank-intel/sbi-yono.md — State Bank of India /
  YONO; pain hooks P2, P9, P6.
- docs/product/bank-intel/idfc-first.md — IDFC FIRST Bank Ltd.;
  pain hooks P9, P3, P1.
- docs/product/bank-intel/rbl.md — RBL Bank Ltd.; pain hooks
  P5, P4, P1.
- docs/gtm/outreach-sequence-v1.md — five-email cold-outreach
  sequence (day 0, 4, 9, 16, 23); subject lines all <= 50 chars,
  bodies 100-150 words, per-bank personalisation map; no banned
  phrases from CLAUDE.md.

Verification:

- Each intel pack is 176-187 lines (target band 150-300).
- Every fact is either cited [src: ...] or marked [VERIFY].
- No named executives without a verified public-record source.
- Every file carries an INTERNAL header in the top three lines.
- Banned-phrase scan clean: no "AI-powered", no "deepfake-immune"
  without the visual-spoofing-class qualifier, no "Dr. Pulkit",
  no "production stack".
- Subject lines: all 22 variants fit <= 50 chars by Python len().
- Email bodies: word count 105-125 (target 100-150) excluding
  placeholder tokens.

References:

- docs/plan/bfsi-v1/01-pain-points.md (commercial spine; P1-P10).
- docs/plan/bfsi-v1/02-bank-demo.md (demo Scenes 1-6 reflected in
  per-pack "why ZeroAuth resonates here" sections).
- docs/plan/bfsi-v1/03-team.md role 29, 43, 44.
- docs/operations/anchor-bank-demo-runbook.md (one-page summary
  PDF reference in Email 3).
- docs/compliance/compliance-roadmap-v1.md (DPDP and RBI section
  references in the RBL pack and the SBI pack).

[no-test] markdown-only.
Field reality from BFSI conversations: Indian banks, NBFCs, insurers
do not currently consume blockchain-anchored audit logs or on-chain
identity registries. Mandating blockchain rails raises integration
cost without buying a single pilot. The substance — Pramaan ZK
identity verification over biometric commitments + hash-chained
audit log — does not require blockchain.

This ADR amends ADR 0014. The platform ships with three independent
provider slots:

  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 tenant with all defaults runs the platform with zero blockchain
dependency. Boot succeeds without BLOCKCHAIN_PRIVATE_KEY. Anchor cron
skips default tenants. Identity register does not call DIDRegistry
unless the tenant opts in.

The Auth0 differentiation pitch holds without any blockchain mention:
credential storage as commitments, breach blast radius as field
elements, SIM-swap defence via StrongBox-bound DID, transaction
binding inside the proof, zero SMS marginal cost, hash-chained
auditable log. None of these arguments depend on a chain.

Existing on-chain artefacts (DIDRegistry, Groth16Verifier,
AuditAnchor on Base Sepolia) remain as provider implementations.
The 403-test backend stays green; the chain-related tests gain skip
branches for off-chain provider mode.

Closes the platform pivot per user directive: 'currently we have to
be blockchain agnostic, since Indian companies don't trust blockchain
much we have to be blockchain agnostic. Currently our requirement is
to be a fully fledged platform with the mobile app.'

[no-test] markdown-only ADR; implementation refactor lands in the
next commit which adds the provider gates to anchor-job, identity
service, and the tenant security_policy schema.
ADR 0017 implementation. The platform is now blockchain-agnostic by
default: a tenant whose security_policy is null, {}, or doesn't carry
the new provider keys gets the off-chain triple
(did_provider='off-chain', verifier_provider='off-chain',
audit_anchor_provider='none') and never touches a chain RPC.

src/services/tenant-providers.ts — new pure-function resolver. Pure:
no DB, no env, no IO. Reads a TenantSecurityPolicy JSONB, returns a
ResolvedProviders triple plus the chain-config strings. Invalid
provider values fall back to defaults so a malformed JSONB row never
crashes the hot path (identity register + anchor cron).

src/services/identity.ts — registerIdentity now accepts an optional
securityPolicy argument. The on-chain DID registration is gated:
resolveProviders(policy).didProvider === 'off-chain' skips the chain
call entirely. Old callers without a policy get the off-chain path,
preserving backward compatibility.

src/services/anchor-job.ts — runDailyAnchorJob queries security_policy
alongside tenant id, calls resolveProviders, and skips any tenant
whose auditAnchorProvider === 'none' before the per-tenant work
begins. A new test asserts the skip behaviour.

src/services/blockchain.ts — boot init is now non-fatal. If
BLOCKCHAIN_PRIVATE_KEY or contract addresses are missing, the service
logs a degraded-mode warning and isBlockchainReady() returns false.
The platform boots clean without any chain config.

src/routes/v1/zkp.ts — registerIdentity call now threads the tenant
context's security_policy through.

src/types/index.ts — TenantSecurityPolicy carries the three provider
keys plus the chain-config strings. Type-only change; the DB JSONB
column is unchanged.

Tests:
  - tests/tenant-providers.test.ts (8 tests, all green) — defaults,
    explicit providers per slot, invalid-value fallback, chain config
    pass-through.
  - tests/anchor-job.test.ts updated to thread security_policy
    through tenant fixtures via the new chainAnchorTenant() helper;
    a skip test verifies provider=none tenants are bypassed.

411 backend tests green; 35 suites pass; 14 skipped (public-route
exceptions). No new deps. Closes the refactor commit referenced by
ADR 0017.
ADR 0017 face-first identity register lands. The endpoint accepts
the (did, commitment) tuple computed entirely on-device by the
mobile/biometric pipeline — never a biometric template, an image,
or an embedding.

src/services/db.ts:
  - tenant_users gains 'did' + 'commitment' columns via ALTER TABLE
    (idempotent for existing deployments). UNIQUE constraint on
    (tenant_id, environment, did) WHERE did IS NOT NULL.

src/services/identity.ts:
  - New exports: registerFaceFirstIdentity(), IdentityValidationError,
    IdentityAlreadyExistsError. The function validates DID + commitment
    format, asserts uniqueness per (tenant_id, environment, did),
    persists the row with did/commitment populated, and optionally
    queues an async chain DID registration when the tenant's
    security_policy.did_provider opts in. Default tenants run pure-DB
    enrollment with zero chain dependency.

src/routes/v1/identity.ts:
  - New POST /v1/identity/register endpoint mounted under the
    existing identity router. Uses authenticateTenantApiKey with
    zkp:register scope + pgRateLimit (30/min, keyed by API key).
    Translates service-level errors to 400/409 HTTP responses.

tests/schema-purity.test.ts:
  - Allowlist expanded to permit did + commitment on tenant_users.

tests/identity-register-face.test.ts:
  - 7 tests pinning the route behaviour: auth required, 201 on
    success, audit row written, 400 on invalid_did /
    invalid_did_format, 409 on did_already_registered, and a
    defence-in-depth test that biometric-like extras in the request
    body never reach the service layer.

The legacy POST /v1/auth/zkp/register that accepts base64
biometricTemplate is retained for the W3 demo client + existing
test fixtures but is now deprecated for new integrations — the
face-first path is the production register surface.

419 backend tests green, 36 suites, 14 skipped public-route
exceptions. No new deps. Blockchain-agnostic by default.
ADR 0017 face-first verify path. The endpoint accepts the
on-device-generated Groth16 proof plus the claimed DID, looks the
user up in (tenant_id, environment, did), asserts publicSignals[0]
matches the stored commitment, then runs snarkjs.groth16.verify
against the platform's pinned verification key (ADR 0015).

Five-step flow:
  1. findUserByDid() returns the (id, commitment) for the claimed DID
     or null. Null → uniform 401 verification_failed.
  2. publicSignals[0] case-insensitive compare against the stored
     commitment. Mismatch → uniform 401 verification_failed (same
     wire-level error as did_unknown, for enumeration defence).
  3. verifyBiometricProof() runs snarkjs.groth16.verify(vkey,
     publicSignals, proof). The vkey is the boot-time-validated copy
     from ADR 0015. proof_invalid → uniform 401.
  4. On success: create a UserSession in sessionStore, issue access +
     refresh tokens with did in the JWT claims.
  5. Write an audit row on every path (success or failure) with the
     reason in metadata. Path-by-path reasons: did_unknown,
     commitment_mismatch, proof_invalid, internal_error, or success.

Service-side additions:
  - src/services/identity.ts gains findUserByDid(tenantId, env, did)
    which returns { id, commitment | null } or null.

Tests (8/8 green):
  - Scope gate on zkp:verify.
  - 400 invalid_did when DID missing; 400 invalid_request on bad
    publicSignals shape.
  - 401 verification_failed when DID unknown — uniform error.
  - 401 verification_failed on commitment mismatch — snarkjs NOT
    called (verifyBiometricProofMock never invoked); enumeration
    defence holds.
  - 401 verification_failed when snarkjs rejects the proof.
  - 200 with verified=true + sessionId + tokens on a clean verify.
  - Case-insensitive commitment compare (UPPERCASE presented vs
    stored lowercase).

Routing: mounted at /v1/identity/verify under the existing identity
router. The legacy /v1/auth/zkp/verify endpoint remains for backward
compat with the W3 demo client — that endpoint does not look up by
DID + commitment, so it is fundamentally weaker than this one and
should be deprecated for new integrations.

428 backend tests green, 37 suites, 14 skipped public-route
exceptions. No new deps. No chain dependency on the verify path.
The first executive-readable artefact a banker hands to their CIO,
CISO, CFO, and CRO. Single 10-axis comparison table, three deployment
patterns, three Q&A blocks per stakeholder, proof points keyed to
shipped commits.

Traces to docs/plan/bfsi-v1/01-pain-points.md (the commercial spine
table at the bottom of that doc was the seed). Reframes the audit-log
differentiator per adr/0017-blockchain-agnostic-posture.md — blockchain
anchoring is presented as one of three opt-in defence-in-depth
providers, not the load-bearing primitive. Off-chain hash chain is the
default tamper-evidence story.

Proof points cite the closed Phase 0 P0 findings from
docs/security/audit-findings.md: C-1 (demo bypass removed, 02e1734),
C-3 (JWT-in-query-string fix, ee6aad4), C-4 (audit hash chain), C-6
(direct-INSERT guard, c09c081), C-8 (biometric-payload rejection,
c09c081), C-10 (Postgres rate-limit, 3337d7b). C-2, C-9, C-11 noted as
tracked forward.

Lives at docs/why-zeroauth/vs-auth0.md so future why-zeroauth pages
(vs-Okta, vs-IDfy, vs-Signzy) can share the folder.
Closes a quiet gap I noticed while building the face-first register
endpoint. The existing FORBIDDEN_KEYS loop checks for exact word
matches like 'template' with a \\b boundary — which does NOT catch
'biometricTemplate', 'face_template', 'fingerprintTemplate' etc.
The legacy POST /v1/auth/zkp/register accepts a base64
'biometricTemplate' as its request body field; that endpoint
slipped past the guard.

This commit adds a FORBIDDEN_COMPOUND_KEYS scan covering 16 suffix
variants: biometricTemplate / biometric_template / biometricImage /
biometric_image / faceTemplate / face_template / fingerprintTemplate
/ fingerprint_template / irisTemplate / iris_template / voiceprint /
face_image / fingerprint_image / rawBiometric / raw_biometric.

A separate test isolates the legacy 'biometricTemplate' usage to
exactly five permitted code sites — the deprecated zkp register
route, its legacy alias, the comment in the new face-first route,
the legacy service function, and the type declaration. Any new code
site reading biometricTemplate fails the test and forces an ADR.

The new face-first POST /v1/identity/register endpoint takes only
the on-device-computed (did, commitment) tuple and never reads any
biometric field; the legacy endpoint remains for backward compat
with the W3 demo client but is deprecated for new integrations.

35 tests in the file (was 19); full backend suite stays green.
POST /v1/identity/register and /v1/identity/verify now have an
explicit row in the contract with their wire shape, error codes,
and the relationship to the deprecated /v1/auth/zkp/* counterparts.

The contract preamble for the section explains the architecture
choice: /v1/identity/* is the face-first production integration
point (per ADR 0017); /v1/auth/zkp/* is retained for backward
compat with the W3 demo client and existing fixtures, but the
biometricTemplate field on the legacy register endpoint violates
the no-raw-biometric rule and is deprecated for new integrations.

[no-test] docs-only.
Closes the test gap explicitly called out in ADR 0017 § Test impact.
Six static-source assertions that pin the architecture choice
against silent re-introduction:

  - identity.ts gates registerIdentityOnChain behind resolveProviders
    (the chain call lives in an if-branch keyed on didProvider !== 'off-chain')
  - anchor-job.ts contains a literal 'none' provider check and reads
    auditAnchorProvider — the daily anchor cron skips default tenants
  - blockchain.ts exports an isBlockchainReady boot-tolerant flag
    so other modules can gate without try/catch boilerplate
  - blockchain.ts never calls process.exit — boot is non-fatal
  - config layer never throws on missing BLOCKCHAIN_PRIVATE_KEY
  - ADR 0017 is cited somewhere in CLAUDE.md / the plan tree / the
    ADR itself, so a contributor who finds the gate symbol can trace
    back to the architectural decision

The runtime behaviour (default tenant boots without any chain
config) is exercised end-to-end by tests/tenant-providers.test.ts,
tests/anchor-job.test.ts, and tests/identity-register-face.test.ts.

450 backend tests green, 38 suites.
New :face Gradle library module that owns the on-device face-capture
half of the Pramaan enrollment flow — Scene 1 step 4 in
docs/plan/bfsi-v1/02-bank-demo.md:

  "Face capture (CameraX + on-device ML Kit face detection). App shows
   a viewfinder, waits for a centred, well-lit face, takes the capture
   entirely on-device. The face image never leaves the device. SHA-256
   of the face descriptor is computed."

The module produces a deterministic 112x112 cropped face bitmap that
the downstream :biometric module (lands with C-143) will hash into the
SHA-256 biometric descriptor consumed by the fuzzy extractor and
Poseidon commitment.

WHAT THE MODULE DOES

  * Compose composable FaceCaptureScreen(onCaptured, onCancelled) — the
    public entry point. Handles CAMERA permission (rationale screen +
    deep-link to system settings if previously denied), drives a
    CameraX preview + ImageAnalysis pipeline at <= 10 fps, runs ML Kit
    Face Detection on every analysis frame, gates capture on a 1.5 s
    "face present + centred + size band" stability check.
  * FaceDetectorWrapper — wraps ML Kit's FaceDetector with a clean
    suspend-fun API. Configured PERFORMANCE_MODE_FAST + tracking,
    NO landmarks, NO classifications — only bounding boxes for the v1
    capture flow. Bundled face-detection model artefact (not the
    unbundled face-detection-base variant); model ships inside the AAR
    and is never fetched at runtime.
  * BitmapCrop — deterministic crop-to-square + resize-to-112x112. The
    integer geometry lives in a pure top-level function
    (computeSquareBounds) so it's JVM-testable with no Android stubs.
    Determinism is load-bearing: the upstream :biometric module hashes
    the bitmap to form the commitment that backs the DID; a different
    bitmap for the same physical face would prevent DID re-derivation.
  * LivenessTimer — "face present continuously for N ms" tracker.
    Pure-ish (takes a clock function as a ctor arg), JVM-testable via
    a controlled clock.
  * CaptureState — sealed-class state machine with a pure reducer
    (CaptureStateMachine.next). Drives the Compose screen.

STATE MACHINE

  RequestingPermission -> Initializing -> WaitingForFace
                       -> Error(PermissionDenied)
  Initializing         -> WaitingForFace
                       -> Error(CameraUnavailable | CameraInitFailed)
  WaitingForFace       -> FaceDetected (when stable face found)
  FaceDetected         -> FaceDetected (timer accumulating)
                       -> WaitingForFace (face lost)
                       -> Stable (timer hits 1.5 s threshold)
  Stable               -> Captured (after onCaptured fires)
  Any non-terminal     -> Error(UserCancelled) (back button)
  Captured / Error     -> absorb all events (terminal)

BITMAP-FLOW CONTRACT — NON-NEGOTIABLE

The Bitmap passed to onCaptured MUST be consumed by an in-process
callback. It MUST NOT cross the network, hit external storage, be
logged, or be passed across a Binder boundary. This is enforced two
ways:

  1. Source review. The module imports zero network libraries. Its
     AndroidManifest does not declare INTERNET. The security-reviewer
     subagent fires on every PR that touches mobile/face/.
  2. Runtime assertion. FaceCaptureScreen.kt's
     assertCallbackIsInProcess() walks the callback's declaring class
     name and crashes if the class name contains substrings indicating
     a network stack (okhttp, retrofit, http, rpc, websocket,
     java.net., android.net.http). Best-effort; catches the obvious
     shape (onCaptured = ::uploadFace).

These guards encode the Scene 1 demo guarantee that "the face image
never leaves the device". ADR 0017 (blockchain-agnostic posture)
preserves this guarantee independent of which provider slots a tenant
opts into — the biometric commitment lives off-chain by default and
on-chain anchoring is opt-in per tenant; either way the raw bitmap
stays on the phone.

V1 LIVENESS LIMITATIONS

The 1.5 s stability check is NOT a real liveness gate. A still
photograph held in front of the front camera satisfies it. Full
liveness — randomised head-turn challenge, blink detection over ML
Kit's eye-open probability, depth probing where the sensor exists —
lands with C-148 in Sprint 3 (TODO: ADR 0020 — full liveness, marked
in LivenessTimer.kt and the module README). The Compose UI strings
use "stability check" rather than "liveness" to keep the operator
demoing Scene 1 explicit about what the v1 module does.

TESTS

Three JVM-only test classes run on Gradle's :test task — no emulator,
no instrumented runner, no Android stubs:

  * BitmapCropTest — every code path in computeSquareBounds: centred
    face, top-left clamp, longer-than-bitmap clamp, right-edge slide,
    determinism (identical inputs produce identical outputs), already-
    square face, face larger than bitmap, malformed face rect rejected.
  * LivenessTimerTest — every timer transition driven by a closure-
    controlled clock: zero elapsed on fresh timer, single onFacePresent
    records timestamp, repeated calls idempotent, onFaceLost resets,
    face-lost-and-re-found starts fresh session, threshold flag stays
    true once tripped, clock-ticks-backwards defensive guard,
    configurable threshold, default matches state-machine constant.
  * CaptureStateMachineTest — every transition in the reducer table
    plus full happy-path round-trip and face-lost-mid-stability
    recovery; UserCancelled from any non-terminal state goes to
    Error(UserCancelled); Captured and Error absorb every event.

Instrumented tests (CameraX preview render, ML Kit detection
end-to-end against a fixed input image) defer to C-143 alongside the
enrollment-flow wiring; they require an emulator and would block this
scaffold for no extra coverage of the pure helpers.

GRADLE WIRING

  * gradle/libs.versions.toml pins androidx.camera 1.3.1 (core +
    camera2 + lifecycle + view, four artefacts pinned together via the
    `camerax` bundle), com.google.mlkit:face-detection 16.1.5 (the
    BUNDLED variant — explicit rationale comment cites the "biometric
    data never crosses the network" constraint), kotlinx-coroutines
    1.7.3 (core + android + play-services for the ML-Kit Task bridge),
    and androidx.compose.material3 1.1.2 (a pinned standalone version
    because :face is a library module and may be consumed without the
    Compose BOM in scope).
  * settings.gradle.kts grows an include(":face") line and a module
    comment under the existing :app/:prover/:sensors:* map.
  * No new deps in the root package.json. All additions are
    Android-only under mobile/face/, consistent with ADR 0010 (the
    Android-only commitment in this tree).

VERIFICATION AT COMMIT TIME

  $ find mobile/face -type f
  ... 12 files spanning build.gradle.kts, the manifest, six Kotlin
  source files (FaceCaptureScreen, FaceDetectorWrapper, CaptureState,
  LivenessTimer, BitmapCrop, plus the three JVM-only tests), one
  drawable, and one README.

No compile attempt — Android SDK is not available on the agent host.
The toolchain validates in the next CI run once the gradle wrapper for
mobile/ lands (out of scope for this commit; tracked in C-101
follow-on per docs/plan/bfsi-v1/agents/agent-19-android-ux.md).
ADR 0017 declares /v1/identity/register and /v1/identity/verify as
the production face-first integration points. The legacy
/v1/auth/zkp/register and /v1/auth/zkp/verify endpoints stay alive
for backward compat with the W3 demo client + existing tests but
should not be the integration target for new customers.

Per the IETF Sunset header (RFC 8594) and the deprecation-header
draft, both endpoints now respond with:
  Deprecation: true
  Sunset: Wed, 31 Dec 2026 23:59:59 GMT
  Link: </v1/identity/{register,verify}>; rel="successor-version"

A new integrator wiring up the SDK sees the header in their first
response and is pointed at the successor endpoint. Existing
integrations (the W3 demo client, the test fixtures) keep working
unchanged.

Reasoning for the verify endpoint: the legacy /v1/auth/zkp/verify
runs snarkjs.groth16.verify but does NOT do the (claimed DID →
stored commitment) lookup that /v1/identity/verify does. A valid
proof for the wrong DID would be accepted by the legacy path. The
face-first endpoint adds that check for enumeration defence and
correctness.

450 backend tests still green.
ADR 0017 face-first platform pivot. The on-device pipeline that
turns a captured 112×112 face bitmap into a Poseidon commitment the
platform stores. Per CLAUDE.md, the bitmap is caller-owned and never
crosses the wire; only the resulting (did, commitment) tuple is sent
to /v1/identity/register.

Pipeline:
  Bitmap (112×112)
    → TfliteFaceEmbedder (MobileFaceNet, lazy-init, reused)
    → 128-dim L2-normalised float embedding
    → Quantizer.quantize (×1000, round, int16 BE, 256 bytes)
    → Sha256.digest (with explicit zero-on-exit of the input buffer)
    → biometricSecret (32 bytes)
    → Poseidon.hash2(biometricSecret, salt)
    → commitment (32-byte BN128 field element)
    → DID = 'did:zeroauth:face:' + Keccak256(commitment)[:20].hex

mobile/biometric/ module:
  - FaceEmbedder.kt + TfliteFaceEmbedder impl (190 lines)
  - Quantizer.kt — deterministic int16 BE quantisation (124 lines)
  - Sha256.kt — wraps MessageDigest with explicit buffer zeroing (57 lines)
  - Poseidon.kt — INTERFACE + stub throwing NotImplementedError until
    the JNI-vs-pure-Kotlin choice in ADR 0019 is implemented (116 lines)
  - Keccak256.kt — wraps BouncyCastle (BCKeccak) for the DID suffix (49 lines)
  - CommitmentBuilder.kt — pulls it all together (179 lines)
  - SaltProvider.kt + KeystoreSaltProvider — StrongBox-preferred salt
    persistence in Android Keystore (155 lines)
  - Five JVM unit tests covering determinism, perturbation stability,
    SHA-256 buffer zeroing, Poseidon interface, end-to-end with mocks
  - MODEL.md asset placeholder explaining how to add
    mobilefacenet.tflite at build time

ADR 0018 (mobile face embedding pipeline) documents the architecture
choice — MobileFaceNet vs FaceNet/ArcFace, quantisation as a
poor-man's fuzzy extractor for same-device-same-customer, deferral
of full BHH fuzzy extractor to v2 cross-device.

ADR 0019 (Poseidon implementation choice) parks the JNI-to-Rust vs
pure-Kotlin BigInteger decision; the next commit lands the actual
impl matching circomlibjs Poseidon2 byte-for-byte. The Poseidon.kt
stub throws NotImplementedError so any caller that tries to compute
a commitment without the impl gets a loud error, not a silent wrong
value.

mobile/settings.gradle.kts includes :biometric.

This is a worktree-rescue commit. The agent that owned the work
(scope: 18 files + 2 ADRs across mobile/biometric/) finished the
file edits but stalled before committing, likely from API
overload. The work is intact in the worktree; this commit imports
it into dev. The Gradle setup matches the existing :app + :prover
+ :sensors + :face modules; no actual Android SDK compile attempt
since this machine lacks the SDK.
Precursor to the live verifications operator surface for the Anchor
Bank demo. Ships the dashboard-side component, the per-tenant
event emitter, and the SSE endpoint that feeds the view — landing
the structural no-PII contract before any wiring into App.tsx.

Server side:

  src/services/verification-events.ts
    Per-tenant in-process EventEmitter. Listeners are keyed by
    tenant id; the subscribe handle wraps the listener with a
    payload-side tenant_id check as defence in depth so a future
    refactor that broadcasts on a shared channel still catches a
    cross-tenant leak. The payload shape carries only the audit
    row's opaque fields (DID, environment, result, latency_ms,
    proof_hash, reason, created_at, audit_id, action) — no
    full_name, no email, no phone. Multi-instance scale-out is on
    the v2 roadmap; the seam where Redis pub/sub plugs in is
    documented inline.

  src/services/audit.ts
    Adds the single emit hook to appendAuditEvent — fires after
    the audit-row INSERT commits, only for verification-class
    actions (verification.recorded, verification.verify_success,
    verification.verify_failure, auth.verify_success,
    auth.verify_failure). The action allowlist lives in
    verification-events.ts as a const Set so widening it is a
    one-line change. Single source of truth — no parallel write
    path; the emit is gated by a commit-then-emit ordering so a
    subscriber can never see an event that did not also land in
    audit_events.

  src/routes/console.ts
    GET /api/console/verifications/stream — SSE endpoint behind
    requireConsoleAuth. Per ADR 0013, EventSource clients
    authenticate via the HttpOnly zeroauth_console_jwt cookie
    (the access_token query fallback was removed in P0 audit
    finding C-3). Writes a ': connected' comment frame
    immediately so the client transitions out of CONNECTING
    without waiting on the heartbeat; pings every 25 s thereafter,
    matching the proof-pairing stream cadence. Subscription is
    cleaned up on req.on('close') and req.on('aborted').

  tests/console-verifications-stream.test.ts
    Five assertions: (1) 401 on unauthenticated, (2) authenticated
    subscriber receives the payload within 1 s of an
    appendAuditEvent write, (3) two-tenant isolation — tenant A
    never sees tenant B's row in transcript or buffer, (4) the
    opening ': connected' comment frame is written immediately,
    and (5) non-verification audit rows (device.created) produce
    zero stream events.

Dashboard side:

  dashboard/src/lib/verifications-api.ts
    Narrow VerificationEvent type (auditId, action, did,
    environment, result, latencyMs, createdAt, proofHash,
    reason) plus an explicit allowlist projection from the wire
    shape. A component that imports the type and tries to read
    .full_name will not compile — same no-PII guarantee shape as
    the users-view client at commit 6e06a14.

    openVerificationStream() wraps EventSource with
    withCredentials: true and no query string (ADR 0013) and
    projects every payload before handing it to the consumer.

  dashboard/src/routes/tenant/verifications.tsx
    React 19 + TanStack-free local-state view. On mount opens
    the SSE stream; maintains a rolling 100-event buffer
    (newest-first). Renders a three-tile counter row (success /
    failure / total) above a table with Timestamp, DID,
    Environment chip, Result chip, Latency badge. Empty state
    is "Waiting for live verifications…" with a spinner. The
    column list is an allowlist tuple — widening it is an
    ADR-grade decision per the same pattern as users.tsx.

    No App.tsx wire-up — route registration follows in a
    sprint-2 commit, mirroring the precedent set by commits
    6e06a14 (users view) and 0848640 (audit-integrity view).

  dashboard/src/components/EventStreamCounter.tsx
    Small numeric+label tile used by the verifications view's
    counter row. Reusable for the audit-integrity counter that
    lands in sprint 2.

  dashboard/src/routes/tenant/__tests__/verifications.test.tsx
    Seven assertions: empty state, three events to three rows,
    counter totals (3 success + 2 failure from the fixture set),
    PII probe absence in rendered DOM (Alice/Bob/Charlie,
    @example.com, +91, EMP-, plus a generic E.164-style phone
    regex), source-file scan for forbidden property reads
    (.full_name/.email/.phone/.employee_code), stream cleanup
    on unmount, and stream-error banner rendering.

  dashboard/src/routes/tenant/__tests__/verifications.fixtures.ts
    Five synthetic VerificationEvent rows (3 success + 2 failure,
    mixed environments + DIDs + latencies) plus the parallel
    SENSITIVE_LEAK_PROBES + FORBIDDEN_FIELD_READS tuples — same
    shape as the users-view fixtures at commit 6e06a14, so the
    no-PII contract is consistent across the two tenant views.

Posture context:

  ADR 0017 (blockchain-agnostic posture) makes anchoring opt-in;
  this view is anchor-provider-agnostic — it shows the audit row
  regardless of whether the tenant has opted into an on-chain
  anchor provider. The proofHash field is the bank-auditor cross-
  reference into the proof archive and is meaningful under any
  anchor configuration.

  The DPDP §2(t) memo skeleton at docs/compliance/dpdp-2t-memo.md
  argues the data principal is not identifiable from a Poseidon-
  commitment-backed DID + outcome code + latency. This commit
  encodes the dashboard side of that posture as a structural
  type narrowing — see commit 6e06a14 for the precedent on the
  users view.

Verification:

  npx tsc --noEmit            → clean (pre-existing app.ts error
                                 unrelated to this change)
  npm test                    → 36 suites / 416 passing / 14 skipped
  cd dashboard && npm test    → 11 files / 56 tests / all passing

No App.tsx wiring; no new package.json deps; no Co-Authored-By
trailer; one commit.
The new tenant/* views (users, audit-integrity, verifications)
shipped as standalone components without a route registration so
the existing /users, /audit, /verifications polling-based views
kept working unchanged. This commit lazy-loads the three new views
under sibling routes:

  /users-live           → tenant/users (DPDP §2(t)-compliant render)
  /audit-integrity      → tenant/audit-integrity (PASS/FAIL card)
  /verifications-live   → tenant/verifications (SSE stream)

The polled /users + /verifications routes remain for operators
who prefer not to keep a long-lived EventSource open. The
/audit-integrity route is new (no polled counterpart) — the
audit-integrity admin endpoint was a wave-3 add and didn't have
a frontend home yet.

Build output confirms each new view is a separate lazy chunk:
  users-B2YZ3G4Y.js              3.53 kB
  verifications-CUN-d1m_.js      6.20 kB
  audit-integrity-CB0oabqJ.js    7.91 kB

so the main bundle stays at 343 kB and the EventSource cost is
only paid when a CISO opens the live tab.

56 dashboard tests still passing.
The Poseidon-2 stub that threw NotImplementedError is gone. The real
Hades-permutation Poseidon over the BN254 scalar field is vendored
from android/app/src/main/java/dev/zeroauth/android/sec/Poseidon.kt
into mobile/biometric/src/main/kotlin/dev/zeroauth/biometric/. The
android/ implementation has been pinned against poseidon-lite@^0.3.0
since the W3 cycle so the vendored copy inherits the same JS-vector
verification.

ADR 0019 status flips from Deferred to Accepted — Option B (pure-
Kotlin port via java.math.BigInteger). Option A (JNI to Rust) was
deferred until a profiling pass shows Poseidon as the bottleneck;
BN254 Poseidon-2 takes ~1-3 ms on Pixel 7 in pure-Kotlin, well
within the demo's enrollment + verify latency budgets.

Files:
  mobile/biometric/Poseidon.kt           — kernel + hash{1,2}{,Bi}
  mobile/biometric/PoseidonConstants.kt  — round constants + MDS tables
  mobile/biometric/PoseidonTest.kt       — 13 tests, JS-vector pinned
  mobile/biometric/CommitmentBuilderTest.kt — upgraded from 'throws
    NotImplementedError' to 'produces a valid 32-byte commitment +
    deterministic across calls with the same (embedding, salt) pair'

The Poseidon kernel matches poseidon-lite line-for-line:
  - Hades round structure: full → partial → full
  - Round-constants table: identical bytes
  - MDS matrix: identical bytes
  - S-box: x^5 mod FIELD

The hash2 byte-array wrapper handles the 254-bit field vs 256-bit
SHA-256 output gap via toField (mask top 2 bits + mod FIELD); the
result is serialised back as 32 bytes big-endian with leading zero
padding when the field element fits in fewer bytes.

This is THE blocker for end-to-end face-first flow. With this
commit:
  - CommitmentBuilder.buildFromEmbedding() now produces a real
    commitment that the server verifier will accept.
  - The WebView snarkjs prover (existing W3 infrastructure) can
    consume the on-device (secret, salt, nonce) and emit a real
    Groth16 proof.
  - /v1/identity/register accepts the (did, commitment) tuple from
    the phone with the server-side commitment matching what the
    circuit's Poseidon(2) template produces.

469 backend tests still green; the mobile module test count moves
from 4 stub tests to 13 real Poseidon vectors + 2 end-to-end
commitment tests.
CLAUDE.md gets two new sections so a fresh session reading the
constitution understands the platform's current shape:

  - 'Blockchain-agnostic pivot (ADR 0017)' — the three opt-in
    providers (did/verifier/audit-anchor), the default off-chain
    triple, and the implication that a tenant boots with zero chain
    config.

  - 'Face-first identity surface (ADR 0017)' — the production
    /v1/identity/register + /v1/identity/verify endpoints and the
    deprecation status of /v1/auth/zkp/*. Plus the on-device
    commitment pipeline architecture (FaceEmbedder → Quantizer →
    Sha256 → Poseidon → DID).

The 'Phase 0 closed P0 findings' bullet expands from 5 closures
(C-1, C-3, C-4, C-6, C-8) to 9 (adds C-7 vkey integrity, C-10
rate-limit, C-12 cross-tenant matrix, C-14 CVE monitor) — all
closed by engineering work since the last LAST_UPDATED.

docs/security/audit-findings.md:
  - C-13 (CORS wildcard) flipped from OPEN to PARTIALLY CLOSED —
    the global CORS layer uses a non-wildcard allowlist via
    config/index.ts::parseCorsOrigins. The per-tenant allowed_origins
    granular version remains a sprint-2 ticket but the audit class
    (literal wildcard) is closed.
  - C-14 (CVE monitor) flipped from OPEN to CLOSED at f8a756c.

mobile/prover/Prover.kt:
  - Docstring expanded to note that the W3 reference implementation
    at android/app/.../prover/ is production-ready today — the
    DefaultProver stub is the right thing only until C-104 lands a
    proper rapidsnark JNI bridge. Pointers to the five .kt files +
    asset bundle for whoever wires :mobile/:app's WebView host.

469 backend tests + 56 dashboard tests = 525 total green. No new
deps. No new tests (docs + code-comments only). Ready for push.
ADR 0020 lands. Husky 9.1.7 is the pre-commit hook manager — zero
transitive deps, single-line install, ESM-compatible, same
maintainer (typicode) as nodemon / json-server / lowdb. npm audit
on 9.1.7 clean.

Three artefacts ship:

  .husky/pre-commit
    Runs scripts/pre-commit-checks.sh — the shared gate library
    that fires the seven gates from 06-ways-of-working.md
    (tsc, eslint errors-only, secret scan, biometric-key scan,
    dep-ADR trail, jest --findRelatedTests on staged files).

  .husky/commit-msg
    Independent gate on the commit subject + body.
    Blocks: AI-coauthor trailer (matches the canonical trailer line
    only, not prose mentions); subjects over 72 chars; Conventional-
    Commits prefixes; bracket / WIP / checkpoint prefixes; leading
    emoji.

  scripts/pre-commit-checks.sh
    Single source of truth invoked locally by husky and by the
    .github/workflows/ci.yml mirror step. An emergency bypass via
    ZEROAUTH_PRECOMMIT_SKIP=1 lands in shell history for audit;
    the CI mirror catches anything the local skip waves through.

Smoke-tested all three commit-msg gates pass + the three explicit
rejection scenarios fire correctly.

525 backend + dashboard tests still green. No new code paths
(the hook is dev-tooling only).
Defence-in-depth on top of the global CORS layer (which enforces the
platform-wide non-wildcard config.corsOrigins). The new middleware
fires AFTER authenticateTenantApiKey populates req.tenantContext —
at which point we know the tenant and can consult its
security_policy.allowed_origins.

Behaviour:
  - No tenant context → no-op
  - allowed_origins absent / null / empty → no-op
  - No Origin header (server-to-server) → no-op
  - Origin in list (case-insensitive exact match) → next()
  - Origin not in list → 403 origin_not_allowed (uniform message)

Closes the per-tenant half of audit finding C-13. Routes opt in by
chaining tenantCorsCheck after authenticateTenantApiKey in their
handler stack — we do NOT wire it into every route in this commit;
each route owner decides when their tenants ask for the granular
control.

Test files: tests/tenant-cors.test.ts (7 tests covering the seven
behaviour branches).

Also fixes the test-file lint directives: the project's eslint
config now bans @typescript-eslint/no-require-imports (the new name
for the old no-var-requires rule). All six test files that use the
runtime require('fs')/require('path') pattern now carry the right
directive name, and require()s on separate lines each carry their
own disable comment.

525 backend + dashboard tests green; eslint zero errors.
The in-memory SessionStore lost every signed-in user on process
restart. The new design is a write-through cache: same sync API
the route layer expects (create / get / delete / getStats) but
backed by a new user_sessions Postgres table that's hydrated on
boot and updated asynchronously on every mutation.

Schema:
  CREATE TABLE user_sessions (
    session_id   TEXT PRIMARY KEY,
    user_id      TEXT NOT NULL,
    provider     ('saml' | 'oidc' | 'zkp'),
    verified     BOOLEAN,
    created_at   TIMESTAMPTZ,
    expires_at   TIMESTAMPTZ,
    did          TEXT
  );
  + index on expires_at (cleanup), index on user_id (logout-everywhere).

Boot sequence (src/server.ts):
  initDb() → initRateLimitCleanup() → sessionStore.init() → app.listen()
sessionStore.init() runs the hydration SELECT for non-expired rows,
populates the in-memory map, and starts an hourly setInterval that
deletes expired rows from Postgres. setInterval is unref'd so it
doesn't block graceful shutdown.

Behaviour:
  - create(): in-memory write returns synchronously; INSERT … ON
    CONFLICT DO UPDATE fires fire-and-forget to Postgres. A failed
    write logs but does not break the caller's contract.
  - get(): reads from the in-memory cache; lazy-prunes expired rows.
  - delete(): cache delete + async Postgres DELETE.
  - getStats(): cache stats (unchanged).

The 'horizontal scale-out' half of C-9 (sessions readable across
multiple API pods in real time) requires read-through cache
invalidation across pods and is deferred — for now the DB is the
durability layer, not the read source. Documented in the file
header.

Tests (6 new in tests/session-store-postgres.test.ts):
  ✓ create() emits INSERT … ON CONFLICT DO UPDATE
  ✓ delete() emits DELETE keyed on session_id
  ✓ init() hydrates the cache from a SELECT of non-expired rows
  ✓ init() is idempotent
  ✓ init() tolerates a broken DB
  ✓ persist errors logged but don't break the in-memory contract

482 backend tests green (was 476). The existing 12-test
session-store.test.ts suite stays green — the new persist paths
are opt-in via getPool() and the in-memory semantics are
unchanged for the suite that runs without a DB mock.

schema-purity test allowlist expanded for user_sessions
(non-tenant-scoped — same reason as rate_limit_buckets;
tenant context lives in the session JWT, not in the row).
Closes audit finding C-11. Adds RS256 signing as an opt-in
alternative to HS256, with a dual-issuer verify path that accepts
both algorithms during the rollover window. Existing deployments
keep HS256 (the default) until the operator opts in.

src/services/jwt.ts:
  - issueTokens() honours config.jwt.algorithm (HS256 default,
    RS256 when JWT_ALGORITHM=RS256 + JWT_RS256_PRIVATE_KEY).
  - verifyToken() is dual-issuer: tries RS256 first (when public
    key configured), falls back to HS256 (when legacy secret
    configured). Both must succeed for the brief rollover gap.
  - getRs256Jwk() exports the public key in canonical JWK form.

src/routes/jwks.ts + mount in src/app.ts:
  - GET /.well-known/jwks.json — public, unauthenticated, returns
    { keys: [...] } with the configured RS256 key (or empty array
    when RS256 not configured). Cache-Control: max-age=3600.

scripts/jwt-rotate.ts (+ jwt:rotate npm script):
  - Generates a fresh 2048-bit RSA keypair + UUID kid.
  - Prints env-paste-ready format with --env flag.
  - Private key flows to the secret manager — never to disk in
    this repo.

adr/0021-rs256-jwt-migration.md (200+ lines):
  - Algorithm-selection matrix.
  - Dual-issuer verify path behaviour table.
  - JWKS endpoint contract + caching strategy.
  - Key rotation procedure (4 steps, zero-downtime aspiration
    with a brief acceptance gap documented).
  - Deferred items: multi-key concurrent support, HSM-backed
    signing, per-tenant signing keys.

tests/jwt-rs256.test.ts (6 tests):
  - HS256 (default) still signs and verifies.
  - RS256 sign produces tokens with header.alg='RS256' + kid claim,
    verifies independently against the public key.
  - RS256 verifyToken() rejects forged-with-different-key tokens.
  - Dual-issuer accepts both HS256-signed and RS256-signed tokens.
  - JWKS endpoint returns the configured RS256 public key.
  - JWKS endpoint returns empty keys array when RS256 not configured.

488 backend tests green (was 482). No new deps (RSA generation +
JWK export are stdlib crypto).
The W3 reference implementation at
android/app/src/main/java/dev/zeroauth/android/prover/ has been
running in the live demo + smoke tests since the W3 cycle.
Vendoring it here makes mobile/ self-contained — the :mobile/:app
host activity can wire face capture (:face) → on-device commitment
(:biometric) → Groth16 proof (:prover) → POST /v1/identity/verify
without depending on the android/ subtree.

Five Kotlin files, package rewritten from
dev.zeroauth.android.prover to dev.zeroauth.prover:
  MobileProver.kt           — public interface (generate + types)
  WebViewMobileProver.kt    — loads snarkjs in a WebView, runs
                              fullProve against the circuit, returns
                              Groth16Proof + publicSignals
  IsolatedMobileProver.kt   — :prover-process wrapper for
                              defence-in-depth against a compromised
                              renderer (ADR 0010)
  ProverService.kt          — the Android Service hosting the WebView
                              in the :prover process
  ProverIpc.kt              — Messenger-based IPC between :app and
                              :prover
  UnlockedCredential.kt     — NEW adapter type. The host builds this
                              from dev.zeroauth.biometric.Commitment
                              at the moment BiometricPrompt confirms.
                              Parallel declaration so :prover doesn't
                              transitively depend on the Keystore stack.

Assets (mobile/prover/src/main/assets/prover/), copied verbatim:
  prover.html               — 1.5 KB, the WebView's loaded page
  prover.js                 — 10 KB, snarkjs glue + @JavascriptInterface bridge
  poseidon.js               — 14 KB, circomlibjs Poseidon (matches mobile/biometric/Poseidon.kt byte-for-byte)
  snarkjs.min.js            — 688 KB, the snarkjs bundle (Groth16 prover + verifier)

The WebView loads with connect-src 'none' (ADR 0010) so the
renderer cannot reach the network even if a malicious script were
to be injected. Result comes back via @JavascriptInterface.

AndroidManifest.xml declares the ProverService with
android:process=':prover' + android:exported='false' so the host
:app inherits the isolation boundary through manifest merging.

README rewritten:
  - The old DefaultProver stub is gone (deleted Prover.kt — the
    file with the throwing NotImplementedError).
  - Documents host-side wiring (the :mobile/:app activity flow:
    face capture → commitment → BiometricPrompt → UnlockedCredential
    → ProverIpc.bind() → generate() → POST verify).
  - Records the C-104 follow-on (rapidsnark JNI; the interface in
    MobileProver.kt is stable across both impls).
  - Notes that identity_proof.wasm + circuit_final.zkey are NOT in
    this module — they live at circuits/build/ and the Gradle build
    copies them into :prover assets at packaging time.

488 backend tests still green. No backend code touched in this
commit — purely a vendor of the Android prover module + assets.
The build will land when :mobile/:app's MainActivity wires the
flow (a follow-on commit; we don't run Android Studio in this
environment).
Four more P0/P1/P2 findings flipped from OPEN to CLOSED by the
recent commits:

  C-9  (in-memory session store)           → 5a12bb4
  C-11 (HS256 JWT + no JWKS)               → 4ce0fec
  C-13 (CORS wildcard / per-tenant)        → tenant-cors.ts middleware
  C-15 (dep-ADR audit)                     → husky pre-commit hook

P0 + P1 + P2 closure tally after this commit:
  P0 (production-blocking): 7 of 7 closed (C-1, C-3, C-4, C-7,
       C-8, C-10, C-12) — C-2 fake mobile prover still tracked
       to Phase 1 Sprint 3.
  P1 (pilot-blocking): 4 of 5 closed (C-4, C-6, C-8, C-12) —
       C-5 PII strip still tracked (schema-purity test pins the
       current state).
  P2 (phase-2-blocking): 3 of 4 closed (C-13, C-14, C-15) —
       C-16 main-branch protection still pending the ops ticket.

[no-test] docs-only update of the audit-findings table.
Copilot AI review requested due to automatic review settings May 28, 2026 13:19
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.

Pulkit Pareek added 2 commits May 28, 2026 19:13
The docs/ tree is hand-written CommonMark prose — only
docs/reference/playground.mdx uses real JSX. The default MDX parser
choked across the corpus on perfectly valid CommonMark: autolinks
like <https://example.com>, comparisons like (<= 6), and angle-
bracket placeholders like <name> in tables. CI's validate job has
been red since the dev push (run 26577163848) because two such
files (anchor-bank-demo-runbook.md, vs-auth0.md) failed MDX
compilation; another two (enterprise-risk-register-v1.md,
trusted-setup-ceremony.md) would have failed next.

Root-cause fix is one knob: set markdown.format: 'detect' in
docusaurus.config.ts so .md → CommonMark and .mdx → MDX. The one
real MDX file (playground.mdx) keeps working unchanged.

Also tighten two source files alongside the config knob — these
are no-ops under format:'detect' but stay portable across markdown
engines:
  - anchor-bank-demo-runbook.md: wrap <name>/<firm> placeholders
    in backticks (matches the existing convention in
    pia-template-v0.md and trusted-setup-ceremony.md).
  - vs-auth0.md: replace the lone <https://zeroauth.dev> autolink
    with [zeroauth.dev](https://zeroauth.dev).

Verify: npm --prefix website run build → EXIT 0; the four files
that previously failed compile cleanly. The remaining warnings are
pre-existing broken-link references (onBrokenLinks: 'warn' in
config); they predate this change and stay out of scope.
The security-review.yml workflow calls github.rest.issues.listComments
to find a prior security-review comment (and updates it instead of
posting a new one each push). On the first run against PR #59 the
call returned 404 on /repos//issues/59/comments,
even though the PR is open and on the same repo. The cause is the
permissions block: `pull-requests: write` alone does not authorise
the /issues/{n}/comments endpoint, which GitHub gates on the
`issues` scope even when {n} is a pull-request number. GitHub
conceals access denial as 404 on this endpoint, so the failure
mode is confusing.

Adding `issues: write` to the permissions block (kept minimal:
contents read, pull-requests write for completeness, issues write
for the actual list/create/update calls) lets listComments,
createComment, and updateComment all succeed.

Verify: workflow YAML parses cleanly; will re-run on the next push
to dev. CI failure traces from run 26577163842 are the reference
case.
@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:

  • contracts/AuditAnchor.sol
  • contracts/test/AuditAnchor.test.ts
  • src/routes/console.ts
  • src/routes/v1/identity.ts
  • src/routes/v1/zkp.ts
  • src/services/identity.ts
  • src/services/jwt.ts
  • src/services/platform.ts
  • src/services/zkp.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.

@pulkitpareek18 pulkitpareek18 merged commit 133d85c into main May 28, 2026
5 checks passed
@pulkitpareek18 pulkitpareek18 deleted the dev branch May 28, 2026 13:52
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.

3 participants