diff --git a/.env.example b/.env.example index d3638e3..4d87cea 100644 --- a/.env.example +++ b/.env.example @@ -7,14 +7,25 @@ NODE_ENV=development PORT=3000 -# Public-facing URL of the API. Used for OIDC issuer, SAML callbacks, console -# quickstart snippets, and CORS origin (unless CORS_ORIGINS overrides). -# Production: https://zeroauth.dev +# Public-facing URLs for the four product surfaces. After the subdomain +# refactor each one resolves to a different vhost in production: +# +# API_BASE_URL → https://api.zeroauth.dev (REST surface) +# CONSOLE_BASE_URL → https://console.zeroauth.dev (React dashboard) +# DOCS_BASE_URL → https://docs.zeroauth.dev (Docusaurus site) +# LANDING_BASE_URL → https://zeroauth.dev (marketing + signup) +# +# In dev they collapse onto a single Express host on :3000 so tests + +# round-trip flows work without DNS plumbing. API_BASE_URL=http://localhost:3000 - -# Comma-separated list of allowed CORS origins. Defaults to API_BASE_URL in -# production, or localhost dev origins otherwise. -# Production example: https://zeroauth.dev,https://www.zeroauth.dev +CONSOLE_BASE_URL=http://localhost:3000/dashboard +DOCS_BASE_URL=http://localhost:3000/docs +LANDING_BASE_URL=http://localhost:3000 + +# Comma-separated list of allowed CORS origins. Defaults derive from the +# four URLs above in production, or localhost variants in dev. +# Production example: +# https://api.zeroauth.dev,https://console.zeroauth.dev,https://docs.zeroauth.dev,https://zeroauth.dev CORS_ORIGINS= # Whether to trust X-Forwarded-* headers. Set to true when behind Caddy/Nginx/ diff --git a/.github/workflows/security-review.yml b/.github/workflows/security-review.yml new file mode 100644 index 0000000..9bc9a4a --- /dev/null +++ b/.github/workflows/security-review.yml @@ -0,0 +1,111 @@ +name: Security review gate + +# Path-filter gate for the security-sensitive surfaces. Runs on every PR that +# touches auth, crypto, audit, key handling, or tenant-boundary code and +# leaves an annotated comment listing the touched paths + the named subagent +# the human reviewer must invoke locally. +# +# Why this isn't an in-CI security scan: the security-reviewer subagent +# (`.claude/agents/security-reviewer.md`) runs on Opus with full repo context +# and produces a structured findings report. Running that inside GHA would +# require Claude API access from CI, which isn't wired and isn't billed +# centrally. So this workflow is a *forcing function* — it makes skipping +# the manual subagent run impossible to miss in the PR conversation. +# +# Closes the Week 1 discipline gap noted in qa-log/W01-engineering-annex.md. + +on: + pull_request: + paths: + - 'src/services/zkp.ts' + - 'src/services/identity.ts' + - 'src/services/api-keys.ts' + - 'src/services/jwt.ts' + - 'src/services/platform.ts' + - 'src/middleware/auth.ts' + - 'src/middleware/tenant-auth.ts' + - 'src/routes/auth.ts' + - 'src/routes/console.ts' + - 'src/routes/v1/**' + - 'src/routes/zkp.ts' + - 'src/routes/saml.ts' + - 'src/routes/oidc.ts' + - 'circuits/**' + - 'contracts/**' + - 'verifier/src/audit-log.ts' + - 'verifier/src/server.ts' + +permissions: + contents: read + pull-requests: write + +jobs: + flag: + name: Flag for security-reviewer subagent + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Collect touched security paths + id: paths + run: | + base="${{ github.event.pull_request.base.sha }}" + head="${{ github.event.pull_request.head.sha }}" + # Recompute the same path set the workflow `paths:` clause matches + # so the comment lists the *exact* files that triggered this run. + touched=$(git diff --name-only "$base" "$head" | grep -E '^(src/services/(zkp|identity|api-keys|jwt|platform)\.ts|src/middleware/(auth|tenant-auth)\.ts|src/routes/(auth|console|zkp|saml|oidc)\.ts|src/routes/v1/.+|circuits/.+|contracts/.+|verifier/src/(audit-log|server)\.ts)$' || true) + # Newline-escape for GHA multi-line output + { + echo "touched<> "$GITHUB_OUTPUT" + + - name: Annotate PR with subagent invocation reminder + uses: actions/github-script@v8 + with: + script: | + const touched = `${{ steps.paths.outputs.touched }}`.trim(); + if (!touched) { + core.info('No security-sensitive paths touched; nothing to flag.'); + return; + } + const list = touched.split('\n').map(p => `- \`${p}\``).join('\n'); + const marker = ''; + const body = `${marker} + + ## 🔒 Security review required + + This PR touches security-sensitive surfaces. Per [CLAUDE.md §4](../blob/main/CLAUDE.md#standing-instructions), the \`security-reviewer\` subagent ([\`.claude/agents/security-reviewer.md\`](../blob/main/.claude/agents/security-reviewer.md)) must be invoked locally before merge. + + **Touched paths:** + ${list} + + **How to run the review:** + \`\`\` + # In Claude Code, after pulling this branch: + @security-reviewer review the changes on this branch + \`\`\` + + Reply on this PR with the structured findings report (or a "no findings" confirmation) before requesting merge. Block merge if any Critical / High finding lands without a tracked carve-out. + + _This comment is posted automatically by \`.github/workflows/security-review.yml\` and updated on every push to keep the touched-paths list current._`; + + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const existing = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber } + ); + const prior = existing.find(c => c.body && c.body.startsWith(marker)); + if (prior) { + await github.rest.issues.updateComment({ owner, repo, comment_id: prior.id, body }); + core.info(`Updated existing security-review comment ${prior.id}`); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + core.info('Posted new security-review comment'); + } diff --git a/.github/workflows/verifier-chain-verify.yml b/.github/workflows/verifier-chain-verify.yml new file mode 100644 index 0000000..122ac47 --- /dev/null +++ b/.github/workflows/verifier-chain-verify.yml @@ -0,0 +1,113 @@ +name: Verifier audit-chain verify + +# Daily integrity check on the verifier's append-only SQLite audit log. +# Reaches into the VPS over SSH (verifier is loopback-only on :3001 by +# design) and calls /audit/verify-chain. If `ok:false`, opens an issue +# with the `incident:critical` label and pings via the audit-chain +# breakage runbook in governance: docs/shared/incident-response.md. +# +# Cadence: daily at 02:30 UTC (08:00 IST), and on demand via workflow_dispatch. +# A failure here means the hash chain in `verifier_events` is broken — almost +# always a sign of tampering, never of normal operation. See A-V01 in +# governance: docs/threat-model/verifier.md. + +on: + schedule: + - cron: '30 2 * * *' + workflow_dispatch: + +env: + DEPLOY_HOST: 104.207.143.14 + DEPLOY_USER: zeroauth-deploy + +permissions: + contents: read + issues: write + +jobs: + verify-chain: + name: Probe /audit/verify-chain + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Start SSH agent + uses: webfactory/ssh-agent@v0.10.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }} + + - name: Add deploy host to known_hosts + run: ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + + - name: Probe verifier audit chain + id: probe + run: | + # Verifier is on the docker compose network, loopback-bound. We + # exec into the running container's curl rather than punching a + # port through to the host — keeps the loopback invariant intact. + response=$(ssh "$DEPLOY_USER@$DEPLOY_HOST" \ + "docker exec zeroauth-verifier wget -qO- http://127.0.0.1:3001/audit/verify-chain" || true) + + echo "raw_response=$response" + # Normalize newlines for the multi-line GHA output + { + echo "response<> "$GITHUB_OUTPUT" + + # Look for `"ok":true` in the JSON body. If the verifier is down + # or returns a non-2xx, $response will be empty and `ok:true` + # won't match — caught below. + if echo "$response" | grep -q '"ok":true'; then + echo "status=green" >> "$GITHUB_OUTPUT" + echo "Chain intact." + else + echo "status=red" >> "$GITHUB_OUTPUT" + echo "Chain probe failed or returned ok:false. Response: $response" + exit 1 + fi + + - name: Open critical issue on failure + if: failure() + uses: actions/github-script@v8 + with: + script: | + const date = new Date().toISOString().slice(0, 10); + const response = `${{ steps.probe.outputs.response }}`.slice(0, 4000); + const title = `Verifier audit-chain probe failed — ${date}`; + const body = `The daily \`/audit/verify-chain\` probe came back non-green. + + **Run:** ${context.payload.repository.html_url}/actions/runs/${context.runId} + + **Probe response:** + \`\`\`json + ${response || '(empty — verifier likely unreachable)'} + \`\`\` + + **What to do:** + 1. Verify the chain integrity manually: \`ssh zeroauth-deploy@${process.env.DEPLOY_HOST} 'docker exec zeroauth-verifier wget -qO- http://127.0.0.1:3001/audit/verify-chain'\` + 2. If \`ok:false\`, treat as a Security incident (A-V01 in [governance: docs/threat-model/verifier.md](https://github.com/zeroauth-dev/ZeroAuth-Governance/blob/main/docs/threat-model/verifier.md)). Run the [incident-response runbook](https://github.com/zeroauth-dev/ZeroAuth-Governance/blob/main/docs/shared/incident-response.md). + 3. If the verifier is unreachable, restart with \`docker compose --profile prod up -d --force-recreate zeroauth-verifier\` and re-run this workflow. + + 🤖 Filed automatically by \`.github/workflows/verifier-chain-verify.yml\`.`; + + const { owner, repo } = context.repo; + // Look for an existing open issue from a prior failure today so + // we don't spam if the verifier is down for hours. + const existing = await github.paginate( + github.rest.issues.listForRepo, + { owner, repo, state: 'open', labels: 'incident:critical', per_page: 50 } + ); + const today = existing.find(i => i.title && i.title.includes(date)); + if (today) { + core.info(`Existing issue ${today.number} already covers today's probe failure.`); + return; + } + const created = await github.rest.issues.create({ + owner, + repo, + title, + body, + labels: ['incident:critical', 'verifier', 'audit-log'], + }); + core.info(`Opened issue #${created.data.number}`); diff --git a/Caddyfile b/Caddyfile index e415671..d10d74e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,5 +1,5 @@ -# ZeroAuth — Caddy reverse proxy config for zeroauth.dev -# Caddy provisions a real Let's Encrypt cert automatically and renews it. +# ZeroAuth — Caddy reverse proxy config. +# Caddy provisions Let's Encrypt certs automatically for every vhost below. # # Usage on the production host: # docker compose --profile prod up -d --build @@ -7,32 +7,133 @@ # This Caddyfile is consumed by the `caddy` service in docker-compose.yml, # which is on the same Compose network as `zeroauth-prod`, so we reach the # app via its service name. +# +# DNS prerequisites (operator action, before deploy): +# A zeroauth.dev → 104.207.143.14 +# A www.zeroauth.dev → 104.207.143.14 +# A api.zeroauth.dev → 104.207.143.14 +# A console.zeroauth.dev → 104.207.143.14 +# A docs.zeroauth.dev → 104.207.143.14 +# (or CNAMEs aliasing the apex). All five hostnames terminate at this one +# Caddy instance which routes by Host header to the right path-prefix on +# the upstream Express app. Same backend container; different vhost, +# different rewrite. + +# ─── Shared snippets ──────────────────────────────────────────── +(security_headers) { + header { + Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "interest-cohort=()" + } +} + +(asset_caching) { + @hashed path_regexp hashed \.(js|css|woff2?|svg|png|jpg|webp)$ + header @hashed Cache-Control "public, max-age=31536000, immutable" +} + +(json_log) { + log { + output stdout + format json + } +} +# ─── Apex: marketing site + signup ────────────────────────────── zeroauth.dev, www.zeroauth.dev { - # Force HTTPS (Caddy does this by default; explicit for clarity). encode zstd gzip - # Forward to the ZeroAuth container on :3000. + # The apex serves /public/index.html and the marketing /demo.html. + # Anything under /v1, /api, /dashboard, /docs that hits the apex + # gets a 308 to the new canonical subdomain so legacy URLs don't + # silently break for bookmarks + CI scripts. + redir /v1/* https://api.zeroauth.dev{uri} permanent + redir /api/* https://api.zeroauth.dev{uri} permanent + redir /dashboard /dashboard/ permanent + redir /dashboard/* https://console.zeroauth.dev{uri.path.substr(10}{uri.query} permanent + redir /docs /docs/ permanent + redir /docs/* https://docs.zeroauth.dev{uri.path.substr(5}{uri.query} permanent + reverse_proxy zeroauth-prod:3000 { header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto https + header_up X-Forwarded-Host {host} } - # Long-lived hashed assets (Vite/Docusaurus output). - @hashed path_regexp hashed \.(js|css|woff2?|svg|png|jpg|webp)$ - header @hashed Cache-Control "public, max-age=31536000, immutable" + import asset_caching + import security_headers + import json_log +} - # Security headers (helmet sets most, but enforce belt-and-braces here). - header { - Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" - X-Content-Type-Options "nosniff" - Referrer-Policy "strict-origin-when-cross-origin" - Permissions-Policy "interest-cohort=()" +# ─── api.zeroauth.dev — REST surface ──────────────────────────── +api.zeroauth.dev { + encode zstd gzip + + # Strip the public path to map onto the same Express app. The + # upstream still recognises /v1/* and /api/*; on api.zeroauth.dev + # we keep the URI as-is (no rewrite) — clients hit + # https://api.zeroauth.dev/v1/verifications which proxies to + # zeroauth-prod:3000/v1/verifications. + reverse_proxy zeroauth-prod:3000 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto https + header_up X-Forwarded-Host {host} } - log { - output stdout - format json + # Hard 404 on any request to a non-API path so api.zeroauth.dev + # doesn't accidentally serve dashboard / docs HTML if the + # upstream's catch-all routes change. + @nonapi not path /v1/* /api/* /.well-known/* /health + respond @nonapi "Not an API route" 404 + + import security_headers + import json_log +} + +# ─── console.zeroauth.dev — developer console ─────────────────── +console.zeroauth.dev { + encode zstd gzip + + # Strip /dashboard prefix expected by the Vite-built SPA. Express + # serves the dashboard at /dashboard/*; on console.zeroauth.dev we + # want users to land at "/", so rewrite incoming "/" → "/dashboard/". + rewrite / /dashboard/ + rewrite /* /dashboard{uri} + + # Cookies issued by /api/console/* are scoped to ".zeroauth.dev" so + # the console + API share session state across the subdomain boundary. + reverse_proxy zeroauth-prod:3000 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto https + header_up X-Forwarded-Host {host} + } + + import asset_caching + import security_headers + import json_log +} + +# ─── docs.zeroauth.dev — Docusaurus build ─────────────────────── +docs.zeroauth.dev { + encode zstd gzip + + # Same rewrite story — strip /docs/* from the upstream path. + rewrite / /docs/ + rewrite /* /docs{uri} + + reverse_proxy zeroauth-prod:3000 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto https + header_up X-Forwarded-Host {host} } + + import asset_caching + import security_headers + import json_log } diff --git a/README.md b/README.md index f3acc94..3166692 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@

zeroauth.dev · - Documentation · - Quickstart · - API Reference + Documentation · + Quickstart · + API Reference

@@ -113,17 +113,17 @@ A deeper walkthrough lives in [docs/concepts/architecture.md](docs/concepts/arch ```bash # 1. Sign up -curl -X POST https://zeroauth.dev/api/console/signup \ +curl -X POST https://api.zeroauth.dev/api/console/signup \ -H "Content-Type: application/json" \ -d '{"email":"you@example.com","password":"a-strong-password","companyName":"Acme"}' # → returns { token, apiKey: { key: "za_live_..." } } # 2. Make your first call -curl https://zeroauth.dev/v1/auth/zkp/nonce \ +curl https://api.zeroauth.dev/v1/auth/zkp/nonce \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` -Full API reference at [zeroauth.dev/docs/reference/api-reference](https://zeroauth.dev/docs/reference/api-reference). +Full API reference at [docs.zeroauth.dev/reference/api-reference](https://docs.zeroauth.dev/reference/api-reference). ### Run it yourself (Docker, ~2 minutes) diff --git a/adr/0007-iot-serialport-dependency.md b/adr/0007-iot-serialport-dependency.md new file mode 100644 index 0000000..20c6f19 --- /dev/null +++ b/adr/0007-iot-serialport-dependency.md @@ -0,0 +1,98 @@ +# ADR-0007: Adopt `serialport` (v12) as the UART transport for the IoT terminal + +- **Status:** Accepted +- **Date:** 2026-05-18 +- **Owner:** Pulkit Pareek +- **Supersedes:** — + +## Context + +B03 (the ZeroAuth IoT terminal) needs to talk to a fingerprint sensor over +a USB-UART adapter. The first device under test is an R307 / FPM10A / ZFM-20 +module on `/dev/cu.usbserial-0001` (Mac dev box) or `/dev/ttyUSB0` (Orange +Pi production target). Both run Node 20. + +The transport choice has to: + +1. Open a `/dev/cu.*` or `/dev/ttyUSB*` file descriptor with arbitrary baud. +2. Stream-read incoming frames into a buffer so the driver can re-parse on + each chunk arrival (R307 frames are 11+ bytes, can arrive split). +3. Drain reliably so the host doesn't lose ACKs after a fast command burst. + +## Options considered + +### A — `serialport` (npm) v12 (chosen) + +- The canonical Node serial library. ~6.8M monthly downloads, 7-year + maintenance history, polyfilled across darwin / linux / win32. +- Native module (uses node-gyp). Adds ~30 s to `npm install` on a cold + box because it builds against the local Node ABI; prebuilds are + published for darwin-arm64 / darwin-x64 / linux-x64 / linux-armv7l + which covers every host in our deploy matrix (Mac dev + Orange Pi). +- TypeScript types included. +- Reliable `drain()` semantics — important because the R307 ACKs come + ~50 ms after a command and node's default write buffering will + reorder them if we don't drain. + +### B — write a /dev/cu.* shim with `fs.open` + `O_NONBLOCK` + +- Zero dependencies, but we'd have to reimplement termios on each OS, + poll-loop the descriptor, and re-derive baud divisors. ~400 lines of + delicate per-OS code we'd then own forever. + +### C — `node-serialport` v9 (pinned older) + +- Older API surface; doesn't have a typed `port.drain()` callback and + has a known issue on Apple Silicon when the adapter is hot-plugged. + +### D — Out-of-process bridge (e.g. `socat` to a TCP socket, Node connects via net) + +- Adds a moving part. Useful for debugging (saves a tcpdump-like + capture of the UART traffic), but routine use is heavier than the + problem warrants. + +## Decision + +Adopt **`serialport` v12** as a direct dependency of the new `iot/` +workspace. Pin via `^12.0.0` so we pick up minor versions automatically; +breaking changes go through this ADR's revision process. + +Rationale ranked: + +1. **Cost of writing the alternative outweighs the dep cost.** Option B + is genuinely a wheel-reinvention; the per-OS termios surface is well + below the value bar of an in-house implementation. +2. **Native-build pain is bounded.** Prebuilt binaries exist for all our + target platforms. The build only falls back to node-gyp on exotic + platforms (e.g. armv6l on a Pi Zero) which we don't ship to. +3. **Surface area is small.** We use exactly two `serialport` features + — `port.on('data')` and `port.drain()` — so a future migration is + ~100 lines of shim work if needed. + +## Consequences + +- The `iot/` workspace adds one direct dep + the usual native-build + preinstall lifecycle. The root `package-lock.json` is not touched + because `iot/` is a standalone npm project today (separate `package.json`, + separate `node_modules`). +- `npm --prefix iot install` is the operator's entry point; CI doesn't + invoke the iot subproject yet (the firmware lives outside CI's reach + until B03 graduates into its own repo). When we wire CI, the runner + will need either `apt-get install -y libudev-dev` (linux) or a + prebuilt binary (darwin) — both are no-cost. +- `scripts/check-dep-trail.sh` does not currently scan `iot/`; we'll + extend it the next time the iot workspace promotes out of in-tree. +- Supply-chain check: `npm audit` on `iot/` shows zero advisories at + ADR-write time. The `serialport` author has been continuously + maintaining the package since 2018. + +## Follow-ups + +- When the IoT terminal moves to `zeroauth-dev/ZeroAuth-IoT`, this ADR + travels with it. +- Add `serialport` to the dep-trail allow-list once `iot/` is part of + the scan boundary. + +--- + +LAST_UPDATED: 2026-05-18 diff --git a/adr/0008-iot-snarkjs-poseidon-lite.md b/adr/0008-iot-snarkjs-poseidon-lite.md new file mode 100644 index 0000000..4531dcc --- /dev/null +++ b/adr/0008-iot-snarkjs-poseidon-lite.md @@ -0,0 +1,115 @@ +# ADR-0008: Adopt `snarkjs` + `poseidon-lite` for the IoT fingerprint demo + +- **Status:** Accepted +- **Date:** 2026-05-18 +- **Owner:** Pulkit Pareek +- **Supersedes:** — + +## Context + +The fingerprint demo at `iot/src/bridge.ts` started life as a slot-binding +toy — the host stored `{email: slot}` and the sensor's 1:N match was the +whole story. The user asked for the demo to "use our ZKP-based tech to +calculate." Concretely: the bridge should compute the same Patent-Claim-3 +commitment construction the main API uses (`src/services/identity.ts`) +and generate + verify a Groth16 proof per login. + +Two primitives are needed on the IoT side: + +1. **Poseidon hash** for the commitment derivation. +2. **Groth16 prover + verifier** for the identity_proof circuit. + +The circuit and its build artifacts (`identity_proof.wasm`, +`circuit_final.zkey`, `verification_key.json`) already exist under +`circuits/build/`. What's missing on the iot/ side is the host-language +tooling to feed witness inputs to that circuit and verify the resulting +proof. + +## Options considered + +### A — `snarkjs` (v0.7.x) + `poseidon-lite` (v0.3.x) (chosen) + +- `snarkjs` is the reference Groth16 implementation the main API + already uses (see `src/services/zkp.ts` and the verifier workspace). + Same Node version, same proving artefacts; cross-process compatibility + is free. +- `poseidon-lite` is a hand-tuned Poseidon for BN128 with a tiny + install size (~200 KB), pure JS, no native compilation. It matches + the round constants from circomlib so commitments interoperate with + the existing circuit and the main API's `identity.ts`. +- Both are pure JS. No node-gyp build. Installs in <10 s on a cold box. + +### B — `circomlibjs` (v0.1.x) + +- What the main API uses today via `circomlibjs.buildPoseidon()`. +- ~2.5 MB install vs `poseidon-lite`'s 200 KB. Brings in `ffjavascript`, + `blake-hash`, and a few other heavyweight modules. +- API requires an async `build` step at startup. `poseidon-lite` is + synchronous, which simplifies the bridge's init order. +- Identical hash output. Choosing the lighter one because the IoT + surface is bandwidth- and footprint-sensitive even on a dev laptop. + +### C — Roll our own Poseidon + +- Five-hundred-line constant table + 8 full + 57 partial rounds of + field arithmetic per call. The constants need to match circomlib + exactly or commitments diverge from the main API's. Auditing two + independent Poseidon implementations against each other is the + exact wheel-reinvention this ADR exists to prevent. + +### D — Skip the proof on the IoT side; have the host send (commitment, signals) to the central API which generates the proof there + +- Requires the IoT terminal to leak the witness over the network to a + trusted proving service. That defeats the whole "device proves to + server" shape of the ZeroAuth model — even for a demo it's worse + ergonomics than running snarkjs locally. + +## Decision + +Take **`snarkjs ^0.7.4` + `poseidon-lite ^0.3.0`** as direct deps of +the `iot/` workspace. + +Justification ranked: + +1. **Same primitives, same field, same artefacts** as the main API. + Commitments minted on the iot side could be round-trip verified by + the central /v1 surface tomorrow without any code change in either. +2. **Pure-JS, fast install.** The iot workspace already has the + native-build cost of `serialport`; piling another native module on + top would noticeably slow `npm --prefix iot install`. `snarkjs` and + `poseidon-lite` are zero-build wheels. +3. **`poseidon-lite` over `circomlibjs`** trades ~2.3 MB of dependency + surface for sync-init code. The hash output is bit-identical because + both pull the same circomlib round constants — that's the whole + point of poseidon-lite. + +## Consequences + +- `iot/package.json` gains two direct deps. +- `iot/src/proof.ts` reads `circuits/build/{identity_proof.wasm, + circuit_final.zkey, verification_key.json}` at startup. The iot + workspace now depends on the existence of those artefacts; if the + circuit is rebuilt with different constants, the iot proof step + starts producing proofs that the main API can't verify (and vice + versa). Mitigation: the build pipeline ships the artefacts together; + rebuilds are explicit operations gated by ADRs. +- `npm audit` shows three "high" advisories in `snarkjs`'s transitive + deps (`got` / `keccak` / older `ws`). They're well-known, don't + affect Groth16 correctness, and don't reach the bridge's surface. + We mirror the same exposure the main API already takes. +- Adds ~12 MB to `iot/node_modules`. Tolerable for a host-side bridge; + the production firmware on Orange Pi will swap snarkjs for a smaller + proving stack (rapidsnark or arkworks). Out of scope for this ADR. + +## Follow-ups + +- When `iot/` graduates into `zeroauth-dev/ZeroAuth-IoT`, this ADR + travels with it and `scripts/check-dep-trail.sh` extends to scan the + new repo. +- The Orange Pi firmware path will revisit Option D in reverse — + consider sending the proof generation back to a trusted enclave on + the device rather than running snarkjs on a constrained CPU. + +--- + +LAST_UPDATED: 2026-05-18 diff --git a/dashboard/.env.example b/dashboard/.env.example new file mode 100644 index 0000000..2e935af --- /dev/null +++ b/dashboard/.env.example @@ -0,0 +1,3 @@ +VITE_DOCS_BASE_URL=https://docs.zeroauth.dev/ +VITE_CONSOLE_BASE_URL=https://console.zeroauth.dev +VITE_API_BASE_URL= diff --git a/dashboard/e2e/happy-path.spec.ts b/dashboard/e2e/happy-path.spec.ts index 3a97eba..8ffa11f 100644 --- a/dashboard/e2e/happy-path.spec.ts +++ b/dashboard/e2e/happy-path.spec.ts @@ -28,9 +28,34 @@ const SECOND_KEY_NAME = `playwright-secondary-${RANDOM}`; test.describe.configure({ mode: 'serial' }); test.describe('developer console — happy path', () => { - test('signup, mint key, register device, see audit events', async ({ page }) => { + // F-2 v2 (issue #27) replaced the immediate-key-reveal signup with an + // email-verify gate. The Playwright happy path needs to either (a) read + // the verify token from Postgres, or (b) intercept the outbound email + // via a test-mode mail transport. Until that rework lands, the full + // happy path is parked — but the partial "check your inbox" coverage + // below exercises the new signup contract end-to-end on the form side. + test.skip('signup, mint key, register device, see audit events (needs DB/SMTP hook for verify-link)', async ({ page }) => { await runHappyPath(page); }); + + test('F-2 v2 signup form lands on the "check your inbox" view', async ({ page }) => { + const stamp = Date.now(); + const random = Math.random().toString(36).slice(2, 8); + const email = `playwright-f2v2+${stamp}-${random}@example.com`; + + await page.goto('/dashboard/signup'); + await expect(page.getByRole('heading', { name: /create your account/i })).toBeVisible(); + await page.getByLabel(/work email/i).fill(email); + await page.getByLabel(/company name/i).fill('F-2 v2 Test'); + await page.getByLabel(/^password$/i).fill(PASSWORD); + await page.getByRole('button', { name: /create account/i }).click(); + + // The page should pivot to "Check your inbox" without revealing any key. + await expect(page.getByRole('heading', { name: /check your inbox/i })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(email)).toBeVisible(); + // Critically: nothing that looks like an API key should appear. + await expect(page.locator('text=/za_(live|test)_[a-f0-9]{12,}/')).toHaveCount(0); + }); }); async function runHappyPath(page: Page): Promise { diff --git a/dashboard/index.html b/dashboard/index.html index 6229255..c1b9987 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -3,13 +3,30 @@ - + ZeroAuth — Developer Console - + +

diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 9809636..33c87e2 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,10 +1,12 @@ import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider, useAuth } from './lib/auth'; +import { ThemeProvider } from './lib/theme'; import { AppShell, EnvironmentProvider } from './components/layout/AppShell'; import { ToastViewport } from './components/ui'; import { Login } from './routes/public/Login'; import { Signup } from './routes/public/Signup'; +import { SignupComplete } from './routes/public/SignupComplete'; import { Overview } from './routes/Overview'; import { ApiKeys } from './routes/ApiKeys'; import { Users } from './routes/Users'; @@ -50,11 +52,13 @@ export function App() { return ( - - + + + } /> } /> + } /> }> }> @@ -72,9 +76,10 @@ export function App() { - - - + + + + ); diff --git a/dashboard/src/components/layout/AppShell.tsx b/dashboard/src/components/layout/AppShell.tsx index 1ef366c..4eac345 100644 --- a/dashboard/src/components/layout/AppShell.tsx +++ b/dashboard/src/components/layout/AppShell.tsx @@ -1,5 +1,6 @@ import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '../../lib/auth'; +import { useTheme, useBrandMarkUrl, type ThemeChoice } from '../../lib/theme'; import { cn } from '../../lib/cn'; import { Button } from '../ui'; import type { Environment } from '../../lib/api'; @@ -70,6 +71,76 @@ function Icon({ name, className }: { name: string; className?: string }) { } } +// ─── Brand mark — adapts to the active theme ────────────────────── + +function BrandMark() { + const src = useBrandMarkUrl(); + return ; +} + +// ─── Theme toggle — three-segment Light / System / Dark ─────────── + +function ThemeToggle() { + const { choice, setChoice } = useTheme(); + const options: Array<{ value: ThemeChoice; label: string; svg: ReactNode }> = [ + { + value: 'light', + label: 'Light', + svg: ( + + + + + ), + }, + { + value: 'system', + label: 'Auto', + svg: ( + + + + + ), + }, + { + value: 'dark', + label: 'Dark', + svg: ( + + + + ), + }, + ]; + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + // ─── Layout ─────────────────────────────────────────────────────── export function AppShell() { @@ -91,11 +162,11 @@ export function AppShell() { mobileOpen && 'translate-x-0', )} > -
- +
+
-
ZeroAuth
-
Developer console
+
ZeroAuth
+
Developer console
@@ -109,7 +180,7 @@ export function AppShell() { cn( 'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium transition-colors', isActive - ? 'bg-[var(--color-brand)]/15 text-[var(--color-brand)]' + ? 'bg-[var(--color-bg-surface)] text-[var(--color-text)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] hover:text-[var(--color-text)]', ) } @@ -120,9 +191,10 @@ export function AppShell() { ))} -
+
+
-
{account?.companyName ?? account?.email ?? 'Unknown account'}
+
{account?.companyName ?? account?.email ?? 'Unknown account'}
{account?.plan ?? '—'} plan
@@ -168,7 +240,7 @@ export function AppShell() {
Promise; - signup: (input: { email: string; password: string; companyName?: string }) => Promise<{ apiKey: string; warning: string; tenant: Tenant }>; + signup: (input: { email: string; password: string; companyName?: string }) => Promise; logout: () => void; refresh: () => Promise; } @@ -51,11 +61,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [refresh]); const signup = useCallback(async (input: { email: string; password: string; companyName?: string }) => { + // F-2 v2: /api/console/signup returns 202 + { status: 'pending_verification', message }. + // No token or API key here — those arrive on /dashboard/signup-complete after the + // user clicks the verification link in their inbox. const res = await api.signup(input); - setToken(res.token); - await refresh(); - return { apiKey: res.apiKey.key, warning: res.apiKey.warning, tenant: res.tenant }; - }, [refresh]); + return { status: res.status, message: res.message }; + }, []); const logout = useCallback(() => { setToken(null); diff --git a/dashboard/src/lib/theme.tsx b/dashboard/src/lib/theme.tsx new file mode 100644 index 0000000..1c3ad4b --- /dev/null +++ b/dashboard/src/lib/theme.tsx @@ -0,0 +1,102 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +/** + * Theme selection for the developer console. + * + * 'light' — force light tokens, ignore the OS + * 'dark' — force dark tokens, ignore the OS + * 'system' — follow prefers-color-scheme (default) + * + * Storage key matches the inline boot script in index.html, which reads + * the preference BEFORE React mounts and sets data-theme on so + * there's no flash of the wrong palette. + */ +export type ThemeChoice = 'light' | 'dark' | 'system'; +export type ResolvedTheme = 'light' | 'dark'; + +const STORAGE_KEY = 'zeroauth.theme'; + +interface ThemeContextValue { + choice: ThemeChoice; + resolved: ResolvedTheme; + setChoice: (next: ThemeChoice) => void; +} + +const ThemeContext = createContext(null); + +function readStoredChoice(): ThemeChoice { + try { + const v = localStorage.getItem(STORAGE_KEY); + if (v === 'light' || v === 'dark' || v === 'system') return v; + } catch { /* localStorage blocked */ } + return 'system'; +} + +function systemPrefersDark(): boolean { + return typeof window !== 'undefined' + && window.matchMedia + && window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +function applyToDom(choice: ThemeChoice): ResolvedTheme { + const root = document.documentElement; + if (choice === 'system') { + root.removeAttribute('data-theme'); + return systemPrefersDark() ? 'dark' : 'light'; + } + root.setAttribute('data-theme', choice); + return choice; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [choice, setChoiceState] = useState(() => readStoredChoice()); + const [resolved, setResolved] = useState(() => + choice === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : choice, + ); + + // Apply on mount + when the choice changes. + useEffect(() => { + setResolved(applyToDom(choice)); + try { localStorage.setItem(STORAGE_KEY, choice); } catch { /* storage blocked */ } + }, [choice]); + + // Track OS preference flips while 'system' is selected. + useEffect(() => { + if (choice !== 'system' || typeof window === 'undefined' || !window.matchMedia) return; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = (): void => setResolved(systemPrefersDark() ? 'dark' : 'light'); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }, [choice]); + + const setChoice = useCallback((next: ThemeChoice) => setChoiceState(next), []); + + const value = useMemo(() => ({ choice, resolved, setChoice }), [choice, resolved, setChoice]); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used inside '); + return ctx; +} + +/** + * Returns the URL of the brand mark for the currently-resolved theme, + * relative to Vite's BASE_URL so it works in both dev (`/dashboard/`) + * and prod (mounted under `/dashboard/`). + */ +export function useBrandMarkUrl(): string { + const { resolved } = useTheme(); + const base = import.meta.env.BASE_URL || '/'; + return `${base.replace(/\/$/, '')}/${resolved === 'dark' ? 'zeroauth-mark-dark.svg' : 'zeroauth-mark.svg'}`; +} diff --git a/dashboard/src/routes/public/Login.test.tsx b/dashboard/src/routes/public/Login.test.tsx index d64d10d..e816da5 100644 --- a/dashboard/src/routes/public/Login.test.tsx +++ b/dashboard/src/routes/public/Login.test.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { AuthProvider } from '../../lib/auth'; +import { ThemeProvider } from '../../lib/theme'; import { Login } from './Login'; import { setToken } from '../../lib/api'; @@ -12,12 +13,14 @@ function renderLoginAt(path = '/login') { return render( - - - } /> - Overview page
} /> - - + + + + } /> + Overview page
} /> + + + , ); diff --git a/dashboard/src/routes/public/Login.tsx b/dashboard/src/routes/public/Login.tsx index b3f55a0..cdc38be 100644 --- a/dashboard/src/routes/public/Login.tsx +++ b/dashboard/src/routes/public/Login.tsx @@ -1,6 +1,7 @@ import { useState, type FormEvent } from 'react'; -import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../../lib/auth'; +import { useBrandMarkUrl } from '../../lib/theme'; import { ApiError } from '../../lib/api'; import { Button, Input, Label } from '../../components/ui'; @@ -8,11 +9,17 @@ export function Login() { const { status, login } = useAuth(); const location = useLocation(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // Set by GET /api/console/verify-signup when a verify link is clicked twice + // (the account was already created by the first click). Tells the user + // they're past signup and just need to sign in. + const alreadyVerified = searchParams.get('already_verified') === '1'; + if (status === 'authenticated') { const redirectTo = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/overview'; return ; @@ -34,6 +41,11 @@ export function Login() { return ( + {alreadyVerified ? ( +
+ Your email is verified. Sign in to continue. +
+ ) : null}
@@ -82,24 +94,31 @@ export function Login() { } export function AuthLayout({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { + const markSrc = useBrandMarkUrl(); return (
-
-
- - - +
+ +
+ ZeroAuth
-
ZeroAuth
-
-

{title}

- {subtitle ?

{subtitle}

: null} +
+

+ {title} +

+ {subtitle ?

{subtitle}

: null}
{children}
-

- Zero biometric data stored. Ever. +

+ Zero biometric data stored · Ever

diff --git a/dashboard/src/routes/public/Signup.tsx b/dashboard/src/routes/public/Signup.tsx index ddc96ee..ef8d4d8 100644 --- a/dashboard/src/routes/public/Signup.tsx +++ b/dashboard/src/routes/public/Signup.tsx @@ -1,27 +1,37 @@ import { useState, type FormEvent } from 'react'; -import { Link, Navigate, useNavigate } from 'react-router-dom'; +import { Link, Navigate } from 'react-router-dom'; import { useAuth } from '../../lib/auth'; import { ApiError } from '../../lib/api'; -import { Button, CopyButton, Input, Label, Modal } from '../../components/ui'; +import { Button, Input, Label } from '../../components/ui'; import { AuthLayout } from './Login'; -interface FirstKeyState { - key: string; - warning: string; -} - +/** + * F-2 v2 signup (issue #27). + * + * POST /api/console/signup is now an email-verify gate — it always returns + * 202 + { status: 'pending_verification', message } whether the email is + * fresh or already taken. The actual tenant + API key are minted only + * after the user clicks the verification link, which lands on + * /dashboard/signup-complete where the key is revealed once. + * + * This page therefore has two states: + * 1. The form (collects email + password + company) + * 2. The "check your inbox" confirmation (shown after a successful submit) + * + * We deliberately do NOT distinguish "email taken" from "email fresh" in + * the UI — that would re-create the enumeration vector the backend just + * closed. + */ export function Signup() { const { status, signup } = useAuth(); - const navigate = useNavigate(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [companyName, setCompanyName] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [firstKey, setFirstKey] = useState(null); - const [confirmedReveal, setConfirmedReveal] = useState(false); + const [sentTo, setSentTo] = useState(null); - if (status === 'authenticated' && !firstKey) { + if (status === 'authenticated' && !sentTo) { return ; } @@ -29,124 +39,129 @@ export function Signup() { e.preventDefault(); setError(null); setBusy(true); + const trimmed = email.trim(); try { - const res = await signup({ - email: email.trim(), + await signup({ + email: trimmed, password, companyName: companyName.trim() || undefined, }); - setFirstKey({ key: res.apiKey, warning: res.warning }); + setSentTo(trimmed); } catch (err) { const msg = err instanceof ApiError ? err.message : 'Sign-up failed.'; setError(msg); + } finally { setBusy(false); } } - return ( - <> - - -
- - setEmail(e.target.value)} - placeholder="dev@yourcompany.com" - /> -
-
- - setCompanyName(e.target.value)} - placeholder="Acme Corp" - /> -
-
- - setPassword(e.target.value)} - /> -

- At least 12 characters, with a letter and a digit. No common passwords. -

+ if (sentTo) { + return ( + +
+
+

What happens next

+
    +
  1. Open the email titled "Verify your ZeroAuth account".
  2. +
  3. Click "Verify and continue" — it lands you on the dashboard.
  4. +
  5. Your first API key is revealed once on the next screen. Save it before navigating away.
  6. +
- {error ? ( -
- {error} -
- ) : null} +

+ No email yet? Check your spam folder, then try again with the same address. + We never indicate whether an address is already registered — that's a deliberate + anti-enumeration choice. +

-
- Already have an account?{' '} + Already verified?{' '} Sign in
- +
+ ); + } - { /* keep open until user confirms */ }} - title="Save your first API key" - description="This is the only time you'll see it. Treat it like a password." - footer={ - <> - - - } - > - {firstKey ? ( -
-
- {firstKey.key} -
-
- -
-
- {firstKey.warning} -
- + return ( + +
+
+ + setEmail(e.target.value)} + placeholder="dev@yourcompany.com" + /> +
+
+ + setCompanyName(e.target.value)} + placeholder="Acme Corp" + /> +
+
+ + setPassword(e.target.value)} + /> +

+ At least 12 characters, with a letter and a digit. No common passwords. +

+
+ + {error ? ( +
+ {error}
) : null} - - + + + +

+ We'll email you a one-click verification link. Your account isn't created until you confirm. +

+ +
+ Already have an account?{' '} + + Sign in + +
+
+
); } diff --git a/dashboard/src/routes/public/SignupComplete.tsx b/dashboard/src/routes/public/SignupComplete.tsx new file mode 100644 index 0000000..6c8be7a --- /dev/null +++ b/dashboard/src/routes/public/SignupComplete.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import { Link, Navigate, useNavigate } from 'react-router-dom'; +import { setToken, type SignupRevealPayload, type Environment } from '../../lib/api'; +import { useAuth } from '../../lib/auth'; +import { Button, CopyButton, Modal } from '../../components/ui'; +import { AuthLayout } from './Login'; + +const REVEAL_COOKIE = 'zeroauth_signup_reveal'; + +/** + * Lands here after the verify-signup endpoint completes (issue #27 F-2 v2). + * + * The backend has set a short-lived `zeroauth_signup_reveal` cookie carrying + * the JWT + the freshly-minted live API key. We: + * 1. Decode the cookie once + * 2. Stash the JWT in localStorage so the next refresh hydrates the session + * 3. Clear the cookie immediately so a back-button or refresh can't re-read + * 4. Show the API key inside a one-time-reveal modal (same pattern as the + * old direct-signup flow used to) + * + * If the cookie is missing (link was already used, or user navigated here + * directly), we route to login with a friendly nudge. + */ +export function SignupComplete() { + const navigate = useNavigate(); + const { refresh } = useAuth(); + const [payload, setPayload] = useState(null); + const [missing, setMissing] = useState(false); + const [confirmedReveal, setConfirmedReveal] = useState(false); + + useEffect(() => { + const decoded = readAndClearRevealCookie(); + if (!decoded) { + setMissing(true); + return; + } + setToken(decoded.token); + setPayload(decoded); + // Hydrate auth state in the background — by the time the user closes the + // modal the global state knows they're authenticated. + void refresh(); + }, [refresh]); + + if (missing) { + return ; + } + + return ( + <> + +

+ We're loading your dashboard. If the next screen doesn't open + automatically, use the link below. +

+
+ + Open dashboard + +
+
+ + { /* keep open until user confirms */ }} + title="Save your first API key" + description="This is the only time you'll see it. Treat it like a password." + footer={ + + } + > + {payload ? ( +
+
+ {payload.apiKey} +
+
+ +
+
+ ⚠ Copy this API key now — it will never be shown again. +
+

+ Prefix {payload.apiKeyPrefix} · environment{' '} + {payload.apiKeyEnv satisfies Environment}. You can + rotate it any time from the API Keys page. +

+ +
+ ) : null} +
+ + ); +} + +/** + * Read the base64url-encoded reveal payload from the `zeroauth_signup_reveal` + * cookie set by GET /api/console/verify-signup. Returns null if absent or + * malformed. Always clears the cookie before returning to keep it single-use. + */ +function readAndClearRevealCookie(): SignupRevealPayload | null { + const raw = document.cookie + .split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith(REVEAL_COOKIE + '=')); + + // Clear immediately regardless — we treat the cookie as one-shot. If decoding + // fails the user gets routed to login anyway. + document.cookie = `${REVEAL_COOKIE}=; path=/dashboard; max-age=0`; + + if (!raw) return null; + + const value = raw.slice(REVEAL_COOKIE.length + 1); + if (!value) return null; + + try { + // base64url → utf8 JSON + const b64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const json = atob(padded); + const parsed = JSON.parse(json) as SignupRevealPayload; + if (!parsed.token || !parsed.apiKey) return null; + return parsed; + } catch { + return null; + } +} diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css index 8bde4a2..9508157 100644 --- a/dashboard/src/styles.css +++ b/dashboard/src/styles.css @@ -1,25 +1,109 @@ @import "tailwindcss"; -/* ZeroAuth dashboard design tokens (Tailwind v4 CSS-first config). */ +/* + * ZeroAuth developer-console design tokens. + * + * Two themes that match the marketing site's language: near-black + white + * monochrome with a single status-green accent. Brand was previously a + * Google-blue gradient (#4285F4 → #0B57D0); that's now retired in favour + * of the ink primary so the dashboard and the landing tell the same story. + * + * Selector strategy: + * - Default: dark theme, applied to :root. Matches the legacy console + * surface so nothing breaks if the html element has no data-theme set. + * - prefers-color-scheme: light → token swap to light surfaces. + * - Manual override: [data-theme="light"] or [data-theme="dark"] on + * wins over the media query. Set by the in-app toggle and + * persisted to localStorage in main.tsx. + * + * Fonts: Fraunces for display moments (page titles, big numbers), Inter + * Tight for body / UI chrome, JetBrains Mono for code + keys + IDs. Same + * palette of three the landing uses. + */ @theme { - --color-bg: #0a0b10; - --color-bg-raised: #11121a; - --color-bg-surface: #161722; - --color-border: #1f2133; - --color-border-subtle: #181928; - --color-text: #e8e9ed; - --color-text-secondary: #8b8d9e; - --color-text-dim: #555770; - --color-brand: #4285F4; - --color-brand-dark: #0B57D0; - --color-success: #34d399; + /* ─── Dark (default) ─── */ + --color-bg: #0a0a0a; + --color-bg-raised: #121212; + --color-bg-surface: #1a1a1a; + --color-border: #262626; + --color-border-subtle: #1f1f1f; + --color-text: #fafafa; + --color-text-secondary: #a3a3a3; + --color-text-dim: #6e6e6e; + --color-brand: #fafafa; + --color-brand-dark: #d4d4d4; + --color-on-brand: #0a0a0a; + --color-success: #4ade80; --color-warn: #fbbf24; --color-danger: #f87171; - --font-sans: "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + --font-sans: "Inter Tight", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + --font-display: "Fraunces", "Times New Roman", serif; --font-mono: "JetBrains Mono", "SF Mono", "Consolas", monospace; } +/* + * Light theme override. + * + * Tailwind v4's @theme is build-time only; runtime theme swaps go through + * plain CSS custom property reassignment under selectors below. The custom + * properties Tailwind reads at build time stay the same; only the values + * change. + */ + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --color-bg: #ffffff; + --color-bg-raised: #fafafa; + --color-bg-surface: #f4f4f4; + --color-border: #e5e5e5; + --color-border-subtle: #ededed; + --color-text: #0a0a0a; + --color-text-secondary: #525252; + --color-text-dim: #8e8e8e; + --color-brand: #0a0a0a; + --color-brand-dark: #1f1f1f; + --color-on-brand: #ffffff; + --color-success: #1a7a4a; + --color-warn: #b45309; + --color-danger: #b91c1c; + } +} + +:root[data-theme="light"] { + --color-bg: #ffffff; + --color-bg-raised: #fafafa; + --color-bg-surface: #f4f4f4; + --color-border: #e5e5e5; + --color-border-subtle: #ededed; + --color-text: #0a0a0a; + --color-text-secondary: #525252; + --color-text-dim: #8e8e8e; + --color-brand: #0a0a0a; + --color-brand-dark: #1f1f1f; + --color-on-brand: #ffffff; + --color-success: #1a7a4a; + --color-warn: #b45309; + --color-danger: #b91c1c; +} + +:root[data-theme="dark"] { + --color-bg: #0a0a0a; + --color-bg-raised: #121212; + --color-bg-surface: #1a1a1a; + --color-border: #262626; + --color-border-subtle: #1f1f1f; + --color-text: #fafafa; + --color-text-secondary: #a3a3a3; + --color-text-dim: #6e6e6e; + --color-brand: #fafafa; + --color-brand-dark: #d4d4d4; + --color-on-brand: #0a0a0a; + --color-success: #4ade80; + --color-warn: #fbbf24; + --color-danger: #f87171; +} + html, body, #root { @@ -31,7 +115,9 @@ body, body { -webkit-font-smoothing: antialiased; - font-feature-settings: "cv02", "cv03", "cv04", "cv11"; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss01"; + text-rendering: optimizeLegibility; } * { @@ -47,14 +133,32 @@ button { font-family: inherit; } -/* Scrollbar — subtle, brand-colored on hover. */ -::-webkit-scrollbar { - width: 10px; - height: 10px; +/* Smooth theme transitions on background/color only — never on transform + or layout properties. Disabled during initial paint to avoid the flash. */ +html.theme-ready body, +html.theme-ready body * { + transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease; } -::-webkit-scrollbar-track { - background: transparent; + +/* Display headings — use Fraunces. Apply explicitly via the `.display` + utility so we don't accidentally serif-ify every h1 / h2 in the tree. */ +.display { + font-family: var(--font-display); + font-weight: 300; + font-variation-settings: "opsz" 144; + letter-spacing: -0.025em; +} +.display em { font-style: italic; font-weight: 400; } +.display-2 { + font-family: var(--font-display); + font-weight: 400; + font-variation-settings: "opsz" 60; + letter-spacing: -0.018em; } + +/* Scrollbar — subtle, theme-aware. */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 8px; diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..ee36f38 --- /dev/null +++ b/dashboard/src/vite-env.d.ts @@ -0,0 +1,15 @@ +/// + +interface ImportMetaEnv { + readonly BASE_URL: string; + readonly MODE: string; + readonly DEV: boolean; + readonly PROD: boolean; + readonly VITE_API_BASE_URL?: string; + readonly VITE_CONSOLE_BASE_URL?: string; + readonly VITE_DOCS_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/docs/README.md b/docs/README.md index 94e15eb..26c7468 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ The platform also provides: ## How It Works -1. **Sign up** at `https://zeroauth.dev/api/console/signup` and get your API key. +1. **Sign up** at `https://api.zeroauth.dev/api/console/signup` and get your API key. 2. **Authenticate requests** with `Authorization: Bearer za_live_YOUR_KEY`. 3. **Call v1 endpoints** — register identities, verify ZK proofs, initiate SSO flows. 4. **Monitor usage** via the developer console API. diff --git a/docs/concepts/production-readiness.md b/docs/concepts/production-readiness.md index 5992145..1376d86 100644 --- a/docs/concepts/production-readiness.md +++ b/docs/concepts/production-readiness.md @@ -31,7 +31,7 @@ All authentication and identity endpoints are available under the `/v1/` version ZeroAuth is a hosted API platform. Integration requires: -1. **Sign up** at `https://zeroauth.dev/api/console/signup` +1. **Sign up** at `https://api.zeroauth.dev/api/console/signup` 2. **Get an API key** — shown once at creation, stored as SHA-256 hash 3. **Make API calls** with `Authorization: Bearer za_live_YOUR_KEY` 4. **Monitor usage** via the developer console API diff --git a/docs/getting-started/api-keys.md b/docs/getting-started/api-keys.md index 06b863a..ef7c935 100644 --- a/docs/getting-started/api-keys.md +++ b/docs/getting-started/api-keys.md @@ -18,7 +18,7 @@ za_{environment}_{48 hex characters} A default live key is automatically created when you sign up: ```bash -curl -X POST https://zeroauth.dev/api/console/signup \ +curl -X POST https://api.zeroauth.dev/api/console/signup \ -H "Content-Type: application/json" \ -d '{"email": "dev@co.com", "password": "secure123"}' ``` @@ -28,7 +28,7 @@ curl -X POST https://zeroauth.dev/api/console/signup \ Create additional keys via the console API: ```bash -curl -X POST https://zeroauth.dev/api/console/keys \ +curl -X POST https://api.zeroauth.dev/api/console/keys \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -76,7 +76,7 @@ Default scopes for new keys: `zkp:verify`, `zkp:register`, `identity:read`, `non ## Listing Keys ```bash -curl https://zeroauth.dev/api/console/keys \ +curl https://api.zeroauth.dev/api/console/keys \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" ``` @@ -87,7 +87,7 @@ Returns all keys (active and revoked) with prefix, scopes, environment, and last Revocation is immediate and irreversible: ```bash -curl -X DELETE https://zeroauth.dev/api/console/keys/KEY_UUID \ +curl -X DELETE https://api.zeroauth.dev/api/console/keys/KEY_UUID \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" ``` diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 4e69ddf..cd98cd7 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -4,7 +4,7 @@ ZeroAuth is a hosted API — there is nothing to install or configure on your se ## Getting Started -1. [Sign up](https://zeroauth.dev/api/console/signup) for a ZeroAuth account. +1. [Sign up](https://api.zeroauth.dev/api/console/signup) for a ZeroAuth account. 2. Copy your API key (shown once at signup). 3. Add your API key to your application's environment: @@ -13,7 +13,7 @@ ZeroAuth is a hosted API — there is nothing to install or configure on your se ZEROAUTH_API_KEY=za_live_YOUR_KEY_HERE ``` -4. Make API calls to `https://zeroauth.dev/v1/*`. +4. Make API calls to `https://api.zeroauth.dev/v1/*`. ## API Key Configuration @@ -23,11 +23,11 @@ Use one of two methods in every request: ```bash # Option A: Authorization header (recommended) -curl https://zeroauth.dev/v1/auth/zkp/nonce \ +curl https://api.zeroauth.dev/v1/auth/zkp/nonce \ -H "Authorization: Bearer za_live_YOUR_KEY" # Option B: X-API-Key header -curl https://zeroauth.dev/v1/auth/zkp/nonce \ +curl https://api.zeroauth.dev/v1/auth/zkp/nonce \ -H "X-API-Key: za_live_YOUR_KEY" ``` @@ -37,7 +37,7 @@ When creating additional API keys, restrict scopes to only what each service nee ```bash # Backend that only verifies proofs -curl -X POST https://zeroauth.dev/api/console/keys \ +curl -X POST https://api.zeroauth.dev/api/console/keys \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -62,7 +62,7 @@ Use separate keys for development and production: Your backend calls ZeroAuth APIs directly: ``` -Your Backend --> https://zeroauth.dev/v1/* +Your Backend --> https://api.zeroauth.dev/v1/* ``` Store the API key as a server-side environment variable. Never expose it to the browser. @@ -117,7 +117,7 @@ Limits are per-tenant (not per-key): Monitor your usage: ```bash -curl https://zeroauth.dev/api/console/usage \ +curl https://api.zeroauth.dev/api/console/usage \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" ``` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index bdd4597..d672db8 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -5,7 +5,7 @@ Get ZeroAuth running in under 5 minutes. No packages to install — just API cal ## Step 1: Create Your Account ```bash -curl -X POST https://zeroauth.dev/api/console/signup \ +curl -X POST https://api.zeroauth.dev/api/console/signup \ -H "Content-Type: application/json" \ -d '{ "email": "dev@yourcompany.com", @@ -41,7 +41,7 @@ The server stores only a SHA-256 hash — the raw key is never persisted. ### Register a Device ```bash -curl -X POST https://zeroauth.dev/v1/devices \ +curl -X POST https://api.zeroauth.dev/v1/devices \ -H "Authorization: Bearer za_live_YOUR_KEY_HERE" \ -H "Content-Type: application/json" \ -d '{ @@ -55,7 +55,7 @@ curl -X POST https://zeroauth.dev/v1/devices \ ### Create an Enrolled User ```bash -curl -X POST https://zeroauth.dev/v1/users \ +curl -X POST https://api.zeroauth.dev/v1/users \ -H "Authorization: Bearer za_live_YOUR_KEY_HERE" \ -H "Content-Type: application/json" \ -d '{ @@ -68,7 +68,7 @@ curl -X POST https://zeroauth.dev/v1/users \ ### Get a Nonce for ZKP ```bash -curl https://zeroauth.dev/v1/auth/zkp/nonce \ +curl https://api.zeroauth.dev/v1/auth/zkp/nonce \ -H "Authorization: Bearer za_live_YOUR_KEY_HERE" ``` @@ -83,7 +83,7 @@ curl https://zeroauth.dev/v1/auth/zkp/nonce \ ### Register an Identity ```bash -curl -X POST https://zeroauth.dev/v1/auth/zkp/register \ +curl -X POST https://api.zeroauth.dev/v1/auth/zkp/register \ -H "Authorization: Bearer za_live_YOUR_KEY_HERE" \ -H "Content-Type: application/json" \ -d '{"biometricTemplate": "BASE64_ENCODED_BIOMETRIC_DATA"}' @@ -92,7 +92,7 @@ curl -X POST https://zeroauth.dev/v1/auth/zkp/register \ ### Verify a ZK Proof ```bash -curl -X POST https://zeroauth.dev/v1/auth/zkp/verify \ +curl -X POST https://api.zeroauth.dev/v1/auth/zkp/verify \ -H "Authorization: Bearer za_live_YOUR_KEY_HERE" \ -H "Content-Type: application/json" \ -d '{ @@ -112,7 +112,7 @@ curl -X POST https://zeroauth.dev/v1/auth/zkp/verify \ ### Record a Verification Event ```bash -curl -X POST https://zeroauth.dev/v1/verifications \ +curl -X POST https://api.zeroauth.dev/v1/verifications \ -H "Authorization: Bearer za_live_YOUR_KEY_HERE" \ -H "Content-Type: application/json" \ -d '{ @@ -127,7 +127,7 @@ curl -X POST https://zeroauth.dev/v1/verifications \ ### Record Attendance ```bash -curl -X POST https://zeroauth.dev/v1/attendance \ +curl -X POST https://api.zeroauth.dev/v1/attendance \ -H "Authorization: Bearer za_live_YOUR_KEY_HERE" \ -H "Content-Type: application/json" \ -d '{ @@ -141,7 +141,7 @@ curl -X POST https://zeroauth.dev/v1/attendance \ ## Step 3: Check Your Usage ```bash -curl https://zeroauth.dev/api/console/usage \ +curl https://api.zeroauth.dev/api/console/usage \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" ``` diff --git a/docs/integrations/oidc.md b/docs/integrations/oidc.md index 9b2befd..0a3d44c 100644 --- a/docs/integrations/oidc.md +++ b/docs/integrations/oidc.md @@ -21,7 +21,7 @@ All OIDC endpoints require an API key with the appropriate scope. **Required scope:** `oidc:authorize` ```bash -curl https://zeroauth.dev/v1/auth/oidc/authorize \ +curl https://api.zeroauth.dev/v1/auth/oidc/authorize \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` @@ -41,7 +41,7 @@ Redirect the user's browser to `authorizeUrl`. ZeroAuth handles PKCE challenge g **Required scope:** `oidc:callback` ```bash -curl -X POST https://zeroauth.dev/v1/auth/oidc/callback \ +curl -X POST https://api.zeroauth.dev/v1/auth/oidc/callback \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -83,7 +83,7 @@ Validation enforced: Create a key with OIDC scopes: ```bash -curl -X POST https://zeroauth.dev/api/console/keys \ +curl -X POST https://api.zeroauth.dev/api/console/keys \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ diff --git a/docs/integrations/saml-sso.md b/docs/integrations/saml-sso.md index 479688e..431a773 100644 --- a/docs/integrations/saml-sso.md +++ b/docs/integrations/saml-sso.md @@ -20,7 +20,7 @@ All SAML endpoints require an API key with the appropriate scope. **Required scope:** `saml:login` ```bash -curl https://zeroauth.dev/v1/auth/saml/login \ +curl https://api.zeroauth.dev/v1/auth/saml/login \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` @@ -41,7 +41,7 @@ Redirect the user's browser to `redirectUrl` to initiate the SSO flow. **Required scope:** `saml:callback` ```bash -curl -X POST https://zeroauth.dev/v1/auth/saml/callback \ +curl -X POST https://api.zeroauth.dev/v1/auth/saml/callback \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -73,7 +73,7 @@ Success response: **Required scope:** `saml:login` ```bash -curl https://zeroauth.dev/v1/auth/saml/metadata \ +curl https://api.zeroauth.dev/v1/auth/saml/metadata \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` @@ -91,7 +91,7 @@ Your API key needs these scopes for SAML integration: Create a key with SAML scopes: ```bash -curl -X POST https://zeroauth.dev/api/console/keys \ +curl -X POST https://api.zeroauth.dev/api/console/keys \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ diff --git a/docs/integrations/zkp-biometric-auth.md b/docs/integrations/zkp-biometric-auth.md index f93802d..46a2b2d 100644 --- a/docs/integrations/zkp-biometric-auth.md +++ b/docs/integrations/zkp-biometric-auth.md @@ -19,7 +19,7 @@ ZeroAuth's ZKP flow provides privacy-preserving biometric authentication. The se ### Request ```bash -curl -X POST https://zeroauth.dev/v1/auth/zkp/register \ +curl -X POST https://api.zeroauth.dev/v1/auth/zkp/register \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -124,7 +124,7 @@ Artifacts you need on the client side: You can inspect the configured circuit metadata from: ```bash -curl https://zeroauth.dev/v1/auth/zkp/circuit-info \ +curl https://api.zeroauth.dev/v1/auth/zkp/circuit-info \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` @@ -138,7 +138,7 @@ The API does not directly expose the proving key, so most teams either: Fetch a nonce before proof submission: ```bash -curl https://zeroauth.dev/v1/auth/zkp/nonce \ +curl https://api.zeroauth.dev/v1/auth/zkp/nonce \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` @@ -162,7 +162,7 @@ Verification checks: ### Request ```bash -curl -X POST https://zeroauth.dev/v1/auth/zkp/verify \ +curl -X POST https://api.zeroauth.dev/v1/auth/zkp/verify \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ diff --git a/docs/operations/admin-dashboard.md b/docs/operations/admin-dashboard.md index 96de3ce..06312f5 100644 --- a/docs/operations/admin-dashboard.md +++ b/docs/operations/admin-dashboard.md @@ -7,7 +7,7 @@ The ZeroAuth Developer Console provides API key management, usage monitoring, an Console endpoints use session tokens (not API keys). Get a console token by logging in: ```bash -curl -X POST https://zeroauth.dev/api/console/login \ +curl -X POST https://api.zeroauth.dev/api/console/login \ -H "Content-Type: application/json" \ -d '{"email": "dev@yourcompany.com", "password": "your-password"}' ``` @@ -38,7 +38,7 @@ Console tokens expire after 24 hours. ### List Keys ```bash -curl https://zeroauth.dev/api/console/keys \ +curl https://api.zeroauth.dev/api/console/keys \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" ``` @@ -47,7 +47,7 @@ Returns all keys (active and revoked) with prefix, scopes, environment, and last ### Create a Key ```bash -curl -X POST https://zeroauth.dev/api/console/keys \ +curl -X POST https://api.zeroauth.dev/api/console/keys \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -62,7 +62,7 @@ The raw API key is shown **exactly once** in the response. Copy it immediately. ### Revoke a Key ```bash -curl -X DELETE https://zeroauth.dev/api/console/keys/KEY_UUID \ +curl -X DELETE https://api.zeroauth.dev/api/console/keys/KEY_UUID \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" ``` @@ -73,7 +73,7 @@ Revocation is immediate and irreversible. Maximum 10 active keys per account. ### Usage Summary ```bash -curl https://zeroauth.dev/api/console/usage \ +curl https://api.zeroauth.dev/api/console/usage \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" ``` @@ -98,7 +98,7 @@ Returns: ### Account Info ```bash -curl https://zeroauth.dev/api/console/account \ +curl https://api.zeroauth.dev/api/console/account \ -H "Authorization: Bearer YOUR_CONSOLE_TOKEN" ``` diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 1c66927..299ff33 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -82,7 +82,7 @@ The remote script: 1. validates Docker Compose config 2. runs `docker compose --profile prod up -d --build --remove-orphans` 3. waits for `zeroauth-prod` to become healthy -4. calls `https://zeroauth.dev/api/health` +4. calls `https://api.zeroauth.dev/api/health` 5. prunes dangling Docker images ## Important Build Detail @@ -100,7 +100,7 @@ That means deploys no longer depend on someone manually prebuilding `website/bui 1. Add `DEPLOY_SSH_KEY` to GitHub repository secrets. 2. Ensure `/opt/zeroauth/.env` exists on the VPS and is not overwritten by CI/CD. 3. Push to `main` or trigger the Deploy workflow manually. -4. Verify [https://zeroauth.dev/api/health](https://zeroauth.dev/api/health). +4. Verify [https://api.zeroauth.dev/api/health](https://api.zeroauth.dev/api/health). ## Recommended Hardening diff --git a/docs/operations/env-vars.md b/docs/operations/env-vars.md index 8a37609..d56c02c 100644 --- a/docs/operations/env-vars.md +++ b/docs/operations/env-vars.md @@ -107,9 +107,9 @@ Some examples by feature: | Feature | Smoke | |---|---| -| SMTP env added | `curl -X POST https://zeroauth.dev/api/console/signup -d '{"email":"smoke+@yushuexcellence.in","password":"Smoke2026!Pass"}'` → check logs for `Email: sent` with a messageId | -| Database creds changed | `curl https://zeroauth.dev/api/health` returns `{"status":"healthy"}` and `subsystems.postgres` is `connected` | -| Blockchain wallet rotated | `curl https://zeroauth.dev/api/health` returns `{"subsystems":{"blockchain":{"status":"connected","chainId":84532}}}` | +| SMTP env added | `curl -X POST https://api.zeroauth.dev/api/console/signup -d '{"email":"smoke+@yushuexcellence.in","password":"Smoke2026!Pass"}'` → check logs for `Email: sent` with a messageId | +| Database creds changed | `curl https://api.zeroauth.dev/api/health` returns `{"status":"healthy"}` and `subsystems.postgres` is `connected` | +| Blockchain wallet rotated | `curl https://api.zeroauth.dev/api/health` returns `{"subsystems":{"blockchain":{"status":"connected","chainId":84532}}}` | | JWT secret rotated | Existing console sessions break (expected). Re-login from `/dashboard/login` works. | Tail the logs while you smoke: diff --git a/docs/reference/api-reference.md b/docs/reference/api-reference.md index 551fb29..0901778 100644 --- a/docs/reference/api-reference.md +++ b/docs/reference/api-reference.md @@ -49,7 +49,7 @@ Register a new biometric identity. The biometric template is processed on the se **Request:** ```bash -curl -X POST https://zeroauth.dev/v1/auth/zkp/register \ +curl -X POST https://api.zeroauth.dev/v1/auth/zkp/register \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"biometricTemplate": "BASE64_ENCODED_DATA"}' @@ -84,7 +84,7 @@ Verify a Groth16 zero-knowledge proof and issue session tokens. **Request:** ```bash -curl -X POST https://zeroauth.dev/v1/auth/zkp/verify \ +curl -X POST https://api.zeroauth.dev/v1/auth/zkp/verify \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -135,7 +135,7 @@ Generate a fresh nonce for client-side proof generation. **Required scope:** `nonce:create` ```bash -curl https://zeroauth.dev/v1/auth/zkp/nonce \ +curl https://api.zeroauth.dev/v1/auth/zkp/nonce \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` @@ -158,7 +158,7 @@ Returns circuit metadata for client-side proof generation setup. **Required scope:** `zkp:verify` ```bash -curl https://zeroauth.dev/v1/auth/zkp/circuit-info \ +curl https://api.zeroauth.dev/v1/auth/zkp/circuit-info \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` @@ -188,7 +188,7 @@ Initiate SAML SSO flow. **Required scope:** `saml:login` ```bash -curl https://zeroauth.dev/v1/auth/saml/login \ +curl https://api.zeroauth.dev/v1/auth/saml/login \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` @@ -215,7 +215,7 @@ Initiate OIDC authorization code flow with PKCE. **Required scope:** `oidc:authorize` ```bash -curl https://zeroauth.dev/v1/auth/oidc/authorize \ +curl https://api.zeroauth.dev/v1/auth/oidc/authorize \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` @@ -238,7 +238,7 @@ Get the authenticated user's profile from a session token. **Additional header:** `X-Session-Token: ` ```bash -curl https://zeroauth.dev/v1/identity/me \ +curl https://api.zeroauth.dev/v1/identity/me \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "X-Session-Token: eyJhbGci..." ``` diff --git a/docs/reference/central-api.md b/docs/reference/central-api.md index beaa4c3..2c01cc1 100644 --- a/docs/reference/central-api.md +++ b/docs/reference/central-api.md @@ -20,7 +20,7 @@ These endpoints are for the developer or operator building on ZeroAuth. Create a tenant account and receive the first live API key. ```bash -curl -X POST https://zeroauth.dev/api/console/signup \ +curl -X POST https://api.zeroauth.dev/api/console/signup \ -H "Content-Type: application/json" \ -d '{ "email": "dev@company.com", @@ -104,7 +104,7 @@ Devices model real enterprise assets such as the battery-powered attendance devi Register a device. ```bash -curl -X POST https://zeroauth.dev/v1/devices \ +curl -X POST https://api.zeroauth.dev/v1/devices \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -141,7 +141,7 @@ Users are business identities enrolled under a tenant. This layer stores non-bio Create an enrolled user. ```bash -curl -X POST https://zeroauth.dev/v1/users \ +curl -X POST https://api.zeroauth.dev/v1/users \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -177,7 +177,7 @@ Verifications are product-level decision records. This is the shared API contrac Record a verification outcome. ```bash -curl -X POST https://zeroauth.dev/v1/verifications \ +curl -X POST https://api.zeroauth.dev/v1/verifications \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -229,7 +229,7 @@ Attendance is the Week 2 showcase surface. It should be driven by the same verif Record a check-in or check-out event. ```bash -curl -X POST https://zeroauth.dev/v1/attendance \ +curl -X POST https://api.zeroauth.dev/v1/attendance \ -H "Authorization: Bearer za_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ diff --git a/docs/reference/contracts-and-circuit.md b/docs/reference/contracts-and-circuit.md index c37ba1e..7eeb11b 100644 --- a/docs/reference/contracts-and-circuit.md +++ b/docs/reference/contracts-and-circuit.md @@ -84,7 +84,7 @@ The circuit artifacts needed for client-side proof generation: Fetch circuit metadata from the API: ```bash -curl https://zeroauth.dev/v1/auth/zkp/circuit-info \ +curl https://api.zeroauth.dev/v1/auth/zkp/circuit-info \ -H "Authorization: Bearer za_live_YOUR_KEY" ``` diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 4fc5b11..de9a42c 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -28,7 +28,7 @@ If you are integrating with ZeroAuth's hosted API, you do not need to configure | --- | --- | --- | --- | | `SAML_ENTRY_POINT` | `https://idp.example.com/sso/saml` | No | IdP SSO endpoint returned by `/v1/auth/saml/login`. | | `SAML_ISSUER` | `zeroauth-sp` | No | SP entity ID used in login response and metadata XML. | -| `SAML_CALLBACK_URL` | `https://zeroauth.dev/v1/auth/saml/callback` | No | Assertion Consumer Service URL in metadata XML. | +| `SAML_CALLBACK_URL` | `https://api.zeroauth.dev/v1/auth/saml/callback` | No | Assertion Consumer Service URL in metadata XML. | | `SAML_CERT` | empty | No | IdP certificate for assertion validation. | ## OIDC @@ -38,7 +38,7 @@ If you are integrating with ZeroAuth's hosted API, you do not need to configure | `OIDC_ISSUER` | `https://accounts.google.com` | No | Used to build the authorize URL. | | `OIDC_CLIENT_ID` | empty | No | Included in the authorize URL. | | `OIDC_CLIENT_SECRET` | empty | No | Used for token exchange. | -| `OIDC_REDIRECT_URI` | `https://zeroauth.dev/v1/auth/oidc/callback` | No | Included in the authorize URL. | +| `OIDC_REDIRECT_URI` | `https://api.zeroauth.dev/v1/auth/oidc/callback` | No | Included in the authorize URL. | ## Session and Admin diff --git a/docs/reference/playground.mdx b/docs/reference/playground.mdx new file mode 100644 index 0000000..9cd9542 --- /dev/null +++ b/docs/reference/playground.mdx @@ -0,0 +1,44 @@ +--- +title: API playground +sidebar_label: Playground +description: Try ZeroAuth's REST surface in the browser. Paste your API key, pick an endpoint, hit Send. +--- + +import ApiPlayground from '@site/src/components/ApiPlayground'; + +# API playground + +A no-install way to hit the ZeroAuth REST surface. Paste your +`za_test_…` or `za_live_…` key, choose an endpoint from the dropdown, +and Send. The response status, body, and round-trip time render right +underneath. + +:::caution Your key never leaves your browser +The playground runs the request from your browser directly to +`api.zeroauth.dev`. Nothing on the docs site (or in any analytics) +captures your API key — open the Network panel to confirm. If you'd +like to use the playground against a self-hosted deployment, change +the **API base** field at the top. +::: + + + +## Tips + +- **Test keys vs live keys.** `za_test_…` keys only see test-environment + data and don't accrue billing. Mint one from the + [API Keys page](https://console.zeroauth.dev/api-keys) before you + start poking. +- **CORS.** `api.zeroauth.dev` allows cross-origin requests from + `docs.zeroauth.dev`, the dashboard, the landing site, and localhost. + If you're testing from a self-hosted docs build, add your origin to + `CORS_ORIGINS` on the API. +- **Rate limits.** Each endpoint is gated by the standard per-tenant + limiter. If you hammer the playground you'll see a `429` with the + remaining window in the response headers. + +## See also + +- [API reference](./api-reference.md) — header formats + every endpoint +- [Central API delivery plan](../operations/central-api-delivery-plan.md) — how the REST surface is sliced over the 8-week build +- [Environment variables](./environment-variables.md) — for self-hosted setups diff --git a/docs/threat_model.md b/docs/threat_model.md index 9c5471b..a25f534 100644 --- a/docs/threat_model.md +++ b/docs/threat_model.md @@ -11,12 +11,12 @@ | Surface | Exposure | Notes | |---|---|---| -| `https://zeroauth.dev/v1/*` | Public, tenant-API-key authenticated | Scoped to `(tenant_id, environment)`. Rate-limit + monthly quota per tenant. | -| `https://zeroauth.dev/api/console/*` | Public, JWT-authenticated for everything except signup + login | Per-IP rate limit on signup/login. Password policy enforced. | -| `https://zeroauth.dev/api/admin/*` | Public, `x-api-key` (single shared admin key in `.env`) | Read-only. | -| `https://zeroauth.dev/api/health` | Public, unauthenticated | Health + subsystem status only. | -| `https://zeroauth.dev/api/auth/saml/*`, `…/oidc/*` | Public, gated by `ENABLE_DEMO_AUTH` flag | Demo stubs; **do not** validate real SAML signatures or OIDC tokens. Off in production. | -| `https://zeroauth.dev/api/leads/*` | Public, unauthenticated | Marketing forms; writes to `leads` table. | +| `https://api.zeroauth.dev/v1/*` | Public, tenant-API-key authenticated | Scoped to `(tenant_id, environment)`. Rate-limit + monthly quota per tenant. | +| `https://api.zeroauth.dev/api/console/*` | Public, JWT-authenticated for everything except signup + login | Per-IP rate limit on signup/login. Password policy enforced. | +| `https://api.zeroauth.dev/api/admin/*` | Public, `x-api-key` (single shared admin key in `.env`) | Read-only. | +| `https://api.zeroauth.dev/api/health` | Public, unauthenticated | Health + subsystem status only. | +| `https://api.zeroauth.dev/api/auth/saml/*`, `…/oidc/*` | Public, gated by `ENABLE_DEMO_AUTH` flag | Demo stubs; **do not** validate real SAML signatures or OIDC tokens. Off in production. | +| `https://api.zeroauth.dev/api/leads/*` | Public, unauthenticated | Marketing forms; writes to `leads` table. | | Base Sepolia `DIDRegistry` | Public RPC, `onlyOwner` writes | Deployer wallet is the single owner. Rotate via `npm run wallet:rotate`. | | VPS SSH (`104.207.143.14:22`) | Internet, key-only | `root` (laptop key) and `zeroauth-deploy` (CI key) authorized. UFW open only on 22/80/443. | diff --git a/iot/.gitignore b/iot/.gitignore new file mode 100644 index 0000000..294602b --- /dev/null +++ b/iot/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.env +.env.local +data/ diff --git a/iot/README.md b/iot/README.md new file mode 100644 index 0000000..5ba0156 --- /dev/null +++ b/iot/README.md @@ -0,0 +1,128 @@ +# @zeroauth/iot — fingerprint terminal driver + +Reference firmware skeleton for the ZeroAuth IoT terminal. Talks the R307 / +FPM10A / ZFM-20 family ("ZhiAn protocol") over a serial UART and uploads the +opaque **characteristic bytes** to the host so they can be hashed locally — +the raw fingerprint image never leaves this process. + +This is the first piece of B03 from the 8-week build plan. Eventually moves +into its own repo `zeroauth-dev/ZeroAuth-IoT` once the protocol is stable; +for now it lives in-tree so we can iterate. + +## What's in scope + +- R307 family driver (TypeScript, Node 20+) +- Five CLI commands — `info`, `enroll`, `search`, `capture`, `wipe` +- SHA-256 placeholder commitment for the captured characteristic + +## What's NOT in scope yet + +- The Pramaan fuzzy extractor + Poseidon commitment (lives in `/circuits`) +- POST to `/v1/users/register` / `/v1/verifications` (next pass, once a + fuzzy-extractor binding is wired) +- Hardware attestation, secure element key sealing, network resilience +- The GT-521 protocol or any of the non-ZhiAn families + +## Hardware setup + +| Sensor pin | UART adapter pin | +|---|---| +| VCC (red) | 3.3 V or 5 V — check the datasheet for your board variant. The R307 itself runs 4.2–6 V; many UART adapters expose 3.3 V which is borderline. If the LED never lights, swap to 5 V. | +| GND (black) | GND | +| TX (yellow) | RX | +| RX (white) | TX | + +On macOS the adapter shows up as `/dev/cu.usbserial-XXXX`. On Linux it's +`/dev/ttyUSB0` (CH340) or `/dev/ttyUSB1` (FT232 / CP2102). Set `ZA_IOT_PORT` +if yours isn't `/dev/cu.usbserial-0001`. + +## Fingerprint demo web app + +A minimal HTML+TS demo that uses the sensor as the login password. + +```bash +npm --prefix iot run demo +# → http://localhost:3100 +``` + +The bridge serves a static page (`iot/demo/index.html`) and exposes: + +| Method | Path | Body | Behaviour | +|---|---|---|---| +| POST | `/api/demo/signup` | `{ email }` | Two-capture enrollment, binds the email to the chosen slot | +| POST | `/api/demo/login` | `{ email }` | Single scan, 1:N match, checks the matched slot is the one bound to that email | +| GET | `/api/demo/accounts` | — | Lists all in-memory bindings | +| POST | `/api/demo/reset` | — | Wipes the sensor library + clears the binding map | + +Bindings are mirrored to `iot/data/demo-accounts.json` so the demo survives +restarts. The R307's own template store is already persistent. + +**Demo guard rails (not production code):** + +- Bridge binds 127.0.0.1 only. +- No auth on the endpoints — any local process can list accounts or reset. +- Matching uses the sensor's internal algorithm (slot-index lookup), NOT + the Pramaan fuzzy extractor + Groth16 pipeline. The slot index leaves + the sensor in cleartext. + +## Install + run + +```bash +# From the repo root: +npm --prefix iot install + +# Probe the connection: +npm --prefix iot run info + +# Two-capture enrollment at slot 0: +npm --prefix iot run enroll -- 0 + +# 1:N match against the on-sensor library: +npm --prefix iot run search + +# Single capture → upload characteristic → SHA-256: +npm --prefix iot run capture + +# Wipe the entire template library (interactive confirm): +npm --prefix iot run wipe +``` + +The `info` command does not touch the sensor's flash and is safe to run +any time. `enroll` and `wipe` mutate persistent state. + +## Environment overrides + +| Variable | Default | Note | +|---|---|---| +| `ZA_IOT_PORT` | `/dev/cu.usbserial-0001` | Serial path | +| `ZA_IOT_BAUD` | `57600` | R307 default; some clones ship at 9600 | +| `ZA_IOT_PASSWORD` | `00000000` | 4-byte hex; sensors that were locked at the factory use a non-zero password | + +## Protocol summary (reference) + +Each frame on the wire is: + +``` +header (0xEF 0x01) | address (4B) | PID (1B) | length (2B) | payload | checksum (2B) +``` + +The driver in [`src/sensor.ts`](src/sensor.ts) implements the subset of the +ZhiAn command set needed for the five CLI verbs. See the inline +constants — `CMD`, `CONF`, `PID` — for the byte values and the per-command +ack semantics. + +## Security notes + +- The fingerprint **image** stays inside the sensor IC. We only ever read + the **characteristic** (an opaque template — 256–512 bytes). That + characteristic still derives from the underlying biometric; treat it as + sensitive in memory and never persist it outside this process. The + `capture` CLI deliberately discards it after hashing. +- The on-sensor template store remains a soft secret: anyone with physical + access to the sensor can run `wipe` and erase the user enrolment. The + production terminal locks this behind a tamper-evident enclosure. +- The default password is 0x00000000. Production deploys should rotate it + via the `SetPassword` command before the device leaves the factory. +- Per the ZeroAuth threat model A-V01, this driver and the eventual + firmware live in a separate trust domain from the central API. The + network surface here is "outbound only" — no listening sockets. diff --git a/iot/demo/index.html b/iot/demo/index.html new file mode 100644 index 0000000..7ee2ca7 --- /dev/null +++ b/iot/demo/index.html @@ -0,0 +1,761 @@ + + + + + + ZeroAuth — Sign in + + + + + + + +
+ +
+ ZeroAuth +
ZeroAuth
+
Email · OTP · Fingerprint
+
+ +
+
+ + +
+ + +
+
+
Step 1 of 3 · Identify
+

Sign in to ZeroAuth

+

We'll email you a one-time code, then ask for your finger.

+
+
+ + +
+ + +
+ + + + + + + + + + +
+ +
+ Local bridge · http://localhost:3100 · Sensor: R307 @ /dev/cu.usbserial-0001 +
+ +
+ + + + + + diff --git a/iot/package-lock.json b/iot/package-lock.json new file mode 100644 index 0000000..ecc60c5 --- /dev/null +++ b/iot/package-lock.json @@ -0,0 +1,1204 @@ +{ + "name": "@zeroauth/iot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@zeroauth/iot", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "poseidon-lite": "^0.3.0", + "serialport": "^12.0.0", + "snarkjs": "^0.7.4" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@iden3/bigarray": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@iden3/bigarray/-/bigarray-0.0.2.tgz", + "integrity": "sha512-Xzdyxqm1bOFF6pdIsiHLLl3HkSLjbhqJHVyqaTxXt3RqXBEnmsUmEW47H7VOi/ak7TdkRpNkxjyK5Zbkm+y52g==", + "license": "GPL-3.0" + }, + "node_modules/@iden3/binfileutils": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@iden3/binfileutils/-/binfileutils-0.0.12.tgz", + "integrity": "sha512-naAmzuDufRIcoNfQ1d99d7hGHufLA3wZSibtr4dMe6ZeiOPV1KwOZWTJ1YVz4HbaWlpDuzVU72dS4ATQS4PXBQ==", + "license": "GPL-3.0", + "dependencies": { + "fastfile": "0.0.20", + "ffjavascript": "^0.3.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-12.0.1.tgz", + "integrity": "sha512-r2XOwY2dDvbW7dKqSPIk2gzsr6M6Qpe9+/Ngs94fNaNlcTRCV02PfaoDmRgcubpNVVcLATlxSxPTIDw12dbKOg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "11.0.0", + "debug": "4.3.4", + "node-addon-api": "7.0.0", + "node-gyp-build": "4.6.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-11.0.0.tgz", + "integrity": "sha512-aZLJhlRTjSmEwllLG7S4J8s8ctRAS0cbvCpO87smLvl3e4BgzbVgF6Z6zaJd3Aji2uSiYgfedCdNc4L6W+1E2g==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-11.0.0.tgz", + "integrity": "sha512-rRAivhRkT3YO28WjmmG4FQX6L+KMb5/ikhyylRfzWPw0nSXy97+u07peS9CbHqaNvJkMhH1locp2H36aGMOEIA==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "11.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-12.0.0.tgz", + "integrity": "sha512-0ei0txFAj+s6FTiCJFBJ1T2hpKkX8Md0Pu6dqMrYoirjPskDLJRgZGLqoy3/lnU1bkvHpnJO+9oJ3PB9v8rNlg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-12.0.0.tgz", + "integrity": "sha512-0PfLzO9t2X5ufKuBO34DQKLXrCCqS9xz2D0pfuaLNeTkyGUBv426zxoMf3rsMRodDOZNbFblu3Ae84MOQXjnZw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-12.0.0.tgz", + "integrity": "sha512-GnCh8K0NAESfhCuXAt+FfBRz1Cf9CzIgXfp7SdMgXwrtuUnCC/yuRTUFWRvuzhYKoAo1TL0hhUo77SFHUH1T/w==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-12.0.0.tgz", + "integrity": "sha512-p1hiCRqvGHHLCN/8ZiPUY/G0zrxd7gtZs251n+cfNTn+87rwcdUeu9Dps3Aadx30/sOGGFL6brIRGK4l/t7MuQ==", + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-12.0.0.tgz", + "integrity": "sha512-ygDwj3O4SDpZlbrRUraoXIoIqb8sM7aMKryGjYTIF0JRnKeB1ys8+wIp0RFMdFbO62YriUDextHB5Um5cKFSWg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-12.0.0.tgz", + "integrity": "sha512-dCAVh4P/pZrLcPv9NJ2mvPRBg64L5jXuiRxIlyxxdZGH4WubwXVXY/kBTihQmiAMPxbT3yshSX8f2+feqWsxqA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-12.0.0.tgz", + "integrity": "sha512-0APxDGR9YvJXTRfY+uRGhzOhTpU5akSH183RUcwzN7QXh8/1jwFsFLCu0grmAUfi+fItCkR+Xr1TcNJLR13VNA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-12.0.0.tgz", + "integrity": "sha512-dozONxhPC/78pntuxpz/NOtVps8qIc/UZzdc/LuPvVsqCoJXiRxOg6ZtCP/W58iibJDKPZPAWPGYeZt9DJxI+Q==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-12.0.0.tgz", + "integrity": "sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.3.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bfj": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", + "integrity": "sha512-I6MMLkn+anzNdCUp9hMRyui1HaNEUCco50lxbvNS4+EyXg8lN3nJ48PjPWtbH8UVS9CuMoaKE9U2V3l29DaRQw==", + "license": "MIT", + "dependencies": { + "bluebird": "^3.7.2", + "check-types": "^11.2.3", + "hoopy": "^0.1.4", + "jsonpath": "^1.1.1", + "tryer": "^1.0.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/check-types": { + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", + "integrity": "sha512-+67P1GkJRaxQD6PKK0Et9DhwQB+vGg3PM5+aavopCpZT1lj9jeqfvpgTLAWErNj8qApkkmXlu/Ug74kmhagkXg==", + "license": "MIT" + }, + "node_modules/circom_runtime": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/circom_runtime/-/circom_runtime-0.1.28.tgz", + "integrity": "sha512-ACagpQ7zBRLKDl5xRZ4KpmYIcZDUjOiNRuxvXLqhnnlLSVY1Dbvh73TI853nqoR0oEbihtWmMSjgc5f+pXf/jQ==", + "license": "Apache-2.0", + "dependencies": { + "ffjavascript": "0.3.1" + }, + "bin": { + "calcwit": "calcwit.js" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esprima": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.5.tgz", + "integrity": "sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastfile": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/fastfile/-/fastfile-0.0.20.tgz", + "integrity": "sha512-r5ZDbgImvVWCP0lA/cGNgQcZqR+aYdFx3u+CtJqUE510pBUVGMn4ulL/iRTI4tACTYsNJ736uzFxEBXesPAktA==", + "license": "GPL-3.0" + }, + "node_modules/ffjavascript": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.3.1.tgz", + "integrity": "sha512-4PbK1WYodQtuF47D4pRI5KUg3Q392vuP5WjE1THSnceHdXwU3ijaoS0OqxTzLknCtz4Z2TtABzkBdBdMn3B/Aw==", + "license": "GPL-3.0", + "dependencies": { + "wasmbuilder": "0.0.16", + "wasmcurves": "0.2.2", + "web-worker": "1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hoopy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", + "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jsonpath": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.3.0.tgz", + "integrity": "sha512-0kjkYHJBkAy50Z5QzArZ7udmvxrJzkpKYW27fiF//BrMY7TQibYLl+FYIXN2BiYmwMIVzSfD8aDRj6IzgBX2/w==", + "license": "MIT", + "dependencies": { + "esprima": "1.2.5", + "static-eval": "2.1.1", + "underscore": "1.13.6" + } + }, + "node_modules/logplease": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/logplease/-/logplease-1.2.15.tgz", + "integrity": "sha512-jLlHnlsPSJjpwUfcNyUxXCl33AYg2cHhIf9QhGL2T4iPT0XPB+xP1LRKFPgIg1M/sg9kAJvy94w9CzBNrfnstA==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz", + "integrity": "sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", + "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/poseidon-lite": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/poseidon-lite/-/poseidon-lite-0.3.0.tgz", + "integrity": "sha512-ilJj4MIve4uBEG7SrtPqUUNkvpJ/pLVbndxa0WvebcQqeIhe+h72JR4g0EvwchUzm9sOQDlOjiDNmRAgxNZl4A==", + "license": "MIT" + }, + "node_modules/r1csfile": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/r1csfile/-/r1csfile-0.0.48.tgz", + "integrity": "sha512-kHRkKUJNaor31l05f2+RFzvcH5XSa7OfEfd/l4hzjte6NL6fjRkSMfZ4BjySW9wmfdwPOtq3mXurzPvPGEf5Tw==", + "license": "GPL-3.0", + "dependencies": { + "@iden3/bigarray": "0.0.2", + "@iden3/binfileutils": "0.0.12", + "fastfile": "0.0.20", + "ffjavascript": "0.3.0" + } + }, + "node_modules/r1csfile/node_modules/ffjavascript": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.3.0.tgz", + "integrity": "sha512-l7sR5kmU3gRwDy8g0Z2tYBXy5ttmafRPFOqY7S6af5cq51JqJWt5eQ/lSR/rs2wQNbDYaYlQr5O+OSUf/oMLoQ==", + "license": "GPL-3.0", + "dependencies": { + "wasmbuilder": "0.0.16", + "wasmcurves": "0.2.2", + "web-worker": "1.2.0" + } + }, + "node_modules/serialport": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-12.0.0.tgz", + "integrity": "sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==", + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "12.0.1", + "@serialport/parser-byte-length": "12.0.0", + "@serialport/parser-cctalk": "12.0.0", + "@serialport/parser-delimiter": "12.0.0", + "@serialport/parser-inter-byte-timeout": "12.0.0", + "@serialport/parser-packet-length": "12.0.0", + "@serialport/parser-readline": "12.0.0", + "@serialport/parser-ready": "12.0.0", + "@serialport/parser-regex": "12.0.0", + "@serialport/parser-slip-encoder": "12.0.0", + "@serialport/parser-spacepacket": "12.0.0", + "@serialport/stream": "12.0.0", + "debug": "4.3.4" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/snarkjs": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/snarkjs/-/snarkjs-0.7.6.tgz", + "integrity": "sha512-4uH1xA5JzVU5jaaWS2fXej3+RC6L5Erhr6INTJtUA27du4Elbh4VXCeeRjB4QiwL6N6y7SNKePw5prTxyEf4Zg==", + "license": "GPL-3.0", + "dependencies": { + "@iden3/binfileutils": "0.0.12", + "@noble/hashes": "^1.7.1", + "bfj": "^7.0.2", + "circom_runtime": "0.1.28", + "ejs": "^3.1.6", + "fastfile": "0.0.20", + "ffjavascript": "0.3.1", + "logplease": "^1.2.15", + "r1csfile": "0.0.48" + }, + "bin": { + "snarkjs": "build/cli.cjs" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, + "node_modules/tryer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", + "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", + "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wasmbuilder": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/wasmbuilder/-/wasmbuilder-0.0.16.tgz", + "integrity": "sha512-Qx3lEFqaVvp1cEYW7Bfi+ebRJrOiwz2Ieu7ZG2l7YyeSJIok/reEQCQCuicj/Y32ITIJuGIM9xZQppGx5LrQdA==", + "license": "GPL-3.0" + }, + "node_modules/wasmcurves": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/wasmcurves/-/wasmcurves-0.2.2.tgz", + "integrity": "sha512-JRY908NkmKjFl4ytnTu5ED6AwPD+8VJ9oc94kdq7h5bIwbj0L4TDJ69mG+2aLs2SoCmGfqIesMWTEJjtYsoQXQ==", + "license": "GPL-3.0", + "dependencies": { + "wasmbuilder": "0.0.16" + } + }, + "node_modules/web-worker": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", + "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==", + "license": "Apache-2.0" + } + } +} diff --git a/iot/package.json b/iot/package.json new file mode 100644 index 0000000..e837f3e --- /dev/null +++ b/iot/package.json @@ -0,0 +1,31 @@ +{ + "name": "@zeroauth/iot", + "private": true, + "version": "0.1.0", + "description": "Reference IoT terminal firmware for ZeroAuth — R307/FPM10A fingerprint capture + Poseidon commitment.", + "type": "module", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "scripts": { + "info": "tsx src/cli.ts info", + "enroll": "tsx src/cli.ts enroll", + "search": "tsx src/cli.ts search", + "capture": "tsx src/cli.ts capture", + "wipe": "tsx src/cli.ts wipe", + "demo": "tsx src/bridge.ts", + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "dependencies": { + "poseidon-lite": "^0.3.0", + "serialport": "^12.0.0", + "snarkjs": "^0.7.4" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } +} diff --git a/iot/src/bridge.ts b/iot/src/bridge.ts new file mode 100644 index 0000000..157d3c6 --- /dev/null +++ b/iot/src/bridge.ts @@ -0,0 +1,692 @@ +/** + * Local HTTP bridge for the ZeroAuth fingerprint demo. + * + * Browsers can't talk to a UART directly (Web Serial is gated, varies by + * browser, and even where it works it's a poor fit for a held-open serial + * port). This process is the bridge: it owns the open R307, serializes + * access with a mutex, and exposes two endpoints the demo page calls. + * + * POST /api/demo/signup { email } enroll a finger and bind it to + * the email at the next free slot. + * Two captures (place → lift → place). + * + * POST /api/demo/login { email } single scan + 1:N search on the + * sensor's stored templates. Login + * succeeds iff the matched slot is + * the same one we bound to this + * email at signup. + * + * GET /api/demo/accounts returns the in-memory list. Demo- + * only; never copy into prod. + * + * POST /api/demo/reset wipe sensor + clear the binding + * map. + * + * GET / serves iot/demo/index.html + * + * Persistence: the email → slot map is mirrored to `iot/data/demo-accounts.json` + * on every change so the demo survives `Ctrl-C` + restart. The R307's own + * template storage is already persistent. + * + * Security caveats (please re-read before reusing this anywhere real): + * - The bridge listens on 127.0.0.1 only. Even so, ANY local process can + * reach the API. That's fine for a single-operator laptop demo, NOT + * fine for a shared workstation. + * - No auth on the endpoints. Anyone who can reach the port can enroll + * fingerprints or list accounts. + * - The bridge does NOT do the "real" ZeroAuth pipeline (fuzzy extractor + * → Poseidon → Groth16). The matching here is the sensor's internal + * algorithm, and the slot index travels in the clear over loopback. + */ + +import http from 'node:http'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CONF, R307Sensor } from './sensor.js'; +import { deriveSignals, shortHex } from './crypto.js'; +import { generateProof, verifyProof, initProver } from './proof.js'; +import * as otp from './otp.js'; +import { OtpRateLimitedError } from './otp.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const PORT = Number(process.env.ZA_IOT_BRIDGE_PORT ?? 3100); +const HOST = process.env.ZA_IOT_BRIDGE_HOST ?? '127.0.0.1'; +const SERIAL_PATH = process.env.ZA_IOT_PORT ?? '/dev/cu.usbserial-0001'; +const SERIAL_BAUD = Number.parseInt(process.env.ZA_IOT_BAUD ?? '57600', 10); +const SERIAL_PASSWORD = Number.parseInt(process.env.ZA_IOT_PASSWORD ?? '0', 16); + +/** + * In dev (default), the bridge returns the freshly-issued OTP in the + * /api/demo/request-otp response so the operator can use the demo + * without SMTP. Set `ZA_IOT_HIDE_OTP=1` to flip into "production + * shape" — the response then carries only metadata + the operator has + * to read the OTP from the bridge logs (or from their inbox once an + * email transport is wired). + */ +const DEV_SHOW_OTP = process.env.ZA_IOT_HIDE_OTP !== '1'; + +const ACCOUNTS_FILE = path.resolve(__dirname, '..', 'data', 'demo-accounts.json'); +const DEMO_HTML_PATH = path.resolve(__dirname, '..', 'demo', 'index.html'); +const FAVICON_SVG_PATH = path.resolve(__dirname, '..', '..', 'public', 'zeroauth-mark.svg'); + +/** + * Minimum 1:1 match score (R307 reports 0-300+ at security level 3). + * Anything below this is treated as "not the right finger." Tunable; + * level-3 scoring on this unit hovers in the 80–180 range for the + * same finger, single-digit when fingers differ. + */ +const MATCH_THRESHOLD = 50; + +/** + * Per-Pramaan storage: the sensor's flash slots are NOT used at all. + * The host owns the template, the commitment, and the proof-side public + * signals. Capacity is bound only by disk. The sensor is reduced to two + * roles: (1) capture+combine produces the stable template at signup; + * (2) 1:1 MATCH at login compares a fresh capture against the host- + * supplied template downloaded into buf2. + * + * Storing the template on the host is the demo's compromise vs. real + * Pramaan. The production construction wraps the template in a fuzzy- + * extractor helper string that's information-theoretically useless + * without a close-enough finger; we approximate that property here by + * the fact that the template alone can't authenticate — you also need + * a finger the sensor will match against it. + */ +interface Account { + email: string; + /** Base64 of the 768-byte sensor template. Re-downloaded at login. */ + template: string; + /** decimal string — BN128 scalar */ + salt: string; + /** decimal string — Poseidon(biometricSecret, salt) */ + commitment: string; + /** decimal string — Poseidon(SHA-256(did)_F) */ + didHash: string; + /** decimal string — Poseidon(biometricSecret, didHash) */ + identityBinding: string; + did: string; + createdAt: string; +} + +// ─── State ──────────────────────────────────────────────────────────────── + +const accounts = new Map(); +let sensor: R307Sensor | null = null; + +/** + * Async mutex around sensor access. The R307 only handles one command at a + * time and the protocol has no "request id" — concurrent commands collide. + * Chain everything off a single Promise. + */ +let sensorLock: Promise = Promise.resolve(); +function withSensorLock(fn: () => Promise): Promise { + const next = sensorLock.then(() => fn()); + // Suppress unhandled rejection on the chain; callers see their own throw. + sensorLock = next.catch(() => undefined); + return next; +} + +function isValidAccount(a: unknown): a is Account { + if (!a || typeof a !== 'object') return false; + const o = a as Record; + return ( + typeof o.email === 'string' && + typeof o.template === 'string' && + typeof o.salt === 'string' && + typeof o.commitment === 'string' && + typeof o.didHash === 'string' && + typeof o.identityBinding === 'string' && + typeof o.did === 'string' && + typeof o.createdAt === 'string' + ); +} + +async function loadAccounts(): Promise { + try { + const raw = await fs.readFile(ACCOUNTS_FILE, 'utf8'); + const arr = JSON.parse(raw) as unknown[]; + let skipped = 0; + for (const candidate of arr) { + if (!isValidAccount(candidate)) { + skipped += 1; + continue; + } + accounts.set(candidate.email.toLowerCase(), candidate); + } + if (skipped > 0) { + console.warn(`[bridge] skipped ${skipped} legacy account(s) without ZK commitment fields — re-signup to migrate.`); + } + console.log(`[bridge] restored ${accounts.size} account(s) from ${ACCOUNTS_FILE}`); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn(`[bridge] could not load accounts: ${(err as Error).message}`); + } + } +} + +async function saveAccounts(): Promise { + await fs.mkdir(path.dirname(ACCOUNTS_FILE), { recursive: true }); + const arr = [...accounts.values()]; + await fs.writeFile(ACCOUNTS_FILE, JSON.stringify(arr, null, 2), 'utf8'); +} + +function normalizeEmail(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const trimmed = raw.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return null; + return trimmed; +} + +// ─── Sensor flows ───────────────────────────────────────────────────────── + +/** + * Streaming progress events. The bridge emits one of these as a single + * NDJSON line every time the sensor flow transitions, so the browser + * can show the right "place finger" / "lift finger" / etc. UI. + * + * `step` distinguishes the two captures during signup. For login it's `0`. + */ +export type Phase = + | { phase: 'awaiting_finger'; step: 1 | 2 | 0 } + | { phase: 'captured'; step: 1 | 2 | 0 } + | { phase: 'awaiting_removal'; step: 1 } + | { phase: 'removed'; step: 1 } + | { phase: 'uploading_template' } + | { phase: 'loading_template' } + | { phase: 'matching'; score?: number } + | { phase: 'deriving'; commitmentPreview: string } + | { phase: 'proving' } + | { phase: 'verifying' } + /** + * A capture-or-combine attempt failed for a retryable reason and we're + * about to start the whole two-capture flow over. The UI should reset + * its stepper to step 1 and show `reason` so the operator knows what + * to do differently this time. + */ + | { phase: 'retry'; attempt: number; reason: string } + | { phase: 'done'; result: unknown } + | { phase: 'error'; message: string }; + +/** + * Sensor confirmations that mean "user/sensor action problem, try again" + * rather than "protocol or hardware broken." We auto-restart enrollment + * on any of these (up to MAX_ENROLL_ATTEMPTS times). + */ +function classifyRetryable(err: Error): string | null { + const msg = err.message; + if (msg.includes(`0x${CONF.COMBINE_FAIL.toString(16)}`)) { + return 'The two scans did not match each other. Use the same finger both times, with similar placement.'; + } + if (msg.includes(`0x${CONF.TOO_FUZZY.toString(16)}`)) { + return 'The scan was unclear. Try a cleaner, more centred placement.'; + } + if (msg.includes(`0x${CONF.TOO_FEW_FEATURE.toString(16)}`)) { + return 'Not enough ridge detail captured. Press a little more firmly.'; + } + return null; +} + +const MAX_ENROLL_ATTEMPTS = 3; + +type ProgressFn = (event: Phase) => void; + +export class EmailAlreadyRegisteredError extends Error { + constructor(public readonly email: string) { + super(`Email already registered: ${email}`); + this.name = 'EmailAlreadyRegisteredError'; + } +} + +async function enroll(email: string, onProgress: ProgressFn): Promise { + if (!sensor) throw new Error('Sensor not initialised'); + if (accounts.has(email)) { + throw new EmailAlreadyRegisteredError(email); + } + return withSensorLock(async () => { + let lastErr: Error | undefined; + for (let attempt = 1; attempt <= MAX_ENROLL_ATTEMPTS; attempt++) { + try { + console.log(`[bridge] signup: attempt ${attempt}/${MAX_ENROLL_ATTEMPTS}, capture 1/2 for ${email}`); + onProgress({ phase: 'awaiting_finger', step: 1 }); + await sensor!.waitForFinger(); + onProgress({ phase: 'captured', step: 1 }); + await sensor!.imageToCharBuffer(1); + + console.log('[bridge] signup: waiting for finger removal'); + onProgress({ phase: 'awaiting_removal', step: 1 }); + await sensor!.waitForFingerRemoval(); + onProgress({ phase: 'removed', step: 1 }); + + console.log('[bridge] signup: capture 2/2'); + onProgress({ phase: 'awaiting_finger', step: 2 }); + await sensor!.waitForFinger(); + onProgress({ phase: 'captured', step: 2 }); + await sensor!.imageToCharBuffer(2); + + console.log('[bridge] signup: combining captures into template'); + await sensor!.combineToTemplate(); + + // Pull the stable template off the sensor into host memory. + // Sensor's flash slot is never touched — this is the Pramaan + // shape: sensor captures, host owns everything else. + console.log('[bridge] signup: uploading template to host'); + onProgress({ phase: 'uploading_template' }); + const templateBytes = await sensor!.uploadCharacteristic(1); + + // Patent-Claim-3 derivation. biometricID = SHA-256(template); + // commitment = Poseidon(Poseidon(bid, salt), salt). + const signals = deriveSignals({ templateBytes, email }); + onProgress({ phase: 'deriving', commitmentPreview: shortHex(signals.commitment) }); + + console.log('[bridge] signup: generating Groth16 proof'); + onProgress({ phase: 'proving' }); + const { proof, publicSignals } = await generateProof({ + biometricSecret: signals.biometricSecret, + salt: signals.salt, + commitment: signals.commitment, + didHash: signals.didHash, + identityBinding: signals.identityBinding, + }); + + console.log('[bridge] signup: verifying Groth16 proof'); + onProgress({ phase: 'verifying' }); + const ok = await verifyProof({ proof, publicSignals }); + if (!ok) { + throw new Error('Signup-time proof failed verification — refusing to persist account.'); + } + + const account: Account = { + email, + template: templateBytes.toString('base64'), + salt: signals.salt.toString(), + commitment: signals.commitment.toString(), + didHash: signals.didHash.toString(), + identityBinding: signals.identityBinding.toString(), + did: signals.did, + createdAt: new Date().toISOString(), + }; + accounts.set(email, account); + await saveAccounts(); + console.log(`[bridge] signup OK for ${email}, template ${templateBytes.length}B, commitment ${shortHex(signals.commitment)}`); + return account; + } catch (err) { + const reason = classifyRetryable(err as Error); + if (!reason || attempt === MAX_ENROLL_ATTEMPTS) { + throw err; + } + lastErr = err as Error; + console.log(`[bridge] signup attempt ${attempt} failed (${reason}); retrying`); + await sensor!.waitForFingerRemoval().catch(() => undefined); + onProgress({ phase: 'retry', attempt: attempt + 1, reason }); + } + } + throw lastErr ?? new Error('Enrollment exhausted without resolution'); + }); +} + +interface AuthResult { + matched: boolean; + email: string; + score?: number; + reason?: 'no_account' | 'no_match' | 'wrong_finger' | 'proof_failed'; + /** Set on success. The commitment the bridge stored for this account. */ + commitmentPreview?: string; + did?: string; +} + +async function authenticate(email: string, onProgress: ProgressFn): Promise { + if (!sensor) throw new Error('Sensor not initialised'); + return withSensorLock(async () => { + const account = accounts.get(email); + console.log(`[bridge] login: capture for ${email}`); + onProgress({ phase: 'awaiting_finger', step: 0 }); + await sensor!.waitForFinger(); + onProgress({ phase: 'captured', step: 0 }); + await sensor!.imageToCharBuffer(1); + + if (!account) { + console.log(`[bridge] login: no account for ${email}`); + return { matched: false, email, reason: 'no_account' }; + } + + // Per Pramaan: never search the sensor's flash. Push the stored + // template into buf2, then ask the sensor to MATCH buf1 (fresh + // capture) against buf2 (the host's stored template). Sensor's role + // is reduced to capture + 1:1 comparison. + console.log('[bridge] login: downloading stored template into sensor buf2'); + onProgress({ phase: 'loading_template' }); + const templateBytes = Buffer.from(account.template, 'base64'); + await sensor!.downloadCharacteristic(2, templateBytes); + + console.log('[bridge] login: 1:1 match'); + onProgress({ phase: 'matching' }); + const match = await sensor!.match(); + if (!match) { + console.log('[bridge] login: sensor reported NO_MATCH'); + return { matched: false, email, reason: 'wrong_finger' }; + } + onProgress({ phase: 'matching', score: match.score }); + if (match.score < MATCH_THRESHOLD) { + console.log(`[bridge] login: score ${match.score} below threshold ${MATCH_THRESHOLD}`); + return { matched: false, email, reason: 'wrong_finger', score: match.score }; + } + + // Match succeeded. Re-derive the ZK signals using the stored template + // + stored salt — both deterministic, so the commitment + public + // signals MUST equal the ones we persisted at signup. If they don't, + // the on-disk account file was tampered with and we refuse to auth. + const signals = deriveSignals({ + templateBytes, + email, + salt: BigInt(account.salt), + }); + onProgress({ phase: 'deriving', commitmentPreview: shortHex(signals.commitment) }); + + console.log('[bridge] login: generating Groth16 proof'); + onProgress({ phase: 'proving' }); + const { proof, publicSignals } = await generateProof({ + biometricSecret: signals.biometricSecret, + salt: signals.salt, + commitment: signals.commitment, + didHash: signals.didHash, + identityBinding: signals.identityBinding, + }); + + const [pubCommit, pubDidHash, pubBinding] = publicSignals; + if ( + pubCommit !== account.commitment || + pubDidHash !== account.didHash || + pubBinding !== account.identityBinding + ) { + console.log('[bridge] login: public signal mismatch (stored account corrupted)'); + return { matched: false, email, reason: 'proof_failed' }; + } + + console.log('[bridge] login: verifying Groth16 proof'); + onProgress({ phase: 'verifying' }); + const ok = await verifyProof({ proof, publicSignals }); + if (!ok) { + console.log('[bridge] login: proof failed verification'); + return { matched: false, email, reason: 'proof_failed' }; + } + + console.log(`[bridge] login OK for ${email} (match score ${match.score}, commitment ${shortHex(signals.commitment)})`); + return { + matched: true, + email, + score: match.score, + commitmentPreview: shortHex(signals.commitment), + did: account.did, + }; + }); +} + +async function reset(): Promise { + // Per Pramaan we no longer use the sensor's flash, so the reset is + // purely host-side. The sensor only holds transient buffers (cleared + // implicitly between commands) and any QC templates from the factory + // that we never used. + accounts.clear(); + await saveAccounts(); + console.log('[bridge] reset: host accounts cleared (sensor flash untouched)'); +} + +// ─── HTTP ───────────────────────────────────────────────────────────────── + +async function readJson(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf8') || '{}'; + return JSON.parse(raw); +} + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); +} + +/** + * Run a sensor flow and stream NDJSON progress events to the response. + * The handler always finishes the response cleanly; on throw, emits a + * single `error` phase event and ends. Status code stays 200 because + * the stream itself carries the success/failure signal — the browser's + * fetch already has the body open by the time we know the outcome. + */ +async function runStreamed( + res: http.ServerResponse, + run: (write: (event: Phase) => void) => Promise, +): Promise { + res.writeHead(200, { + 'Content-Type': 'application/x-ndjson; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Accel-Buffering': 'no', + }); + const write = (event: Phase): void => { + res.write(JSON.stringify(event) + '\n'); + }; + try { + await run(write); + } catch (err) { + write({ phase: 'error', message: (err as Error).message }); + } finally { + res.end(); + } +} + +async function sendStatic(res: http.ServerResponse, filePath: string, contentType: string): Promise { + try { + const body = await fs.readFile(filePath); + res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-store' }); + res.end(body); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + res.writeHead(404).end(); + return; + } + throw err; + } +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const isReadable = req.method === 'GET' || req.method === 'HEAD'; + + if (isReadable && (url.pathname === '/' || url.pathname === '/index.html')) { + await sendStatic(res, DEMO_HTML_PATH, 'text/html; charset=utf-8'); + return; + } + if (isReadable && url.pathname === '/zeroauth-mark.svg') { + await sendStatic(res, FAVICON_SVG_PATH, 'image/svg+xml'); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/demo/request-otp') { + const body = (await readJson(req)) as { email?: unknown; kind?: unknown }; + const email = normalizeEmail(body.email); + if (!email) { + sendJson(res, 400, { error: 'invalid_email' }); + return; + } + const kind = body.kind === 'signup' || body.kind === 'login' ? body.kind : null; + if (!kind) { + sendJson(res, 400, { error: 'invalid_kind' }); + return; + } + // Surface the already-registered / no-account checks at the OTP + // step so the user doesn't waste a code on a hopeless flow. + if (kind === 'signup' && accounts.has(email)) { + sendJson(res, 409, { error: 'already_registered', email, did: accounts.get(email)!.did }); + return; + } + if (kind === 'login' && !accounts.has(email)) { + sendJson(res, 404, { error: 'no_account', email }); + return; + } + try { + const issued = otp.request(email, kind); + // The plaintext code never makes it into the logs — only the + // metadata. The operator-facing line is below, gated on the + // dev flag. + console.log(`[bridge] otp issued for ${email} (${kind}); expires ${issued.expiresAt.toISOString()}`); + if (DEV_SHOW_OTP) console.log(`[bridge] DEV_SHOW_OTP code=${issued.code}`); + sendJson(res, 200, { + email, + kind, + expiresAt: issued.expiresAt.toISOString(), + ...(DEV_SHOW_OTP ? { devCode: issued.code } : {}), + }); + } catch (err) { + if (err instanceof OtpRateLimitedError) { + sendJson(res, 429, { error: 'rate_limited', retryAfterMs: err.retryAfterMs }); + return; + } + sendJson(res, 500, { error: 'otp_request_failed', message: (err as Error).message }); + } + return; + } + + if (req.method === 'POST' && url.pathname === '/api/demo/verify-otp') { + const body = (await readJson(req)) as { email?: unknown; otp?: unknown; kind?: unknown }; + const email = normalizeEmail(body.email); + if (!email) { + sendJson(res, 400, { error: 'invalid_email' }); + return; + } + const code = typeof body.otp === 'string' ? body.otp.trim() : ''; + if (!/^\d{6}$/.test(code)) { + sendJson(res, 400, { error: 'invalid_otp_format' }); + return; + } + const kind = body.kind === 'signup' || body.kind === 'login' ? body.kind : null; + if (!kind) { + sendJson(res, 400, { error: 'invalid_kind' }); + return; + } + const result = otp.verify(email, code, kind); + if (!result.ok) { + sendJson(res, 401, { error: 'otp_invalid', reason: result.reason }); + return; + } + sendJson(res, 200, { + email: result.email, + kind: result.kind, + sessionToken: result.sessionToken, + sessionExpiresAt: result.sessionExpiresAt.toISOString(), + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/demo/signup') { + const body = (await readJson(req)) as { email?: unknown; sessionToken?: unknown }; + const email = normalizeEmail(body.email); + const sessionToken = typeof body.sessionToken === 'string' ? body.sessionToken : ''; + if (!email) { + sendJson(res, 400, { error: 'invalid_email' }); + return; + } + if (accounts.has(email)) { + sendJson(res, 409, { error: 'already_registered', email, did: accounts.get(email)!.did }); + return; + } + if (!sessionToken || !otp.consumeSession(sessionToken, email, 'signup')) { + sendJson(res, 401, { error: 'otp_required', message: 'Verify your email with the code first.' }); + return; + } + await runStreamed(res, async (write) => { + const account = await enroll(email, write); + write({ + phase: 'done', + result: { + email: account.email, + createdAt: account.createdAt, + commitmentPreview: shortHex(BigInt(account.commitment)), + did: account.did, + }, + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/demo/login') { + const body = (await readJson(req)) as { email?: unknown; sessionToken?: unknown }; + const email = normalizeEmail(body.email); + const sessionToken = typeof body.sessionToken === 'string' ? body.sessionToken : ''; + if (!email) { + sendJson(res, 400, { error: 'invalid_email' }); + return; + } + if (!sessionToken || !otp.consumeSession(sessionToken, email, 'login')) { + sendJson(res, 401, { error: 'otp_required', message: 'Verify your email with the code first.' }); + return; + } + await runStreamed(res, async (write) => { + const result = await authenticate(email, write); + write({ phase: 'done', result }); + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/api/demo/accounts') { + sendJson(res, 200, [...accounts.values()]); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/demo/reset') { + try { + await reset(); + sendJson(res, 200, { ok: true }); + } catch (err) { + sendJson(res, 500, { error: 'reset_failed', message: (err as Error).message }); + } + return; + } + + res.writeHead(404).end(); + } catch (err) { + sendJson(res, 500, { error: 'internal_error', message: (err as Error).message }); + } +}); + +async function main(): Promise { + await loadAccounts(); + + console.log('[bridge] preloading Groth16 proving + verification keys…'); + await initProver(); + + sensor = new R307Sensor({ + path: SERIAL_PATH, + baudRate: SERIAL_BAUD, + password: SERIAL_PASSWORD, + fingerTimeoutMs: 30_000, // demo gives the user a generous window + }); + + console.log(`[bridge] opening ${SERIAL_PATH} @ ${SERIAL_BAUD} baud…`); + await sensor.open(); + const ok = await sensor.verifyPassword(); + if (!ok) { + console.error('[bridge] sensor password verification failed.'); + process.exit(1); + } + + server.listen(PORT, HOST, () => { + console.log(`[bridge] demo running at http://${HOST}:${PORT}`); + console.log(`[bridge] open that URL in your browser to use the fingerprint demo.`); + }); + + const shutdown = async (sig: string): Promise => { + console.log(`\n[bridge] ${sig} — closing sensor + server`); + server.close(); + if (sensor) await sensor.close(); + process.exit(0); + }; + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); +} + +void main(); diff --git a/iot/src/cli.ts b/iot/src/cli.ts new file mode 100644 index 0000000..84551ff --- /dev/null +++ b/iot/src/cli.ts @@ -0,0 +1,246 @@ +/** + * CLI for the R307 fingerprint terminal driver. + * + * Usage: + * npm --prefix iot run info -- read sensor params + slot occupancy + * npm --prefix iot run enroll -- -- two-capture enrollment + on-sensor store + * npm --prefix iot run search -- single capture + 1:N match + * npm --prefix iot run capture -- single capture + upload characteristic + SHA-256 + * npm --prefix iot run wipe -- empty the entire on-sensor template library + * + * Environment overrides: + * ZA_IOT_PORT serial path (default: /dev/cu.usbserial-0001) + * ZA_IOT_BAUD baud rate (default: 57600) + * ZA_IOT_PASSWORD 4-byte hex password (default: 00000000) + * + * Threat-model notes: + * - The raw fingerprint image never crosses this process. We read the + * CHARACTERISTIC bytes (an opaque template, ~512 bytes for R307) via + * the sensor's UpChar command, hash them locally, and only ever + * surface the hex digest. + * - The on-sensor template store is the persistence layer here. In the + * production ZeroAuth terminal that store gets wiped on every reboot + * and replaced with a per-tenant view fetched over a mutual-TLS + * channel; that work isn't in this script. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { createInterface } from 'node:readline/promises'; +import { stdin, stdout } from 'node:process'; +import { CONF, R307Sensor } from './sensor.js'; + +/** + * Some confirmation codes from the sensor are "operator should try again," + * not "the protocol is broken." Pull out a small retry helper so the search + * and capture flows don't bail on the first bad-image scan. CONF.TOO_FUZZY + * (0x06) and CONF.TOO_FEW_FEATURE (0x07) are the two we see in practice on + * the R307 when the finger is partial, dry, or off-centre. + */ +async function withRetry(label: string, attempts: number, run: () => Promise): Promise { + let lastErr: Error | undefined; + for (let i = 1; i <= attempts; i++) { + try { + return await run(); + } catch (err) { + const msg = (err as Error).message; + const isRetryable = + msg.includes(`0x${CONF.TOO_FUZZY.toString(16)}`) || + msg.includes(`0x${CONF.TOO_FEW_FEATURE.toString(16)}`); + if (!isRetryable || i === attempts) { + throw err; + } + lastErr = err as Error; + console.log(yellow(` retry ${i}/${attempts - 1} — ${label}: image was unreadable. Try a different placement.`)); + } + } + // Unreachable, but keep TS happy. + throw lastErr ?? new Error(`withRetry: ${label} exhausted without resolution`); +} + +const PORT = process.env.ZA_IOT_PORT ?? '/dev/cu.usbserial-0001'; +const BAUD = Number.parseInt(process.env.ZA_IOT_BAUD ?? '57600', 10); +const PASSWORD = Number.parseInt(process.env.ZA_IOT_PASSWORD ?? '0', 16); + +function bold(s: string): string { return `\x1b[1m${s}\x1b[0m`; } +function dim(s: string): string { return `\x1b[2m${s}\x1b[0m`; } +function green(s: string): string { return `\x1b[32m${s}\x1b[0m`; } +function red(s: string): string { return `\x1b[31m${s}\x1b[0m`; } +function yellow(s: string): string { return `\x1b[33m${s}\x1b[0m`; } + +async function withSensor(run: (s: R307Sensor) => Promise): Promise { + const sensor = new R307Sensor({ path: PORT, baudRate: BAUD, password: PASSWORD }); + console.log(dim(`opening ${PORT} @ ${BAUD} baud…`)); + await sensor.open(); + console.log(dim(`verifying password 0x${PASSWORD.toString(16).padStart(8, '0')}…`)); + const ok = await sensor.verifyPassword(); + if (!ok) { + await sensor.close(); + throw new Error('Password verification failed. Set ZA_IOT_PASSWORD if the sensor uses a non-default password.'); + } + try { + return await run(sensor); + } finally { + await sensor.close(); + } +} + +async function cmdInfo(): Promise { + await withSensor(async (sensor) => { + const params = await sensor.getSystemParams(); + const count = await sensor.getTemplateCount(); + const slots = await sensor.readIndexTable(params.templateLibrarySize); + + console.log(bold('Sensor params:')); + console.log(` ${dim('status register ')} 0x${params.statusRegister.toString(16).padStart(4, '0')}`); + console.log(` ${dim('system identifier ')} 0x${params.systemIdentifier.toString(16).padStart(4, '0')}`); + console.log(` ${dim('library size ')} ${params.templateLibrarySize} slots`); + console.log(` ${dim('security level ')} ${params.securityLevel} (1=easy ↔ 5=strict)`); + console.log(` ${dim('device address ')} 0x${params.deviceAddress.toString(16).padStart(8, '0')}`); + console.log(` ${dim('packet size ')} ${params.packetSize} bytes`); + console.log(` ${dim('baud rate ')} ${params.baudRate}`); + console.log(); + console.log(bold('Templates stored:')); + console.log(` ${dim('count ')} ${count}`); + if (slots.length === 0) { + console.log(` ${dim('occupied slots ')} ${yellow('(none — factory fresh)')}`); + } else { + const preview = slots.slice(0, 16).map((s) => s.toString()).join(', '); + const trailer = slots.length > 16 ? `, … (${slots.length - 16} more)` : ''; + console.log(` ${dim('occupied slots ')} ${preview}${trailer}`); + } + }); +} + +async function prompt(line: string): Promise { + const rl = createInterface({ input: stdin, output: stdout }); + await rl.question(line); + rl.close(); +} + +async function cmdEnroll(slotArg?: string): Promise { + const slot = slotArg !== undefined ? Number.parseInt(slotArg, 10) : 0; + if (!Number.isFinite(slot) || slot < 0) { + throw new Error(`Invalid slot: "${slotArg}". Must be a non-negative integer.`); + } + await withSensor(async (sensor) => { + const before = await sensor.readIndexTable(); + if (before.includes(slot)) { + console.log(yellow(`Slot ${slot} already has a template. Overwriting.`)); + } + + console.log(bold(`Enrolling at slot ${slot}.`)); + console.log(dim('Step 1/2 — place finger on the sensor…')); + await sensor.waitForFinger(); + await sensor.imageToCharBuffer(1); + console.log(green(' ✓ first scan captured')); + console.log(dim(' remove finger…')); + await sensor.waitForFingerRemoval(); + + console.log(dim('Step 2/2 — place the SAME finger again…')); + await sensor.waitForFinger(); + await sensor.imageToCharBuffer(2); + console.log(green(' ✓ second scan captured')); + + console.log(dim('Combining scans into a template…')); + await sensor.combineToTemplate(); + + console.log(dim(`Storing template at slot ${slot}…`)); + await sensor.storeTemplate(slot); + console.log(green(` ✓ stored`)); + + // Read the characteristic for a commitment preview. This is what the + // production firmware would feed to the Pramaan fuzzy extractor + + // Poseidon — here we just SHA-256 it so the operator can see the + // pipeline is wired. + const characteristic = await sensor.uploadCharacteristic(1); + const commitment = createHash('sha256').update(characteristic).digest('hex'); + console.log(); + console.log(bold('Enrolled.')); + console.log(` ${dim('slot ')} ${slot}`); + console.log(` ${dim('characteristic ')} ${characteristic.length} bytes`); + console.log(` ${dim('sha-256(char) ')} ${commitment}`); + console.log(); + console.log(dim(`This SHA-256 is a placeholder commitment. The production`)); + console.log(dim(`flow runs the characteristic through a fuzzy extractor`)); + console.log(dim(`then Poseidon → BN128 scalar. Different scans of the`)); + console.log(dim(`same finger produce different SHA-256s; that's expected.`)); + }); +} + +async function cmdSearch(): Promise { + await withSensor(async (sensor) => { + console.log(bold('Matching against on-sensor templates.')); + await withRetry('search capture', 3, async () => { + console.log(dim('Place finger on sensor…')); + await sensor.waitForFinger(); + await sensor.imageToCharBuffer(1); + console.log(green(' ✓ captured')); + await sensor.waitForFingerRemoval(); + }); + + const result = await sensor.search(); + if (!result) { + console.log(red(' ✗ no match found')); + return; + } + console.log(green(` ✓ match: slot ${result.pageId}, score ${result.matchScore}`)); + }); +} + +async function cmdCapture(): Promise { + await withSensor(async (sensor) => { + const eventId = randomUUID(); + console.log(bold('Single-capture + characteristic upload.')); + console.log(` ${dim('event id ')} ${eventId}`); + await withRetry('capture', 3, async () => { + console.log(dim('Place finger on sensor…')); + await sensor.waitForFinger(); + await sensor.imageToCharBuffer(1); + }); + const characteristic = await sensor.uploadCharacteristic(1); + const commitment = createHash('sha256').update(characteristic).digest('hex'); + console.log(green(' ✓ captured + uploaded')); + console.log(` ${dim('characteristic ')} ${characteristic.length} bytes`); + console.log(` ${dim('sha-256(char) ')} ${commitment}`); + console.log(` ${dim('first 32 bytes ')} ${characteristic.subarray(0, 32).toString('hex')}…`); + }); +} + +async function cmdWipe(): Promise { + await prompt(yellow('Wipe ALL templates on the sensor? Press Enter to confirm, Ctrl-C to cancel. ')); + await withSensor(async (sensor) => { + await sensor.emptyDatabase(); + console.log(green(' ✓ template library cleared')); + }); +} + +async function main(): Promise { + const [, , command, ...rest] = process.argv; + try { + switch (command) { + case 'info': + await cmdInfo(); + break; + case 'enroll': + await cmdEnroll(rest[0]); + break; + case 'search': + await cmdSearch(); + break; + case 'capture': + await cmdCapture(); + break; + case 'wipe': + await cmdWipe(); + break; + default: + console.error(`Usage: cli `); + process.exit(2); + } + } catch (err) { + console.error(red(`✗ ${(err as Error).message}`)); + process.exit(1); + } +} + +void main(); diff --git a/iot/src/crypto.ts b/iot/src/crypto.ts new file mode 100644 index 0000000..c9f097d --- /dev/null +++ b/iot/src/crypto.ts @@ -0,0 +1,117 @@ +/** + * Patent-Claim-3 commitment derivation for the demo. + * + * Mirrors src/services/identity.ts in the main ZeroAuth API so the + * demo's commitments live in the same scalar field and would be + * round-trip-verifiable on the existing /v1 surface if we ever wire + * the bridge to it. The construction is: + * + * biometricID = SHA-256(slotSeed) // 32 B + * biometricSecret = Poseidon(biometricID_F, salt) // BN128 scalar + * commitment = Poseidon(biometricSecret, salt) + * didHash = Poseidon(SHA-256(did)_F) + * identityBinding = Poseidon(biometricSecret, didHash) + * + * The 31-byte truncation everywhere keeps inputs strictly inside the + * BN128 scalar field (2^248 < p < 2^254) — same trick the main API + * uses, same trick the circuit expects. + * + * The demo's "biometric template" is the **matched slot ID** from the + * R307, not the raw characteristic bytes. The sensor's internal 1:N + * match is acting as the fuzzy extractor: it maps a noisy finger + * placement to a stable integer (the slot we enrolled into). Whether + * a slot number is enough entropy for production is settled by the + * Pramaan circuit work in /circuits — for a single-operator demo it's + * the right shape. + */ + +import { createHash, randomBytes } from 'node:crypto'; +import { poseidon1, poseidon2 } from 'poseidon-lite'; + +/** BN128 scalar field modulus. Same constant snarkjs operates over. */ +export const BN128_FIELD_MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; + +/** + * Patent step 1: SHA-256 of the biometric template. The template is the + * stable 768-byte representation produced by `combineToTemplate()` on + * the R307 — same finger → same template (modulo enrollment noise that + * gets absorbed by combining two captures). The host treats this hash + * as the device-derived biometric identity. + */ +export function biometricId(templateBytes: Buffer): Buffer { + return createHash('sha256').update(templateBytes).digest(); +} + +/** Truncate a 32-byte buffer to 31 bytes and read as a big-endian bigint. */ +export function toFieldElement(buf: Buffer): bigint { + if (buf.length < 31) throw new Error(`toFieldElement: buffer too short (${buf.length})`); + return BigInt('0x' + buf.subarray(0, 31).toString('hex')); +} + +/** Random 31-byte salt as a BN128 field element. */ +export function randomSalt(): bigint { + return toFieldElement(randomBytes(31)); +} + +/** Patent step 4: biometricSecret = Poseidon(biometricID_F, salt). */ +export function deriveBiometricSecret(biometricIDBuf: Buffer, salt: bigint): bigint { + return poseidon2([toFieldElement(biometricIDBuf), salt]); +} + +/** Patent step 5: commitment = Poseidon(biometricSecret, salt). */ +export function computeCommitment(biometricSecret: bigint, salt: bigint): bigint { + return poseidon2([biometricSecret, salt]); +} + +/** DID identifier — stable per email. Public input, fine to derive locally. */ +export function deriveDid(email: string): string { + const suffix = createHash('sha256').update(email.trim().toLowerCase()).digest('hex').slice(0, 32); + return `did:zeroauth:demo:${suffix}`; +} + +/** Patent step 6: didHash = Poseidon(SHA-256(did)_F). */ +export function computeDidHash(did: string): bigint { + const buf = createHash('sha256').update(did).digest(); + return poseidon1([toFieldElement(buf)]); +} + +/** Circuit step 2: identityBinding = Poseidon(biometricSecret, didHash). */ +export function computeIdentityBinding(biometricSecret: bigint, didHash: bigint): bigint { + return poseidon2([biometricSecret, didHash]); +} + +/** + * One-call wrapper that produces every public + private signal the + * identity_proof circuit consumes. `salt` is optional so the verify + * leg can pass in the salt that was stored at signup; signup leaves + * it undefined and we generate a fresh one. + */ +export interface IdentitySignals { + biometricSecret: bigint; + salt: bigint; + commitment: bigint; + didHash: bigint; + identityBinding: bigint; + did: string; +} + +export function deriveSignals(input: { + templateBytes: Buffer; + email: string; + salt?: bigint; +}): IdentitySignals { + const bid = biometricId(input.templateBytes); + const salt = input.salt ?? randomSalt(); + const biometricSecret = deriveBiometricSecret(bid, salt); + const commitment = computeCommitment(biometricSecret, salt); + const did = deriveDid(input.email); + const didHash = computeDidHash(did); + const identityBinding = computeIdentityBinding(biometricSecret, didHash); + return { biometricSecret, salt, commitment, didHash, identityBinding, did }; +} + +/** Format helpers — short hex preview for the UI, full decimal for snarkjs. */ +export function shortHex(n: bigint, width = 8): string { + const hex = n.toString(16).padStart(64, '0'); + return `0x${hex.slice(0, width)}…${hex.slice(-4)}`; +} diff --git a/iot/src/otp.ts b/iot/src/otp.ts new file mode 100644 index 0000000..7a7ff78 --- /dev/null +++ b/iot/src/otp.ts @@ -0,0 +1,195 @@ +/** + * Email-OTP service for the fingerprint demo. + * + * The flow is plain MFA: the user proves email ownership with a 6-digit + * code, THEN places their finger. The OTP plus the finger together are + * the credential; either alone isn't enough. + * + * Two artefacts, both in-process Maps: + * + * `pending` : email → { codeHash, expiresAt, attempts, kind } + * `sessions` : sessionToken → { email, kind, verifiedAt, expiresAt } + * + * `request()` generates a code, stores its SHA-256 hash, and returns + * the plaintext. The caller (the bridge's HTTP handler) is responsible + * for delivering it — either over real SMTP (when configured) or by + * surfacing it in the API response in dev mode. + * + * `verify()` checks the code, increments the attempts counter, and on + * success consumes the pending entry + mints a single-use session + * token that the signup/login endpoint requires. Tokens are bound to + * one (email, kind) pair and expire after 2 minutes — enough time to + * place a finger but not enough for offline replay. + * + * Constant-time comparison is via `crypto.timingSafeEqual` over the + * hash buffers. The plaintext code itself is never persisted. + * + * Demo-grade: in-memory only. Restarting the bridge wipes pending + * codes and sessions, which is what you want for a demo (no stale + * state surviving a Ctrl-C / re-launch cycle). + */ + +import { createHash, randomBytes, randomInt, timingSafeEqual } from 'node:crypto'; + +export type OtpKind = 'signup' | 'login'; + +export interface RequestOtpResult { + /** The plaintext 6-digit code. Caller decides how to deliver it. */ + code: string; + /** When the code expires. Absolute timestamp. */ + expiresAt: Date; +} + +export interface VerifyOk { + ok: true; + email: string; + kind: OtpKind; + /** One-shot token the signup/login endpoint must echo back. */ + sessionToken: string; + sessionExpiresAt: Date; +} +export interface VerifyErr { + ok: false; + reason: 'no_pending' | 'expired' | 'too_many_attempts' | 'wrong_code' | 'kind_mismatch'; +} +export type VerifyResult = VerifyOk | VerifyErr; + +const CODE_LENGTH = 6; +const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const SESSION_TTL_MS = 2 * 60 * 1000; // 2 minutes +const MAX_ATTEMPTS = 5; +const REQUEST_COOLDOWN_MS = 30 * 1000; // 30 seconds between resends + +interface PendingEntry { + codeHash: Buffer; + expiresAt: number; + attempts: number; + kind: OtpKind; + /** Used for resend cooldown. */ + issuedAt: number; +} + +interface Session { + email: string; + kind: OtpKind; + expiresAt: number; +} + +const pending = new Map(); +const sessions = new Map(); + +function hashCode(code: string): Buffer { + return createHash('sha256').update(code, 'utf8').digest(); +} + +function generateCode(): string { + // 000000 – 999999, zero-padded. randomInt is uniform. + return randomInt(0, 1_000_000).toString().padStart(CODE_LENGTH, '0'); +} + +/** + * Request a fresh OTP for (email, kind). Returns the plaintext code so + * the caller can deliver it (SMTP or dev-mode response). Throws if the + * caller is hitting the resend cooldown window — the bridge surfaces + * that as a 429-flavoured response. + */ +export class OtpRateLimitedError extends Error { + constructor(public readonly retryAfterMs: number) { + super(`OTP rate-limited; retry in ${Math.ceil(retryAfterMs / 1000)}s`); + this.name = 'OtpRateLimitedError'; + } +} + +export function request(email: string, kind: OtpKind): RequestOtpResult { + const now = Date.now(); + const key = `${email}|${kind}`; + const existing = pending.get(key); + if (existing && now - existing.issuedAt < REQUEST_COOLDOWN_MS) { + throw new OtpRateLimitedError(REQUEST_COOLDOWN_MS - (now - existing.issuedAt)); + } + + const code = generateCode(); + pending.set(key, { + codeHash: hashCode(code), + expiresAt: now + CODE_TTL_MS, + attempts: 0, + kind, + issuedAt: now, + }); + return { + code, + expiresAt: new Date(now + CODE_TTL_MS), + }; +} + +export function verify(email: string, code: string, kind: OtpKind): VerifyResult { + const key = `${email}|${kind}`; + const entry = pending.get(key); + if (!entry) return { ok: false, reason: 'no_pending' }; + + const now = Date.now(); + if (now >= entry.expiresAt) { + pending.delete(key); + return { ok: false, reason: 'expired' }; + } + if (entry.kind !== kind) { + return { ok: false, reason: 'kind_mismatch' }; + } + if (entry.attempts >= MAX_ATTEMPTS) { + pending.delete(key); + return { ok: false, reason: 'too_many_attempts' }; + } + + entry.attempts += 1; + const submitted = hashCode(code); + // timingSafeEqual requires equal length. hash output is fixed-size + // 32 bytes for sha256 so this always passes. + const matched = submitted.length === entry.codeHash.length && timingSafeEqual(submitted, entry.codeHash); + if (!matched) { + if (entry.attempts >= MAX_ATTEMPTS) { + pending.delete(key); + return { ok: false, reason: 'too_many_attempts' }; + } + return { ok: false, reason: 'wrong_code' }; + } + + // Success — consume the pending entry and mint a one-shot session. + pending.delete(key); + const sessionToken = randomBytes(24).toString('base64url'); + sessions.set(sessionToken, { + email, + kind, + expiresAt: now + SESSION_TTL_MS, + }); + return { + ok: true, + email, + kind, + sessionToken, + sessionExpiresAt: new Date(now + SESSION_TTL_MS), + }; +} + +/** + * Consume a session token. Returns true iff the token was valid AND was + * for (email, kind). One-shot — successful consumption deletes the + * session. The signup/login endpoints call this at the start of their + * stream, so an OTP-verified user has ~2 minutes to actually present + * their finger. + */ +export function consumeSession(sessionToken: string, email: string, kind: OtpKind): boolean { + const session = sessions.get(sessionToken); + if (!session) return false; + sessions.delete(sessionToken); // one-shot regardless of outcome + if (session.expiresAt < Date.now()) return false; + if (session.email !== email) return false; + if (session.kind !== kind) return false; + return true; +} + +/** Periodic cleanup. Cheap to run; called from the bridge's reset path. */ +export function gc(): void { + const now = Date.now(); + for (const [k, v] of pending) if (v.expiresAt < now) pending.delete(k); + for (const [k, v] of sessions) if (v.expiresAt < now) sessions.delete(k); +} diff --git a/iot/src/proof.ts b/iot/src/proof.ts new file mode 100644 index 0000000..7bd2138 --- /dev/null +++ b/iot/src/proof.ts @@ -0,0 +1,99 @@ +/** + * Groth16 prover + verifier for the demo. Wraps `snarkjs` against the + * existing identity_proof circuit artifacts in `../../circuits/build`. + * + * The bridge calls fullProve at signup AND login. On signup the proof + * proves the device just minted the commitment; on login it proves the + * device still knows the secrets that derived the stored commitment. + * In both cases the same artifact load is reused — the proving key and + * verification key live in module-level singletons so we don't take + * the ~1s loading cost on every request. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { groth16 } from 'snarkjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CIRCUIT_DIR = path.resolve(__dirname, '..', '..', 'circuits', 'build'); +const WASM_PATH = path.join(CIRCUIT_DIR, 'identity_proof_js', 'identity_proof.wasm'); +const ZKEY_PATH = path.join(CIRCUIT_DIR, 'circuit_final.zkey'); +const VKEY_PATH = path.join(CIRCUIT_DIR, 'verification_key.json'); + +let verificationKey: object | null = null; + +/** + * Eagerly load the verification key + sanity-check the proving key + * exists. Idempotent. Call once at bridge startup. + */ +export async function initProver(): Promise { + if (verificationKey) return; + const [vkey, _zkey, _wasm] = await Promise.all([ + fs.readFile(VKEY_PATH, 'utf8').then((s) => JSON.parse(s)), + fs.stat(ZKEY_PATH), + fs.stat(WASM_PATH), + ]); + verificationKey = vkey; +} + +/** + * Generate a Groth16 proof for the identity_proof circuit. + * + * The circuit's interface (see circuits/identity_proof.circom): + * private: biometricSecret, salt + * public : commitment, didHash, identityBinding + * + * Returns the proof object + the public signals in canonical snarkjs + * order (matches public input declaration in the circuit's `component + * main` line). + */ +export interface Groth16Proof { + pi_a: [string, string, string]; + pi_b: [[string, string], [string, string], [string, string]]; + pi_c: [string, string, string]; + protocol: 'groth16'; + curve: 'bn128'; +} + +export interface ProveResult { + proof: Groth16Proof; + publicSignals: string[]; // [commitment, didHash, identityBinding] as decimal strings +} + +export async function generateProof(input: { + biometricSecret: bigint; + salt: bigint; + commitment: bigint; + didHash: bigint; + identityBinding: bigint; +}): Promise { + const witness = { + biometricSecret: input.biometricSecret.toString(), + salt: input.salt.toString(), + commitment: input.commitment.toString(), + didHash: input.didHash.toString(), + identityBinding: input.identityBinding.toString(), + }; + const { proof, publicSignals } = await groth16.fullProve(witness, WASM_PATH, ZKEY_PATH); + return { proof: proof as Groth16Proof, publicSignals: publicSignals as string[] }; +} + +/** + * Verify a Groth16 proof against the in-memory verification key. Returns + * true on success, false otherwise. Never throws — the caller treats a + * verification failure as "rejected," not "broken." + */ +export async function verifyProof(input: { + proof: Groth16Proof; + publicSignals: string[]; +}): Promise { + if (!verificationKey) { + await initProver(); + } + try { + return await groth16.verify(verificationKey!, input.publicSignals, input.proof); + } catch { + return false; + } +} diff --git a/iot/src/sensor.ts b/iot/src/sensor.ts new file mode 100644 index 0000000..ed70021 --- /dev/null +++ b/iot/src/sensor.ts @@ -0,0 +1,509 @@ +/** + * R307 / FPM10A / ZFM-20 fingerprint sensor driver (the "ZhiAn" protocol). + * + * Speaks the canonical packet format used by the Hangzhou-Synochip / + * Adafruit fingerprint family. All commands are wrapped in: + * + * header (0xEF 0x01) | address (4B BE) | PID (1B) | length (2B BE) | payload | checksum (2B BE) + * + * where the checksum is the low 16 bits of (PID + length-bytes + payload-bytes). + * + * The driver is intentionally minimal: enough surface to enroll a finger, + * search the on-sensor template store, read the index table, and upload + * the raw characteristic bytes for off-device hashing. Anything more + * (fast search, capacitance tuning, encryption mode toggles) is product + * work and lives downstream of this PR. + * + * Threat model context: + * - Raw fingerprint bytes never leave the host. We upload the + * CHARACTERISTIC (an opaque template — fingerprint minutiae or + * feature vector, not an image) so the host can hash it locally + * and forward only the commitment. + * - The sensor's own template store still holds the characteristic, + * so resetting / wiping is part of the operational story. + */ + +import { SerialPort } from 'serialport'; + +// ─── Constants ──────────────────────────────────────────────────────────── + +export const PACKET_HEADER = 0xef01; +export const DEFAULT_ADDRESS = 0xffffffff; +export const DEFAULT_PASSWORD = 0x00000000; + +/** Packet identifier byte. */ +export const PID = { + COMMAND: 0x01, + DATA: 0x02, + ACK: 0x07, + END_DATA: 0x08, +} as const; + +/** Command opcodes — selected subset. */ +export const CMD = { + GET_IMAGE: 0x01, + IMG_TO_TZ: 0x02, + MATCH: 0x03, + SEARCH: 0x04, + REG_MODEL: 0x05, + STORE: 0x06, + LOAD_CHAR: 0x07, + UP_CHAR: 0x08, + DOWN_CHAR: 0x09, + UP_IMAGE: 0x0a, + DELETE_CHAR: 0x0c, + EMPTY: 0x0d, + SET_PASSWORD: 0x12, + VERIFY_PASSWORD: 0x13, + GET_RANDOM: 0x14, + GET_SYS_PARAMS: 0x0f, + TEMPLATE_COUNT: 0x1d, + READ_INDEX_TABLE: 0x1f, + HANDSHAKE: 0x40, +} as const; + +/** Confirmation codes — the first byte of an ACK payload. */ +export const CONF = { + OK: 0x00, + PACKET_RECV_ERR: 0x01, + NO_FINGER: 0x02, + ENROLL_FAIL: 0x03, + TOO_FUZZY: 0x06, + TOO_FEW_FEATURE: 0x07, + NOT_MATCH: 0x08, + NO_MATCH_FOUND: 0x09, + COMBINE_FAIL: 0x0a, + ADDRESSING_PAGEID_OOR: 0x0b, + READ_TEMPLATE_FAIL: 0x0c, + UPLOAD_FAIL: 0x0d, + RECEIVE_FAIL: 0x0e, + DELETE_FAIL: 0x10, + CLEAR_FAIL: 0x11, + BAUD_INVALID: 0x1a, + PASSWORD_INCORRECT: 0x13, + INVALID_REGISTER: 0x1a, + FLASH_FAIL: 0x18, +} as const; + +export interface SystemParams { + statusRegister: number; + systemIdentifier: number; + templateLibrarySize: number; + securityLevel: number; + deviceAddress: number; + packetSize: number; // 32 / 64 / 128 / 256 bytes + baudRate: number; +} + +export interface SearchResult { + pageId: number; + matchScore: number; +} + +// ─── Codec ──────────────────────────────────────────────────────────────── + +function buildPacket(pid: number, payload: Buffer, address = DEFAULT_ADDRESS): Buffer { + const length = payload.length + 2; // payload + checksum + const header = Buffer.alloc(2); + header.writeUInt16BE(PACKET_HEADER, 0); + + const addr = Buffer.alloc(4); + addr.writeUInt32BE(address >>> 0, 0); + + const lenBuf = Buffer.alloc(2); + lenBuf.writeUInt16BE(length, 0); + + let checksum = pid + lenBuf[0]! + lenBuf[1]!; + for (const b of payload) checksum += b; + const ckBuf = Buffer.alloc(2); + ckBuf.writeUInt16BE(checksum & 0xffff, 0); + + return Buffer.concat([header, addr, Buffer.from([pid]), lenBuf, payload, ckBuf]); +} + +interface ParsedPacket { + address: number; + pid: number; + payload: Buffer; +} + +/** + * Greedy packet parser. Reads bytes from `buf` starting at `offset` and + * returns the first complete packet found, with the new offset. Returns + * null if there aren't enough bytes yet for a complete packet. + */ +function tryParsePacket(buf: Buffer, offset: number): { packet: ParsedPacket; nextOffset: number } | null { + // Find the header + while (offset + 1 < buf.length && buf.readUInt16BE(offset) !== PACKET_HEADER) { + offset += 1; + } + if (offset + 9 > buf.length) return null; // need at least header(2)+addr(4)+pid(1)+len(2) + + const address = buf.readUInt32BE(offset + 2); + const pid = buf.readUInt8(offset + 6); + const length = buf.readUInt16BE(offset + 7); + const totalSize = 9 + length; + if (offset + totalSize > buf.length) return null; + + const payload = buf.subarray(offset + 9, offset + 9 + length - 2); + // (We could verify the checksum here; the sensor is reliable on a wired + // UART so we trust it and let downstream parsers fail loudly if not.) + return { + packet: { address, pid, payload }, + nextOffset: offset + totalSize, + }; +} + +// ─── Driver ─────────────────────────────────────────────────────────────── + +export interface SensorOptions { + path: string; + baudRate?: number; + address?: number; + password?: number; + /** Maximum time to wait for a single packet, in ms. */ + packetTimeoutMs?: number; + /** Maximum time to wait for the user's finger between prompts. */ + fingerTimeoutMs?: number; +} + +export class R307Sensor { + private port: SerialPort; + private buffer = Buffer.alloc(0); + private waiters: Array<{ resolve: (p: ParsedPacket) => void; reject: (e: Error) => void; deadline: number }> = []; + private readonly address: number; + private readonly password: number; + private readonly packetTimeoutMs: number; + private readonly fingerTimeoutMs: number; + + constructor(opts: SensorOptions) { + this.address = opts.address ?? DEFAULT_ADDRESS; + this.password = opts.password ?? DEFAULT_PASSWORD; + this.packetTimeoutMs = opts.packetTimeoutMs ?? 3000; + this.fingerTimeoutMs = opts.fingerTimeoutMs ?? 15000; + this.port = new SerialPort({ + path: opts.path, + baudRate: opts.baudRate ?? 57600, + autoOpen: false, + }); + this.port.on('data', (chunk: Buffer) => this.onData(chunk)); + this.port.on('error', (err: Error) => { + for (const w of this.waiters) w.reject(err); + this.waiters = []; + }); + } + + private onData(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (true) { + const result = tryParsePacket(this.buffer, 0); + if (!result) break; + this.buffer = this.buffer.subarray(result.nextOffset); + const waiter = this.waiters.shift(); + if (waiter) waiter.resolve(result.packet); + } + } + + open(): Promise { + return new Promise((resolve, reject) => { + this.port.open((err) => (err ? reject(err) : resolve())); + }); + } + + close(): Promise { + return new Promise((resolve) => { + if (!this.port.isOpen) { + resolve(); + return; + } + this.port.close(() => resolve()); + }); + } + + private writeRaw(buf: Buffer): Promise { + return new Promise((resolve, reject) => { + this.port.write(buf, (err) => (err ? reject(err) : resolve())); + this.port.drain((err) => (err ? reject(err) : undefined)); + }); + } + + private readPacket(timeoutMs = this.packetTimeoutMs): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + const waiter = { resolve, reject, deadline }; + this.waiters.push(waiter); + const t = setTimeout(() => { + const i = this.waiters.indexOf(waiter); + if (i >= 0) this.waiters.splice(i, 1); + reject(new Error(`Timeout waiting for sensor packet (${timeoutMs}ms)`)); + }, timeoutMs); + const origResolve = waiter.resolve; + waiter.resolve = (p) => { + clearTimeout(t); + origResolve(p); + }; + }); + } + + /** Send a command and read the ACK. Throws on non-OK confirmation. */ + private async cmd(opcode: number, args: Buffer = Buffer.alloc(0), timeoutMs?: number): Promise { + const payload = Buffer.concat([Buffer.from([opcode]), args]); + await this.writeRaw(buildPacket(PID.COMMAND, payload, this.address)); + const ack = await this.readPacket(timeoutMs ?? this.packetTimeoutMs); + if (ack.pid !== PID.ACK) { + throw new Error(`Expected ACK packet, got pid=0x${ack.pid.toString(16)}`); + } + return ack.payload; + } + + /** Verify the 4-byte password — first call after opening the port. */ + async verifyPassword(password = this.password): Promise { + const args = Buffer.alloc(4); + args.writeUInt32BE(password >>> 0, 0); + const ack = await this.cmd(CMD.VERIFY_PASSWORD, args); + return ack.readUInt8(0) === CONF.OK; + } + + async getSystemParams(): Promise { + const ack = await this.cmd(CMD.GET_SYS_PARAMS); + if (ack.readUInt8(0) !== CONF.OK) { + throw new Error(`get_sys_params confirmation=0x${ack.readUInt8(0).toString(16)}`); + } + // Payload after the OK byte: 16 bytes of parameters (big-endian shorts). + return { + statusRegister: ack.readUInt16BE(1), + systemIdentifier: ack.readUInt16BE(3), + templateLibrarySize: ack.readUInt16BE(5), + securityLevel: ack.readUInt16BE(7), + deviceAddress: ack.readUInt32BE(9), + packetSize: 32 << ack.readUInt16BE(13), // 0→32, 1→64, 2→128, 3→256 + baudRate: ack.readUInt16BE(15) * 9600, + }; + } + + async getTemplateCount(): Promise { + const ack = await this.cmd(CMD.TEMPLATE_COUNT); + if (ack.readUInt8(0) !== CONF.OK) { + throw new Error(`template_count confirmation=0x${ack.readUInt8(0).toString(16)}`); + } + return ack.readUInt16BE(1); + } + + /** + * Returns the list of slot indices that currently hold a template. + * The sensor returns 32 bytes per page (one bit per slot), so we + * decode all `templateLibrarySize` bits across one or two pages. + */ + async readIndexTable(librarySize = 1000): Promise { + const slots: number[] = []; + const pages = Math.ceil(librarySize / 256); + for (let page = 0; page < pages; page++) { + const ack = await this.cmd(CMD.READ_INDEX_TABLE, Buffer.from([page])); + if (ack.readUInt8(0) !== CONF.OK) { + throw new Error(`read_index_table page=${page} confirmation=0x${ack.readUInt8(0).toString(16)}`); + } + const bitmap = ack.subarray(1); // 32 bytes + for (let byteIdx = 0; byteIdx < bitmap.length; byteIdx++) { + for (let bitIdx = 0; bitIdx < 8; bitIdx++) { + if ((bitmap[byteIdx]! >> bitIdx) & 1) { + slots.push(page * 256 + byteIdx * 8 + bitIdx); + } + } + } + } + return slots; + } + + /** + * Wait for a finger to settle on the sensor. Polls GetImage every 200ms + * until it returns OK or the per-call timeout elapses. + */ + async waitForFinger(): Promise { + const deadline = Date.now() + this.fingerTimeoutMs; + while (Date.now() < deadline) { + const ack = await this.cmd(CMD.GET_IMAGE); + const conf = ack.readUInt8(0); + if (conf === CONF.OK) return; + if (conf === CONF.NO_FINGER) { + await new Promise((r) => setTimeout(r, 200)); + continue; + } + throw new Error(`get_image confirmation=0x${conf.toString(16)}`); + } + throw new Error(`Finger not detected within ${this.fingerTimeoutMs}ms`); + } + + /** Wait for the finger to be REMOVED. */ + async waitForFingerRemoval(): Promise { + const deadline = Date.now() + this.fingerTimeoutMs; + while (Date.now() < deadline) { + const ack = await this.cmd(CMD.GET_IMAGE); + const conf = ack.readUInt8(0); + if (conf === CONF.NO_FINGER) return; + await new Promise((r) => setTimeout(r, 150)); + } + throw new Error(`Finger not removed within ${this.fingerTimeoutMs}ms`); + } + + /** Convert the captured image to a characteristic file in buffer 1 or 2. */ + async imageToCharBuffer(buffer: 1 | 2): Promise { + const ack = await this.cmd(CMD.IMG_TO_TZ, Buffer.from([buffer])); + const conf = ack.readUInt8(0); + if (conf !== CONF.OK) { + throw new Error(`img_to_tz buffer=${buffer} confirmation=0x${conf.toString(16)}`); + } + } + + /** Combine buffer 1 + buffer 2 into a template stored in BOTH buffers. */ + async combineToTemplate(): Promise { + const ack = await this.cmd(CMD.REG_MODEL); + const conf = ack.readUInt8(0); + if (conf !== CONF.OK) { + throw new Error(`reg_model confirmation=0x${conf.toString(16)}`); + } + } + + /** Persist the template currently in buffer 1 into the on-sensor slot. */ + async storeTemplate(slot: number, buffer: 1 | 2 = 1): Promise { + const args = Buffer.alloc(3); + args.writeUInt8(buffer, 0); + args.writeUInt16BE(slot, 1); + const ack = await this.cmd(CMD.STORE, args); + const conf = ack.readUInt8(0); + if (conf !== CONF.OK) { + throw new Error(`store slot=${slot} confirmation=0x${conf.toString(16)}`); + } + } + + /** + * 1:N search against the on-sensor template store using the + * characteristic in buffer 1. + */ + async search(startSlot = 0, count = 1000, buffer: 1 | 2 = 1): Promise { + const args = Buffer.alloc(5); + args.writeUInt8(buffer, 0); + args.writeUInt16BE(startSlot, 1); + args.writeUInt16BE(count, 3); + const ack = await this.cmd(CMD.SEARCH, args); + const conf = ack.readUInt8(0); + if (conf === CONF.NO_MATCH_FOUND) return null; + if (conf !== CONF.OK) { + throw new Error(`search confirmation=0x${conf.toString(16)}`); + } + return { + pageId: ack.readUInt16BE(1), + matchScore: ack.readUInt16BE(3), + }; + } + + /** + * Upload the characteristic bytes from sensor buffer N to the host. + * Returns the concatenated payload of all the data packets the sensor + * sends after the ACK. + * + * The packet size used here is whatever the sensor reports in + * GetSystemParams (32/64/128/256). We don't insist on a particular + * total length — for R307 it's typically 512 bytes — and let the + * sensor flag the last packet with PID=0x08 (END_DATA). + */ + async uploadCharacteristic(buffer: 1 | 2 = 1): Promise { + const ack = await this.cmd(CMD.UP_CHAR, Buffer.from([buffer])); + const conf = ack.readUInt8(0); + if (conf !== CONF.OK) { + throw new Error(`up_char confirmation=0x${conf.toString(16)}`); + } + + const chunks: Buffer[] = []; + while (true) { + const pkt = await this.readPacket(this.packetTimeoutMs); + if (pkt.pid !== PID.DATA && pkt.pid !== PID.END_DATA) { + throw new Error(`Expected DATA/END_DATA, got pid=0x${pkt.pid.toString(16)}`); + } + chunks.push(pkt.payload); + if (pkt.pid === PID.END_DATA) break; + } + return Buffer.concat(chunks); + } + + /** + * Download host-held template bytes into one of the sensor's character + * buffers. Used by the Pramaan flow at login: the host loads the + * previously-uploaded template into buf2, captures the live finger to + * buf1, then calls `match()`. The sensor's flash is never involved. + * + * Mirror of uploadCharacteristic — same packet sizing rules + * (sensor's configured packet size; we chunk to 128 by default which + * matches the R307 factory packet-size register at boot). + */ + async downloadCharacteristic(buffer: 1 | 2, data: Buffer, packetSize = 128): Promise { + if (data.length === 0) throw new Error('downloadCharacteristic: empty data'); + if (data.length % packetSize !== 0) { + // R307 templates are typically 768 B; 128 * 6. If a future caller + // feeds an oddly-sized buffer we'd rather fail loudly than send a + // partial last packet. + throw new Error(`downloadCharacteristic: data length ${data.length} is not a multiple of packetSize ${packetSize}`); + } + + const ack = await this.cmd(CMD.DOWN_CHAR, Buffer.from([buffer])); + const conf = ack.readUInt8(0); + if (conf !== CONF.OK) { + throw new Error(`down_char buffer=${buffer} confirmation=0x${conf.toString(16)}`); + } + + const totalChunks = data.length / packetSize; + for (let i = 0; i < totalChunks; i++) { + const isLast = i === totalChunks - 1; + const chunk = data.subarray(i * packetSize, (i + 1) * packetSize); + const pid = isLast ? PID.END_DATA : PID.DATA; + await this.writeRaw(buildPacket(pid, chunk, this.address)); + } + // R307 does not ACK after the data stream completes. The next + // command (e.g. MATCH) will fail loudly if the download was rejected. + } + + /** + * 1:1 match — compares the characteristic files in buf1 and buf2. + * Returns the match score (R307 reports 0-300+ at security level 3; + * roughly: >50 = same finger, depending on level). Returns null on + * "no match" (CONF.NOT_MATCH). + */ + async match(): Promise<{ score: number } | null> { + const ack = await this.cmd(CMD.MATCH); + const conf = ack.readUInt8(0); + if (conf === CONF.NOT_MATCH) return null; + if (conf !== CONF.OK) { + throw new Error(`match confirmation=0x${conf.toString(16)}`); + } + return { score: ack.readUInt16BE(1) }; + } + + /** Delete a single stored template. */ + async deleteTemplate(slot: number): Promise { + const args = Buffer.alloc(4); + args.writeUInt16BE(slot, 0); + args.writeUInt16BE(1, 2); // count = 1 + const ack = await this.cmd(CMD.DELETE_CHAR, args); + const conf = ack.readUInt8(0); + if (conf !== CONF.OK) { + throw new Error(`delete slot=${slot} confirmation=0x${conf.toString(16)}`); + } + } + + /** Wipe the entire on-sensor template library. */ + async emptyDatabase(): Promise { + const ack = await this.cmd(CMD.EMPTY); + const conf = ack.readUInt8(0); + if (conf !== CONF.OK) { + throw new Error(`empty confirmation=0x${conf.toString(16)}`); + } + } + + /** Ask the sensor for a random 32-bit number — useful as a host-side nonce. */ + async getRandom(): Promise { + const ack = await this.cmd(CMD.GET_RANDOM); + if (ack.readUInt8(0) !== CONF.OK) { + throw new Error(`get_random confirmation=0x${ack.readUInt8(0).toString(16)}`); + } + return ack.readUInt32BE(1); + } +} diff --git a/iot/tsconfig.json b/iot/tsconfig.json new file mode 100644 index 0000000..3582b23 --- /dev/null +++ b/iot/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/public/index.html b/public/index.html index 8851ba2..440f088 100644 --- a/public/index.html +++ b/public/index.html @@ -3,6 +3,7 @@ + @@ -33,6 +34,12 @@ button { background: none; border: 0; padding: 0; cursor: pointer; } code, pre { font-family: var(--font-mono); } + /* + * Theme tokens. Two palettes that swap on prefers-color-scheme. + * The page uses "inverted" sections (code, whitepaper, dark + * breach-card) for visual rhythm — in dark mode those flip light, + * so contrast is preserved in either theme. + */ :root { --bg: #ffffff; --bg-muted: #fafafa; @@ -58,6 +65,34 @@ --section-y: clamp(80px, 10vw, 128px); } + @media (prefers-color-scheme: dark) { + :root { + --bg: #0a0a0a; + --bg-muted: #121212; + --bg-inverse: #fafafa; + --bg-inverse-raised: #ededed; + --ink: #fafafa; + --ink-2: #a3a3a3; + --ink-3: #6e6e6e; + --ink-inverse: #0a0a0a; + --ink-inverse-2: #525252; + --line: #262626; + --line-strong: #404040; + --line-inverse: #d4d4d4; + } + /* The nav is sticky with a translucent backdrop; the colour we + mix in has to match the dark page background, not the light. */ + .nav { background: rgba(10,10,10,0.86); } + /* The brand mark in the nav + footer is the black SVG by default. + Swap to the white variant for dark mode via a CSS filter so we + don't need a second element. */ + .brand img, .footer-brand-col .brand img { + filter: invert(1); + } + /* Same for any inline inside .demo-frame (none today, but + defensive — the iframe carries its own theme). */ + } + html { scroll-behavior: smooth; } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } @@ -915,13 +950,13 @@
Quickstart Demo Product - Docs + Docs GitHub
@@ -940,7 +975,7 @@

What we store cannot be reversed, replayed, or sold.

@@ -1018,8 +1053,8 @@

03Verify the proof

On every login, send the Groth16 proof to /v1/verifications. Get back a verified principal in under 100 ms.

@@ -1033,7 +1068,7 @@

03Verify the proof

# 1. Register a user with a commitment
-curl -X POST https://zeroauth.dev/v1/users/register \
+curl -X POST https://api.zeroauth.dev/v1/users/register \
   -H "Authorization: Bearer $ZEROAUTH_API_KEY" \
   -H "Content-Type: application/json" \
   -d '{
@@ -1042,7 +1077,7 @@ 

03Verify the proof

}'
# 2. Verify a Groth16 proof at login -curl -X POST https://zeroauth.dev/v1/verifications \ +curl -X POST https://api.zeroauth.dev/v1/verifications \ -H "Authorization: Bearer $ZEROAUTH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -1385,7 +1420,7 @@

Talk to our team

-

For self-serve: create an account →

+

For self-serve: create an account →

@@ -1494,8 +1529,8 @@

Developers

@@ -78,12 +78,12 @@ export function welcomeEmail(input: { Your developer account ${input.email} is active${input.companyName ? ` and linked to ${input.companyName}` : ''}. -Your first API key was revealed once in the dashboard — copy it to your password manager if you haven't yet. We never email plaintext API keys, by design (per our security policy). If you lost it, mint a new one from https://zeroauth.dev/dashboard/api-keys +Your first API key was revealed once in the dashboard — copy it to your password manager if you haven't yet. We never email plaintext API keys, by design (per our security policy). If you lost it, mint a new one from https://console.zeroauth.dev/api-keys Next steps: -- Read the Quickstart: https://zeroauth.dev/docs/getting-started/quickstart/ -- Verify your first proof: curl https://zeroauth.dev/v1/auth/zkp/verify -- Skim the Pramaan whitepaper: https://zeroauth.dev/docs/whitepaper.pdf +- Read the Quickstart: https://docs.zeroauth.dev/getting-started/quickstart/ +- Verify your first proof: curl https://api.zeroauth.dev/v1/auth/zkp/verify +- Skim the Pramaan whitepaper: https://docs.zeroauth.dev/whitepaper.pdf ${FOOTER_TEXT}`; return { subject, html, text }; @@ -111,12 +111,12 @@ export function signupAttemptedNoticeEmail(input: {

If this was you (you forgot you had an account), sign in at - zeroauth.dev/dashboard/login. + console.zeroauth.dev/login.

If this wasn't you, your account is unaffected — no password attempt was made. Consider rotating your password as a precaution: - dashboard/login → forgot password. + dashboard/login → forgot password.

${input.attemptIp ? `

Attempt source IP: ${escapeHtml(input.attemptIp)}

` : ''} ${FOOTER_HTML} @@ -128,7 +128,7 @@ export function signupAttemptedNoticeEmail(input: { Someone just tried to create a new ZeroAuth account with ${input.email}. Your account already exists, so the signup was rejected. -If this was you (you forgot you had an account), sign in at https://zeroauth.dev/dashboard/login +If this was you (you forgot you had an account), sign in at https://console.zeroauth.dev/login If this wasn't you, your account is unaffected — no password attempt was made. Consider rotating your password as a precaution: dashboard/login → forgot password. ${input.attemptIp ? `\nAttempt source IP: ${input.attemptIp}\n` : ''}${FOOTER_TEXT}`; @@ -136,6 +136,69 @@ ${input.attemptIp ? `\nAttempt source IP: ${input.attemptIp}\n` : ''}${FOOTER_TE return { subject, html, text }; } +/** + * Sent on the first leg of the F-2 v2 byte-identical signup flow. Contains + * the one-shot magic link that completes account creation. The link is + * a 24h-TTL token + URL parameter; nothing in this email is private to + * the recipient until they click it. + * + * Per security-policy §10 we never email plaintext API keys — the key is + * revealed in the dashboard once after the verify endpoint completes. + */ +export function verifySignupEmail(input: { + email: string; + verifyUrl: string; + expiresAt: Date; +}): { subject: string; html: string; text: string } { + const subject = 'Verify your ZeroAuth account'; + const safeEmail = escapeHtml(input.email); + const expiresIso = input.expiresAt.toISOString(); + + const html = ` +
+

One click to finish signup.

+

+ You started a ZeroAuth account for ${safeEmail}. + Click the button below to verify and finish setup. +

+

+ Verify and continue +

+

+ Or paste this URL into your browser:
+ ${input.verifyUrl} +

+

+ Link expires ${expiresIso} (24h after signup). After + verification you'll be redirected to the dashboard where your first + API key will be revealed once. +

+

+ Didn't try to sign up? You can ignore this email — no account exists + on this address until the link is clicked. +

+ ${FOOTER_HTML} +
+ `; + + const text = `One click to finish signup. + +You started a ZeroAuth account for ${input.email}. Open the link below to +verify and finish setup: + +${input.verifyUrl} + +Link expires ${expiresIso} (24h after signup). After verification you'll +be redirected to the dashboard where your first API key will be revealed +once. + +Didn't try to sign up? You can ignore this email — no account exists on +this address until the link is clicked. +${FOOTER_TEXT}`; + + return { subject, html, text }; +} + /** * Sent when someone requests the whitepaper from the landing page. The PDF * is attached so the recipient never has to come back to a download page; @@ -165,8 +228,8 @@ export function whitepaperEmail(): { subject: string; html: string; text: string Useful next steps:

${FOOTER_HTML} @@ -187,8 +250,8 @@ If you have questions after reading, reply to this email or open an issue at https://github.com/zeroauth-dev/ZeroAuth/issues Useful next steps: -- Read the Quickstart: https://zeroauth.dev/docs/getting-started/quickstart/ -- Browse the API reference: https://zeroauth.dev/docs/reference/api-reference +- Read the Quickstart: https://docs.zeroauth.dev/getting-started/quickstart/ +- Browse the API reference: https://docs.zeroauth.dev/reference/api-reference - Self-host the reference implementation: https://github.com/zeroauth-dev/ZeroAuth ${FOOTER_TEXT}`; diff --git a/src/services/pending-signups.ts b/src/services/pending-signups.ts new file mode 100644 index 0000000..3575c75 --- /dev/null +++ b/src/services/pending-signups.ts @@ -0,0 +1,125 @@ +import crypto from 'crypto'; +import { getPool } from './db'; +import { logger } from './logger'; + +/** + * Pending-signups store for the F-2 v2 byte-identical signup flow. + * + * Why this exists: + * The fast path (POST /api/console/signup creates a tenant immediately, + * returns the JWT + API key in one round-trip) is a textbook email- + * enumeration vector — 201 vs 409 telegraphs whether an address is + * registered. The v2 fix splits creation into two steps: + * + * 1. POST /api/console/signup ALWAYS returns 202 with the same body + * (regardless of whether the email is taken). If the email is + * fresh, we park the request here in `pending_signups` and email + * a one-shot verification link. If the email is taken, we send + * a security-signal notice to the legitimate holder. Both paths + * consume comparable CPU (scrypt dominates), keeping the timing + * side-channel closed too. + * + * 2. GET /api/console/verify-signup?token=… consumes the token, + * creates the real tenant + API key, and redirects to the + * dashboard. + * + * Security properties of this module: + * - Tokens are 32 random bytes (256 bits) of urandom — well past any + * guessing threshold inside the 24h expiry window. + * - Only the SHA-256 of the token is persisted. If the DB is read by + * an attacker, they can't replay live tokens — they'd need to + * intercept the email body too. (sha256 is fine here; we're not + * hashing a low-entropy password — we're indexing a 256-bit nonce.) + * - `consume()` is a single UPDATE that atomically marks the row + * consumed in the same statement that returns the payload, so a + * racing second click can't double-consume. + * - Expired rows refuse to consume. A periodic purge keeps the table + * bounded. + */ + +export interface PendingSignup { + email: string; + passwordHash: string; + companyName: string | null; +} + +const TOKEN_BYTES = 32; +const TTL_HOURS = 24; + +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +/** + * Create a pending-signup row and return the raw token. The caller is + * responsible for emailing the verify link to the operator — the token + * is never persisted plaintext, so this is the only chance to use it. + */ +export async function createPendingSignup(input: PendingSignup): Promise<{ token: string; expiresAt: Date }> { + const pool = getPool(); + const token = crypto.randomBytes(TOKEN_BYTES).toString('base64url'); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + TTL_HOURS * 60 * 60 * 1000); + + await pool.query( + `INSERT INTO pending_signups (email, password_hash, company_name, token_hash, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + [input.email.trim().toLowerCase(), input.passwordHash, input.companyName?.trim() || null, tokenHash, expiresAt], + ); + + logger.info('Pending signup parked', { email: input.email, expiresAt }); + return { token, expiresAt }; +} + +/** + * Consume a verification token. Returns the parked payload on success, + * null if the token is unknown / expired / already consumed. + * + * Atomically marks the row consumed so a second click is a no-op. The + * caller should treat the returned payload as one-shot — the row is + * gone after this call returns (logically; the row stays for audit + * with consumed_at set, but consume() will never return it again). + */ +export async function consumePendingSignup(token: string): Promise { + const pool = getPool(); + const tokenHash = hashToken(token); + + const result = await pool.query( + `UPDATE pending_signups + SET consumed_at = NOW() + WHERE token_hash = $1 + AND consumed_at IS NULL + AND expires_at > NOW() + RETURNING email, password_hash, company_name`, + [tokenHash], + ); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + return { + email: row.email, + passwordHash: row.password_hash, + companyName: row.company_name, + }; +} + +/** + * Delete expired pending-signup rows. Intended to be called from a + * periodic cron; safe to call any time. Returns the number of rows + * removed. + */ +export async function purgeExpiredPendingSignups(): Promise { + const pool = getPool(); + const result = await pool.query( + `DELETE FROM pending_signups + WHERE expires_at <= NOW() + OR (consumed_at IS NOT NULL AND consumed_at < NOW() - INTERVAL '7 days')`, + ); + if (result.rowCount && result.rowCount > 0) { + logger.info('Purged pending signups', { removed: result.rowCount }); + } + return result.rowCount ?? 0; +} diff --git a/src/services/tenants.ts b/src/services/tenants.ts index fe508a3..56ee3e6 100644 --- a/src/services/tenants.ts +++ b/src/services/tenants.ts @@ -6,8 +6,12 @@ import { Tenant, PlanTier, PLAN_LIMITS } from '../types'; /** * Hash a password using scrypt (no bcrypt dependency needed). * Format: salt:hash (both hex-encoded). 16-byte salt, 64-byte derived key. + * + * Exported so services that defer tenant creation (pending-signups) can + * burn the same CPU at request time and store the hash alongside the + * pending row — never re-hash on the verify path. */ -async function hashPassword(password: string): Promise { +export async function hashPassword(password: string): Promise { const salt = crypto.randomBytes(16).toString('hex'); return new Promise((resolve, reject) => { crypto.scrypt(password, salt, 64, (err, derivedKey) => { @@ -64,8 +68,22 @@ export async function createTenant( companyName?: string, plan: PlanTier = 'free', ): Promise { - const pool = getPool(); const passwordHash = await hashPassword(password); + return createTenantWithHash(email, passwordHash, companyName, plan); +} + +/** + * Create a tenant from an already-hashed password. Used by the email-verify + * flow (F-2 v2) so we don't re-hash on the verify path — the hash was + * computed at signup time and parked in `pending_signups`. + */ +export async function createTenantWithHash( + email: string, + passwordHash: string, + companyName?: string | null, + plan: PlanTier = 'free', +): Promise { + const pool = getPool(); const limits = PLAN_LIMITS[plan]; const result = await pool.query( diff --git a/tests/console-signup.test.ts b/tests/console-signup.test.ts index 5ba7614..008b567 100644 --- a/tests/console-signup.test.ts +++ b/tests/console-signup.test.ts @@ -1,28 +1,34 @@ /** - * Integration tests for the F-2 partial mitigation in /api/console/signup + * Integration tests for the F-2 v2 byte-identical signup mitigation * (issue #27). Asserts: * - * - Fresh signup → 201 with token + apiKey + welcome email queued - * - Duplicate signup → 409 email_taken + notice email queued + no leak - * of credentials in the 409 response body - * - The 409 path runs scrypt (timing equalization), so the wall-clock - * time of the two paths is similar (not byte-identical, but no longer - * a free timing oracle) - * - The welcome email goes to the new tenant's email - * - The notice email goes to the EXISTING tenant's email (NOT the - * attacker's email — that's the whole point of the notice) + * - POST /api/console/signup returns 202 + UNIFORM body whether the + * email is fresh or already registered (no enumeration via status + * or body) + * - Fresh email → pending_signups row created + verify-signup email + * fired to the address that signed up + * - Duplicate email → notice email fired to the LEGITIMATE holder + * (not the attacker) + NO tenant created + NO pending row + * - Password is scrypt-hashed on BOTH branches (timing equalization) + * - 400 paths (missing field, weak password) still 400 — those don't + * leak account existence + * - GET /api/console/verify-signup consumes a valid token, creates + * the real tenant + API key, and 303-redirects to a dashboard page * - * The full byte-identical F-2 fix (return 202 always + email verification - * link to complete signup) is the v2, deferred to a follow-up PR because - * it breaks the existing dashboard signup flow + Playwright happy path. + * The v1 partial-mitigation tests (201/409 split with timing burn) + * are obsolete now that v2 is in place. */ const sendMailMock = jest.fn(); const createTenantMock = jest.fn(); +const createTenantWithHashMock = jest.fn(); +const hashPasswordMock = jest.fn(); const authenticateTenantMock = jest.fn(); const getTenantByIdMock = jest.fn(); const getTenantByEmailMock = jest.fn(); const createApiKeyMock = jest.fn(); +const createPendingSignupMock = jest.fn(); +const consumePendingSignupMock = jest.fn(); jest.mock('../src/services/email', () => ({ sendMail: (...args: unknown[]) => sendMailMock(...args), @@ -31,11 +37,19 @@ jest.mock('../src/services/email', () => ({ jest.mock('../src/services/tenants', () => ({ createTenant: (...args: unknown[]) => createTenantMock(...args), + createTenantWithHash: (...args: unknown[]) => createTenantWithHashMock(...args), + hashPassword: (...args: unknown[]) => hashPasswordMock(...args), authenticateTenant: (...args: unknown[]) => authenticateTenantMock(...args), getTenantById: (...args: unknown[]) => getTenantByIdMock(...args), getTenantByEmail: (...args: unknown[]) => getTenantByEmailMock(...args), })); +jest.mock('../src/services/pending-signups', () => ({ + createPendingSignup: (...args: unknown[]) => createPendingSignupMock(...args), + consumePendingSignup: (...args: unknown[]) => consumePendingSignupMock(...args), + purgeExpiredPendingSignups: jest.fn(), +})); + jest.mock('../src/services/api-keys', () => ({ createApiKey: (...args: unknown[]) => createApiKeyMock(...args), listApiKeys: jest.fn().mockResolvedValue([]), @@ -70,80 +84,98 @@ const app = createApp(); const VALID_PASSWORD = 'Aa1!stuvwxyz'; -describe('POST /api/console/signup — F-2 partial mitigation (issue #27)', () => { +const UNIFORM_MESSAGE = /If this email isn't already registered, we've sent a verification link/; + +describe('POST /api/console/signup — F-2 v2 byte-identical (issue #27)', () => { beforeEach(() => { sendMailMock.mockReset(); createTenantMock.mockReset(); + createTenantWithHashMock.mockReset(); + hashPasswordMock.mockReset(); getTenantByEmailMock.mockReset(); createApiKeyMock.mockReset(); + createPendingSignupMock.mockReset(); + consumePendingSignupMock.mockReset(); sendMailMock.mockResolvedValue({ ok: true, messageId: '' }); + hashPasswordMock.mockResolvedValue('aabbccdd:eeff0011'); + createPendingSignupMock.mockResolvedValue({ + token: 'tok_abc123', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); }); - describe('fresh email signup', () => { + describe('fresh email', () => { beforeEach(() => { getTenantByEmailMock.mockResolvedValue(null); - createTenantMock.mockResolvedValue({ - id: 'tenant-new', - email: 'fresh@example.com', - company_name: 'Acme', - plan: 'free', - status: 'active', - }); - createApiKeyMock.mockResolvedValue({ - key: 'za_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - id: 'key-1', - name: 'Default Live Key', - key_prefix: 'za_live_aaaaaa', - scopes: [], - environment: 'live', - }); }); - it('returns 201 with the token + apiKey shape', async () => { + it('returns 202 with the uniform pending_verification body', async () => { const res = await request(app) .post('/api/console/signup') .send({ email: 'fresh@example.com', password: VALID_PASSWORD, companyName: 'Acme' }); - expect(res.status).toBe(201); - expect(res.body.token).toBeDefined(); - expect(res.body.apiKey.key).toMatch(/^za_live_[a-f0-9]{48}$/); - expect(res.body.tenant.id).toBe('tenant-new'); + expect(res.status).toBe(202); + expect(res.body.status).toBe('pending_verification'); + expect(res.body.message).toMatch(UNIFORM_MESSAGE); + // No token / API key in the response — those leak on verify, not on signup. + expect(res.body.token).toBeUndefined(); + expect(res.body.apiKey).toBeUndefined(); + expect(res.body.tenant).toBeUndefined(); }); - it('triggers the welcome email to the new tenant\'s address', async () => { + it('hashes the password (timing equalization with the duplicate path)', async () => { await request(app) .post('/api/console/signup') - .send({ email: 'fresh@example.com', password: VALID_PASSWORD, companyName: 'Acme' }); + .send({ email: 'fresh@example.com', password: VALID_PASSWORD }); - // Welcome email is fire-and-forget — wait one tick for the void IIFE. - await new Promise(resolve => setImmediate(resolve)); + expect(hashPasswordMock).toHaveBeenCalledWith(VALID_PASSWORD); + }); - expect(sendMailMock).toHaveBeenCalledWith( + it('parks the request in pending_signups with the hash + company', async () => { + await request(app) + .post('/api/console/signup') + .send({ email: 'fresh@example.com', password: VALID_PASSWORD, companyName: 'Acme' }); + + expect(createPendingSignupMock).toHaveBeenCalledWith( expect.objectContaining({ - to: 'fresh@example.com', - subject: expect.stringContaining('Welcome to ZeroAuth'), + email: 'fresh@example.com', + passwordHash: 'aabbccdd:eeff0011', + companyName: 'Acme', }), ); }); - it('welcome email body never contains the API key (security-policy §10)', async () => { + it('does NOT create the tenant or an API key yet', async () => { await request(app) .post('/api/console/signup') - .send({ email: 'fresh@example.com', password: VALID_PASSWORD, companyName: 'Acme' }); + .send({ email: 'fresh@example.com', password: VALID_PASSWORD }); + + expect(createTenantMock).not.toHaveBeenCalled(); + expect(createTenantWithHashMock).not.toHaveBeenCalled(); + expect(createApiKeyMock).not.toHaveBeenCalled(); + }); + + it('fires the verify-signup email to the signing-up address with a verify URL', async () => { + await request(app) + .post('/api/console/signup') + .send({ email: 'fresh@example.com', password: VALID_PASSWORD }); await new Promise(resolve => setImmediate(resolve)); - const call = sendMailMock.mock.calls.find(c => - (c[0] as { subject: string }).subject?.includes('Welcome'), + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'fresh@example.com', + subject: expect.stringMatching(/Verify your ZeroAuth account/), + }), ); - expect(call).toBeDefined(); - const body = (call![0] as { html: string; text: string }); - expect(body.html).not.toMatch(/za_live_[a-f0-9]{48}/); - expect(body.text).not.toMatch(/za_live_[a-f0-9]{48}/); + const call = sendMailMock.mock.calls[0]; + const body = call[0] as { html: string; text: string }; + expect(body.html).toContain('verify-signup?token=tok_abc123'); + expect(body.text).toContain('verify-signup?token=tok_abc123'); }); }); - describe('duplicate email signup (F-2 partial mitigation)', () => { + describe('duplicate email', () => { beforeEach(() => { getTenantByEmailMock.mockResolvedValue({ id: 'tenant-existing', @@ -151,16 +183,17 @@ describe('POST /api/console/signup — F-2 partial mitigation (issue #27)', () = }); }); - it('returns 409 email_taken (status code split is the v1 deferred — see issue #27)', async () => { + it('returns the SAME 202 + uniform body as the fresh path', async () => { const res = await request(app) .post('/api/console/signup') .send({ email: 'existing@example.com', password: VALID_PASSWORD }); - expect(res.status).toBe(409); - expect(res.body.error).toBe('email_taken'); + expect(res.status).toBe(202); + expect(res.body.status).toBe('pending_verification'); + expect(res.body.message).toMatch(UNIFORM_MESSAGE); }); - it('triggers the notice email to the LEGITIMATE account holder (not the attacker)', async () => { + it('fires the notice email to the LEGITIMATE holder', async () => { await request(app) .post('/api/console/signup') .send({ email: 'existing@example.com', password: VALID_PASSWORD }); @@ -175,32 +208,29 @@ describe('POST /api/console/signup — F-2 partial mitigation (issue #27)', () = ); }); - it('does NOT create a tenant', async () => { + it('does NOT park a pending signup and does NOT create a tenant', async () => { await request(app) .post('/api/console/signup') .send({ email: 'existing@example.com', password: VALID_PASSWORD }); + + expect(createPendingSignupMock).not.toHaveBeenCalled(); expect(createTenantMock).not.toHaveBeenCalled(); + expect(createTenantWithHashMock).not.toHaveBeenCalled(); }); - it('does NOT leak the tenant id in the 409 response', async () => { - const res = await request(app) + it('hashes the password (timing parity with the fresh path)', async () => { + await request(app) .post('/api/console/signup') .send({ email: 'existing@example.com', password: VALID_PASSWORD }); - expect(JSON.stringify(res.body)).not.toContain('tenant-existing'); + + expect(hashPasswordMock).toHaveBeenCalledWith(VALID_PASSWORD); }); - it('runs scrypt on the duplicate path (timing equalization)', async () => { - // The check is a wall-clock floor — scrypt at default cost takes - // multiple ms. If the duplicate path returned in <1ms we'd know the - // equalization was skipped. - const t0 = Date.now(); - await request(app) + it('does NOT leak the existing tenant id in the response body', async () => { + const res = await request(app) .post('/api/console/signup') .send({ email: 'existing@example.com', password: VALID_PASSWORD }); - const elapsed = Date.now() - t0; - // Conservative lower bound — scrypt N=16k r=8 p=1 (Node defaults) - // is ~50ms on commodity hardware. Test machine may be slower; use 10. - expect(elapsed).toBeGreaterThanOrEqual(10); + expect(JSON.stringify(res.body)).not.toContain('tenant-existing'); }); }); @@ -224,3 +254,84 @@ describe('POST /api/console/signup — F-2 partial mitigation (issue #27)', () = }); }); }); + +describe('GET /api/console/verify-signup — F-2 v2 second leg', () => { + beforeEach(() => { + sendMailMock.mockReset(); + consumePendingSignupMock.mockReset(); + getTenantByEmailMock.mockReset(); + createTenantWithHashMock.mockReset(); + createApiKeyMock.mockReset(); + sendMailMock.mockResolvedValue({ ok: true, messageId: '' }); + }); + + it('400s with HTML when the token query param is missing', async () => { + const res = await request(app).get('/api/console/verify-signup'); + expect(res.status).toBe(400); + expect(res.text).toContain('Missing or invalid verification token'); + }); + + it('400s with HTML when the token is unknown / expired', async () => { + consumePendingSignupMock.mockResolvedValue(null); + const res = await request(app).get('/api/console/verify-signup?token=garbage'); + expect(res.status).toBe(400); + expect(res.text).toMatch(/invalid or has already been used/); + }); + + it('on success, consumes the token + creates the tenant + redirects to /dashboard/signup-complete', async () => { + consumePendingSignupMock.mockResolvedValue({ + email: 'fresh@example.com', + passwordHash: 'aabbccdd:eeff0011', + companyName: 'Acme', + }); + getTenantByEmailMock.mockResolvedValue(null); + createTenantWithHashMock.mockResolvedValue({ + id: 'tenant-new', + email: 'fresh@example.com', + company_name: 'Acme', + plan: 'free', + status: 'active', + }); + createApiKeyMock.mockResolvedValue({ + key: 'za_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + id: 'key-1', + name: 'Default Live Key', + key_prefix: 'za_live_aaaaaa', + scopes: [], + environment: 'live', + }); + + const res = await request(app).get('/api/console/verify-signup?token=tok_abc123'); + + expect(consumePendingSignupMock).toHaveBeenCalledWith('tok_abc123'); + expect(createTenantWithHashMock).toHaveBeenCalledWith( + 'fresh@example.com', + 'aabbccdd:eeff0011', + 'Acme', + ); + expect(createApiKeyMock).toHaveBeenCalledWith('tenant-new', 'Default Live Key', 'live'); + expect(res.status).toBe(303); + // Redirect target is the resolved consoleBaseUrl + '/signup-complete'. + // In dev that's http://localhost:3000/dashboard/signup-complete; in + // prod it's https://console.zeroauth.dev/signup-complete. + expect(res.headers.location).toMatch(/\/signup-complete$/); + // One-time reveal cookie is set so the dashboard can read it once. + const setCookie = res.headers['set-cookie'] as unknown as string[] | undefined; + expect(setCookie?.join(';')).toMatch(/zeroauth_signup_reveal=/); + }); + + it('on race (email got claimed between signup and verify), redirects to /dashboard/login?already_verified=1', async () => { + consumePendingSignupMock.mockResolvedValue({ + email: 'fresh@example.com', + passwordHash: 'aabbccdd:eeff0011', + companyName: null, + }); + getTenantByEmailMock.mockResolvedValue({ id: 'tenant-racy', email: 'fresh@example.com' }); + + const res = await request(app).get('/api/console/verify-signup?token=tok_abc123'); + + expect(res.status).toBe(303); + expect(res.headers.location).toBe('/dashboard/login?already_verified=1'); + expect(createTenantWithHashMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/email.test.ts b/tests/email.test.ts index 77f4ea8..d1848e4 100644 --- a/tests/email.test.ts +++ b/tests/email.test.ts @@ -206,9 +206,9 @@ describe('services/email-templates', () => { it('links to the dashboard, the Quickstart, and the whitepaper', () => { const t = welcomeEmail(input); - expect(t.html).toContain('https://zeroauth.dev/dashboard/api-keys'); - expect(t.html).toContain('https://zeroauth.dev/docs/getting-started/quickstart/'); - expect(t.html).toContain('https://zeroauth.dev/docs/whitepaper.pdf'); + expect(t.html).toContain('https://console.zeroauth.dev/api-keys'); + expect(t.html).toContain('https://docs.zeroauth.dev/getting-started/quickstart/'); + expect(t.html).toContain('https://docs.zeroauth.dev/whitepaper.pdf'); }); }); @@ -235,7 +235,7 @@ describe('services/email-templates', () => { it('points the legitimate user to the dashboard login + password-reset flow', () => { const t = signupAttemptedNoticeEmail(input); - expect(t.html).toContain('https://zeroauth.dev/dashboard/login'); + expect(t.html).toContain('https://console.zeroauth.dev/login'); }); it('escapes the source IP value (defense-in-depth — IPs are technically attacker-controlled)', () => { diff --git a/website/sidebars.ts b/website/sidebars.ts index ce2d526..3e570a2 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -45,6 +45,7 @@ const sidebars: SidebarsConfig = { items: [ 'reference/central-api', 'reference/api-reference', + 'reference/playground', 'reference/environment-variables', 'reference/contracts-and-circuit', ], diff --git a/website/src/components/ApiPlayground/index.tsx b/website/src/components/ApiPlayground/index.tsx new file mode 100644 index 0000000..78c6aeb --- /dev/null +++ b/website/src/components/ApiPlayground/index.tsx @@ -0,0 +1,290 @@ +import React, { useState, useMemo } from 'react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import styles from './styles.module.css'; + +/** + * Interactive API tester for the docs site. + * + * Lets a reader paste their `za_live_…` or `za_test_…` key, pick an + * endpoint from the catalogue below, edit the request body, and hit + * Send. Response status + body + timing render below. Designed for + * the reference page; never auto-fires, never persists the key. + * + * Endpoints surfaced here mirror the public REST surface in + * docs/reference/api-reference.md. Add new ones by appending to the + * ENDPOINTS array — no other change needed. + */ + +interface EndpointSpec { + id: string; + method: 'GET' | 'POST' | 'DELETE'; + path: string; + label: string; + description: string; + /** Default body shown in the editor. Null = no body (GET / DELETE). */ + defaultBody: string | null; + /** True if this endpoint uses the admin x-api-key header instead of Bearer. */ + admin?: boolean; +} + +const ENDPOINTS: EndpointSpec[] = [ + { + id: 'health', + method: 'GET', + path: '/api/health', + label: 'Health check', + description: 'Unauthenticated. Pings every subsystem and returns a JSON status report.', + defaultBody: null, + }, + { + id: 'nonce', + method: 'GET', + path: '/v1/auth/zkp/nonce', + label: 'Fetch a ZKP nonce', + description: 'Replay-defence nonce for the verify endpoint. Bind it into the proof, then submit.', + defaultBody: null, + }, + { + id: 'circuit-info', + method: 'GET', + path: '/v1/auth/zkp/circuit-info', + label: 'Circuit metadata', + description: 'Curve + protocol descriptor for the client-side snarkjs runner.', + defaultBody: null, + }, + { + id: 'register', + method: 'POST', + path: '/v1/users/register', + label: 'Register a user', + description: 'Bind an external_id to a Poseidon commitment. The biometric never leaves the client.', + defaultBody: JSON.stringify( + { + external_id: 'user_42', + commitment: '0x1f3c…', + }, + null, + 2, + ), + }, + { + id: 'verify', + method: 'POST', + path: '/v1/verifications', + label: 'Verify a Groth16 proof', + description: 'Submit the proof + public signals returned by snarkjs. Server verifies and returns a principal.', + defaultBody: JSON.stringify( + { + external_id: 'user_42', + proof: { a: ['…'], b: [['…']], c: ['…'] }, + public_signals: ['0x1f3c…'], + }, + null, + 2, + ), + }, + { + id: 'devices-list', + method: 'GET', + path: '/v1/devices', + label: 'List devices', + description: 'Tenant + environment scoped.', + defaultBody: null, + }, + { + id: 'audit-list', + method: 'GET', + path: '/v1/audit', + label: 'Audit log (tail)', + description: 'Most-recent audit events for the calling tenant. Append-only on the server side.', + defaultBody: null, + }, +]; + +const DEFAULT_BASE_URL = + typeof window !== 'undefined' && window.location.host.endsWith('zeroauth.dev') + ? 'https://api.zeroauth.dev' + : 'https://api.zeroauth.dev'; + +interface ResponseSnapshot { + status: number; + statusText: string; + durationMs: number; + bodyText: string; + contentType: string; +} + +function PlaygroundInner(): JSX.Element { + const [apiKey, setApiKey] = useState(''); + const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); + const [endpointId, setEndpointId] = useState(ENDPOINTS[0].id); + const [body, setBody] = useState(ENDPOINTS[0].defaultBody ?? ''); + const [response, setResponse] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const endpoint = useMemo(() => ENDPOINTS.find((e) => e.id === endpointId)!, [endpointId]); + + function selectEndpoint(id: string): void { + const next = ENDPOINTS.find((e) => e.id === id); + if (!next) return; + setEndpointId(id); + setBody(next.defaultBody ?? ''); + setResponse(null); + setError(null); + } + + async function send(): Promise { + setBusy(true); + setError(null); + setResponse(null); + const url = `${baseUrl.replace(/\/$/, '')}${endpoint.path}`; + const headers: Record = {}; + if (endpoint.method === 'POST') headers['Content-Type'] = 'application/json'; + if (apiKey) { + if (endpoint.admin) headers['x-api-key'] = apiKey; + else headers['Authorization'] = `Bearer ${apiKey}`; + } + const init: RequestInit = { method: endpoint.method, headers }; + if (endpoint.method === 'POST') init.body = body || '{}'; + + const t0 = performance.now(); + try { + const res = await fetch(url, init); + const text = await res.text(); + setResponse({ + status: res.status, + statusText: res.statusText, + durationMs: Math.round(performance.now() - t0), + bodyText: text, + contentType: res.headers.get('content-type') ?? '', + }); + } catch (err) { + setError((err as Error).message); + } finally { + setBusy(false); + } + } + + function prettyBody(text: string, contentType: string): string { + if (!contentType.includes('json')) return text; + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + + return ( +
+
+ + +
+ + + +

{endpoint.description}

+ + {endpoint.method === 'POST' ? ( +