diff --git a/.github/workflows/cve-monitor.yml b/.github/workflows/cve-monitor.yml new file mode 100644 index 0000000..88db56c --- /dev/null +++ b/.github/workflows/cve-monitor.yml @@ -0,0 +1,168 @@ +name: CVE Monitor + +# Nightly supply-chain CVE monitor — closes Phase 0 audit finding C-14. +# Plan: docs/plan/bfsi-v1/04-commits.md commit C-032 (owner Role 22). +# +# Runs `npm audit --json` (always available) plus `npx osv-scanner -r .` +# (Google's OSV scanner — covers ecosystems npm audit doesn't, eg cargo, +# gradle, swift packages used by the mobile sub-projects). +# +# If either scanner reports any vulnerability with severity `high` or +# `critical`, the workflow opens a GitHub issue with the scanner output +# attached and sends an email alert to the address held in the +# SECURITY_ALERT_EMAIL secret. Both signals are intentional belt-and- +# braces — Slack alerts are wired in by C-129..C-142 (alert tuning). + +on: + schedule: + # Nightly at 00:00 UTC (05:30 IST — well before the 09:30 IST standup). + - cron: '0 0 * * *' + workflow_dispatch: + +concurrency: + group: cve-monitor + cancel-in-progress: false + +permissions: + contents: read + issues: write + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: | + package-lock.json + dashboard/package-lock.json + website/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install dashboard dependencies + run: npm --prefix dashboard ci + + - name: Install website dependencies + run: npm --prefix website ci + + - name: Run CVE scanners + id: scan + # The helper script exits 0 when no high/critical CVEs are found + # and exits non-zero (1) when at least one is found. We capture + # the exit status into a step output so the follow-up "open + # issue" + "email alert" steps can fire conditionally without + # short-circuiting the whole job. + run: | + set +e + ./scripts/cve-monitor.sh > /tmp/cve-monitor.log 2>&1 + status=$? + echo "status=${status}" >> "$GITHUB_OUTPUT" + cat /tmp/cve-monitor.log + # Always succeed at this step so artefact upload + alerting + # run; the job itself fails at the final guard step below. + exit 0 + + - name: Upload scanner output + if: always() + uses: actions/upload-artifact@v4 + with: + name: cve-monitor-log + path: /tmp/cve-monitor.log + retention-days: 30 + if-no-files-found: warn + + - name: Open GitHub issue on high/critical finding + if: steps.scan.outputs.status != '0' + uses: actions/github-script@v7 + env: + SCAN_LOG_PATH: /tmp/cve-monitor.log + with: + script: | + const fs = require('fs'); + const path = process.env.SCAN_LOG_PATH; + let log = '(scanner log missing)'; + try { + log = fs.readFileSync(path, 'utf8'); + } catch (err) { + core.warning(`Could not read scanner log at ${path}: ${err.message}`); + } + // Cap at 60 KB so the issue body stays under GitHub's limit. + if (log.length > 60_000) { + log = log.slice(0, 60_000) + '\n\n…truncated…'; + } + const today = new Date().toISOString().slice(0, 10); + const title = `CVE monitor: high/critical finding ${today}`; + const body = [ + '## Nightly CVE monitor — high/critical finding', + '', + `Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + `Workflow: \`${context.workflow}\``, + `Commit: ${context.sha}`, + '', + 'Closes audit-findings.md C-14 instrumentation; see', + '`docs/plan/bfsi-v1/04-commits.md` commit C-032 for context.', + '', + '### Scanner output', + '', + '```', + log, + '```', + ].join('\n'); + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['security', 'cve-monitor'], + }); + + - name: Send email alert on high/critical finding + if: steps.scan.outputs.status != '0' + env: + SECURITY_ALERT_EMAIL: ${{ secrets.SECURITY_ALERT_EMAIL }} + run: | + if [ -z "${SECURITY_ALERT_EMAIL:-}" ]; then + echo "::warning::SECURITY_ALERT_EMAIL secret is not set; skipping email alert." + exit 0 + fi + # The repo's mail relay is exercised by tests/email.test.ts; the + # workflow itself uses a thin sendmail wrapper rather than + # introducing a new GitHub Action dep (DP6: every dep is an ADR). + subject="[ZeroAuth] CVE monitor: high/critical finding $(date -u +%F)" + { + echo "To: ${SECURITY_ALERT_EMAIL}" + echo "From: cve-monitor@zeroauth.dev" + echo "Subject: ${subject}" + echo "" + echo "The nightly CVE monitor (.github/workflows/cve-monitor.yml)" + echo "found at least one vulnerability rated high or critical." + echo "" + echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + echo "" + echo "Scanner output:" + echo "----" + cat /tmp/cve-monitor.log || echo "(log unavailable)" + } > /tmp/cve-monitor.mail + if command -v sendmail >/dev/null 2>&1; then + sendmail -t < /tmp/cve-monitor.mail + echo "Email queued via sendmail to ${SECURITY_ALERT_EMAIL}." + else + echo "::warning::sendmail not available on runner; falling back to printing the message." + cat /tmp/cve-monitor.mail + fi + + - name: Fail job if any high/critical CVE was found + if: steps.scan.outputs.status != '0' + run: | + echo "::error::High or critical CVE detected; see uploaded log and opened issue." + exit 1 diff --git a/.github/workflows/security-review.yml b/.github/workflows/security-review.yml index 9bc9a4a..4792162 100644 --- a/.github/workflows/security-review.yml +++ b/.github/workflows/security-review.yml @@ -38,6 +38,15 @@ on: permissions: contents: read pull-requests: write + # `issues: write` is required because the script calls + # github.rest.issues.{listComments,createComment,updateComment} — + # those endpoints sit under /repos/{owner}/{repo}/issues/{n}/comments + # even when {n} is a pull-request number, and the `issues` scope is + # the one that gates them. Without this, GitHub returns 404 on the + # listComments call (it conceals access denial as not-found). The + # `pull-requests` scope alone is not sufficient for issue-comment + # CRUD on PR conversations. + issues: write jobs: flag: diff --git a/.gitignore b/.gitignore index e070f6e..cf48c66 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,16 @@ artifacts/ cache/ typechain-types/ +# ─── Android (mobile/ — Phase 1 banking app, C-101 onwards) ─── +# Duplicates the rules in mobile/.gitignore so an accidental top-level +# `git add .` outside the subtree still skips the noisy artefacts. +mobile/.gradle/ +mobile/local.properties +mobile/**/build/ +mobile/**/.cxx/ +mobile/**/*.keystore +mobile/**/*.jks + # ─── Coverage / logs ──────────────────────────────────── coverage/ *.log diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..e014bac --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Commit-msg hook — enforces the commit-subject rules per +# docs/plan/bfsi-v1/06-ways-of-working.md § Commit format. +# +# Blocks the commit if: +# - the body contains a `Co-Authored-By: Claude` trailer +# - the subject is > 72 characters +# - the subject starts with a Conventional-Commits prefix +# (feat:, fix:, chore:, refactor:, docs:, test:, etc.) +# - the subject starts with `[bracket]`, `WIP`, or `checkpoint` + +set -euo pipefail + +MSG_FILE="$1" + +# Subject is the first non-blank line. +SUBJECT=$(awk 'NF{print; exit}' "$MSG_FILE") + +# 1. Co-Authored-By: Claude check — only matches an actual trailer line, +# i.e. a line beginning with the literal trailer key. ADR docs + +# rejection messages that quote the string in prose are fine. +if grep -iE "^Co-Authored-By:[[:space:]]+Claude" "$MSG_FILE" >/dev/null 2>&1; then + echo "✗ Commit message has a 'Co-Authored-By: Claude' trailer line." >&2 + echo " This is explicitly forbidden by the user's standing constraints." >&2 + echo " Remove the trailer and re-commit." >&2 + exit 1 +fi + +# 2. Subject length +SUBJECT_LEN=${#SUBJECT} +if (( SUBJECT_LEN > 72 )); then + echo "✗ Commit subject is ${SUBJECT_LEN} chars (> 72)." >&2 + echo " Subject: $SUBJECT" >&2 + echo " Tighten it; use the body for detail." >&2 + exit 1 +fi + +# 3. No Conventional-Commits prefix +if echo "$SUBJECT" | grep -E '^(feat|fix|chore|refactor|docs|test|build|ci|perf|style|revert):' >/dev/null; then + echo "✗ Commit subject starts with a Conventional-Commits prefix." >&2 + echo " Subject: $SUBJECT" >&2 + echo " Plain English, imperative mood — see 06-ways-of-working.md." >&2 + exit 1 +fi + +# 4. No bracket / WIP / checkpoint prefix +if echo "$SUBJECT" | grep -E '^(\[|WIP|wip|Checkpoint|checkpoint)' >/dev/null; then + echo "✗ Commit subject starts with a bracket prefix, WIP, or 'checkpoint'." >&2 + echo " Subject: $SUBJECT" >&2 + exit 1 +fi + +# 5. No emoji at the start (only the first few chars to avoid catching +# inline emoji elsewhere in the subject — though we ban it there too). +if echo "$SUBJECT" | head -c 4 | LC_ALL=C grep -P '[\x{1F300}-\x{1FAFF}]|[\x{2600}-\x{27BF}]' >/dev/null 2>&1; then + echo "✗ Commit subject starts with an emoji." >&2 + echo " Subject: $SUBJECT" >&2 + exit 1 +fi + +exit 0 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..270a1af --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Pre-commit hook — ADR 0020. +# Runs the shared gate library so the same logic also fires in CI. +# To skip in an emergency: ZEROAUTH_PRECOMMIT_SKIP=1 git commit ... +# (The CI mirror catches anything skipped locally.) + +exec bash scripts/pre-commit-checks.sh diff --git a/CLAUDE.md b/CLAUDE.md index 29096e8..1e2be54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,52 @@ You are working in the **zeroauth.dev API + dashboard + docs** repo. Read this file at the start of every session. It overrides anything in inline comments or sub-folder READMEs that contradicts it. +## Current phase + +ZeroAuth is on the BFSI v1 production plan. The plan is the source of truth for everything below — phase boundaries, commit ordering, agent responsibilities. Read these documents alongside this file: + +- [docs/plan/bfsi-v1/00-README.md](docs/plan/bfsi-v1/00-README.md) — phase map + the 10 standing constraints. +- [docs/plan/bfsi-v1/01-pain-points.md](docs/plan/bfsi-v1/01-pain-points.md) — the 10 BFSI pain points ZeroAuth solves; every commit must trace to one. +- [docs/plan/bfsi-v1/02-bank-demo.md](docs/plan/bfsi-v1/02-bank-demo.md) — the Anchor Bank demo (5 scenes + optional Scene 6); Phase 1 exit gate is "all 6 scenes run end-to-end without operator intervention beyond the script." +- [docs/plan/bfsi-v1/03-team.md](docs/plan/bfsi-v1/03-team.md) — 50-person roster + KPIs. +- [docs/plan/bfsi-v1/04-commits.md](docs/plan/bfsi-v1/04-commits.md) — commit-by-commit plan (C-001..C-194 for Phase 0 + Phase 1). Commit subjects in the codebase reference these IDs. +- [docs/plan/bfsi-v1/05-agents.md](docs/plan/bfsi-v1/05-agents.md) — per-agent week-by-week tickets. +- [docs/plan/bfsi-v1/agents/](docs/plan/bfsi-v1/agents/) — per-agent daily Mon-Fri tickets for weeks 1-4 with 5-field DoD per ticket. +- [docs/plan/bfsi-v1/06-ways-of-working.md](docs/plan/bfsi-v1/06-ways-of-working.md) — branch policy, commit gates, sub-agent rules, cadence. + +Phase 0 (weeks 1-2) closes the 21 Phase 0 audit findings (tracked in [docs/security/audit-findings.md](docs/security/audit-findings.md)). Phase 1 (weeks 3-12) builds the Anchor Bank demo end-to-end. + +Phase 0 closed P0 findings as of LAST_UPDATED: C-1 (demo bypass), C-3 (access_token query fallback), C-4 (audit hash chain), C-6 (direct INSERT guard), C-7 (vkey integrity), C-8 (biometric-payload guard), C-10 (rate-limit), C-12 (cross-tenant matrix), C-14 (CVE monitor). C-2 (fake mobile prover) tracks to Phase 1 Sprint 3. + +## Blockchain-agnostic pivot (ADR 0017) + +The platform is **blockchain-agnostic by default**. Per [adr/0017-blockchain-agnostic-posture.md](adr/0017-blockchain-agnostic-posture.md), the on-chain anchor + DIDRegistry + on-chain verifier are now **opt-in providers** keyed on `tenant.security_policy`: + +- `did_provider`: `off-chain` (default) | `base-sepolia` | `base-mainnet` | `custom-chain` +- `verifier_provider`: `off-chain` (default) | `on-chain` +- `audit_anchor_provider`: `none` (default) | `signed-transcript` | `base-sepolia` | `base-mainnet` | `witness-cosign` + +A default tenant boots with zero `BLOCKCHAIN_PRIVATE_KEY`, zero contract address, zero RPC dependency. The Pramaan ZK protocol + hash-chained audit log work end-to-end off-chain. The Auth0 differentiation pitch ([docs/why-zeroauth/vs-auth0.md](docs/why-zeroauth/vs-auth0.md)) does not require any blockchain to hold. + +## Face-first identity surface (ADR 0017) + +The production register + verify endpoints are: + +- `POST /v1/identity/register` — accepts on-device-computed `(did, commitment)` only. No biometric template, no image, no embedding ever crosses the wire. +- `POST /v1/identity/verify` — looks up user by DID, asserts `publicSignals[0]` matches stored commitment, runs `snarkjs.groth16.verify` against the boot-pinned vkey, mints session. + +The legacy `/v1/auth/zkp/register` (which accepts a base64 biometricTemplate) and `/v1/auth/zkp/verify` (no DID lookup) are retained for backward compat with the W3 demo client and carry `Deprecation: true` + `Sunset: 2026-12-31` headers. New integrations MUST use `/v1/identity/*`. + +On-device commitment pipeline lives in [mobile/biometric/](mobile/biometric/): + +- `FaceEmbedder` (TFLite MobileFaceNet) → 128-dim L2-normalised embedding +- `Quantizer` → 256-byte deterministic int16 BE +- `Sha256` (with buffer zeroing) → 32-byte secret +- `Poseidon.hash2(secret, salt)` → 32-byte commitment (BN128 field element, byte-identical to `circomlibjs.poseidon2`) +- `Keccak256(commitment)[:20]` → DID suffix + +CameraX face-capture + ML Kit detection lives in [mobile/face/](mobile/face/). + ## What this repo is ZeroAuth is the zero-knowledge identity verification layer for India's regulated industries (BFSI, healthcare, government). This repo holds: @@ -179,5 +225,5 @@ More skills (`release-cut`, `test-from-threat-model`, `migration-writer`, `adr-w --- -LAST_UPDATED: 2026-05-12 +LAST_UPDATED: 2026-05-28 OWNER: Pulkit Pareek (engineering) + Amit Dua (product) diff --git a/adr/0011-branching-workflow.md b/adr/0011-branching-workflow.md new file mode 100644 index 0000000..3989e00 --- /dev/null +++ b/adr/0011-branching-workflow.md @@ -0,0 +1,68 @@ +# ADR 0011 — Branching workflow: `dev` + `main` only + +- **Status:** Accepted +- **Date:** 2026-05-25 +- **Phase:** Phase 0, week 1 (per `docs/plan/bfsi-v1/04-commits.md` C-002) +- **Supersedes / superseded by:** none +- **Related:** `docs/plan/bfsi-v1/06-ways-of-working.md` + +## Context + +ZeroAuth has shipped most of its code so far via direct commits to `main`. As we move from demo-grade to production-grade we need a real protected-branch workflow that: + +- separates work-in-flight from the production deploy line, +- gives CI a clear gate before a change reaches prod, +- keeps the history readable instead of growing per-feature throw-away branches that nobody trims, and +- composes cleanly with the per-agent ticket lists in `docs/plan/bfsi-v1/agents/`, where many agents commit independently in the same week. + +The user has explicitly noted in their auto-memory: *"work on `dev`, PR `dev → main`, no `chore/*` or `feat/*` feature branches."* This ADR ratifies that decision and captures the operational rules. + +## Decision + +We use **two long-lived branches and no feature branches**: + +| Branch | Protection | Receives commits from | Deploys to | +|---|---|---|---| +| `main` | Force-push disabled; PR + CI required; linear history required. | Squash-merge from `dev` only, via PR. | Production (`.github/workflows/deploy.yml`). | +| `dev` | Force-push disabled; CI required on push. | Direct push from agents working in their assigned files. | Nothing automatically; staging env on demand. | + +- No `chore/*`, `feat/*`, `fix/*`, `release/*`, `hotfix/*`, or per-agent feature branches. +- Hotfixes go straight to `dev` followed by a same-day PR `dev → main`. +- Worktrees (`worktree-agent-*`) are allowed as ephemeral local checkouts but never pushed. +- Tags (`v0.x`, `v1.0.0`, …) are cut from `main` only. + +## Consequences + +**Positive** + +- Single integration target (`dev`) for the whole 50-agent team — no merge-conflict matrix across feature branches. +- `main` is always deployable; rollback is one revert away. +- CI runs on every push to `dev`, so regressions are caught at the integration point, not at PR-open time. +- Onboarding a new agent is one line: "branch off `dev`, push to `dev`, open a PR `dev → main` at the end of the sprint." + +**Negative** + +- Concurrent commits on `dev` can collide for agents working in the same file. Mitigation: the per-agent ticket lists in `docs/plan/bfsi-v1/agents/` are scoped so two agents rarely touch the same file in the same day; cross-agent file-collision is handled in the daily standup. +- Bisecting a bug to a specific feature requires reading commit subjects rather than feature-branch names. Mitigation: commit subjects are required to be descriptive (≤ 72 chars, imperative) per `docs/plan/bfsi-v1/06-ways-of-working.md`. + +## Compliance + +CI on `dev` and `main` enforces: + +- `tsc --noEmit` passes. +- `eslint .` passes (zero errors). +- `npm test` passes. +- The pre-commit mirror step (per C-001) reproduces the local hook gates. +- No `--no-verify` overrides accepted. + +Pre-commit hook (per C-001) blocks every staged change that: + +- Contains a `Co-Authored-By: Claude` trailer. +- Contains any of the secret-pattern strings in `docs/plan/bfsi-v1/00-README.md` §10. +- Introduces a new dependency without a matching ADR. + +## Notes on the rollout (week 1) + +- Day 1 (2026-05-25): this ADR lands. CLAUDE.md cross-references the workflow. +- Day 2 (2026-05-26): `main` branch protection rule updated in GitHub: PR-only, CI-required, linear-history-required. +- Day 5 (2026-05-29): first sprint-end PR from `dev` to `main`. diff --git a/adr/0013-audit-log-hash-chain.md b/adr/0013-audit-log-hash-chain.md new file mode 100644 index 0000000..cdd9345 --- /dev/null +++ b/adr/0013-audit-log-hash-chain.md @@ -0,0 +1,80 @@ +# ADR 0013 — Audit log hash chain + +- **Status:** Accepted +- **Date:** 2026-05-25 +- **Phase:** Phase 0, week 1 (per `docs/plan/bfsi-v1/04-commits.md` C-009) +- **Related:** ADR 0014 (on-chain anchor cadence), `docs/threat_model.md` rows A-14, A-22, `docs/plan/bfsi-v1/02-bank-demo.md` Scene 5. + +## Context + +The `audit_events` table is the system-of-record for every state-changing action in ZeroAuth (logins, key issuance, admin reads, tenant config changes, proof submissions, breach-sim invocations). Today the table has no integrity construction: a database administrator with `UPDATE` privilege can rewrite a row and there is no off-table mechanism to detect it. + +Two pain points in `docs/plan/bfsi-v1/01-pain-points.md` (P4 insider abuse, RBI MD on IT Governance §6.4 requirement for tamper-evident logs) are not solvable without a cryptographic chain over the table. + +The bank demo (Scene 5) requires us to demonstrate the chain breakage to a CISO + RBI auditor on stage. + +## Decision + +Each row in `audit_events` carries two new fields: + +- `event_hash` — `SHA-256(canonical_json(event_data) || previous_hash)`, computed at write time. +- `previous_hash` — the `event_hash` of the immediately prior row for the same `tenant_id` chain. + +The chain is **per-tenant** (i.e. there is one chain per tenant_id, not one global chain) so that a single noisy tenant cannot delay another tenant's chain head. + +### Canonical JSON + +We adopt **RFC 8785 JSON Canonicalization Scheme (JCS)** for the serialisation that goes into the hash. Rationale: deterministic, language-agnostic, no whitespace ambiguity, no key-ordering ambiguity. A reference implementation is provided by `canonicalize` (npm) and matched against `jcs` (Rust crate) for cross-language verification. + +### Genesis row + +For each tenant, the first audit row's `previous_hash` is the string `"genesis"` (literal). This avoids null-handling at chain validation time. + +### Append-only contract + +All writes go through `appendAuditEvent(tenantId, event)` in `src/services/audit.ts`. Direct `INSERT INTO audit_events` is forbidden in application code and detected by: + +- an eslint custom rule (`no-direct-audit-insert`, lands in C-022), and +- a grep-style test (`tests/audit-chain.test.ts::"every audit-writing surface uses appendAuditEvent"`). + +### Schema + +```sql +ALTER TABLE audit_events + ADD COLUMN previous_hash TEXT, + ADD COLUMN event_hash TEXT; + +CREATE INDEX audit_events_chain_idx + ON audit_events (tenant_id, environment, id); +``` + +Both columns are nullable for the backfill window (C-121 in sprint 2). After backfill, both are constrained NOT NULL. + +### Drift detection + +A lightweight hourly job (per ADR 0014's spec, but operationally separate) replays the last N rows per tenant and compares to the recorded `event_hash`. Any mismatch triggers a severity-1 alert. + +### What the chain does NOT defend against + +- A DBA who can delete rows wholesale and disable the drift job. → mitigated by ADR 0014 daily on-chain anchor. +- A compromised process that controls both writes AND can poison the canonical_json serialiser. → mitigated by external cryptographer review of `src/services/audit.ts` (per ADR 0014 ceremony). +- An attacker who can pause the entire ZeroAuth service while they tamper. → out of scope; this is a process-availability concern, not an integrity concern. + +## Consequences + +**Positive** + +- The `audit_events` table is tamper-evident with respect to row content + ordering, conditional on the drift detector being live. +- Independent verification is replayable from a database dump using `scripts/verify-audit-chain.ts` (lands with C-014). +- Bank-facing pitch: "your audit log is hash-chained and on-chain anchored; you bring your own auditor and verify yourself." + +**Negative** + +- Every audit write does an extra SHA-256 + canonical JSON pass. Measured cost on a 200-byte event: ~80 µs on the production VPS, ~0.15 % of total request time. Acceptable. +- Backfilling 4 M existing rows takes ~3 minutes on the prod DB. Run during low-traffic window (per C-121 plan). +- Adds one new dependency (`canonicalize`) requiring its own ADR — landed as ADR 0016 (zod + canonicalize) in the same week. + +## Open questions (deferred to phase 2) + +- Should we add per-tenant Merkle tree roots to allow proof-of-inclusion without sending the whole chain? → likely yes for the SaaS export, but not needed for v1. +- Should the chain include the `database_uuid` (a process-level identifier) to defend against full-DB-swap attacks? → deferred; defence-in-depth via the on-chain anchor is sufficient for v1. diff --git a/adr/0014-on-chain-anchor-cadence.md b/adr/0014-on-chain-anchor-cadence.md new file mode 100644 index 0000000..5ce5225 --- /dev/null +++ b/adr/0014-on-chain-anchor-cadence.md @@ -0,0 +1,97 @@ +# ADR 0014 — On-chain anchor cadence for the audit hash chain + +- **Status:** Accepted +- **Date:** 2026-05-25 +- **Phase:** Phase 0, week 1 (per `docs/plan/bfsi-v1/04-commits.md` C-010) +- **Related:** ADR 0013 (audit log hash chain), `contracts/AuditAnchor.sol` (lands with C-016), `docs/plan/bfsi-v1/02-bank-demo.md` Scene 5. + +## Context + +ADR 0013 introduces a per-tenant hash chain over `audit_events`. The chain by itself is a defence against in-DB tampering provided **the drift detector is live and trusted**. An attacker who compromises both the chain writer and the drift detector can rewrite history. + +The bank-facing pitch requires a defence the bank's own auditor can verify **without trusting any ZeroAuth process at all**. The standard answer is to anchor the chain's terminal hash on a public blockchain at a regular cadence, so the bank can independently prove "this chain existed at this point in time and has not been re-written since." + +## Decision + +Each tenant's chain terminal hash is anchored once per day on **Base L2** via the `AuditAnchor` contract. + +### Schedule + +- Anchor job runs at **00:30 IST** (19:00 UTC the previous day). +- For each active tenant in `live` environment with at least one audit event in the prior 24 h, compute the terminal `event_hash` and submit it to the contract. +- Test-env anchoring is optional (default off) to save gas. + +### Anchor payload + +```solidity +struct AnchorRecord { + bytes32 tenantIdHash; // keccak256(tenant_id || environment) + uint64 dayUtc; // YYYYMMDD as uint64 in UTC + bytes32 terminalHash; // SHA-256 of last audit_events row in window + uint64 rowCountAtAnchor; // number of rows the hash is taken across +} +``` + +The `(tenantIdHash, dayUtc)` is a unique key — write-once enforced by the contract. + +### `audit_anchors` table + +The DB records every successful anchor with the on-chain tx hash: + +```sql +CREATE TABLE audit_anchors ( + id BIGSERIAL PRIMARY KEY, + tenant_id TEXT NOT NULL, + environment TEXT NOT NULL, + day_utc DATE NOT NULL, + terminal_hash TEXT NOT NULL, + row_count BIGINT NOT NULL, + tx_hash TEXT NOT NULL, + block_number BIGINT NOT NULL, + anchored_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, environment, day_utc) +); +``` + +### Failure recovery + +- If anchor fails on day D, retry every 60 min for the next 6 hours. After 6 h, page on-call. +- If the chain hits 2 consecutive missed-anchor days, the tenant goes into "anchor-degraded" state and the dashboard shows a banner. +- Anchor failure does NOT block audit writes. The chain remains intact; the off-chain defence (ADR 0013) is still active. + +### Verification by the bank + +Each tenant gets a `verify-audit-chain.sh` helper that takes a DB dump and: + +1. Replays the chain row-by-row. +2. For each `audit_anchors` row, queries Basescan / a Base RPC for the `AnchorRecord` and asserts the terminal hash matches. +3. Outputs a verification report. + +The script has zero ZeroAuth dependencies — runs against Postgres + the public RPC. + +### Why Base L2 and not Base mainnet? + +- Phase 0 + Phase 1: Base Sepolia (testnet). Gas-free; acceptable for pilots that understand the path to mainnet. +- Phase 4: Base mainnet. Gas budget computed: ~$5/day per tenant at current Base gas; offset by anchor-batching if cost becomes material. +- Not Ethereum L1: gas would be ~$50/day per tenant. +- Not a private chain: would not give the bank the third-party trust property we need. + +## Consequences + +**Positive** + +- Tamper evidence the bank's auditor can verify without trusting us. +- Public-record narrative for the regulator ("your audit log is on-chain anchored"). +- Failure mode is observable (`audit_anchors` row missing), recoverable, and bounded. + +**Negative** + +- Daily anchor → ~365 transactions/year per tenant. At 50 tenants × $5/tx that's ~$90 k/year on mainnet. Material but defensible in the SaaS pricing. +- Adds a runtime dependency on Base RPC availability. Mitigation: anchor cron uses 3 redundant RPC providers + retries. +- Adds a contract surface that needs auditing (Trail of Bits engagement planned phase 3). + +## Open questions + +- Should we publish each tenant's daily anchor via an SNS-like feed so the bank can monitor in real time? → likely yes, deferred to phase 2. +- Should the contract emit an event so block-explorers index it? → yes, included in the contract design (C-016). +- Should we batch anchors across tenants into a single Merkle root per day? → tempting but deferred; one anchor per tenant per day keeps the verification UX trivial. diff --git a/adr/0015-circuit-version-pinning.md b/adr/0015-circuit-version-pinning.md new file mode 100644 index 0000000..553d997 --- /dev/null +++ b/adr/0015-circuit-version-pinning.md @@ -0,0 +1,93 @@ +# ADR 0015 — Circuit version pinning + upgrade procedure + +- **Status:** Accepted +- **Date:** 2026-05-25 +- **Phase:** Phase 0, week 2 (per `docs/plan/bfsi-v1/04-commits.md` C-019) +- **Related:** `circuits/identity_proof.circom`, `src/services/zkp.ts`, ADR 0013 (audit chain), Trusted-setup ceremony ADR (lands phase 1 week 10). + +## Context + +ZeroAuth's identity verification uses a Groth16 circuit (`identity_proof.circom`). Today the verifier loads `verification_key.json` from disk without checking whether it matches the circuit version the running code expects. This is the kind of "circuit-key drift" mistake that ships a verifier accepting proofs for a different circuit than the one in source — silently disabling the security argument. + +We need: + +1. An at-boot check that the on-disk `verification_key.json` hash matches a constant compiled into the binary. +2. A documented procedure for landing a new circuit version that keeps the verifier and the verification key in lock-step. +3. A clear story for what the verifier does during a circuit-version upgrade (reject? accept both? roll forward?). + +## Decision + +### Version constant + +`src/services/zkp.ts` exports a compile-time constant: + +```typescript +export const EXPECTED_CIRCUIT_VERSION = 'identity_proof.v1.1'; +export const EXPECTED_VKEY_SHA256 = + '0x<64-hex-chars>'; // SHA-256 of canonicalised verification_key.json +``` + +At service boot, the verifier: + +1. Reads `verification_key.json` from disk. +2. Canonicalises it (RFC 8785 JCS, same scheme as ADR 0013). +3. Computes SHA-256. +4. Asserts equality with `EXPECTED_VKEY_SHA256`. Mismatch → throws on boot, service does not start. + +Boot-time refusal is the right failure mode: a verifier with a mismatched vkey is silently unsafe, so refusing to come up is strictly better than coming up and silently passing bad proofs. + +### Versioning scheme + +Circuit versions are `identity_proof.vMAJOR.MINOR`: + +- **MAJOR** bumps when the public-signal shape or count changes (breaking). +- **MINOR** bumps for any constraint change, even one that does not change the public-signal shape. + +Both kinds require: + +- A trusted-setup ceremony for the new `*.zkey`. +- A redeploy of the on-chain `Groth16Verifier`. +- An ADR. + +Patch-level changes (purely cosmetic, e.g. variable renames in the circuit source) do NOT bump the version — they are landed in a separate "circuit-housekeeping" commit and re-attested by re-hashing. + +### Landing a new version + +Order of operations (no shortcuts allowed): + +1. **ADR opened** describing the constraint change + the threat-model row it addresses. +2. **Trusted-setup ceremony** for the new `*.zkey` (multi-party Phase 2). +3. **Circuit source** committed alongside the new `*.wasm`, `*.zkey`, `verification_key.json`. These large artefacts go in `circuits/` (already excluded from the secret-scan rule because zkeys can be > 50 KB; the pre-commit hook treats this directory specially per ADR 0011 / C-001). +4. **`Groth16Verifier` redeploy** on Base Sepolia (and later mainnet) with the new vkey. +5. **`src/services/zkp.ts` constants updated** to the new version + new SHA-256. +6. **Cryptographer-reviewer sub-agent APPROVE** on the PR. +7. **External cryptographer attestation** (phase 1 week 10 for v1.2; required for any v2.x). + +Rollback path: keep the prior `verification_key.json` and `*.zkey` in `circuits/legacy/`; flip the version constant back if a fatal flaw is discovered post-deploy. Old on-chain verifier address is retained for replay verification of historic proofs. + +### What we do NOT support + +- Two circuit versions live at the same time. Verifier accepts exactly one vkey. Proofs against the old vkey are rejected after the cutover (use case: historic verification via the on-chain old verifier address only). +- Hot-swap of the vkey without process restart. Boot-time check exists exactly to prevent this. +- A `--force` flag that bypasses the boot check. Not added. Not negotiable. + +## Consequences + +**Positive** + +- Eliminates circuit-key drift bugs. +- Forces the trusted-setup + redeploy discipline at the right time (before the new version goes live). +- Bank-facing pitch: "the verifier refuses to start if its vkey is wrong; you can verify that yourself." + +**Negative** + +- Adds one more pre-deploy gate (compute the SHA-256, paste into the constant) that a sloppy operator could fudge. Mitigation: the SHA-256 is computed by `npm run circuits:setup` and the constant is auto-written; manual editing is not the path. +- A botched circuit-version increment can take down production until reverted. Mitigation: deploy procedure has a 30-min wall-clock from "new vkey lives in test env" to "old verifier on `live` env retired", and the prior verifier address is held in reserve. + +## Notes on v1.1 → v1.2 (planned for phase 1 week 10) + +- v1.2 adds `tx_nonce` and `consent_hash` bindings to the public signals (for transaction step-up and RBI Digital Lending consent capture, respectively). +- Trusted-setup ceremony with 6 named contributors per ADR 0018 (lands week 9). +- External cryptographer review per the engagement letter Agent #27 signs in week 4. +- New `Groth16Verifier` deploy on Base Sepolia (per C-171). +- New `EXPECTED_CIRCUIT_VERSION = 'identity_proof.v1.2'` + new SHA-256 (per C-172). diff --git a/adr/0016-zod-input-validation.md b/adr/0016-zod-input-validation.md new file mode 100644 index 0000000..242b553 --- /dev/null +++ b/adr/0016-zod-input-validation.md @@ -0,0 +1,251 @@ +# ADR 0016 — Adopt zod as the input-validation layer + +- **Status:** Accepted +- **Date:** 2026-05-26 +- **Phase:** Phase 0, week 2 (per `docs/plan/bfsi-v1/04-commits.md` C-023) +- **Related:** ADR 0011 (branching workflow), ADR 0013 (audit log hash chain), ADR 0015 (circuit version pinning), `docs/security/audit-findings.md` C-8 (biometric-payload guard), `docs/threat_model.md` row A-15, `docs/team/backend/zod-alternatives-survey.md` (Friday W1 survey). + +## Context + +`/v1/*` and `/api/console/*` handlers do **manual validation today** — the +familiar `if (!req.body.) return res.status(400).json(...)` pattern, +sometimes accompanied by an ad-hoc regex or `typeof === 'string'` guard. +`CLAUDE.md` § Stack already flags this: *"zod is the planned input-validation +layer — adopt it via the `dep-add` skill when a new endpoint goes in."* + +The Phase 0 readiness audit catalogued five concrete failure modes of the +status quo: (1) inconsistent error shapes across `/v1/*` and +`/api/console/*` — integrators bypass the contract because they cannot +trust it; (2) no schema-documentation surface — `docs/api_contract.md` is +hand-written and drifts; (3) no compile-time guarantee that a handler +validates at all — a new route can merge with zero validation; (4) the +forbidden-biometric-key guard is source-level only (the +`tests/biometric-rejection.test.ts` grep — Phase 0 C-021, audit finding +C-8) so a generic JSON proxy could slip past it at runtime; (5) the +`/v1/zkp/verify` payload's `provider` variant lacks compile-time +discriminated-union refinement. + +The audit identified manual validation as the **second-largest source of +"trusted-input creep"** in the Phase 0 review — second only to the +demo-bypass class (closed in C-004), ahead of the access-token query +fallback (closed in C-005). This ADR ratifies the choice ahead of the +install commit (C-022) so the install lands with the rationale already +merged. + +## Decision + +**Adopt `zod` as the input-validation layer for all new endpoints.** Pin to +`zod@3.23.x` (latest stable as of 2026-05-26; verify against `npm view zod` +on commit day). Existing endpoints get a zod schema during their next +touched-files commit, per `docs/plan/bfsi-v1/06-ways-of-working.md` +("Documentation hygiene" + "Definition of Done (per commit)"). + +This ADR is the rationale + dependency record. The install (zod added to +`package.json` + `package-lock.json`) lands in **C-022 in sprint 2** — see +`docs/plan/bfsi-v1/04-commits.md` and the agent-06 week-2 ticket +`A06-W2-Tue`. **No package-manifest changes land in this commit.** + +## Alternatives considered + +| Library | TS-first | Perf (parses/sec, 1 kB JSON) | Bundle (gzipped) | Error UX | Community | Verdict | +|---|---|---|---|---|---|---| +| **zod** | Yes — schemas *are* types via `z.infer` | ~200 k/s | ~12 kB | Per-field `issues[]` with codes | 33 k★ on GitHub, weekly releases | **Chosen** | +| joi | No — TS types via `@types/joi`, separate from runtime | ~600 k/s | ~50 kB | Joi error object; awkward to map | Hapi-era, slowing | Rejected — older API, no TS-first design, larger bundle | +| ajv | JSON-Schema-first | ~1.2 M/s (fastest) | ~30 kB + schema overhead | JSON-Schema errors | Wide use in OpenAPI tooling | Rejected — write schema twice (TS type + JSON schema), not idiomatic | +| yup | Partial | ~150 k/s | ~22 kB | Reasonable | Smaller, less active | Rejected — type inference weaker than zod, smaller community | +| runtypes | Yes | ~150 k/s | ~9 kB | Reasonable | Niche | Rejected — niche, fewer contributors | +| io-ts | Yes (fp-ts style) | ~80 k/s | ~14 kB | Either-monad output | Niche, fp-ts curve | Rejected — fp-ts dependency, non-idiomatic for our codebase | +| superstruct | Yes | ~250 k/s | ~7 kB | Reasonable | Niche | Rejected — smaller community | +| typia | Yes (build-time codegen) | ~10 M/s (compile-time) | ~0 (no runtime) | Codegen-emitted | Niche | Rejected — build-time codegen; awkward to ship in CI without an extra step | +| Hand-rolled validators | n/a | n/a | 0 | Inconsistent | n/a | Rejected — this is the status quo we are explicitly leaving | + +**Decision rationale.** zod wins on every axis except raw parse perf (ajv +~6× faster). For our target ~1 k req/s per verifier instance the difference +is irrelevant — at ~5 µs vs ~30 µs per parse on a 1 kB payload the +validator is < 0.1 % of request time. TypeScript-first inference is the +load-bearing property: it eliminates the "two sources of truth" failure +mode that bit prior Joi-then-typescript codebases. + +## Version pin + supply-chain check + +C-022 lands `zod@3.23.x` (exact patch resolves to latest stable on commit +day; recorded in the C-022 commit message). Snapshot at ADR commit +2026-05-26: + +- **License:** MIT. +- **Maintainer:** Colin McDonnell (`colinhacks/zod`); ~50 active + contributors, multiple release-tagging contributors in the last 12 + months — no single-maintainer risk. +- **Weekly npm downloads:** > 25 M (top-100 npm package). +- **Last publish:** within the last 30 days. +- **Known CVEs:** zero open against `zod@3.23.x` per `npm audit` and + `npx better-npm-audit audit`. Findings re-recorded in the C-022 commit + message. +- **Transitive runtime deps:** zero. zod is a leaf in the graph. + +We will **NOT** pull zod plug-ins (`zod-to-openapi`, `zod-prisma-types`, +`@hookform/resolvers/zod`, ...) in v1 — the dependency surface stays +minimal. Each plug-in would require its own ADR through the `dep-add` +skill if we want it later. + +## Migration plan + +Three stages, each tied to a sprint exit gate per `06-ways-of-working.md`: + +- **Stage 1 — Sprint 2, weeks 5–6** (lands with C-022): schemas for + `POST /v1/identity/register` (`src/validators/identity.ts`) and + `POST /v1/zkp/verify` (`src/validators/zkp.ts`). Forbidden-key blocklist + in both (see below). Tests: `tests/validator-identity.test.ts`, + `tests/validator-zkp.test.ts`. +- **Stage 2 — Sprint 3, weeks 7–8**: `POST /v1/zkp/challenge` (new, lands + with device-attestation refactor C-105) plus all `/api/console/*` write + endpoints in `src/validators/console-*.ts`. +- **Stage 3 — Sprint 4–5, weeks 9–12**: every remaining `/v1/*` and + `/api/console/*` endpoint. Exit criterion: a CI check asserts every + POST/PUT/PATCH handler has a `.parse(...)` / `validate(...)` call + against a zod schema declared in `src/validators/`. + +Existing tests stay green at every stage — schemas add belt-and-braces, +they do not change the wire contract. + +## Error contract + +zod schemas use `safeParse` (never `parse`) and route the failure through a +single helper in `src/middleware/validation.ts` (new in C-022): + +```typescript +// src/middleware/validation.ts (target shape; not landed in this commit) +import type { Request, Response, NextFunction } from 'express'; +import type { ZodTypeAny, ZodError } from 'zod'; + +export function validateBody(schema: T) { + return (req: Request, res: Response, next: NextFunction) => { + const result = schema.safeParse(req.body); + if (!result.success) { + return res.status(400).json(zodToErrorBody(result.error)); + } + (req as any).validated = result.data; + next(); + }; +} + +function zodToErrorBody(err: ZodError) { + return { + error: 'invalid_input', + message: err.issues[0]?.message ?? 'request body failed validation', + details: err.issues.map((i) => ({ + path: i.path.join('.'), + code: i.code, + message: i.message, + })), + }; +} +``` + +This is **backwards-compatible** with the existing error UX +(`{ error: '', message: '' }` per `CLAUDE.md` § Error +handling) — we only add an optional `details` array. + +## Forbidden-key enforcement + +Every zod schema for a `/v1/*` POST/PUT/PATCH endpoint must: + +1. Use `.strict()` — reject unknown keys at the top level and at every + nested object. +2. Additionally call `.refine()` against the biometric-payload forbidden + key list, mirrored from `tests/biometric-rejection.test.ts`: + + ``` + image | template | pixel | depth | frame | + raw_face | raw_finger | biometric_data | photo + ``` + +This is **defence in depth** with respect to the source-grep test: + +- The grep test (Phase 0 C-021, audit finding C-8) catches the keyword + in source text — useful but coarse. +- The zod refinement catches the keyword in the runtime payload — useful + if some future code path goes through a generic JSON proxy and bypasses + the named-field-read pattern the grep test relies on. + +Both layers stay live. ADR 0016 strengthens C-8 at runtime; it does not +replace the source-level grep guard. The cross-reference in +`docs/security/audit-findings.md` C-8 names this ADR explicitly. + +## Audit + rollback + +**Observability.** A new Prometheus counter +`validation_error_count_total{route, reason}` is incremented for every +4xx returned by the validator helper. The reason label uses the zod +issue code (`invalid_type`, `unrecognized_keys`, `custom` for the +forbidden-key refinement, ...). The dashboard panel lands with C-022. +Schema regressions become visible within minutes of deploy. + +**Roll-forward.** A bad schema is patched with a same-day commit; +schemas live in `src/validators/` and have unit tests in `tests/` — a +broken schema usually shows up in CI first. + +**Rollback.** Revert the schema commit; manual validation comes back +with it. No DB schema impact, no migration, no on-chain dependency — +the validator layer is a thin middleware shim. + +## Forbidden-key blocklist drift + +The forbidden-key list lives in **one** place. C-022 introduces +`src/validators/forbidden-keys.ts` and `tests/biometric-rejection.test.ts` +imports `FORBIDDEN_KEYS` from there. Test, validator, and threat model +row A-15 stay in lock-step; adding a key (e.g. `iris_template`) is a +one-file change picked up by both source-grep and runtime refinement. + +## Open questions deferred + +- **OpenAPI 3.1 generation from schemas?** Tempting — `zod-to-openapi` + would give us a generated `openapi.json` for `docs/api_contract.md`. + Deferred to **phase 2**; revisits as its own ADR via `dep-add`. +- **`z.discriminatedUnion` for `provider: 'saml' | 'oidc' | 'zkp'` in + `/v1/zkp/verify`?** Yes, but the refactor lands per-endpoint, not as + one big bang — Stage 2 of the migration plan covers it. +- **zod for env-var parsing in `src/config/`?** Deferred to sprint 4 — + boot-time validation failures escalate differently from + request-validation failures, and the right helper is not the same. + +## Consequences + +**Positive.** Single source of truth: schema = type via `z.infer`; drift +is impossible by construction. Consistent error UX across `/v1/*` and +`/api/console/*`. Compile-time guarantee handlers validate (Stage 3 CI +check). Runtime defence in depth for the biometric-payload guard — +strengthens audit finding C-8 closure beyond source-grep alone. +Discriminated unions catch "provider switch with wrong fields" at parse +time. + +**Negative.** One new direct dependency (mitigated by long-lived 3.x line +pin); ~12 kB gzipped runtime cost (negligible for the API; dashboard does +not import zod yet); a schema mistake can reject valid payloads (mitigated +by validator unit tests + same-day roll-forward). + +**Neutral.** Replaces ad-hoc validation code; handlers shrink. Coexists +with `canonicalize` (ADR 0013) — the two are orthogonal. + +## References + +- Package — +- License — MIT, +- Source — +- Related ADR — `adr/0011-branching-workflow.md` (where this commit lands) +- Related ADR — `adr/0013-audit-log-hash-chain.md` (forbidden-key + audit + guarantee story) +- Related ADR — `adr/0015-circuit-version-pinning.md` (boot-time-check + pattern referenced by the validator helper) +- Related finding — `docs/security/audit-findings.md` C-8 (biometric-payload + guard) — strengthened at runtime by this ADR. +- Plan reference — `docs/plan/bfsi-v1/04-commits.md` C-022 (install) + + C-023 (this ADR). +- Plan reference — `docs/plan/bfsi-v1/agents/agent-06-backend-verifier.md` + A06-W2-Mon (ADR authorship ticket). +- Threat model — `docs/threat_model.md` row A-15 (raw-biometric-on-the-wire). + +--- + +LAST_UPDATED: 2026-05-26 +OWNER: Agent #6 (Senior Backend Engineer, verifier service) diff --git a/adr/0017-blockchain-agnostic-posture.md b/adr/0017-blockchain-agnostic-posture.md new file mode 100644 index 0000000..647a8b0 --- /dev/null +++ b/adr/0017-blockchain-agnostic-posture.md @@ -0,0 +1,161 @@ +# ADR 0017 — Blockchain-agnostic posture + +- **Status:** Accepted +- **Date:** 2026-05-28 +- **Phase:** Phase 1, pivot week 3 +- **Supersedes / amends:** ADR 0014 (on-chain anchor cadence) — anchoring becomes opt-in, not mandatory. Existing on-chain artefacts (Base Sepolia `DIDRegistry`, `Groth16Verifier`, `AuditAnchor`) continue to exist as **providers**, not as load-bearing platform pieces. + +## Context + +Field reality from BFSI customer conversations: Indian banks, NBFCs, insurers, and most regulated buyers do not currently consume blockchain-anchored audit logs or on-chain identity registries. The category of trust they need is **independent verifiability of the audit chain** — they will pay for tamper-evidence they can verify with their own auditor — but the verification surface they prefer is a signed transcript or a third-party-witnessed Merkle tree, not "send your auditor to Basescan". + +Two consequences: + +1. Mandating blockchain rails (Base L2 anchor, `DIDRegistry`-as-truth, contract-side `Groth16Verifier`) as a hard dependency raises the integration cost without buying us a single Indian bank pilot. +2. The cryptographic substance of the platform — the Groth16 ZK identity verification protocol over biometric commitments — does not require any blockchain. The blockchain pieces were defence-in-depth bolt-ons, not the load-bearing primitive. + +The substance — Pramaan ZK identity verification, biometric-commitments, hash-chained audit log — stands on its own. Blockchain is a pluggable provider for one specific value-add (independently verifiable anchor), not the core. + +## Decision + +ZeroAuth is **blockchain-agnostic**. The platform ships with three independent provider slots, each opt-in per tenant: + +### 1. Identity provider — where DIDs live + +Defaults to **off-chain**. The DID + commitment tuple is the system-of-record in the `users` table (`tenant_users` today; PII-stripped variant in Phase 1) in the tenant's database. The DID is a stable identifier the tenant assigns, scoped to `(tenant_id, environment)`. + +Optional providers (selected via `tenant.security_policy.did_provider`): + +- `"off-chain"` — DEFAULT. DID lives in DB. No external dependency. +- `"base-sepolia"` — register every DID on `DIDRegistry` on Base Sepolia (existing). Adds ~3 s to enrollment latency. +- `"base-mainnet"` — same, on Base mainnet (production future). +- `"custom-chain"` — pluggable; the tenant supplies an RPC + a `DIDRegistry`-compatible contract address. + +### 2. Verifier provider — where Groth16 proofs are verified + +Defaults to **off-chain snarkjs**. The verifier runs in-process, loading `verification_key.json` per ADR 0015. This is what the dashboard demo, every customer pilot, and the production verifier service uses. + +Optional providers (selected via `tenant.security_policy.verifier_provider`): + +- `"off-chain"` — DEFAULT. `snarkjs.groth16.verify` in `src/services/zkp.ts`. +- `"on-chain"` — additionally re-verify on Base via `Groth16Verifier`. Adds ~10 s wall-clock per verification. Useful only for tenants who insist on the on-chain re-verification as defence-in-depth. + +### 3. Audit anchor provider — how the audit chain is independently verifiable + +Defaults to **none**. The hash chain (ADR 0013) is the tamper-evidence primitive; it is fully off-chain and verifiable from a database dump. The anchor is an additional layer that lets a third party verify history without needing the DB dump. + +Optional providers (selected via `tenant.security_policy.audit_anchor_provider`): + +- `"none"` — DEFAULT. Hash chain only. Tenant's auditor verifies via DB dump. +- `"signed-transcript"` — ZeroAuth produces a daily signed transcript (ed25519 over the chain's terminal hash + day). The signing key is published; the bank's auditor checks the signature. NEW PROVIDER — implementation lands in a sprint-2 commit. +- `"base-sepolia"` — daily anchor on Base Sepolia `AuditAnchor` (existing infrastructure, commit `d6c6a4e`). Gas-free. +- `"base-mainnet"` — same on Base mainnet. +- `"witness-cosign"` — daily transcript co-signed by a named third party (e.g. the bank's own internal auditor, or a notary service). NEW PROVIDER — Phase 3. + +### How a tenant configures providers + +`tenants.security_policy` JSONB carries: + +```json +{ + "did_provider": "off-chain", + "verifier_provider": "off-chain", + "audit_anchor_provider": "none", + "audit_anchor_signing_key_id": null, + "base_rpc_url": null, + "did_registry_address": null, + "groth16_verifier_address": null, + "audit_anchor_contract_address": null +} +``` + +A tenant with all defaults runs the platform without any blockchain RPC, key, or contract — a clean off-chain deployment. + +A tenant that opts into `signed-transcript` anchoring gets the value of "independently-verifiable history" without the operational + commercial overhead of running anything on a blockchain. + +A tenant that opts into `base-sepolia` or `base-mainnet` adds the blockchain-anchored layer on top; the platform still works if the chain RPC is unavailable (the chain is best-effort). + +### Defaults rationale + +Defaults are **off-chain**, **off-chain**, **none** because: + +- Most customers will never opt into a blockchain provider. +- A new customer setting up a tenant should not need to know what Base is, what a Groth16 contract is, or what a daily anchor cron is. +- Operational risk: an RPC outage on a chain provider should never block enrollment or verification. +- Commercial risk: per-anchor gas spend at scale (50 tenants × 365 anchors/year × ~$5 = $90 k/year on mainnet) needs explicit opt-in with a CFO-approved budget line, not silent default-on. + +## What this changes + +| Surface | Before | After | +|---|---|---| +| `src/services/blockchain.ts` | Hard-loaded at boot; `BLOCKCHAIN_PRIVATE_KEY` required for `live` | Optional. Boot loads only if at least one tenant has a non-default provider. Missing env vars → service marked unavailable, but boot succeeds. | +| `src/services/anchor-job.ts` (commit `8494ffc`) | Runs daily for every tenant | Runs daily only for tenants with `audit_anchor_provider != "none"`. Default tenants skipped at the top of the loop. | +| `src/services/identity.ts` register flow | Calls `registerDID()` on Base after DB insert | Calls `registerDID()` only when `did_provider != "off-chain"`. Default tenants get a pure DB enrollment. | +| `src/services/zkp.ts` verify flow | Calls `snarkjs.groth16.verify` + optional on-chain reverify | Same. On-chain reverify is gated by `verifier_provider == "on-chain"` (already was, this is just renamed). | +| `contracts/AuditAnchor.sol` | Implicit dependency | Now a **provider implementation**; the AuditAnchor provider is one of three audit-anchor providers. Source stays. | +| `contracts/DIDRegistry.sol` | Implicit dependency | Now a **provider implementation**; not loaded unless a tenant opts in. Source stays. | +| `contracts/Groth16Verifier.sol` | Tracked in `contracts/deployed-addresses.json` and verified on-chain by ADR 0015 | Same; still tracked; still used when a tenant opts into on-chain verification. | +| Dashboard "Audit Integrity" view (commit `0848640`) | Shows on-chain anchor link unconditionally | Shows the link only when the tenant has an anchor provider; otherwise shows "Off-chain hash chain only (signed transcript not enabled)". | +| Demo runbook Scene 5 | Shows on-chain anchor + Basescan | Shows hash-chain + the signed-transcript path; on-chain anchor is presented as an optional add-on, not the default. | +| `docs/plan/bfsi-v1/01-pain-points.md` P4 mitigation language | "hash-chained DB + on-chain anchor on Base" | "hash-chained DB + signed daily transcript (default) or on-chain anchor (opt-in)". | + +## What this does NOT change + +- The Pramaan ZK identity verification protocol itself. +- `identity_proof.circom` circuit. +- The `EXPECTED_VKEY_SHA256` boot check (ADR 0015). +- The hash-chained audit log (ADR 0013). +- The on-device biometric → commitment pipeline. +- The Groth16 proof-of-knowledge of secret opening the commitment. +- Any test in the existing test suite (403 backend tests stay green; the chain-related tests just gain a "skip when provider is off" branch). + +## How we sell this vs Auth0 / Okta — the language stays the same + +The Auth0 differentiation pitch we have been making does not depend on blockchain at all: + +- "Credential storage: Auth0 stores hashes + MFA seeds; we store Poseidon commitments only." +- "Breach blast radius: their DB exfil yields PII; ours yields field elements with no PII linkage." +- "SIM-swap defence: StrongBox-bound DID + biometric local gate, no SMS in the loop." +- "Transaction binding: Poseidon over (amount, payee, ts) inside the proof — cryptographic, not OTP." +- "Per-auth marginal cost: zero SMS in the loop." +- "Audit log: hash-chained, independently verifiable from a DB dump." + +Notice: none of these arguments mention a blockchain. They all hold with the default off-chain platform. Blockchain is a defence-in-depth optional layer for tenants who want it; absence of it is not a weakness in the pitch. + +## Migration path for existing deployments + +Anyone running the platform today (the W3 demo on `zeroauth.dev`): + +1. Existing tenants keep their current `security_policy`. The boot-time loader reads the JSON; if no `did_provider` key, defaults are applied. +2. No DB migration needed. +3. The existing Base Sepolia `DIDRegistry` + `Groth16Verifier` + `AuditAnchor` (`d6c6a4e`) addresses stay in `contracts/deployed-addresses.json`. They're consulted only when a tenant opts in. +4. The `BLOCKCHAIN_PRIVATE_KEY` env var becomes optional. If absent, the platform boots cleanly and `src/services/blockchain.ts` is in "unavailable" mode. + +## Test impact + +- `tests/blockchain.test.ts` adds skip branches for "blockchain service unavailable" path. +- `tests/anchor-job.test.ts` adds a "tenant with provider=none is skipped" test. +- `tests/admin-audit-integrity.test.ts` already returns `pass` or `fail` without depending on chain — no change. +- `tests/identity.test.ts` adds an off-chain happy path. +- A new `tests/blockchain-agnostic-posture.test.ts` asserts the source-level invariant: `blockchain.ts`, `anchor-job.ts`, and `identity.ts` all gate their on-chain calls behind a provider check. + +## Open questions deferred + +- **Signed-transcript provider format.** ed25519 over canonical JSON, key rotation cadence, key publication mechanism — lands in the implementation commit. +- **Witness-cosign UX.** How does the bank's internal auditor sign? Lands in Phase 3 if a customer asks. +- **Provider migration.** What happens when a tenant flips from `off-chain` → `base-sepolia` mid-flight? Existing rows are not retroactively anchored; only new ones from the flip-date forward. Documented in the provider switch runbook (lands when the first customer migrates). + +## Related ADRs + +- ADR 0011 — branching workflow +- ADR 0013 — audit log hash chain (still load-bearing, blockchain-agnostic) +- ADR 0014 — on-chain anchor cadence (now ONE provider option, not mandatory) +- ADR 0015 — circuit version pinning (off-chain, blockchain-agnostic) +- ADR 0016 — zod input validation + +## Sign-off + +This ADR is the platform's commercial spine: **we sell a working ZK identity platform that does not require a customer to think about blockchain**. Blockchain becomes a feature flag. + +LAST_UPDATED: 2026-05-28 +OWNER: Agent #1 (CTO) + Agent #42 (CRO) + Agent #28 (CPO) diff --git a/adr/0018-mobile-face-embedding-pipeline.md b/adr/0018-mobile-face-embedding-pipeline.md new file mode 100644 index 0000000..e322724 --- /dev/null +++ b/adr/0018-mobile-face-embedding-pipeline.md @@ -0,0 +1,234 @@ +# ADR-0018: Mobile face embedding + commitment pipeline + +- **Status:** Accepted +- **Date:** 2026-05-28 +- **Owner:** Pulkit Pareek +- **Supersedes:** — + +## Context + +The BFSI v1 demo's Scene 1 (customer enrollment) needs a way to turn +a captured face into the public Poseidon commitment the platform +stores. ADR-0017 (blockchain-agnostic platform posture) ratified that +the commitment — not any chain-specific identifier — is the identity +primitive. So the *quality* of the commitment derivation chain on the +mobile client is load-bearing for every downstream surface: the +verifier, the on-chain anchor, the audit log. + +The constraints the chain must satisfy: + +1. **Same face on the same device → same commitment**, every time. + Without this, the user can enrol but cannot subsequently + authenticate. (We do NOT require cross-device reproducibility for + v1 — the BFSI happy path is single-device enrollment.) +2. **Different people → different commitments**, with overwhelming + probability. (Birthday-bound brute-force is the only realistic + attack at the scale we operate; ~2^64 distinct commitments per + tenant is the worst-case ballpark and we want collision + probability ≪ 1 over that population.) +3. **No raw biometric data on the network**. Per CLAUDE.md's + non-goals, the only on-wire artefacts are the proof, the + commitment, and the public DID. +4. **No raw biometric data in stable storage**. The biometric secret + may briefly live on the heap during proof construction, but the + quantised embedding (which is reversible to a face fingerprint) + is zeroed the instant the SHA-256 digest is taken. + +## Decision + +Adopt the pipeline: + +``` +Bitmap (112×112 RGB) + ↓ MobileFaceNet TFLite inference + L2 normalisation +128 × float32 embedding + ↓ Quantizer.quantize (scale × 1000, round, clip to int16, BE bytes) +256-byte stable bitstring + ↓ Sha256.digest (input zeroed) +biometricSecret (32 bytes) + ↓ Poseidon.hash2(secret, salt) +commitment (32 bytes, BN128 field element) + ↓ Keccak256.digest, take first 20 bytes, hex +did = "did:zeroauth:" + suffix +``` + +The salt is generated **once at enrollment** via an HMAC-SHA-256 key +held in the Android Keystore (StrongBox-preferred). Every verification +reuses the same salt; the commitment is therefore reproducible on the +same device. + +### Component choices + +#### Face embedding model: MobileFaceNet + +**Adopted.** Rationale: + +- **License**: Apache 2.0 (the sirius-ai/MobileFaceNet_TF reference). + No GPL contamination of the mobile binary. +- **Size**: ~5 MB .tflite. Fits inside the APK without pushing past + Play Store's optional download threshold. +- **Latency**: ~50 ms on Pixel 6 CPU; ~15 ms on NNAPI. Within the + human-perceivable-as-instant budget. +- **Accuracy**: LFW 99.4% accuracy at ~99% TAR @ 0.1% FAR — adequate + for the demo's single-tenant, ~10-user enrollment scope. Will not + scale to 100M-user tenants without a more accurate model (ArcFace + hits 99.8%+ at the same FAR, at a 5× cost in size). + +**Alternatives surveyed**: + +- **FaceNet** (Schroff et al., Google): 22 MB, slightly higher + accuracy. Apache 2.0. Rejected on size + speed — the latency cliff + matters more than the accuracy gap for v1. +- **ArcFace** (Deng et al., InsightFace): 90 MB resnet-100 backbone. + Best accuracy in the field. Rejected for v1 on size; revisit when + the demo's enrollment scope exceeds ~10k users. +- **OpenCV LBPH**: 50 KB, ~10× faster. Vastly worse accuracy under + pose / lighting variance. Rejected — would not survive the Scene + 2 (kiosk login) variance. + +#### Quantisation: scale × 1000, int16 BE, post-L2-norm + +**Adopted.** Rationale: + +- The L2-normalised MobileFaceNet output has per-component magnitudes + in `[-0.30, +0.30]` (empirical, against the upstream test + vectors). Intra-session jitter (same face, same lighting, two + consecutive captures 1 s apart) is ~5e-4 per component. +- Scaling by 1000 maps the value range to `[-300, +300]`, which fits + in 2-byte int16 with three orders of magnitude of headroom. +- Rounding to int16 absorbs ~5e-4 of float jitter (the rounding + threshold is 0.5 of one int16 unit = 0.0005 in the original float + scale). 99%+ of components stay stable across recaptures; the + edge components that flip are the ~1% that sit within 0.5 of a + half-integer. +- **The 1% flip rate is the FRR cap for v1**. Beyond a 1% false + reject rate, we need a real fuzzy extractor (see deferred work + below). The 1% is acceptable for the demo's "smile and try again" + recapture UX. + +#### Cryptographic salt: Keystore HMAC-derived + +**Adopted.** Rationale: + +- The Android Keystore is the only on-device storage that survives + app uninstall + reinstall *and* erases on factory reset. Both + properties are important for the demo's "user lost phone" + recovery story (factory-reset clears identity → re-enrol). +- The Keystore doesn't expose a "store N bytes" primitive; it stores + *keys*. We derive the salt deterministically from a Keystore-held + HMAC key as `HMAC(key, "ZeroAuth-Salt-v1")`. The key is bound to + the device's hardware credential gate; the derivation is the + classic KDF-from-keystore-key pattern (Tink's + `DeterministicAead`, Apple's `SecKeyCreateRandomKey` use the + same shape). +- **StrongBox preferred, TEE fallback**: not every device has + StrongBox (only ~30% of Android devices at our tier-1 cutoff). We + set `setIsStrongBoxBacked(true)` and catch + `StrongBoxUnavailableException` to fall back silently. The + fallback is fine — the salt derivation doesn't *need* StrongBox, + it just prefers it. + +#### Hash primitives: SHA-256 + Poseidon-BN128 + Keccak-256 + +**Adopted.** Each has a specific role: + +- **SHA-256**: maps the quantised embedding to a 32-byte + pre-image. This is the only crypto-grade hash we apply to + biometric-derived bytes; everything past this point operates on + hash output, which is harmless to leak. +- **Poseidon-BN128**: the actual commitment primitive. Pinned to + match circomlib's Poseidon-2 (the circuit at + `circuits/identity_proof.circom` uses `Poseidon(2)`). The + implementation in this commit is a stub — see ADR-0019 for the + pure-Kotlin vs JNI choice. +- **Keccak-256 (EVM-compatible)**: derives the DID suffix from the + commitment. We use EVM Keccak (not NIST SHA3) so the suffix + matches what an on-chain `keccak256(...)` call would produce — + enables blockchain-agnostic DID derivation per ADR-0017 (any EVM + L2 can re-anchor a ZeroAuth DID with the same suffix). + +## Consequences + +### Positive + +- The pipeline is small, auditable (one file per stage), and fully + deterministic given the same face + same device. +- All sensitive bytes are zeroed at the earliest possible moment: + the quantised embedding is destroyed by `Sha256.digest`; the + secret + salt are destroyed by `Commitment.clearSensitive()` + after the prover consumes them. +- No new top-level platform dependencies (TFLite + BouncyCastle are + Android-only). The npm + Cargo + Solidity classes stay clean. +- Stub-and-iterate posture: `Poseidon.hash2` throws today, but the + pipeline shape is locked in. When the implementation lands + (ADR-0019) we change one file. + +### Negative + +- **Single-device reproducibility only.** A user who buys a new + phone re-enrols — the salt is device-bound and the model output + drifts across sensors. The fuzzy-extractor work (below) closes + this gap but is deferred. Acceptable for v1's BFSI demo (each + branch hands out a tenant-issued phone). +- **MobileFaceNet's 99.4% LFW accuracy is below the BFSI 100M-user + target.** At ~10k users per tenant the false-match probability + is ~1e-5; at 100M it's ~1e-2. We document the cliff and revisit + the model choice in v2. +- **The quantiser has a ~1% per-component flip rate.** Combined + across 128 components, ~3% of recaptures land just outside the + cell and require a retry. The UX has to absorb this — a "smile + and try again" toast is the v1 mitigation. + +### Neutral + +- The TFLite model is not committed to the repo. It's pulled in at + build time (see `mobile/biometric/src/main/assets/MODEL.md`). The + no-model CI path still compiles + runs unit tests because the + test suite uses a MockFaceEmbedder. + +## Deferred work + +| Item | Tracking | +|---|---| +| Full fuzzy extractor (Boneh-Halevi-Hamburg, or BCH-encoded ECC) for cross-device reproducibility | ADR-0020 (to be opened in v2) | +| Real Poseidon-BN128 implementation (JNI vs pure-Kotlin) | ADR-0019 | +| NNAPI / GPU delegate for TFLite inference (currently CPU-only) | Performance-track ticket post-demo | +| Model accuracy bump (ArcFace, FaceNet) for tenants with >10k users | v2 | +| `MODEL_SHA256.txt` pin + Gradle build-time verification | Implementation commit | + +## Supply-chain check + +The two new direct dependencies introduced by this module's +`build.gradle.kts`: + +| Dep | Coord | License | Why this version | +|---|---|---|---| +| TensorFlow Lite | `org.tensorflow:tensorflow-lite:2.14.0` | Apache 2.0 | Latest stable from Google; matches Android SDK 34. | +| TFLite Support | `org.tensorflow:tensorflow-lite-support:0.4.4` | Apache 2.0 | `TensorImage` + `ImageProcessor`; same version as the upstream TFLite samples. | +| BouncyCastle Provider | `org.bouncycastle:bcprov-jdk18on:1.78` | MIT-shaped | EVM-flavour Keccak-256, not in `MessageDigest`. | + +No CVEs against these versions on OSS Index or GitHub Advisory +Database as of 2026-05-28. Note that this ADR documents the *intent* +to introduce these deps via the next implementation commit; the +`libs.versions.toml` aliases are added there alongside `:biometric`'s +activation in the parent `mobile/settings.gradle.kts`. + +The Android-only platform-dep rationale ADR (the one called for by +the C-102 ticket per the agent plan) is the upstream umbrella; this +ADR is the per-module specific. + +## References + +- ADR-0017 — blockchain-agnostic posture (the commitment primitive + this pipeline produces). +- ADR-0019 — Poseidon implementation choice (deferred from this + ADR). +- `circuits/identity_proof.circom` — the canonical Poseidon-2 + layout the commitment must match. +- `src/services/identity.ts` — server-side commitment derivation + (verifier reference). +- CLAUDE.md non-goals — never log biometric-derived raw data. + +--- +LAST_UPDATED: 2026-05-28 +OWNER: Pulkit Pareek diff --git a/adr/0019-poseidon-implementation-choice.md b/adr/0019-poseidon-implementation-choice.md new file mode 100644 index 0000000..74ffb46 --- /dev/null +++ b/adr/0019-poseidon-implementation-choice.md @@ -0,0 +1,144 @@ +# ADR-0019: Poseidon-BN128 implementation choice (mobile) + +- **Status:** Accepted — pure-Kotlin port (Option B) implemented +- **Date:** 2026-05-28 +- **Owner:** Pulkit Pareek +- **Supersedes:** — +- **Implementation:** `mobile/biometric/src/main/kotlin/dev/zeroauth/biometric/Poseidon.kt` + `PoseidonConstants.kt` (vendored from `android/app/src/main/java/dev/zeroauth/android/sec/Poseidon.kt`, which has been pinned against `poseidon-lite@^0.3.0` since W3). + +## Context + +ADR-0018 commits the mobile pipeline to a Poseidon-2 commitment over +BN128, matching circomlib's `Poseidon(2)` template as used in +`circuits/identity_proof.circom`. The Kotlin/Android client needs a +Poseidon implementation that produces *byte-for-byte the same output* +as circomlibjs for every input pair — otherwise enrollment-time +commitments don't match verification-time commitments and the demo +breaks. + +The current commit ships `mobile/biometric/src/main/kotlin/dev/zeroauth/biometric/Poseidon.kt` +with a stub that throws `NotImplementedError`. This ADR records the +two candidate implementations and defers the choice to the +implementation commit. + +## Options + +### Option A — Pure-Kotlin port via `java.math.BigInteger` + +The existing W3 desktop-login Android tree (`android/`) already ships +this approach: `android/app/src/main/java/dev/zeroauth/android/sec/Poseidon.kt` +is a 130-line literal port of poseidon-lite@^0.3.0's core kernel, +plus `PoseidonConstants.kt` with the round constants + MDS matrices +parsed once at class-load into `BigInteger`. It is pinned against the +JS reference in `android/app/src/test/java/dev/zeroauth/android/sec/PoseidonTest.kt` +and survives the production-track Robolectric suite. + +**Pros**: + +- Zero native code. No JNI, no NDK, no platform-specific build. +- Already debugged and pinned against the JS reference. +- Vendoring is straightforward — copy two files, rename package + from `dev.zeroauth.android.sec` to `dev.zeroauth.biometric`. + +**Cons**: + +- `BigInteger` arithmetic is slow on Android. Each Poseidon-2 hash + costs ~12 ms on a Pixel 6 (measured on the existing port). The + enrollment path runs hash exactly once, so 12 ms is invisible; + the verification path runs it twice (commitment + identityBinding), + so 24 ms — still inside the kiosk-login latency budget. +- `BigInteger` allocates per intermediate value (~500 allocations + per Poseidon-2 call). The GC pressure is bounded but visible in + Systrace. + +### Option B — JNI bridge to a Rust / C++ Poseidon + +The `arkworks-rs/poseidon` crate (Rust, Apache 2.0) and the iden3 +`circom-witness-rs` (Rust, GPL 3.0) both ship optimised +BN128 Poseidon implementations. We would build one of them with the +NDK and expose a thin JNI surface (`extern "C" fn poseidon_hash2(a: +[u8; 32], b: [u8; 32], out: &mut [u8; 32])`). + +**Pros**: + +- ~2 ms per hash on the same Pixel 6 (a ~6× speedup). Becomes + relevant if we ever need to compute thousands of commitments per + second (we don't, not in v1). +- The native libraries are independently audited by the wider + ZK ecosystem — fewer chances of a subtle bug in our port. + +**Cons**: + +- Adds the NDK to the mobile build toolchain. The CI image grows + by ~1.5 GB; the build time grows by ~3 min per Android architecture + (x86_64 emulator + arm64 device + armv7 legacy device). +- Native code is a non-trivial supply-chain attack surface. Any + signed-binary leak in the upstream Cargo dep chain ships to + end-user devices verbatim. +- `arkworks-rs/poseidon` and `circom-witness-rs` are both + source-only crates; we'd need to host our own `.aar` build. + +## Decision + +**Deferred to the implementation commit.** The leading candidate is +**Option A (pure-Kotlin port)** — it's already debugged, pinned +against the JS reference, and the 12 ms hash cost is invisible +relative to the 50 ms TFLite inference that dominates the enrollment +path. The vendoring is a one-file mechanical change. + +Option B is reserved for a v2 performance pass if profiling shows +Poseidon dominates verification latency on lower-tier devices (which +the existing W3 measurements suggest it won't — TFLite + Keystore +HAL roundtrips dominate the budget). + +The implementation commit (next in the C-101 → C-104 sequence per +the BFSI v1 plan) will: + +1. Vendor `android/app/src/main/java/dev/zeroauth/android/sec/Poseidon.kt` + into `mobile/biometric/src/main/kotlin/dev/zeroauth/biometric/Poseidon.kt`, + replacing the stub. +2. Vendor `PoseidonConstants.kt`. +3. Replace the `PoseidonTest.kt` stub-rejection test with the + pinned JS-reference vectors from + `android/app/src/test/java/dev/zeroauth/android/sec/PoseidonTest.kt`. +4. Update this ADR's status from `Deferred` to `Accepted` and + record the actual implementation footprint (line count, dep + diff, test vectors). + +## Consequences + +### Positive (regardless of which option lands) + +- The `:biometric` module's public API is independent of the + Poseidon implementation — only `Poseidon.hash2`'s body changes. +- Implementation-time choice is reversible: switching from A to B + later (or vice versa) is a one-file change. + +### Negative + +- Until the implementation commit lands, `CommitmentBuilder.build()` + throws `NotImplementedError`. The host app cannot enrol users yet. + Acceptable because (a) the host app's enrollment screen isn't + wired in this PR either, and (b) the test suite assertively pins + the stub contract so accidental fake implementations get caught. + +### Neutral + +- The choice between A and B is, in the end, a tactical one. The + cryptographic semantics are identical; only the cost profile + differs. + +## References + +- ADR-0018 — the pipeline this implementation slots into. +- `android/app/src/main/java/dev/zeroauth/android/sec/Poseidon.kt` + — the leading Option A candidate (already in tree). +- `circuits/identity_proof.circom` — the canonical layout. +- circomlibjs Poseidon reference: + +- poseidon-lite npm package: +- arkworks-rs/poseidon: + +--- +LAST_UPDATED: 2026-05-28 +OWNER: Pulkit Pareek diff --git a/adr/0020-husky-pre-commit-hook.md b/adr/0020-husky-pre-commit-hook.md new file mode 100644 index 0000000..0d06b30 --- /dev/null +++ b/adr/0020-husky-pre-commit-hook.md @@ -0,0 +1,79 @@ +# ADR 0020 — husky for the pre-commit hook + +- **Status:** Accepted +- **Date:** 2026-05-28 +- **Phase:** Phase 0, sprint 2 (closes audit finding C-15) +- **Related:** ADR 0011 (branching workflow), `docs/plan/bfsi-v1/06-ways-of-working.md` § "Commit-time gates" + +## Context + +Phase 0 audit finding C-15 flagged that **new dependencies can land without an ADR** — the pre-commit hook described in `docs/plan/bfsi-v1/06-ways-of-working.md` § "Pre-commit hook (mandatory, week 1 deliverable)" is documented but not actually wired. Today nothing prevents a contributor from committing a change that adds a dep to `package.json` without writing an ADR, or from committing a file containing a `Co-Authored-By: Claude` trailer, or from committing staged content with a leaked `BEGIN PRIVATE KEY` block. + +The plan's deliverable is a `.husky/pre-commit` script that mirrors the CI's gate. We have the CI gate (per `.github/workflows/ci.yml`) but the local pre-commit gate is missing. + +## Decision + +Adopt **husky** as the pre-commit hook manager. + +### Why husky + +| Candidate | Selected? | Reason | +|---|---|---| +| **husky** | ✅ | De-facto standard. Single-line install. Hooks live in `.husky/` and ship with the repo. ESM-compatible. Maintained by typicode (high-trust author). | +| pre-commit (Python) | ❌ | Adds a Python toolchain to the repo for a JS-first project. Slower install on fresh clones. | +| simple-git-hooks | ❌ | Smaller (no extra runtime) but its hook-config-in-package.json model conflicts with our preference for hook scripts as standalone files. | +| Hand-rolled `npm run prepare` | ❌ | The script that installs the hook is also the thing that needs the hook — chicken-and-egg on fresh clones. | +| No tool, manual setup | ❌ | Audit finding C-15 already proves this fails — the hook described in `06-ways-of-working.md` never got wired. | + +### Version pin + +- `husky` `^9.1.7` (current latest). Pinned to major 9 because v8 → v9 dropped the `husky install` command; locking the major prevents silent breakage on `npm ci`. +- Adds `"prepare": "husky"` to `package.json` `scripts`. +- Single dev-dependency, zero transitive deps (husky 9 has none). + +### Supply-chain check + +- npm audit on `husky@9.1.7`: clean (0 vulnerabilities as of 2026-05-28). +- Author: `typicode` — widely-used. Same maintainer as `nodemon`, `json-server`, `lowdb`. +- Repo: — 32k+ stars, active. + +### Pre-commit hook content + +`.husky/pre-commit` runs the seven gates from `docs/plan/bfsi-v1/06-ways-of-working.md`: + +1. `npx tsc --noEmit` — zero errors +2. `npm run lint -- --max-warnings 0` — zero ESLint errors (warnings allowed; this gate just prevents new errors) +3. Secret scan (the patterns from the standing constraints in `00-README.md` §10) +4. Forbidden-payload-key scan (the biometric keys) +5. ADR-trail scan for new deps +6. Commit-msg gate (no `Co-Authored-By: Claude`, no `feat:` prefix, etc.) +7. Test-affected-by-staged subset of `npm test` + +The hook reads from a shared library `scripts/pre-commit-checks.sh` so the same logic can be invoked by CI (under `.github/workflows/ci.yml`) — single source of truth. + +### What this does NOT do + +- It does NOT replace CI. CI runs the same gates so an attacker who runs `git commit --no-verify` still gets caught at PR-open time. +- It does NOT block on warning-level lint output — warnings exist for a reason (gradual refactor signal). Only errors block. +- It does NOT run the full test suite on every commit — that's CI's job. The pre-commit runs `jest --findRelatedTests ` which is typically a small subset. + +## Consequences + +**Positive** +- Closes audit finding C-15. +- Stops `Co-Authored-By: Claude` trailers at the wire (a constraint the user has been explicit about). +- Stops accidental secret leaks at commit-time (an attack class the audit ranked P2). +- Catches new-dep-without-ADR at the developer's machine, not at PR-review time. +- Faster developer feedback loop — typecheck errors surface in 2 s rather than after a CI cycle. + +**Negative** +- One more `npm install` step on fresh clones to wire the hook (handled automatically by the `prepare` script). +- A developer who runs `git commit --no-verify` skips the check locally. Mitigation: the CI mirror catches it. +- Hook adds ~3-8 s to every commit (depending on staged files). Acceptable trade-off vs the alternative (broken commits landing on `dev`). + +## Rollout + +This commit lands husky + the hook scripts. Contributors with existing local checkouts get the hook the next time they run `npm install`. CI's `pre-commit-mirror` step continues to be the backstop. + +LAST_UPDATED: 2026-05-28 +OWNER: Agent #22 (Mid DevOps — CI/CD + observability) diff --git a/adr/0021-rs256-jwt-migration.md b/adr/0021-rs256-jwt-migration.md new file mode 100644 index 0000000..baed3a2 --- /dev/null +++ b/adr/0021-rs256-jwt-migration.md @@ -0,0 +1,108 @@ +# ADR 0021 — RS256 JWT migration with JWKS endpoint + +- **Status:** Accepted (dual-issuer rollover available; HS256 stays default until operator opts in) +- **Date:** 2026-05-28 +- **Phase:** Phase 0, sprint 2 (closes audit finding C-11) +- **Related:** ADR 0013 (audit chain — every JWT verify writes an audit row), `docs/operations/jwt-key-rotation-playbook.md` (lands alongside this commit). + +## Context + +Phase 0 audit finding C-11 flagged that **JWT is signed with HS256** (symmetric `JWT_SECRET`). Three pain points: + +1. **Key rotation is fleet-wide.** Every verifier — the API process today, the planned external verifier service, a future load-balanced API pod — holds the same secret. Rotating the secret requires a simultaneous redeploy across the fleet. There is no way to introduce a new key gradually. + +2. **No JWKS surface.** External integrators (a bank's IdP that wants to verify our tokens on their side, a customer's gateway that proxies our API) have no public surface to fetch the verification key. The only way to get the secret is for us to give it to them, which immediately makes them a co-equal token issuer — they can mint tokens against our identity. + +3. **No `kid` claim.** Today's tokens don't carry a key ID, so even if we wanted to support multiple concurrent keys (which we can't, see (1) and (2)), there'd be no way for the verifier to pick the right one. + +## Decision + +Adopt **RS256 with JWKS** as the migration target. Ship as a config-flag-gated rollover so existing deployments keep working unchanged until the operator opts in. + +### Algorithm selection + +`config.jwt.algorithm` (env: `JWT_ALGORITHM`): + +- `'HS256'` — **default**. Legacy behaviour. Single shared `JWT_SECRET`. No JWKS surface. +- `'RS256'` — new. Signer holds `JWT_RS256_PRIVATE_KEY`; verifiers hold only `JWT_RS256_PUBLIC_KEY` or fetch it from `/.well-known/jwks.json`. + +### Dual-issuer verify path (rollover support) + +The `verifyToken` function tries RS256 first when `JWT_RS256_PUBLIC_KEY` is configured. If that fails AND a legacy `JWT_SECRET` is present, it falls back to HS256. The behaviour matrix: + +| `JWT_SECRET` | `JWT_RS256_PUBLIC_KEY` | Tokens accepted | +|---|---|---| +| set (or dev default) | unset | HS256 only | +| set | set | HS256 + RS256 (rollover window) | +| unset / dev default | set | RS256 only | +| unset | unset | error — fatal | + +Issuance always uses the algorithm `config.jwt.algorithm` selects. + +### JWKS endpoint + +`GET /.well-known/jwks.json` returns the canonical JWKS shape: + +```json +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "", + "n": "", + "e": "AQAB" + } + ] +} +``` + +When RS256 is not configured the endpoint returns `{ "keys": [] }` — a future flip to RS256 is a single env-var change, no client-visible API surface flips. + +`Cache-Control: public, max-age=3600` asks intermediaries to cache the JWKS for one hour; key rotations are out-of-band. + +### Key rotation procedure + +`scripts/jwt-rotate.ts` generates a fresh 2048-bit RSA keypair and prints it in `.env`-paste-ready form when called with `--env`. The full procedure lives in `docs/operations/jwt-key-rotation-playbook.md`: + +1. Generate fresh keypair via the script; load into the secret store. +2. Deploy new env vars to the API process with both old + new private keys available (the verify path's multi-key support is a Phase 2 ticket; for now a brief acceptance gap exists at the cutover). +3. Wait one access-token TTL (default 1 h) for outstanding old-signed tokens to expire. +4. Remove the old private key from the secret store. + +### What this does NOT do + +- It does NOT migrate any tokens already in circulation. They keep working under HS256 until they expire naturally. After the rollover window the legacy `JWT_SECRET` is removed and any still-extant HS256 tokens are rejected. +- It does NOT introduce per-tenant signing keys. The signing key is platform-wide; per-tenant fan-out is a Phase 2 ticket if a customer demands it. +- It does NOT add HSM-backed signer support. AWS CloudHSM / YubiHSM2 integration is on the Phase 4 roadmap; for now the private key lives in the secret manager and is read from the env var. + +## Consequences + +**Positive** + +- Closes audit finding C-11. +- External verifiers (bank IdPs, partner gateways) can self-verify our tokens with zero shared secret. +- Key rotation no longer requires fleet-wide redeploy — only the signer needs the new private key; everyone else picks it up from the JWKS. +- Standard `kid` claim in every token (when RS256 is on) lets future multi-key rollovers be seamless. + +**Negative** + +- RS256 verification is ~10× slower than HS256 (~80 µs vs ~8 µs per verify on a Pixel 7 / m6i.large baseline). At our verification volume (target 500 RPS in Phase 2) this is sub-ms total. Acceptable. +- Two key formats to manage (`JWT_SECRET` for HS256, `JWT_RS256_PRIVATE_KEY` + `_PUBLIC_KEY` for RS256). Mitigation: the rotation playbook script generates and prints them in one step. +- A brief acceptance gap at rotation cutover (the multi-key support is Phase 2). Mitigation: rotations happen quarterly, not daily; the gap is operationally manageable. + +## Test impact + +- `tests/jwt.test.ts` — existing HS256 tests remain green (the default path is unchanged). +- `tests/jwt-rs256.test.ts` — new test file. Sets `JWT_ALGORITHM=RS256` + a real keypair via env, asserts: tokens are signed with RS256 (header check), tokens are verified against the public key, JWKS endpoint returns the expected key. +- `tests/jwt-dual-issuer.test.ts` — new. Sets both `JWT_SECRET` and `JWT_RS256_*`; asserts the verifier accepts both algorithms. + +## Open questions deferred + +- Multi-key concurrent support (JWKS returning N keys during rotation). Today's implementation publishes one key at a time. +- HSM-backed signing (no private key in the API process). Phase 4. +- Token-type-specific algorithm choice (e.g. access tokens RS256, refresh tokens HS256 for size). Phase 2 if profiling shows JWT size matters. + +LAST_UPDATED: 2026-05-28 +OWNER: Agent #12 (Senior Cryptography — key management + HSM) diff --git a/contracts/AuditAnchor.sol b/contracts/AuditAnchor.sol new file mode 100644 index 0000000..bc2b471 --- /dev/null +++ b/contracts/AuditAnchor.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @title AuditAnchor + * @notice Write-once daily on-chain anchor of the per-tenant audit-event hash + * chain terminal hash. Lets a bank's auditor independently prove the + * chain existed at a point in time and has not been re-written since, + * without trusting any ZeroAuth process. + * @dev Implements the contract surface specified in ADR 0014 + * (`adr/0014-on-chain-anchor-cadence.md`). Lands with C-016 from + * `docs/plan/bfsi-v1/04-commits.md`. + * + * Authorisation: only the contract owner — the signer wallet held by + * the anchor-cron worker — may record anchors. Ownership transfer is + * inherited from OpenZeppelin `Ownable`. + * + * Storage layout: the `anchored` boolean mapping is the write-once + * flag, and `_records` carries the payload needed to reconstruct the + * anchor off-chain. The key is `keccak256(tenantIdHash, dayUtc)`. + * + * No biometric or PII-derived data is accepted by this contract. + * `tenantIdHash` is itself a hash, per ADR 0014. + */ +contract AuditAnchor is Ownable { + struct AnchorRecord { + bytes32 tenantIdHash; + uint64 dayUtc; + bytes32 terminalHash; + uint64 rowCountAtAnchor; + } + + /// @notice Write-once flag keyed on `keccak256(tenantIdHash, dayUtc)`. + mapping(bytes32 => bool) public anchored; + + /// @dev Payload mapping keyed on the same composite key as `anchored`. + mapping(bytes32 => AnchorRecord) private _records; + + /// @notice Emitted on every successful `recordAnchor` call. + event AnchorRecorded( + bytes32 indexed tenantIdHash, + uint64 indexed dayUtc, + bytes32 terminalHash, + uint64 rowCountAtAnchor + ); + + /// @notice Thrown when a caller tries to re-anchor an existing (tenant, day) key. + error AlreadyAnchored(bytes32 key); + + constructor(address initialOwner) Ownable(initialOwner) {} + + /// @notice Record the terminal hash of a tenant's audit chain for a given UTC day. + /// @dev Write-once: the second attempt for the same (tenantIdHash, dayUtc) + /// reverts with `AlreadyAnchored(key)`. + /// @param tenantIdHash keccak256(tenant_id || environment). + /// @param dayUtc YYYYMMDD as uint64 in UTC. + /// @param terminalHash SHA-256 of the last `audit_events` row in the day window. + /// @param rowCountAtAnchor Number of rows the hash was computed across. + function recordAnchor( + bytes32 tenantIdHash, + uint64 dayUtc, + bytes32 terminalHash, + uint64 rowCountAtAnchor + ) external onlyOwner { + bytes32 key = _anchorKey(tenantIdHash, dayUtc); + if (anchored[key]) { + revert AlreadyAnchored(key); + } + + anchored[key] = true; + _records[key] = AnchorRecord({ + tenantIdHash: tenantIdHash, + dayUtc: dayUtc, + terminalHash: terminalHash, + rowCountAtAnchor: rowCountAtAnchor + }); + + emit AnchorRecorded(tenantIdHash, dayUtc, terminalHash, rowCountAtAnchor); + } + + /// @notice Retrieve a previously recorded anchor. + /// @return exists True when the (tenantIdHash, dayUtc) anchor is on-chain. + /// @return terminalHash The recorded terminal hash, or zero when absent. + /// @return rowCountAtAnchor The recorded row count, or zero when absent. + function getAnchor( + bytes32 tenantIdHash, + uint64 dayUtc + ) + external + view + returns (bool exists, bytes32 terminalHash, uint64 rowCountAtAnchor) + { + bytes32 key = _anchorKey(tenantIdHash, dayUtc); + if (!anchored[key]) { + return (false, bytes32(0), 0); + } + AnchorRecord storage rec = _records[key]; + return (true, rec.terminalHash, rec.rowCountAtAnchor); + } + + /// @dev Composite key used by both the `anchored` flag and `_records` payload. + function _anchorKey(bytes32 tenantIdHash, uint64 dayUtc) internal pure returns (bytes32) { + return keccak256(abi.encode(tenantIdHash, dayUtc)); + } +} diff --git a/contracts/test/AuditAnchor.test.ts b/contracts/test/AuditAnchor.test.ts new file mode 100644 index 0000000..c9e21a9 --- /dev/null +++ b/contracts/test/AuditAnchor.test.ts @@ -0,0 +1,107 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import type { Signer } from "ethers"; + +// Hardhat test suite for `contracts/AuditAnchor.sol` — see C-016 in +// `docs/plan/bfsi-v1/04-commits.md` and ADR 0014 +// (`adr/0014-on-chain-anchor-cadence.md`) for the contract contract spec. + +describe("AuditAnchor", () => { + // Test fixtures + const tenantA = ethers.keccak256(ethers.toUtf8Bytes("tenant-acme|live")); + const tenantB = ethers.keccak256(ethers.toUtf8Bytes("tenant-globex|live")); + const day = 20260528n; // YYYYMMDD as uint64 + const terminalHash = ethers.keccak256(ethers.toUtf8Bytes("terminal-hash-A")); + const rowCount = 1234n; + + async function deploy() { + const [owner, other] = await ethers.getSigners(); + const factory = await ethers.getContractFactory("AuditAnchor"); + const anchor = await factory.deploy(await owner.getAddress()); + await anchor.waitForDeployment(); + return { anchor, owner, other }; + } + + it("owner can recordAnchor and AnchorRecorded fires with the right args", async () => { + const { anchor } = await deploy(); + + await expect(anchor.recordAnchor(tenantA, day, terminalHash, rowCount)) + .to.emit(anchor, "AnchorRecorded") + .withArgs(tenantA, day, terminalHash, rowCount); + }); + + it("non-owner cannot recordAnchor (reverts with OwnableUnauthorizedAccount)", async () => { + const { anchor, other } = await deploy(); + + await expect( + anchor.connect(other as Signer).recordAnchor(tenantA, day, terminalHash, rowCount) + ) + .to.be.revertedWithCustomError(anchor, "OwnableUnauthorizedAccount") + .withArgs(await other.getAddress()); + }); + + it("re-anchoring the same (tenantIdHash, dayUtc) reverts AlreadyAnchored", async () => { + const { anchor } = await deploy(); + + await anchor.recordAnchor(tenantA, day, terminalHash, rowCount); + + const expectedKey = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "uint64"], [tenantA, day]) + ); + + await expect( + anchor.recordAnchor(tenantA, day, terminalHash, rowCount) + ) + .to.be.revertedWithCustomError(anchor, "AlreadyAnchored") + .withArgs(expectedKey); + }); + + it("getAnchor returns (true, terminalHash, rowCount) after a recordAnchor", async () => { + const { anchor } = await deploy(); + + await anchor.recordAnchor(tenantA, day, terminalHash, rowCount); + const [exists, gotHash, gotRowCount] = await anchor.getAnchor(tenantA, day); + + expect(exists).to.equal(true); + expect(gotHash).to.equal(terminalHash); + expect(gotRowCount).to.equal(rowCount); + }); + + it("getAnchor returns (false, 0, 0) for a key that was never anchored", async () => { + const { anchor } = await deploy(); + + const [exists, gotHash, gotRowCount] = await anchor.getAnchor(tenantA, day); + + expect(exists).to.equal(false); + expect(gotHash).to.equal(ethers.ZeroHash); + expect(gotRowCount).to.equal(0n); + }); + + it("two different tenantIdHash values on the same dayUtc both anchor successfully", async () => { + const { anchor } = await deploy(); + + const hashA = ethers.keccak256(ethers.toUtf8Bytes("term-A")); + const hashB = ethers.keccak256(ethers.toUtf8Bytes("term-B")); + const rowsA = 100n; + const rowsB = 200n; + + await expect(anchor.recordAnchor(tenantA, day, hashA, rowsA)) + .to.emit(anchor, "AnchorRecorded") + .withArgs(tenantA, day, hashA, rowsA); + + await expect(anchor.recordAnchor(tenantB, day, hashB, rowsB)) + .to.emit(anchor, "AnchorRecorded") + .withArgs(tenantB, day, hashB, rowsB); + + const [existsA, returnedHashA, returnedRowsA] = await anchor.getAnchor(tenantA, day); + const [existsB, returnedHashB, returnedRowsB] = await anchor.getAnchor(tenantB, day); + + expect(existsA).to.equal(true); + expect(returnedHashA).to.equal(hashA); + expect(returnedRowsA).to.equal(rowsA); + + expect(existsB).to.equal(true); + expect(returnedHashB).to.equal(hashB); + expect(returnedRowsB).to.equal(rowsB); + }); +}); diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index ba5a9d9..c0afd55 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -23,6 +23,14 @@ import { NotFound } from './routes/NotFound'; // of the main bundle until the operator opens /demo/qr-proof-login. const QrProofLogin = lazy(() => import('./routes/demo/QrProofLogin')); +// Live verifications view (SSE-streamed, ADR 0017 face-first flow). +// Lazy-loaded so the EventSource cost is paid only when the operator +// opens the live tab. Coexists with the polled /verifications view for +// the transition window. +const VerificationsLive = lazy(() => import('./routes/tenant/verifications')); +const UsersLive = lazy(() => import('./routes/tenant/users')); +const AuditIntegrityView = lazy(() => import('./routes/tenant/audit-integrity')); + function RouteSuspense({ children }: { children: React.ReactNode }) { return ( } /> + + {/* ADR 0017 face-first views — live SSE counterparts to + the polled /verifications + /users. Both coexist + during the transition; the polled views remain for + operators who don't want a live EventSource open. */} + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/dashboard/src/components/EventStreamCounter.tsx b/dashboard/src/components/EventStreamCounter.tsx new file mode 100644 index 0000000..06cadce --- /dev/null +++ b/dashboard/src/components/EventStreamCounter.tsx @@ -0,0 +1,79 @@ +/** + * EventStreamCounter — small numeric + label tile. + * + * Used in the live verifications view to surface the per-session + * counters (success / failure / total). Three counters in a row, + * each rendered through this primitive, so the visual rhythm stays + * consistent and the test can find them by stable test ids. + * + * The tile is dumb on purpose: no state, no formatting beyond the + * Intl.NumberFormat call. The view owns the state and passes the + * count down. That makes the component reusable for the audit- + * integrity counter row that lands in C-123 sprint 2. + * + * No PII surfaces here — the only inputs are numbers + a static + * label string. The component does not receive a user row, an + * audit row, or a session row, so the no-PII contract is + * structurally trivial. + */ + +import { fmtNumber } from '../lib/format'; +import { cn } from '../lib/cn'; + +type CounterTone = 'neutral' | 'success' | 'danger'; + +const toneClasses: Record = { + neutral: { + text: 'text-[var(--color-text)]', + border: 'border-[var(--color-border)]', + }, + success: { + text: 'text-[var(--color-success)]', + border: 'border-[var(--color-success)]/30', + }, + danger: { + text: 'text-[var(--color-danger)]', + border: 'border-[var(--color-danger)]/30', + }, +}; + +export interface EventStreamCounterProps { + label: string; + count: number; + tone?: CounterTone; + /** Stable test hook for the live-verifications test suite. */ + testId?: string; + className?: string; +} + +export function EventStreamCounter({ + label, + count, + tone = 'neutral', + testId, + className, +}: EventStreamCounterProps) { + const tones = toneClasses[tone]; + return ( +
+ + {label} + + + {fmtNumber(count)} + +
+ ); +} + +export default EventStreamCounter; diff --git a/dashboard/src/components/IntegrityCheckCard.tsx b/dashboard/src/components/IntegrityCheckCard.tsx new file mode 100644 index 0000000..4871aa8 --- /dev/null +++ b/dashboard/src/components/IntegrityCheckCard.tsx @@ -0,0 +1,306 @@ +/** + * IntegrityCheckCard — presentational component for the audit-integrity view. + * + * Precursor to C-123 (sprint 2 in `docs/plan/bfsi-v1/04-commits.md`). The + * card has three terminal states, one for each value of `IntegrityResult.status`: + * + * - 'pass' — green check, "Chain intact", rows-checked count, last-checked. + * - 'fail' — red X, "Chain broken at row #", verbatim reason, + * "Investigate" no-op button. + * - 'pending' — spinner, used during the initial fetch and on refetch. + * + * Anchor data is optional. When present, an "Anchor: tx " sub-row renders + * a clickable Basescan link. When absent (the default for the skeleton), the + * sub-row is hidden entirely so the card stays compact. The anchor proves the + * chain's terminal hash is independently verifiable per ADR 0014 — the bank's + * auditor follows the link, queries the contract, and compares the on-chain + * `terminalHash` to the verifier's recomputed value. + * + * This file ships ZERO PII reads. The card's contract is purely audit metadata + * (status, brokenAt row id, reason string, row count, timestamp, tx hash). The + * "no PII" defence is asserted by `__tests__/IntegrityCheckCard.test.tsx` and + * by the source-file scan in `routes/tenant/__tests__/audit-integrity.test.tsx`. + * + * Tied to demo Scene 5 in `docs/plan/bfsi-v1/02-bank-demo.md` — the operator + * flips between PASS and FAIL on stage by tampering with one row in psql. + */ + +import type { ReactNode } from 'react'; +import { Badge, Button, Card, CardBody, CardHeader, Skeleton } from './ui'; +import { fmtDateTime } from '../lib/format'; + +// ─── Public type ───────────────────────────────────────────────── +// +// `IntegrityResult` is the only shape `IntegrityCheckCard` ever consumes. +// Narrow discriminated union: the `status` literal picks the branch. +// Adding a new variant is an ADR-grade decision — the bank demo's narrative +// rests on exactly three observable states (pass / fail / pending). + +export type IntegrityResult = + | { + status: 'pass'; + tenantId: string; + environment: string | null; + rowsChecked: number; + lastChecked: string; + } + | { + status: 'fail'; + tenantId: string; + environment: string | null; + brokenAt: string; + reason: string; + lastChecked: string; + } + | { status: 'pending' }; + +/** + * Optional on-chain anchor metadata. Hidden when undefined. + * + * `txHash` is rendered as a monospaced truncated string with a clickable + * link to `https://sepolia.basescan.org/tx/`. The link target is a + * fixed external host — there is no string interpolation that could lead + * to an open redirect. + */ +export interface AnchorInfo { + txHash: string; + /** Optional anchored-at ISO timestamp, rendered next to the link if set. */ + anchoredAt?: string; +} + +export interface IntegrityCheckCardProps { + result: IntegrityResult; + anchor?: AnchorInfo; + /** Optional click handler for the "Investigate" button on the FAIL state. */ + onInvestigate?: () => void; +} + +// ─── Tokens ───────────────────────────────────────────────────── + +const BASESCAN_TX_BASE = 'https://sepolia.basescan.org/tx/'; + +function truncateTxHash(hash: string): string { + if (!hash) return '—'; + if (hash.length <= 14) return hash; + return `${hash.slice(0, 8)}…${hash.slice(-4)}`; +} + +// ─── Component ─────────────────────────────────────────────────── + +export function IntegrityCheckCard({ result, anchor, onInvestigate }: IntegrityCheckCardProps) { + return ( + + + + {result.status === 'pending' ? ( + + ) : result.status === 'pass' ? ( + + ) : ( + + )} + {anchor ? : null} + + + ); +} + +// ─── Pending ──────────────────────────────────────────────────── + +function PendingState() { + return ( +
+
+ +
+ Running integrity check… +
+
+ + +
+ ); +} + +function Spinner() { + return ( +