From d2c5881cffbe7e0f79d05796bcec38df825c5f0f Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Mon, 18 May 2026 12:13:59 +0530 Subject: [PATCH 01/21] Week 2 Day 1: F-2 v2 byte-identical signup + security-reviewer gate + chain-verify cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Week 1 carry-overs closed in one batch on dev. ## F-2 v2 β€” byte-identical /api/console/signup (issue #27) POST /api/console/signup now always returns 202 with the same body regardless of whether the email is already registered. Tenant + API key creation is deferred to GET /api/console/verify-signup, gated on the user clicking a one-shot verification link from the inbound email. Backend - src/services/db.ts: pending_signups table (id, email, password_hash, company_name, token_hash, expires_at, consumed_at) + the two indexes. - src/services/pending-signups.ts (new): create/consume/purge with SHA-256-of-token storage, 24h TTL, atomic single-statement consume. - src/services/tenants.ts: exported hashPassword; new createTenantWithHash so the verify path doesn't re-hash. - src/services/email-templates.ts: verifySignupEmail() template with the magic-link button + plaintext fallback + 24h expiry note. - src/routes/console.ts: - POST /signup uniform 202 + { status: 'pending_verification', message } on both fresh and duplicate branches. Hashes the password on the duplicate path too (timing equalization). Notice email still fires to the legitimate holder on the duplicate path. - GET /verify-signup: consumes the token (atomic), creates the tenant via createTenantWithHash, mints the default live API key, sets a one-shot `zeroauth_signup_reveal` cookie, redirects to /dashboard/signup-complete. On a race where the email is already claimed between signup and verify, redirects to /dashboard/login?already_verified=1. - tests/console-signup.test.ts: rewritten for the new contract; 16/16 pass. Full backend suite: 234/234. Dashboard - src/lib/api.ts: SignupResponse is now { status: 'pending_verification', message }. New SignupRevealPayload type for the verify cookie. - src/lib/auth.tsx: signup() returns SignupResult (no token/apiKey here). - src/routes/public/Signup.tsx: two-state page β€” form, then a "check your inbox" confirmation that does NOT distinguish fresh from duplicate. - src/routes/public/SignupComplete.tsx (new): reads the reveal cookie once, stashes the JWT, shows the API key in the same modal as before. - src/routes/public/Login.tsx: ?already_verified=1 banner. - src/App.tsx: /signup-complete route added. - dashboard typecheck + lint + vitest (18/18) all green. E2E - dashboard/e2e/happy-path.spec.ts: full happy path is parked (test.skip) until the test harness can read the verify token from pg or intercept the outbound email. Added a smaller "lands on check-your-inbox" test that asserts no API key appears on the post-signup view. ## Security-reviewer workflow gate .github/workflows/security-review.yml: path-filtered workflow that fires on PRs touching auth/crypto/audit/tenant boundary code. Posts (or updates) a single sticky PR comment listing the touched paths plus the security-reviewer subagent invocation reminder. Closes the Week 1 discipline-gate gap noted in the W01 annex. ## Verifier audit-chain probe cron .github/workflows/verifier-chain-verify.yml: daily 02:30 UTC SSH probe that execs into the zeroauth-verifier container and calls /audit/verify-chain. On non-`ok:true` response, opens an incident:critical GitHub issue with the response body and a link to the verifier-component A-V01 runbook. De-duplicates per-day so a multi-hour outage doesn't spam new issues. ## QA log qa-log/2026-05-18.md + LATEST.md updated. Engineering GREEN; demo battery HOLD unchanged (gated on B03 + B13). --- .github/workflows/security-review.yml | 111 ++++++++ .github/workflows/verifier-chain-verify.yml | 113 ++++++++ dashboard/e2e/happy-path.spec.ts | 27 +- dashboard/src/App.tsx | 2 + dashboard/src/lib/api.ts | 33 ++- dashboard/src/lib/auth.tsx | 23 +- dashboard/src/routes/public/Login.tsx | 13 +- dashboard/src/routes/public/Signup.tsx | 225 ++++++++-------- .../src/routes/public/SignupComplete.tsx | 145 +++++++++++ qa-log/2026-05-18.md | 93 +++++++ qa-log/LATEST.md | 8 +- src/routes/console.ts | 244 ++++++++++------- src/services/db.ts | 19 ++ src/services/email-templates.ts | 63 +++++ src/services/pending-signups.ts | 125 +++++++++ src/services/tenants.ts | 22 +- tests/console-signup.test.ts | 246 +++++++++++++----- 17 files changed, 1226 insertions(+), 286 deletions(-) create mode 100644 .github/workflows/security-review.yml create mode 100644 .github/workflows/verifier-chain-verify.yml create mode 100644 dashboard/src/routes/public/SignupComplete.tsx create mode 100644 qa-log/2026-05-18.md create mode 100644 src/services/pending-signups.ts 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/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/src/App.tsx b/dashboard/src/App.tsx index 9809636..5283ba2 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -5,6 +5,7 @@ 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'; @@ -55,6 +56,7 @@ export function App() { } /> } /> + } /> }> }> diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 3a9ef85..ff1e685 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -135,18 +135,33 @@ export interface Tenant { status: 'active' | 'suspended' | 'deactivated'; } +/** + * F-2 v2 byte-identical signup response (issue #27). + * + * POST /api/console/signup always returns 202 with this shape regardless + * of whether the email is taken. The dashboard reads `status` to render + * the "check your inbox" state. The actual account + API key are minted + * only after the user clicks the verification link, at which point the + * GET /api/console/verify-signup endpoint sets a one-shot reveal cookie + * and redirects to /dashboard/signup-complete. + */ export interface SignupResponse { + status: 'pending_verification'; message: string; +} + +/** + * Payload set by the backend at verify-signup time and read once by the + * SignupComplete page. Source: the `zeroauth_signup_reveal` cookie, + * base64url-encoded JSON. Cleared after the page reads it. + */ +export interface SignupRevealPayload { token: string; - tenant: Tenant; - apiKey: { - key: string; - id: string; - name: string; - prefix: string; - environment: Environment; - warning: string; - }; + apiKey: string; + apiKeyId: string; + apiKeyName: string; + apiKeyPrefix: string; + apiKeyEnv: Environment; } export interface LoginResponse { diff --git a/dashboard/src/lib/auth.tsx b/dashboard/src/lib/auth.tsx index 5e98bd0..50a8350 100644 --- a/dashboard/src/lib/auth.tsx +++ b/dashboard/src/lib/auth.tsx @@ -1,5 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; -import { api, getToken, setToken, ApiError, type Tenant, type Account } from './api'; +import { api, getToken, setToken, ApiError, type Account } from './api'; interface AuthState { status: 'loading' | 'authenticated' | 'unauthenticated'; @@ -7,9 +7,19 @@ interface AuthState { error: string | null; } +/** + * Signup result surfaced to the UI. Under F-2 v2 (issue #27) the API never + * returns a token or API key on signup β€” those land on /dashboard/signup-complete + * after the user clicks the verification link. + */ +export interface SignupResult { + status: 'pending_verification'; + message: string; +} + interface AuthContextValue extends AuthState { login: (email: string, password: string) => 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/routes/public/Login.tsx b/dashboard/src/routes/public/Login.tsx index b3f55a0..1344564 100644 --- a/dashboard/src/routes/public/Login.tsx +++ b/dashboard/src/routes/public/Login.tsx @@ -1,5 +1,5 @@ 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 { ApiError } from '../../lib/api'; import { Button, Input, Label } from '../../components/ui'; @@ -8,11 +8,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 +40,11 @@ export function Login() { return ( + {alreadyVerified ? ( +
+ Your email is verified. Sign in to continue. +
+ ) : null}
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/qa-log/2026-05-18.md b/qa-log/2026-05-18.md new file mode 100644 index 0000000..b379089 --- /dev/null +++ b/qa-log/2026-05-18.md @@ -0,0 +1,93 @@ +# QA log β€” Mon 2026-05-18 (Week 2, Day 1) + +**Cadence:** DW01 demo battery does not run today (Tue/Thu only). +**Run owner:** Pulkit Pareek (engineering). +**Window:** Sat 2026-05-16 β†’ Mon 2026-05-18. + +--- + +## Rollup + +**Engineering: GREEN.** Demo battery: **HOLD** (unchanged β€” gated on B03 + B13, both Week 2/3 work). + +--- + +## What shipped (Mon 2026-05-18) + +### Branch state + +All work committed to `dev`. Not merged to `main` β€” per the updated workflow +preference, Claude no longer opens PRs or auto-merges; the user promotes +`dev β†’ main` on their own cadence. + +### Items + +| # | Item | Where | Why | +|---|---|---|---| +| 1 | **F-2 v2 byte-identical signup** ([issue #27](https://github.com/zeroauth-dev/ZeroAuth/issues/27)) | `src/routes/console.ts`, `src/services/{pending-signups,tenants,email-templates}.ts`, `src/services/db.ts`, dashboard `Signup` + new `SignupComplete` + Login banner, tests | Closes the medium-severity carve-out from PR #22's security review. POST /signup now returns the same 202 + body regardless of whether the email is taken. Tenant + API key are minted only after the user clicks the verification link, which lands on `/dashboard/signup-complete` with a one-shot reveal cookie. | +| 2 | **`security-reviewer` workflow gate** | `.github/workflows/security-review.yml` | Path-filter trigger for security-sensitive surfaces (auth/crypto/audit/tenant boundary). Posts a sticky PR comment listing touched paths + the subagent invocation reminder. Closes the Week 1 discipline-gate gap noted in the W01 annex. | +| 3 | **Verifier audit-chain cron** | `.github/workflows/verifier-chain-verify.yml` | Daily 02:30 UTC SSH probe into the VPS, calls `/audit/verify-chain` inside the verifier container. On non-green response, opens an `incident:critical` issue with the response body + remediation runbook link. Closes one of the two verifier-threat-model open items. | + +### Tests + checks + +- `npx tsc --noEmit` βœ… clean across backend +- `npm test` βœ… **234 passing across 21 suites** (was 228 β€” added 11 new console-signup F-2 v2 tests, dropped 5 old F-2 v1 tests, net +6) +- `npm --prefix dashboard run typecheck` βœ… clean (new SignupComplete page + auth.tsx + api.ts type updates compile) +- `npm --prefix dashboard run lint` βœ… 0 errors (5 pre-existing warnings, none from today) +- `npm --prefix dashboard test` βœ… 18/18 vitest tests pass +- Playwright happy-path: **parked** with `test.skip` β€” the immediate-key-reveal contract is gone; the new happy path needs either (a) DB access to read the verify token from `pending_signups` or (b) a test-mode SMTP transport. Added a smaller F-2 v2 test that asserts the form pivots to "check your inbox" with no key on screen. + +### Surrogate smokes + +- API health (origin VPS): **green** β€” `curl -k --resolve zeroauth.dev:443:104.207.143.14 https://zeroauth.dev/api/health` returns 200 with all subsystems connected. +- Cloudflare edge β†’ origin: **still failing** (user-side action β€” flagged Fri). +- Verifier audit-chain: not yet probed end-to-end since the new workflow file just landed on `dev`; first scheduled run is 02:30 UTC tomorrow. + +--- + +## Open from the W01 annex β€” status update + +| Item | Status today | +|---|---| +| Issue [#27](https://github.com/zeroauth-dev/ZeroAuth/issues/27) F-2 v2 | **Done in `dev`** β€” pending the user's `dev β†’ main` promotion. After merge, close the issue with a reference to the merge SHA. | +| ADR-0005 counsel engagement | Owner: Amit. No movement from engineering today. Hard target: Wed 2026-05-20. | +| ADR-0006 inline-fallback retirement | On schedule β€” no change today. Hard date 2026-06-08. | +| Periodic chain-verify cron | **Done in `dev`** (item 3 above). | +| Off-host backup of `verifier-audit-data` | **Not started.** Needs an off-host destination (S3-compatible bucket). Decide bucket vendor + ship a nightly `sqlite3 .backup` action. Target: this week. | +| vkey signature at trusted-setup time | **Not started.** Week 7 evidence-pack work; not pulling forward yet. | +| Issue tracker board | **Not started.** Friday item per the W01 annex. | +| Compliance mappings counsel review | Gated on ADR-0005. | +| `security-reviewer` subagent gate | **Done in `dev`** (item 2 above). | +| DW02 daily PR digest | **Not started.** Low priority; Week 2 ops item. | + +--- + +## What's planned for the rest of Week 2 + +Unchanged from the W01 annex (Mon β†’ Fri). Today closed two items that were +scheduled for Thu (chain-verify cron) and Fri (security-reviewer gate), +plus the marquee Week-2 carry-over (F-2 v2). Net: Mon over-shipped by ~3 +items. + +| Day | Item | Status entering this entry | +|---|---|---| +| Mon βœ… | B03 IoT terminal firmware skeleton (`zeroauth-dev/ZeroAuth-IoT` new repo) | **Deferred to its own focused session** β€” creating a new repo + bootstrapping firmware code is substantial enough to deserve dedicated context. Today's session prioritized closing higher-leverage Week-1 carry-overs first. | +| Tue | Counsel outreach (Amit) + Issue #27 v2 (Pulkit) | Engineering half done today. | +| Wed | B13 liveness skeleton + mock-hardware demos | Pending. | +| Thu | Off-host SQLite backup + DPDP mapping counsel review | Backup item still open; counsel review still gated. | +| Fri | W05 #2 weekly review packet + inline-fallback retirement PR draft | Pending. | + +--- + +## Risks I'm watching + +1. **Playwright happy-path parked.** It needs a test-mode SMTP hook or a DB read of pending_signups before the full signupβ†’keyβ†’deviceβ†’audit path can run again. Until it lands, signup regressions can sneak past CI; partial coverage exists in the new "check your inbox" assertion. **Mitigation:** prioritize the test-mode mail transport this week β€” it's also useful for the welcome and notice emails. + +2. **Cloudflare edge still broken** (carry-over from Fri). Public `zeroauth.dev` is unreachable; origin is healthy. Nothing engineering can do without CF dashboard access. + +3. **F-2 v2 sitting on `dev`.** Per the new workflow, Claude is no longer auto-merging. The fix is meaningful security work and shouldn't sit on dev too long β€” recommend promoting after the next round of manual review. + +--- + +LAST_UPDATED: 2026-05-18 +NEXT_RUN: Tuesday 2026-05-19 09:55 IST (DW01 demo battery) diff --git a/qa-log/LATEST.md b/qa-log/LATEST.md index 1b6c14b..0cd3952 100644 --- a/qa-log/LATEST.md +++ b/qa-log/LATEST.md @@ -1,9 +1,9 @@ # Latest QA Run -β†’ [`2026-05-15.md`](2026-05-15.md) +β†’ [`2026-05-18.md`](2026-05-18.md) -**Rollup:** HOLD (every demo Blocked; surrogate smokes green; email delivery NEW + working today) -**Date:** 2026-05-15 -**Next run:** Tuesday 2026-05-19 at 09:55 IST (W2 Day 1 β€” second-week metronome resumes Tue/Thu cadence) +**Rollup:** Engineering GREEN; demo battery HOLD (unchanged). F-2 v2, security-reviewer gate, and verifier chain-verify cron all shipped to `dev` today. +**Date:** 2026-05-18 +**Next run:** Tuesday 2026-05-19 at 09:55 IST (DW01 demo battery β€” Tue/Thu cadence) (This file is overwritten on every run. For history, see the dated files in this directory.) diff --git a/src/routes/console.ts b/src/routes/console.ts index f8ca57e..56fc6bc 100644 --- a/src/routes/console.ts +++ b/src/routes/console.ts @@ -1,17 +1,11 @@ import { Router, Request, Response } from 'express'; import jwt from 'jsonwebtoken'; -import { randomUUID, scrypt as _scrypt } from 'crypto'; -import { promisify } from 'util'; +import { randomUUID } from 'crypto'; import rateLimit from 'express-rate-limit'; - -const scrypt = promisify(_scrypt) as ( - password: string, - salt: string, - keylen: number, -) => Promise; import { config } from '../config'; import { logger } from '../services/logger'; -import { createTenant, authenticateTenant, getTenantById, getTenantByEmail } from '../services/tenants'; +import { createTenant, createTenantWithHash, hashPassword, authenticateTenant, getTenantById, getTenantByEmail } from '../services/tenants'; +import { createPendingSignup, consumePendingSignup } from '../services/pending-signups'; import { createApiKey, listApiKeys, revokeApiKey, countActiveKeys } from '../services/api-keys'; import { getUsageSummary, getRecentCalls, getCurrentMonthUsage } from '../services/usage'; import { @@ -38,7 +32,7 @@ import { VerificationResult, } from '../types'; import { sendMail } from '../services/email'; -import { welcomeEmail, signupAttemptedNoticeEmail } from '../services/email-templates'; +import { welcomeEmail, signupAttemptedNoticeEmail, verifySignupEmail } from '../services/email-templates'; const router = Router(); @@ -167,81 +161,141 @@ function requireConsoleAuth(req: Request, res: Response, next: any): void { * Body: { email, password, companyName? } */ router.post('/signup', authLimiter, async (req: Request, res: Response) => { - try { - const { email, password, companyName } = req.body; + // F-2 v2 byte-identical signup (issue #27): + // + // Goal: an attacker probing addresses against /api/console/signup must + // observe identical responses (status, body, timing) whether the email is + // taken or fresh. The v1 partial-fix kept the 201/409 split to preserve + // the one-round-trip dashboard flow; v2 splits creation into two steps + // and returns a uniform 202 from this endpoint. + // + // Branches (both end with the same 202 response): + // (a) Fresh email: hash the password, park the payload in + // pending_signups under a 24h-TTL token, send a verification + // email. Tenant is NOT created until the user clicks the link. + // (b) Email taken: send the legitimate holder a "someone tried to + // sign up" notice (security signal). Pin the same CPU cost as + // (a) by burning a scrypt hash on the request, so the timing + // side-channel is also closed. + // + // Anything that would 4xx (missing field, weak password) is still + // returned synchronously β€” those checks don't leak account existence. + // + // See governance: docs/threat-model/api.md A-05 (Account Enumeration). + const { email, password, companyName } = req.body; + + if (!email || !password) { + res.status(400).json({ error: 'invalid_request', message: 'Email and password are required.' }); + return; + } - if (!email || !password) { - res.status(400).json({ error: 'invalid_request', message: 'Email and password are required.' }); - return; - } + const passwordError = validatePassword(password); + if (passwordError) { + res.status(400).json({ error: 'invalid_password', message: passwordError }); + return; + } - const passwordError = validatePassword(password); - if (passwordError) { - res.status(400).json({ error: 'invalid_password', message: passwordError }); - return; - } + // Uniform 202 response body β€” referenced from both branches below. + // The wording is deliberately ambiguous about whether the email was + // already registered. Clients show a "check your inbox" view. + const UNIFORM_BODY = { + status: 'pending_verification' as const, + message: 'If this email isn\'t already registered, we\'ve sent a verification link. Check your inbox.', + }; - // F-2 (issue #27): email-enumeration mitigation β€” partial. - // - // The full byte-identical fix (return 202 always + verification email) - // requires a tenant-creation rework that breaks the existing dashboard - // signup-then-reveal-key flow + the Playwright happy path. That work - // is tracked in issue #27 as the v2 of this mitigation. - // - // For now: keep the 201/409 split (so dashboard signup still works in - // one round-trip) but plug the worst-leak surfaces: - // 1. Timing equalization β€” when the email exists, do the scrypt - // hashing the createTenant() path would have done. Hashing - // dominates the response time, so without this the 409 path - // is observably ~50ms faster than the 201 path. With this, - // both paths take the same wall-clock time. - // 2. Security-signal email β€” send a "someone tried to sign up - // with your email" notice to the legitimate account holder, - // so the email-was-taken response isn't free intel for an - // attacker probing addresses. - // - // See ADR-0005 (email service), governance/docs/threat-model/api.md A-05. + try { const existing = await getTenantByEmail(email); + const sourceIp = (req.ip || req.headers['x-forwarded-for'] || '').toString().slice(0, 64) || null; + if (existing) { - // Burn the same CPU we'd burn for createTenant() so the timing - // oracle is closed. scrypt is the dominant cost; do it explicitly. + // Branch (b): email taken. Burn an equivalent scrypt cost (the + // fresh-email branch will also hashPassword + write a row), then + // signal-email the legitimate holder. Fire-and-forget so the + // response timing doesn't leak success/failure of the SMTP call. try { - await scrypt(password, 'enumeration-mitigation-salt', 64); - } catch { /* swallow */ } + await hashPassword(password); + } catch { /* swallow β€” timing only */ } - // Notify the legitimate operator out-of-band. Fire-and-forget; - // never block the response. - const sourceIp = (req.ip || req.headers['x-forwarded-for'] || '').toString().slice(0, 64) || null; void (async () => { const tmpl = signupAttemptedNoticeEmail({ email: existing.email, attemptIp: sourceIp }); await sendMail({ to: existing.email, ...tmpl }); })(); - res.status(409).json({ error: 'email_taken', message: 'An account with this email already exists.' }); + res.status(202).json(UNIFORM_BODY); return; } - const tenant = await createTenant(email, password, companyName); + // Branch (a): fresh email. Hash + park + email. + const passwordHash = await hashPassword(password); + const { token, expiresAt } = await createPendingSignup({ + email, + passwordHash, + companyName: companyName || null, + }); - // Auto-create a default live API key - const defaultKey = await createApiKey(tenant.id, 'Default Live Key', 'live'); + const verifyUrl = `${config.apiBaseUrl.replace(/\/$/, '')}/api/console/verify-signup?token=${encodeURIComponent(token)}`; + void (async () => { + const tmpl = verifySignupEmail({ email, verifyUrl, expiresAt }); + await sendMail({ to: email, ...tmpl }); + })(); - // Issue console session token - const token = issueConsoleToken(tenant.id, tenant.email); + logger.info('Console: Pending signup parked', { sourceIp }); + res.status(202).json(UNIFORM_BODY); + } catch (err) { + logger.error('Console: Signup error', { error: (err as Error).message }); + // Return the same 202 body β€” never confess error state to the + // client because that would create a "this email is registered" + // side channel. + res.status(202).json(UNIFORM_BODY); + } +}); - logger.info('Console: Tenant signup', { tenantId: tenant.id, email: tenant.email }); +/** + * GET /api/console/verify-signup?token=... + * + * Second leg of the F-2 v2 flow. Consumes the verification token, + * creates the real tenant + a default live API key, issues a console + * JWT, and redirects to the dashboard. The dashboard receives the + * JWT via a one-time cookie and reveals the API key on landing. + */ +router.get('/verify-signup', async (req: Request, res: Response) => { + const token = String(req.query.token || ''); + if (!token) { + res.status(400).send(renderVerifyResultHtml({ ok: false, message: 'Missing or invalid verification token.' })); + return; + } + + try { + const payload = await consumePendingSignup(token); + if (!payload) { + res.status(400).send(renderVerifyResultHtml({ ok: false, message: 'This link is invalid or has already been used. Try signing up again.' })); + return; + } + + // Double-check the email isn't taken by a race with another verify or + // a direct DB seed. Idempotent fallback: if the email is now claimed, + // route the user to login rather than re-creating. + const conflict = await getTenantByEmail(payload.email); + if (conflict) { + res.redirect(303, '/dashboard/login?already_verified=1'); + return; + } + + const tenant = await createTenantWithHash(payload.email, payload.passwordHash, payload.companyName); + const defaultKey = await createApiKey(tenant.id, 'Default Live Key', 'live'); + const jwtToken = issueConsoleToken(tenant.id, tenant.email); + + logger.info('Console: Tenant verified + created', { tenantId: tenant.id }); void recordAuditEvent(tenant.id, { actorType: 'console', action: 'tenant.created', entityType: 'tenant', entityId: tenant.id, status: 'success', - summary: `Created tenant account for ${tenant.email}`, - metadata: { companyName: tenant.company_name, plan: tenant.plan }, + summary: `Verified + created tenant account for ${tenant.email}`, + metadata: { companyName: tenant.company_name, plan: tenant.plan, viaEmailVerification: true }, }).catch(() => undefined); - // Send welcome email out-of-band β€” never block the signup response. - // We deliberately do NOT email the API key (per security-policy Β§10). void (async () => { const tmpl = welcomeEmail({ email: tenant.email, @@ -251,41 +305,53 @@ router.post('/signup', authLimiter, async (req: Request, res: Response) => { await sendMail({ to: tenant.email, ...tmpl }); })(); - res.status(201).json({ - message: 'Account created successfully.', - token, - tenant: { - id: tenant.id, - email: tenant.email, - companyName: tenant.company_name, - plan: tenant.plan, - }, - apiKey: { - key: defaultKey.key, - id: defaultKey.id, - name: defaultKey.name, - prefix: defaultKey.key_prefix, - environment: defaultKey.environment, - warning: '⚠ Copy this API key now β€” it will never be shown again.', - }, - quickstart: { - verify: `curl -X POST ${config.apiBaseUrl}/v1/auth/zkp/verify \\ - -H "Authorization: Bearer ${defaultKey.key}" \\ - -H "Content-Type: application/json" \\ - -d '{"proof": {...}, "publicSignals": [...], "nonce": "...", "timestamp": "..."}'`, - nonce: `curl ${config.apiBaseUrl}/v1/auth/zkp/nonce \\ - -H "Authorization: Bearer ${defaultKey.key}"`, - }, + // Hand the dashboard a one-shot reveal payload via signed cookie. + // The dashboard signup-complete page reads it once and clears it. + const revealPayload = Buffer.from(JSON.stringify({ + token: jwtToken, + apiKey: defaultKey.key, + apiKeyId: defaultKey.id, + apiKeyName: defaultKey.name, + apiKeyPrefix: defaultKey.key_prefix, + apiKeyEnv: defaultKey.environment, + }), 'utf8').toString('base64url'); + + res.cookie('zeroauth_signup_reveal', revealPayload, { + httpOnly: false, // dashboard JS must read it + secure: req.secure || req.headers['x-forwarded-proto'] === 'https', + sameSite: 'lax', + maxAge: 5 * 60 * 1000, // 5 minutes β€” single-use; dashboard clears on read + path: '/dashboard', }); + + res.redirect(303, '/dashboard/signup-complete'); } catch (err) { - logger.error('Console: Signup error', { error: (err as Error).message }); - res.status(500).json({ - error: 'signup_failed', - message: 'Could not create the account. Please try again or contact support.', - }); + logger.error('Console: verify-signup error', { error: (err as Error).message }); + res.status(500).send(renderVerifyResultHtml({ ok: false, message: 'Something went wrong completing your signup. Please try the verification link again, or sign up afresh.' })); } }); +function renderVerifyResultHtml(input: { ok: boolean; message: string }): string { + const safeMsg = input.message.replace(/&/g, '&').replace(//g, '>'); + const title = input.ok ? 'Account ready' : 'Verification failed'; + return ` + +${title} β€” ZeroAuth + + +
+

${title}

+

${safeMsg}

+ Try again +
`; +} + /** * POST /api/console/login * diff --git a/src/services/db.ts b/src/services/db.ts index 49199b1..a13d54d 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -46,6 +46,25 @@ const SCHEMA = ` CREATE INDEX IF NOT EXISTS idx_tenants_email ON tenants(email); CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status); + -- ─── Pending signups (F-2 v2: byte-identical /api/console/signup) ─── + -- Holds a hashed password + intended company name keyed by a single-use + -- verification token. Created when POST /api/console/signup is called + -- for a fresh email; consumed when the user clicks the verify link. + -- Lifetime: 24h, after which the row is GC'd by a periodic cleanup + -- (the consume path also rejects expired rows defensively). + CREATE TABLE IF NOT EXISTS pending_signups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + company_name VARCHAR(255), + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + consumed_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_pending_signups_token ON pending_signups(token_hash); + CREATE INDEX IF NOT EXISTS idx_pending_signups_expires ON pending_signups(expires_at) WHERE consumed_at IS NULL; + -- ─── API Keys ─────────────────────────────────────────── -- Keys are prefixed: za_live_* (production) or za_test_* (sandbox) -- Only the SHA-256 hash is stored; the raw key is shown once at creation. diff --git a/src/services/email-templates.ts b/src/services/email-templates.ts index dee7c01..847a794 100644 --- a/src/services/email-templates.ts +++ b/src/services/email-templates.ts @@ -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; 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..25f0d6a 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,81 @@ 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); + expect(res.headers.location).toBe('/dashboard/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(); + }); +}); From c391bb2cf5075438aadf081a619082c28c15bb3d Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Mon, 18 May 2026 14:23:08 +0530 Subject: [PATCH 02/21] iot: R307 fingerprint driver + CLI for the laptop-side test rig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First piece of B03. A small TS subproject under iot/ that speaks the ZhiAn protocol over a USB-UART adapter β€” enough surface to enroll a finger, search the on-sensor template store, upload the raw characteristic, and hash it for the placeholder commitment. Lives in-tree for fast iteration; graduates to zeroauth-dev/ZeroAuth-IoT once the protocol is stable. iot/src/sensor.ts - Packet codec (header / addr / pid / length / payload / checksum) - Streaming parser that handles split-frame UART arrivals - R307Sensor class: verifyPassword, getSystemParams, readIndexTable, waitForFinger, imageToCharBuffer, combineToTemplate, storeTemplate, search, uploadCharacteristic, deleteTemplate, emptyDatabase, getRandom. Per-packet 3s timeout, per-finger 15s. iot/src/cli.ts - Five verbs: info, enroll [slot], search, capture, wipe - ANSI-coloured progress + clear "remove finger / place again" prompts - SHA-256 of the uploaded characteristic as a placeholder commitment (the real Pramaan pipeline goes characteristic β†’ fuzzy extractor β†’ Poseidon β†’ BN128 scalar; that lives in /circuits and isn't called from here yet) iot/README.md - Hardware wiring table, install command, env overrides, threat-model notes (raw image never leaves the sensor IC; on-sensor template store is a soft secret behind the tamper-evident enclosure). adr/0007-iot-serialport-dependency.md - Adopt serialport@^12 as the UART transport. Alternatives weighed: rolling our own termios shim (option B, ~400 LoC of per-OS code we'd own forever), older node-serialport v9 (Apple Silicon hot-plug bug), socat bridge (extra moving part). Native build is bounded β€” prebuilts exist for every host in our deploy matrix. Verified live against the connected R307: - Sensor responds at /dev/cu.usbserial-0001 @ 57600 baud - 1000-slot library, security level 3, 128B packet size - info β†’ enroll β†’ search loop works end-to-end (operator runs the finger-placement steps in their own terminal because the Claude Code Bash tool can't drive physical presence) --- adr/0007-iot-serialport-dependency.md | 98 +++ iot/.gitignore | 5 + iot/README.md | 99 +++ iot/package-lock.json | 846 ++++++++++++++++++++++++++ iot/package.json | 28 + iot/src/cli.ts | 213 +++++++ iot/src/sensor.ts | 457 ++++++++++++++ iot/tsconfig.json | 18 + 8 files changed, 1764 insertions(+) create mode 100644 adr/0007-iot-serialport-dependency.md create mode 100644 iot/.gitignore create mode 100644 iot/README.md create mode 100644 iot/package-lock.json create mode 100644 iot/package.json create mode 100644 iot/src/cli.ts create mode 100644 iot/src/sensor.ts create mode 100644 iot/tsconfig.json 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/iot/.gitignore b/iot/.gitignore new file mode 100644 index 0000000..9014a85 --- /dev/null +++ b/iot/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.env +.env.local diff --git a/iot/README.md b/iot/README.md new file mode 100644 index 0000000..91a5c52 --- /dev/null +++ b/iot/README.md @@ -0,0 +1,99 @@ +# @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`. + +## 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/package-lock.json b/iot/package-lock.json new file mode 100644 index 0000000..4187a3c --- /dev/null +++ b/iot/package-lock.json @@ -0,0 +1,846 @@ +{ + "name": "@zeroauth/iot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@zeroauth/iot", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "serialport": "^12.0.0" + }, + "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/@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/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/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/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/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/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/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/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" + } + } +} diff --git a/iot/package.json b/iot/package.json new file mode 100644 index 0000000..4bf7a9a --- /dev/null +++ b/iot/package.json @@ -0,0 +1,28 @@ +{ + "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", + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "dependencies": { + "serialport": "^12.0.0" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } +} diff --git a/iot/src/cli.ts b/iot/src/cli.ts new file mode 100644 index 0000000..8b59b9b --- /dev/null +++ b/iot/src/cli.ts @@ -0,0 +1,213 @@ +/** + * 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 { R307Sensor } from './sensor.js'; + +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.')); + console.log(dim('Place finger on sensor…')); + await sensor.waitForFinger(); + await sensor.imageToCharBuffer(1); + console.log(green(' βœ“ captured')); + + 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}`); + 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/sensor.ts b/iot/src/sensor.ts new file mode 100644 index 0000000..3dcaf13 --- /dev/null +++ b/iot/src/sensor.ts @@ -0,0 +1,457 @@ +/** + * 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); + } + + /** 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"] +} From 0ea47607695ed2f11847e4fc22f283c6a07cf6cc Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Mon, 18 May 2026 14:30:19 +0530 Subject: [PATCH 03/21] iot: retry on TOO_FUZZY / TOO_FEW_FEATURE + log B03 hardware validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator live-test on /dev/cu.usbserial-0001 surfaced one rough edge: when the captured image has too few minutiae, img_to_tz returns confirmation 0x07 (TOO_FEW_FEATURE) and the CLI bailed instead of asking for a re-scan. Same story for 0x06 (TOO_FUZZY). Added a small withRetry helper that recognises those two confirmations as user-presentation issues, not protocol failures, and re-prompts up to N times. Applied to cmdSearch and cmdCapture. cmdEnroll's two-stage flow is left alone because it has its own remove/replace prompts that naturally re-drive the user. Also recorded the B03 milestone in qa-log/2026-05-18.md β€” the enroll β†’ search-same β†’ search-different β†’ reject-bad-image pipeline all works end-to-end on the operator's R307 module. Characteristic size on this unit is 768 bytes (3 Γ— 256), not the 512 the datasheet suggests; the driver auto-detects via END_DATA so this is a non-issue. Surfaced for follow-up: the SHA-256 commitment is still a placeholder. The Pramaan fuzzy extractor + Poseidon (in /circuits) needs a binding into this CLI before the commitment that goes on the wire is real. --- iot/src/cli.ts | 49 ++++++++++++++++++++++++++++++++++++-------- qa-log/2026-05-18.md | 22 +++++++++++++++++++- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/iot/src/cli.ts b/iot/src/cli.ts index 8b59b9b..84551ff 100644 --- a/iot/src/cli.ts +++ b/iot/src/cli.ts @@ -27,7 +27,35 @@ import { createHash, randomUUID } from 'node:crypto'; import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; -import { R307Sensor } from './sensor.js'; +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); @@ -142,10 +170,13 @@ async function cmdEnroll(slotArg?: string): Promise { async function cmdSearch(): Promise { await withSensor(async (sensor) => { console.log(bold('Matching against on-sensor templates.')); - console.log(dim('Place finger on sensor…')); - await sensor.waitForFinger(); - await sensor.imageToCharBuffer(1); - console.log(green(' βœ“ captured')); + 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) { @@ -161,9 +192,11 @@ async function cmdCapture(): Promise { const eventId = randomUUID(); console.log(bold('Single-capture + characteristic upload.')); console.log(` ${dim('event id ')} ${eventId}`); - console.log(dim('Place finger on sensor…')); - await sensor.waitForFinger(); - await sensor.imageToCharBuffer(1); + 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')); diff --git a/qa-log/2026-05-18.md b/qa-log/2026-05-18.md index b379089..eee2f0f 100644 --- a/qa-log/2026-05-18.md +++ b/qa-log/2026-05-18.md @@ -27,6 +27,26 @@ preference, Claude no longer opens PRs or auto-merges; the user promotes | 1 | **F-2 v2 byte-identical signup** ([issue #27](https://github.com/zeroauth-dev/ZeroAuth/issues/27)) | `src/routes/console.ts`, `src/services/{pending-signups,tenants,email-templates}.ts`, `src/services/db.ts`, dashboard `Signup` + new `SignupComplete` + Login banner, tests | Closes the medium-severity carve-out from PR #22's security review. POST /signup now returns the same 202 + body regardless of whether the email is taken. Tenant + API key are minted only after the user clicks the verification link, which lands on `/dashboard/signup-complete` with a one-shot reveal cookie. | | 2 | **`security-reviewer` workflow gate** | `.github/workflows/security-review.yml` | Path-filter trigger for security-sensitive surfaces (auth/crypto/audit/tenant boundary). Posts a sticky PR comment listing touched paths + the subagent invocation reminder. Closes the Week 1 discipline-gate gap noted in the W01 annex. | | 3 | **Verifier audit-chain cron** | `.github/workflows/verifier-chain-verify.yml` | Daily 02:30 UTC SSH probe into the VPS, calls `/audit/verify-chain` inside the verifier container. On non-green response, opens an `incident:critical` issue with the response body + remediation runbook link. Closes one of the two verifier-threat-model open items. | +| 4 | **B03 IoT terminal β€” R307 driver + CLI** | `iot/src/{sensor,cli}.ts`, `iot/{package.json,tsconfig.json,README.md}`, `adr/0007-iot-serialport-dependency.md` | First leg of the IoT terminal. ZhiAn protocol over USB-UART. CLI has `info / enroll / search / capture / wipe`. `withRetry` helper handles `TOO_FUZZY` (0x06) / `TOO_FEW_FEATURE` (0x07) gracefully. Eventually moves to `zeroauth-dev/ZeroAuth-IoT`. | + +### B03 live-hardware validation + +Verified against an R307 connected to the laptop via USB-UART at +`/dev/cu.usbserial-0001` @ 57600 baud. + +| Step | Result | +|---|---| +| `info` | 1000-slot library, security level 3, 128B packet size, 3 QC templates pre-loaded at slots 0/1/2 (factory leftovers, not user-enrolled) | +| `wipe` | All three QC templates cleared; `info` re-run confirmed 0 templates | +| `enroll 0` (operator-driven) | Two-capture enrollment succeeded; **characteristic = 768 bytes** (3 Γ— 256, not the 512 the datasheet suggests β€” driver auto-detects via END_DATA packet); SHA-256 placeholder commitment printed | +| `search` Γ— 2 (same finger) | match slot 0, score 134; second scan score 98 β€” both well above the security-3 threshold | +| `search` (different finger) | `no match found` β€” correct rejection | +| `search` (partial placement) | `img_to_tz confirmation=0x7` (TOO_FEW_FEATURE) β€” surfaced cleanly; follow-up retry helper landed in the same session | + +Net: the entire enroll β†’ search β†’ reject pipeline works end-to-end on +real hardware. The SHA-256 we print is a placeholder; the production +flow runs the characteristic through the Pramaan fuzzy extractor + +Poseidon (lives in `/circuits`, not yet wired to this CLI). ### Tests + checks @@ -71,7 +91,7 @@ items. | Day | Item | Status entering this entry | |---|---|---| -| Mon βœ… | B03 IoT terminal firmware skeleton (`zeroauth-dev/ZeroAuth-IoT` new repo) | **Deferred to its own focused session** β€” creating a new repo + bootstrapping firmware code is substantial enough to deserve dedicated context. Today's session prioritized closing higher-leverage Week-1 carry-overs first. | +| Mon βœ… | B03 IoT terminal firmware skeleton | **Done in-tree** at `iot/` (will graduate to `zeroauth-dev/ZeroAuth-IoT` once stable). R307 driver + CLI validated end-to-end on the operator's laptop with a USB-UART-connected sensor; enroll β†’ search β†’ reject all work. | | Tue | Counsel outreach (Amit) + Issue #27 v2 (Pulkit) | Engineering half done today. | | Wed | B13 liveness skeleton + mock-hardware demos | Pending. | | Thu | Off-host SQLite backup + DPDP mapping counsel review | Backup item still open; counsel review still gated. | From 6dc69fb3ec86547fc330e7e54f81af3d20795966 Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Mon, 18 May 2026 14:39:04 +0530 Subject: [PATCH 04/21] =?UTF-8?q?iot:=20fingerprint=20demo=20web=20app=20?= =?UTF-8?q?=E2=80=94=20email=20+=20finger=20login=20over=20the=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A minimal-as-possible end-to-end demo of the IoT terminal. Run: npm --prefix iot run demo # β†’ http://localhost:3100 The bridge owns the open R307 serial port and serializes access with an async mutex (R307 commands aren't multiplexed; concurrent requests would collide on the wire). The browser can't talk to UART directly, so the bridge is the indirection. Architecture - iot/src/bridge.ts: a tiny http.createServer that exposes POST /api/demo/signup { email } β†’ two-capture enroll, bind emailβ†’slot POST /api/demo/login { email } β†’ single scan, 1:N search, slot match GET /api/demo/accounts β†’ current bindings POST /api/demo/reset β†’ wipe sensor + clear bindings GET / β†’ serves iot/demo/index.html Bindings mirror to iot/data/demo-accounts.json so the demo survives Ctrl-C. The R307's own template store is already persistent. - iot/demo/index.html: monochrome two-column page (sign up + log in). Inter Tight + Fraunces to match the brand. Status panes show the bridge's progress in mono. Accounts table refreshes after every signup. Reset button is a destructive ghost-button at the bottom. How the "password" works for the demo - Signup picks the next free slot, two-capture-enrolls the finger into the sensor's flash at that slot, and stores {email: slot} on the host. - Login does a single capture + 1:N search and accepts iff the slot the sensor returns is the slot we bound to this email. The actual fingerprint match is the sensor's internal algorithm. NOT production - This is deliberately the simplest path. The real ZeroAuth pipeline runs the characteristic through the Pramaan fuzzy extractor β†’ Poseidon β†’ Groth16 (lives in /circuits, not wired to the bridge). - The bridge has no auth and binds 127.0.0.1 only. Single-operator laptop demo, not a shared workstation. - Slot index leaves the sensor in cleartext over loopback. Verified - Bridge starts, opens /dev/cu.usbserial-0001, verifies password, listens on :3100. - GET / serves the HTML (200, 13.8 KB). - GET /api/demo/accounts returns [] on a fresh bridge. - Page renders the email + fingerprint forms cleanly at desktop and mobile breakpoints (verified in preview). --- iot/README.md | 29 ++++ iot/demo/index.html | 400 ++++++++++++++++++++++++++++++++++++++++++++ iot/package.json | 1 + iot/src/bridge.ts | 326 ++++++++++++++++++++++++++++++++++++ 4 files changed, 756 insertions(+) create mode 100644 iot/demo/index.html create mode 100644 iot/src/bridge.ts diff --git a/iot/README.md b/iot/README.md index 91a5c52..5ba0156 100644 --- a/iot/README.md +++ b/iot/README.md @@ -36,6 +36,35 @@ 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 diff --git a/iot/demo/index.html b/iot/demo/index.html new file mode 100644 index 0000000..4133e96 --- /dev/null +++ b/iot/demo/index.html @@ -0,0 +1,400 @@ + + + + + + ZeroAuth β€” Fingerprint demo + + + + + + + +
+ +
+ +

ZeroAuth β€” Fingerprint demo

+ Local bridge +
+ +
+

Email + finger. That's it.

+

+ Type your email, press a button, place your finger on the R307. On + signup we enroll the template at a free slot and bind it to your + email. On login we ask the sensor which slot the live scan matches; + if it's the one bound to this email, you're in. +

+
+ +
+ +
+

Sign up

+

You'll be asked to place the same finger twice (lift between scans). The two captures are combined into one template stored on the sensor.

+
+ + +
+ +
Idle.
+
+ +
+

Log in

+

Single capture. The sensor runs a 1:N match and we check the slot it returns matches the one we bound to this email at signup.

+
+ + +
+ +
Idle.
+
+ +
+ +
+

Bound accounts (in-memory + JSON mirror)

+ + + + + +
EmailSlotCreated
No accounts yet.
+
+ +
+
+ +
+ Bridge: iot/src/bridge.ts on http://localhost:3100 Β· Sensor: R307 @ /dev/cu.usbserial-0001 +
+ +
+ + + + diff --git a/iot/package.json b/iot/package.json index 4bf7a9a..835adc4 100644 --- a/iot/package.json +++ b/iot/package.json @@ -14,6 +14,7 @@ "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" }, diff --git a/iot/src/bridge.ts b/iot/src/bridge.ts new file mode 100644 index 0000000..5a0cd0d --- /dev/null +++ b/iot/src/bridge.ts @@ -0,0 +1,326 @@ +/** + * 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 { R307Sensor } from './sensor.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); + +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'); + +interface Account { + email: string; + slot: number; + 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; +} + +async function loadAccounts(): Promise { + try { + const raw = await fs.readFile(ACCOUNTS_FILE, 'utf8'); + const arr = JSON.parse(raw) as Account[]; + for (const a of arr) accounts.set(a.email.toLowerCase(), a); + 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 nextFreeSlot(): number { + const used = new Set([...accounts.values()].map((a) => a.slot)); + for (let i = 0; i < 1000; i++) { + if (!used.has(i)) return i; + } + throw new Error('No free slot left on sensor (capacity is 1000).'); +} + +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 ───────────────────────────────────────────────────────── + +async function enroll(email: string): Promise { + if (!sensor) throw new Error('Sensor not initialised'); + return withSensorLock(async () => { + const existing = accounts.get(email); + const slot = existing?.slot ?? nextFreeSlot(); + + console.log(`[bridge] signup: place finger (capture 1/2) for ${email} @ slot ${slot}`); + await sensor!.waitForFinger(); + await sensor!.imageToCharBuffer(1); + console.log('[bridge] signup: captured. waiting for finger removal…'); + await sensor!.waitForFingerRemoval(); + + console.log('[bridge] signup: place finger (capture 2/2)'); + await sensor!.waitForFinger(); + await sensor!.imageToCharBuffer(2); + console.log('[bridge] signup: combining + storing…'); + await sensor!.combineToTemplate(); + await sensor!.storeTemplate(slot); + + const account: Account = { + email, + slot, + createdAt: existing?.createdAt ?? new Date().toISOString(), + }; + accounts.set(email, account); + await saveAccounts(); + console.log(`[bridge] signup OK for ${email} @ slot ${slot}`); + return account; + }); +} + +interface AuthResult { + matched: boolean; + email: string; + score?: number; + reason?: 'no_account' | 'no_match' | 'wrong_finger'; +} + +async function authenticate(email: string): Promise { + if (!sensor) throw new Error('Sensor not initialised'); + return withSensorLock(async () => { + const account = accounts.get(email); + // Always require a scan so the failure modes feel uniform from the + // browser's wall-clock perspective (no instant "no such email" reply). + console.log(`[bridge] login: place finger for ${email}`); + await sensor!.waitForFinger(); + await sensor!.imageToCharBuffer(1); + const result = await sensor!.search(); + if (!account) { + console.log(`[bridge] login: no account for ${email}`); + return { matched: false, email, reason: 'no_account' }; + } + if (!result) { + console.log(`[bridge] login: no match found on sensor`); + return { matched: false, email, reason: 'no_match' }; + } + if (result.pageId !== account.slot) { + console.log(`[bridge] login: matched slot ${result.pageId} but ${email} is bound to slot ${account.slot}`); + return { matched: false, email, reason: 'wrong_finger', score: result.matchScore }; + } + console.log(`[bridge] login OK for ${email} @ slot ${result.pageId} (score ${result.matchScore})`); + return { matched: true, email, score: result.matchScore }; + }); +} + +async function reset(): Promise { + if (!sensor) throw new Error('Sensor not initialised'); + await withSensorLock(async () => { + await sensor!.emptyDatabase(); + }); + accounts.clear(); + await saveAccounts(); + console.log('[bridge] reset: wiped sensor library + accounts map'); +} + +// ─── 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)); +} + +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/signup') { + const body = (await readJson(req)) as { email?: unknown }; + const email = normalizeEmail(body.email); + if (!email) { + sendJson(res, 400, { error: 'invalid_email' }); + return; + } + try { + const account = await enroll(email); + sendJson(res, 201, { email: account.email, slot: account.slot, createdAt: account.createdAt }); + } catch (err) { + sendJson(res, 500, { error: 'enroll_failed', message: (err as Error).message }); + } + return; + } + + if (req.method === 'POST' && url.pathname === '/api/demo/login') { + const body = (await readJson(req)) as { email?: unknown }; + const email = normalizeEmail(body.email); + if (!email) { + sendJson(res, 400, { error: 'invalid_email' }); + return; + } + try { + const result = await authenticate(email); + sendJson(res, result.matched ? 200 : 401, result); + } catch (err) { + sendJson(res, 500, { error: 'login_failed', message: (err as Error).message }); + } + 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(); + + 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(); From fc2b409ad4a270319496d5c513c8305916f391f1 Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Mon, 18 May 2026 14:49:31 +0530 Subject: [PATCH 05/21] iot/demo: streaming progress so the page tells you exactly when to lift/place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo previously sent one big POST and blocked the UI on a static "Place finger on the sensor, lift between scans" message for the full ~5–10 second flow. The operator had to guess which capture they were on, when to lift, when to place again. Now the bridge streams NDJSON phase events for the duration of the sensor flow, and the page animates a per-card indicator (fingerprint icon + serif headline + mono subline + two-dot stepper for signup) that reads in real time. Bridge β€” iot/src/bridge.ts - New Phase union covering awaiting_finger / captured / awaiting_removal / removed / storing / searching / done / error - enroll() and authenticate() take an `onProgress` callback and emit one Phase per sensor-state transition (place finger, lift, place again, store; or place, search) - POST /api/demo/signup and /login now respond 200 with Content-Type: application/x-ndjson and write one JSON line per phase. The final line is always either { phase: 'done', result } or { phase: 'error', message } - runStreamed() wraps the response: writes the header once, calls the flow with a `write` thunk, end()s in finally. Errors become an error-phase line, never a 500 β€” by the time we know the outcome the browser has already opened the body, and a mid-stream status flip isn't a thing in HTTP/1.1 Demo β€” iot/demo/index.html - New block per card with a 72Γ—72 icon, a serif headline, a mono sub-label, and (for signup) a two-dot stepper - Five mood classes β€” placing (blue, pulse), captured (green, scale), lifting (amber, bob), working (grey, spin), success/error β€” drive colour, animation, and the icon swap. Five inline SVG symbols cover the icon set: finger / finger-down / finger-up / check / cross / spinner. Everything is single-currentColor so the mood class is the only knob - streamPost() reads response.body.getReader() + TextDecoder, splits on newlines, and dispatches each JSON line to a phase-handler - applySignupPhase / applyLoginPhase translate the Phase enum into renderState({ mood, icon, line, sub, detail }) + stepper updates. Same phase events render different copy/UI in the two cards because the flows ARE different β€” signup wants "Lift now", login doesn't Housekeeping - iot/data/ gitignored. The demo persists accounts there for restart survival but it's per-instance state, not source. (Removed the row that got committed inadvertently in the last push.) Verified - Bridge restarts cleanly on the new code, opens the serial port, restores the persisted account, and serves the new page at /. Page renders with the "Ready" indicator on both cards; first interaction pivots into the place/lift state machine driven entirely by the stream from the bridge. --- iot/.gitignore | 1 + iot/demo/index.html | 458 ++++++++++++++++++++++++++++++++++++-------- iot/src/bridge.ts | 95 +++++++-- 3 files changed, 451 insertions(+), 103 deletions(-) diff --git a/iot/.gitignore b/iot/.gitignore index 9014a85..294602b 100644 --- a/iot/.gitignore +++ b/iot/.gitignore @@ -3,3 +3,4 @@ dist/ *.log .env .env.local +data/ diff --git a/iot/demo/index.html b/iot/demo/index.html index 4133e96..999c783 100644 --- a/iot/demo/index.html +++ b/iot/demo/index.html @@ -133,19 +133,87 @@ button.ghost:hover:not(:disabled) { background: #0a0a0a; color: #fff; border-color: #0a0a0a; } - .status { - min-height: 64px; - padding: 12px 14px; + + /* ─── Step indicator ─── */ + .indicator { border: 1px solid #e5e5e5; + background: #fafafa; + padding: 24px 20px; + display: flex; flex-direction: column; align-items: center; gap: 14px; + min-height: 220px; + transition: background 240ms ease, border-color 240ms ease; + } + .indicator.placing { background: #f0f7ff; border-color: #c5dbf2; } + .indicator.captured { background: #f0f8f2; border-color: #cfe7d6; } + .indicator.lifting { background: #fffaf0; border-color: #f3dfb8; } + .indicator.working { background: #f4f4f4; border-color: #d4d4d4; } + .indicator.success { background: #f0f8f2; border-color: #cfe7d6; } + .indicator.error { background: #fef2f2; border-color: #fecaca; } + + .ind-icon { + width: 72px; height: 72px; + display: flex; align-items: center; justify-content: center; + color: #0a0a0a; + transition: color 240ms ease, transform 240ms ease; + } + .indicator.placing .ind-icon { color: #1e5fa8; animation: pulse 1.2s ease-in-out infinite; } + .indicator.captured .ind-icon { color: #1a7a4a; transform: scale(1.08); } + .indicator.lifting .ind-icon { color: #b97f0d; animation: lift 0.9s ease-in-out infinite; } + .indicator.working .ind-icon { color: #525252; animation: spin 1.6s linear infinite; } + .indicator.success .ind-icon { color: #1a7a4a; } + .indicator.error .ind-icon { color: #b91c1c; } + + .ind-icon svg { width: 100%; height: 100%; } + + .ind-line { + font-family: 'Fraunces', serif; + font-weight: 400; + font-variation-settings: 'opsz' 60; + font-size: 20px; + letter-spacing: -0.01em; + text-align: center; + color: #0a0a0a; + min-height: 28px; + } + .ind-sub { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + letter-spacing: 0.06em; + color: #6e6e6e; + text-align: center; + text-transform: uppercase; + } + .ind-detail { font-family: 'JetBrains Mono', monospace; font-size: 12px; - line-height: 1.55; color: #525252; - background: #fafafa; - white-space: pre-wrap; + text-align: center; + min-height: 18px; + } + + @keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.12); opacity: 0.75; } + } + @keyframes lift { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } + } + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + /* ─── Stepper ─── */ + .stepper { display: flex; gap: 8px; justify-content: center; margin-top: 4px; } + .stepper .dot { + width: 8px; height: 8px; border-radius: 999px; background: #d4d4d4; + transition: background 200ms ease, transform 200ms ease; } - .status.ok { color: #0a0a0a; background: #f0f8f2; border-color: #cfe7d6; } - .status.err { color: #7f1d1d; background: #fef2f2; border-color: #fecaca; } + .stepper .dot.active { background: #0a0a0a; transform: scale(1.25); } + .stepper .dot.done { background: #1a7a4a; } + + /* ─── Accounts table ─── */ .accounts { margin-top: clamp(40px, 5vw, 56px); padding: clamp(20px, 3vw, 28px); @@ -212,10 +280,10 @@

ZeroAuth β€” Fingerprint demo

Email + finger. That's it.

- Type your email, press a button, place your finger on the R307. On - signup we enroll the template at a free slot and bind it to your - email. On login we ask the sensor which slot the live scan matches; - if it's the one bound to this email, you're in. + Type your email, press a button, then follow the on-screen prompts. + The R307 captures the print, the bridge tells you exactly when to + place and lift your finger, and your login is bound to the slot + the sensor stored your template at.

@@ -223,24 +291,38 @@

Email + finger. That's it.

Sign up

-

You'll be asked to place the same finger twice (lift between scans). The two captures are combined into one template stored on the sensor.

+

Two captures. The bridge will prompt you to place your finger, lift it, then place it again. Both scans are combined into one on-sensor template.

-
Idle.
+
+
+
Ready
+
Press the button above
+
+ + +
+
+

Log in

-

Single capture. The sensor runs a 1:N match and we check the slot it returns matches the one we bound to this email at signup.

+

Single capture. The sensor runs a 1:N match and we accept iff the slot it returns is the one bound to this email.

-
Idle.
+
+
+
Ready
+
Press the button above
+
+
@@ -264,72 +346,268 @@

Bound accounts (in-memory + JSON mirror)<

+ + + diff --git a/iot/src/bridge.ts b/iot/src/bridge.ts index 875651b..157d3c6 100644 --- a/iot/src/bridge.ts +++ b/iot/src/bridge.ts @@ -46,6 +46,8 @@ 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)); @@ -55,6 +57,16 @@ 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'); @@ -494,24 +506,96 @@ const server = http.createServer(async (req, res) => { return; } - if (req.method === 'POST' && url.pathname === '/api/demo/signup') { - const body = (await readJson(req)) as { email?: unknown }; + 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; } - // Already-registered is decided synchronously, BEFORE we begin the - // stream, so the browser can render a clean "log in instead" state - // without showing a place-finger prompt that would just bail. The - // signup endpoint stays 409-flavoured even though the success path - // is a stream β€” it's the only path that 409s. - if (accounts.has(email)) { - sendJson(res, 409, { - error: 'already_registered', + 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, - did: accounts.get(email)!.did, + 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) => { @@ -530,12 +614,17 @@ const server = http.createServer(async (req, res) => { } if (req.method === 'POST' && url.pathname === '/api/demo/login') { - const body = (await readJson(req)) as { email?: unknown }; + 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 }); 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); +} From 4fe8adcf744d72ea23d7492337f7ce5bca9e31df Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Tue, 19 May 2026 15:37:54 +0530 Subject: [PATCH 10/21] dashboard: monochrome palette + Fraunces typography + Light/Auto/Dark theme system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console was still on the legacy Google-blue brand (#4285F4 / #0B57D0) and the typography stack was just Inter + JetBrains Mono. This commit aligns it with the marketing site's language and adds a proper theme system on top. Tokens β€” dashboard/src/styles.css - Replaced the blue brand with monochrome ink (--color-brand maps to the ink colour, on-brand is the bg, so primary buttons stay high-contrast in either theme). - Added Fraunces (display) to --font-display alongside Inter Tight (--font-sans) and JetBrains Mono (--font-mono). Default body weight drops a notch to match the site's lighter feel. - Two palettes: Dark (default + :root[data-theme="dark"]): near-black bg #0a0a0a, text #fafafa, success #4ade80 Light (prefers-color-scheme: light + :root[data-theme="light"]): white bg, near-black text, success #1a7a4a prefers-color-scheme is the auto path; data-theme on is the manual override. Transitions are wired only after the first paint via the .theme-ready class so initial-render doesn't flash. Boot script β€” dashboard/index.html - Inline IIFE reads localStorage('zeroauth.theme') and stamps data-theme on BEFORE the React bundle parses. No flash of the wrong palette. - color-scheme meta widened from "dark" to "light dark" so native form controls + scrollbars follow the active theme. - Font URL switches Inter β†’ Inter Tight + Fraunces; JetBrains Mono unchanged. ThemeProvider β€” dashboard/src/lib/theme.tsx (new) - React context exposing { choice, resolved, setChoice }. choice is the user preference ('light' | 'dark' | 'system'), resolved is the computed palette after the OS check. - useEffect mirrors choice β†’ DOM (data-theme attr) + localStorage. - A second effect subscribes to prefers-color-scheme media-query changes when choice === 'system', so the page flips live if the OS toggles dark mode while the dashboard is open. - useBrandMarkUrl() resolves to either zeroauth-mark.svg or zeroauth-mark-dark.svg via Vite's BASE_URL β€” works in dev (`/dashboard/`) and prod (mounted at `/dashboard/`) without bespoke routing. AppShell β€” dashboard/src/components/layout/AppShell.tsx - Sidebar brand row uses Fraunces wordmark + the theme-aware BrandMark . - New three-segment ThemeToggle radiogroup at the bottom of the sidebar (Light Β· Auto Β· Dark) with inline SVG icons. Lives above the account chip so the toggle is visible without scrolling. - NavLink active state lost the blue tint β€” now uses bg-surface + ink colour, matching the rest of the monochrome system. Login / AuthLayout β€” dashboard/src/routes/public/Login.tsx - AuthLayout header pivots from the gradient Z-square + sans wordmark to the new fingerprint mark + Fraunces wordmark. Title is now serif. - Footer tagline uses an uppercase tracked-out treatment instead of the dim-grey sentence. - useBrandMarkUrl() means the mark adapts to whatever theme the user has set (or the OS preference) β€” no hardcoded asset path. Landing β€” public/index.html - Added prefers-color-scheme: dark { :root { … } } overrides for every base token. The inverted sections (code card, whitepaper, dark breach-card) flip with the base so contrast is preserved in either theme. - Brand mark in nav + footer swaps to the white variant via a single `filter: invert(1)` rule under the dark scheme β€” no second img. - color-scheme meta tag added so form controls follow the theme. Verified - Dashboard typecheck clean. - Login renders in both themes (screenshots in qa-log/2026-05-19.md flow): dark = white mark + serif wordmark + black surfaces with the inverted button; light = black mark + serif wordmark + white surfaces with the standard black-on-white button. - Landing renders in both themes: DOM eval against #whitepaper in dark mode confirms the inversion (bg rgb(250,250,250) + color rgb(10,10,10)), proving the swap took. Re-anchoring (qa-log/2026-05-19.md) - The fingerprint demo at iot/ is a side experiment, not the platform. No marketing-site / dashboard / docs reference it. The only shared resource is the read-only circuits/build/* artefacts, same set the central API uses. ADR-0007 and ADR-0008 are scoped to iot/. --- dashboard/index.html | 21 ++- dashboard/src/App.tsx | 13 +- dashboard/src/components/layout/AppShell.tsx | 86 ++++++++++- dashboard/src/lib/theme.tsx | 102 +++++++++++++ dashboard/src/routes/public/Login.tsx | 30 ++-- dashboard/src/styles.css | 144 ++++++++++++++++--- public/index.html | 35 +++++ qa-log/2026-05-19.md | 84 +++++++++++ qa-log/LATEST.md | 8 +- 9 files changed, 474 insertions(+), 49 deletions(-) create mode 100644 dashboard/src/lib/theme.tsx create mode 100644 qa-log/2026-05-19.md 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 5283ba2..33c87e2 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,6 +1,7 @@ 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'; @@ -51,8 +52,9 @@ export function App() { return ( - - + + + } /> } /> @@ -74,9 +76,10 @@ export function App() { - - - + + + + ); diff --git a/dashboard/src/components/layout/AppShell.tsx b/dashboard/src/components/layout/AppShell.tsx index 1ef366c..3ef4ee5 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
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.tsx b/dashboard/src/routes/public/Login.tsx index 1344564..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, 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'; @@ -93,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/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/public/index.html b/public/index.html index 8851ba2..1f0aac2 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; } } diff --git a/qa-log/2026-05-19.md b/qa-log/2026-05-19.md new file mode 100644 index 0000000..9e7d893 --- /dev/null +++ b/qa-log/2026-05-19.md @@ -0,0 +1,84 @@ +# QA log β€” Tue 2026-05-19 (Week 2, Day 2) + +> **Cadence:** DW01 demo battery does not run today (paused while the +> demo-rebuild work is in flight). Resumes Thu 2026-05-21. + +**Run owner:** Pulkit Pareek (engineering). +**Window:** Mon 2026-05-18 evening β†’ Tue 2026-05-19. + +--- + +## Rollup + +**Engineering: GREEN.** Demo battery: **HOLD** (unchanged). + +--- + +## What shipped today + +All work on `dev`; nothing promoted to `main` per the established +workflow. + +| # | Item | Where | Why | +|---|---|---|---| +| 1 | **Developer-console redesign β€” palette, typography, theme system** | `dashboard/src/styles.css`, `dashboard/index.html`, `dashboard/src/lib/theme.tsx` (new), `dashboard/src/components/layout/AppShell.tsx`, `dashboard/src/routes/public/Login.tsx` | Brand was still on the legacy `#4285F4` Google blue. Switched to monochrome ink to match the marketing site. Added Fraunces + Inter Tight to the dashboard's font stack. Built a proper theme system: light + dark tokens, `prefers-color-scheme` default with `[data-theme]` override, three-segment **Light Β· Auto Β· Dark** toggle in the sidebar bottom, persisted to localStorage with an inline boot script that applies the choice before React mounts (no flash of wrong theme). The brand mark now resolves via `import.meta.env.BASE_URL` so dev and prod both serve it correctly. | +| 2 | **Landing-site auto theme detection** | `public/index.html` | Added a `prefers-color-scheme: dark` token swap on top of the existing :root palette. The inverted sections (code, whitepaper, dark breach-card) flip with the base so visual contrast is preserved in either theme. The brand mark in the nav + footer swaps via `filter: invert(1)` on dark; no second `` needed. | + +### Pramaan-flow Pramaan-flow notes (continued from Mon) + +The fingerprint demo at `iot/` is **a side experiment**, not the +platform. Tracked here only for completeness; the marketing site, the +developer console, the API, the circuits, and the docs never reference +it. To re-anchor: + +- `iot/` is a separate workspace (own `package.json`, `tsconfig.json`, + `node_modules`, README). +- The only shared resource is `circuits/build/*` β€” read-only, same + artefacts the central API uses. No reverse dependency from the + central API to `iot/`. +- No demo / landing page links into the fingerprint demo, and no + dashboard route surfaces it. +- ADR-0007 and ADR-0008 document the iot deps; both are scoped to the + iot workspace. + +Sunday rebuild captured for the record: +- `iot/src/{sensor,crypto,proof,otp,bridge}.ts` +- `iot/demo/index.html` (centered card, brand header, email β†’ OTP β†’ finger wizard) +- `iot/data/` gitignored (per-bridge instance state) + +This is the operator's hardware experiment. It graduates to a separate +repo (`zeroauth-dev/ZeroAuth-IoT`) when the protocol stabilises; +until then it sits in `iot/` so iteration is fast. + +--- + +## Open from the W01 annex + 2026-05-18 + +| Item | Status today | +|---|---| +| Issue [#27](https://github.com/zeroauth-dev/ZeroAuth/issues/27) F-2 v2 | **In `dev`** since Mon β€” pending the user's `dev β†’ main` promotion. | +| ADR-0005 counsel engagement | Owner: Amit. No movement from engineering today. | +| ADR-0006 inline-fallback retirement | On schedule β€” no change today. Hard date 2026-06-08. | +| Periodic chain-verify cron | Shipped to `dev` Mon. First scheduled run was 2026-05-19 02:30 UTC β€” confirm it fired after the next `dev β†’ main` promotion. | +| Off-host backup of `verifier-audit-data` | **Not started.** Carries to Thu. | +| vkey signature at trusted-setup time | Week 7 work. Not pulled forward. | +| Issue tracker board | Not started. Fri item per the W01 annex. | +| Compliance mappings counsel review | Gated on ADR-0005. | +| `security-reviewer` subagent gate | Shipped to `dev` Mon. | +| DW02 daily PR digest | Not started. Low priority. | +| B03 IoT terminal | Out-of-platform experiment; lives in `iot/`. Not a platform deliverable. | + +--- + +## Risks I'm watching + +1. **Theme rollout coverage.** The dashboard's theme tokens flipped, but only `AuthLayout` and `AppShell` got an explicit visual pass. Individual route pages (Overview, ApiKeys, Devices, Users, etc.) inherit the tokens but haven't been audited for hardcoded `text-white` / `bg-black` / similar style escapes. Walk-through is on the Wed plan. + +2. **`dev` accumulating.** Three substantive sets of changes are now sitting on `dev` un-promoted: F-2 v2, security-reviewer + chain-verify cron, and today's redesign. Coordinated review + merge to `main` is overdue. + +3. **iot/ vs central-platform conflation.** The user explicitly re-anchored today β€” the fingerprint work is an experiment, not a product line. Engineering should resist surfacing it in customer-facing artefacts (landing, dashboard, docs, evidence pack). This log entry is the audit-trail of that re-anchoring. + +--- + +LAST_UPDATED: 2026-05-19 +NEXT_RUN: Thu 2026-05-21 09:55 IST (DW01 demo battery resumes) diff --git a/qa-log/LATEST.md b/qa-log/LATEST.md index 0cd3952..77140e1 100644 --- a/qa-log/LATEST.md +++ b/qa-log/LATEST.md @@ -1,9 +1,9 @@ # Latest QA Run -β†’ [`2026-05-18.md`](2026-05-18.md) +β†’ [`2026-05-19.md`](2026-05-19.md) -**Rollup:** Engineering GREEN; demo battery HOLD (unchanged). F-2 v2, security-reviewer gate, and verifier chain-verify cron all shipped to `dev` today. -**Date:** 2026-05-18 -**Next run:** Tuesday 2026-05-19 at 09:55 IST (DW01 demo battery β€” Tue/Thu cadence) +**Rollup:** Engineering GREEN; demo battery HOLD. Dashboard redesign (palette + typography + theme system) and landing-site auto-theme shipped to `dev` today. iot/ re-anchored as an experiment, not a platform line. +**Date:** 2026-05-19 +**Next run:** Thursday 2026-05-21 at 09:55 IST (DW01 demo battery) (This file is overwritten on every run. For history, see the dated files in this directory.) From 8a4e0a413c8cd1acb4ca5c281a709d1b986fa967 Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Tue, 19 May 2026 15:56:10 +0530 Subject: [PATCH 11/21] config: add consoleBaseUrl / docsBaseUrl / landingBaseUrl for the subdomain split Centralises the public-facing URLs for the four product surfaces ahead of the api./console./docs.zeroauth.dev refactor. Backend code can now read config.consoleBaseUrl, config.docsBaseUrl, etc. instead of string-concat'ing `config.apiBaseUrl + '/dashboard'` like it used to. Dev defaults keep the single-host shape (everything mounts at :3000) so the round-trip tests don't need DNS plumbing. Production resolves to the real subdomains, overridable via env. --- .env.example | 25 ++++++++++++++++++------- src/config/index.ts | 24 +++++++++++++++++++++--- 2 files changed, 39 insertions(+), 10 deletions(-) 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/src/config/index.ts b/src/config/index.ts index 2a0e65f..f68575c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -16,11 +16,16 @@ function parseCorsOrigins(): string[] { if (raw && raw.trim().length > 0) { return raw.split(',').map(s => s.trim()).filter(Boolean); } - // Fallback: derive from API_BASE_URL in prod, or use dev defaults + // Fallback: derive from the resolved public URLs in prod, dev defaults otherwise. if (process.env.NODE_ENV === 'production') { - return [process.env.API_BASE_URL ?? 'https://zeroauth.dev']; + return [ + process.env.API_BASE_URL ?? 'https://api.zeroauth.dev', + process.env.CONSOLE_BASE_URL ?? 'https://console.zeroauth.dev', + process.env.DOCS_BASE_URL ?? 'https://docs.zeroauth.dev', + process.env.LANDING_BASE_URL ?? 'https://zeroauth.dev', + ]; } - return ['http://localhost:3000', 'http://localhost:5173']; + return ['http://localhost:3000', 'http://localhost:5173', 'http://localhost:5050']; } // Demo-auth gate: the legacy SAML/OIDC routes are not real protocol @@ -38,6 +43,19 @@ export const config = { nodeEnv: process.env.NODE_ENV ?? 'development', port: parseInt(process.env.PORT ?? '3000', 10), apiBaseUrl: process.env.API_BASE_URL ?? 'http://localhost:3000', + /** + * Public-facing URLs for the four product surfaces. After the + * subdomain refactor these resolve to: + * api β†’ https://api.zeroauth.dev + * console β†’ https://console.zeroauth.dev + * docs β†’ https://docs.zeroauth.dev + * landing β†’ https://zeroauth.dev + * In dev they collapse onto a single Express host so the existing + * round-trip tests don't need DNS plumbing. + */ + consoleBaseUrl: process.env.CONSOLE_BASE_URL ?? (process.env.NODE_ENV === 'production' ? 'https://console.zeroauth.dev' : 'http://localhost:3000/dashboard'), + docsBaseUrl: process.env.DOCS_BASE_URL ?? (process.env.NODE_ENV === 'production' ? 'https://docs.zeroauth.dev' : 'http://localhost:3000/docs'), + landingBaseUrl: process.env.LANDING_BASE_URL ?? (process.env.NODE_ENV === 'production' ? 'https://zeroauth.dev' : 'http://localhost:3000'), corsOrigins: parseCorsOrigins(), trustProxy: process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production', enableDemoAuth: resolveDemoAuthFlag(), From d1d63978bb3b299f27c94434964e70d45dc71341 Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Tue, 19 May 2026 15:57:10 +0530 Subject: [PATCH 12/21] Caddyfile: vhosts for api./console./docs.zeroauth.dev + apex redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five terminating Caddy vhosts: zeroauth.dev / www - landing site + apex 308s for legacy /v1, /api, /dashboard, /docs paths to the new subdomains (so bookmarks + CI scripts don't silently break) api.zeroauth.dev - REST surface; proxies /v1/* and /api/* to the upstream, hard 404 on anything else console.zeroauth.dev - developer console; rewrites '/' β†’ '/dashboard/' so users see a bare-domain SPA docs.zeroauth.dev - Docusaurus build; same rewrite trick for /docs/ All five hit the same zeroauth-prod:3000 upstream; the split is purely at the edge. Cert provisioning is automatic on first request β€” DNS needs to point all five names at the VPS before this deploys. DNS prerequisites (operator action) are documented at the top of the file. Shared snippets pulled out (security_headers, asset_caching, json_log) so each vhost stanza stays under 30 lines of intent. --- Caddyfile | 133 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 117 insertions(+), 16 deletions(-) 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 } From 223ba75a52f54ea19577d477e71308d2469788a2 Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Tue, 19 May 2026 15:58:34 +0530 Subject: [PATCH 13/21] console: cross-subdomain cookie + post-verify redirect via consoleBaseUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verify-signup endpoint mints a one-shot reveal cookie that the SignupComplete page reads. Pre-refactor the cookie was scoped to path=/dashboard and the redirect was a relative '/dashboard/signup-complete' β€” that works when the API + console share a host, but fails when they're on separate subdomains (api.zeroauth.dev sets the cookie; console.zeroauth.dev tries to read it). Two tightly-coupled fixes: - Cookie domain = '.zeroauth.dev' when consoleBaseUrl resolves to a *.zeroauth.dev host; undefined otherwise (dev keeps the implicit host-only scope). Path widened from '/dashboard' to '/' so the console can read it regardless of basename. - Redirect target now '${consoleBaseUrl}/signup-complete' β€” relative '/dashboard/signup-complete' in dev, absolute https://console.zeroauth.dev/signup-complete in prod. Tests: tests/console-signup.test.ts loosened the redirect-target assertion to match the suffix only, since the prefix is dev/prod-dependent. All 16 console-signup specs pass. --- src/routes/console.ts | 19 ++++++++++++++++--- tests/console-signup.test.ts | 5 ++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/routes/console.ts b/src/routes/console.ts index 56fc6bc..9ddd59a 100644 --- a/src/routes/console.ts +++ b/src/routes/console.ts @@ -316,15 +316,28 @@ router.get('/verify-signup', async (req: Request, res: Response) => { apiKeyEnv: defaultKey.environment, }), 'utf8').toString('base64url'); + // Cross-subdomain cookie. After the api./console.zeroauth.dev split + // the verify-signup endpoint lives on api.zeroauth.dev but the + // dashboard reads the reveal cookie on console.zeroauth.dev β€” they + // share state only if the cookie is scoped to the eTLD+1. + const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https'; + const apexHost = (() => { + try { return new URL(config.consoleBaseUrl).hostname; } catch { return null; } + })(); + const cookieDomain = apexHost && apexHost.endsWith('zeroauth.dev') ? '.zeroauth.dev' : undefined; res.cookie('zeroauth_signup_reveal', revealPayload, { httpOnly: false, // dashboard JS must read it - secure: req.secure || req.headers['x-forwarded-proto'] === 'https', + secure: isHttps, sameSite: 'lax', maxAge: 5 * 60 * 1000, // 5 minutes β€” single-use; dashboard clears on read - path: '/dashboard', + path: '/', + ...(cookieDomain ? { domain: cookieDomain } : {}), }); - res.redirect(303, '/dashboard/signup-complete'); + // After verification we land the user on the console. In dev that's + // /dashboard/signup-complete on the same host; in prod it's + // console.zeroauth.dev/signup-complete. + res.redirect(303, `${config.consoleBaseUrl.replace(/\/$/, '')}/signup-complete`); } catch (err) { logger.error('Console: verify-signup error', { error: (err as Error).message }); res.status(500).send(renderVerifyResultHtml({ ok: false, message: 'Something went wrong completing your signup. Please try the verification link again, or sign up afresh.' })); diff --git a/tests/console-signup.test.ts b/tests/console-signup.test.ts index 25f0d6a..008b567 100644 --- a/tests/console-signup.test.ts +++ b/tests/console-signup.test.ts @@ -311,7 +311,10 @@ describe('GET /api/console/verify-signup β€” F-2 v2 second leg', () => { ); expect(createApiKeyMock).toHaveBeenCalledWith('tenant-new', 'Default Live Key', 'live'); expect(res.status).toBe(303); - expect(res.headers.location).toBe('/dashboard/signup-complete'); + // 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=/); From 85172a807e55b4d31bf4489f30596cc3301849b7 Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Tue, 19 May 2026 16:00:24 +0530 Subject: [PATCH 14/21] landing: rewrite internal links to console./docs./api. subdomains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced every relative /dashboard/* + /docs/* + apex https://zeroauth.dev/v1/* reference with the absolute subdomain URL. Touched 11 link targets: /dashboard/login β†’ https://console.zeroauth.dev/login /dashboard/signup β†’ https://console.zeroauth.dev/signup /docs/ β†’ https://docs.zeroauth.dev/ /docs/getting-started/... β†’ https://docs.zeroauth.dev/getting-started/... /docs/reference/api-reference β†’ https://docs.zeroauth.dev/reference/api-reference https://zeroauth.dev/v1/users β†’ https://api.zeroauth.dev/v1/users https://zeroauth.dev/v1/verif.. β†’ https://api.zeroauth.dev/v1/verifications Caddy's apex 308s still catch any /dashboard/* or /docs/* requests that arrive at zeroauth.dev directly (legacy bookmarks, CI scripts) so this isn't a hard cut-over. --- public/index.html | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/public/index.html b/public/index.html index 1f0aac2..440f088 100644 --- a/public/index.html +++ b/public/index.html @@ -950,13 +950,13 @@ Quickstart Demo Product - Docs + Docs GitHub
@@ -975,7 +975,7 @@

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

@@ -1053,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.

@@ -1068,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 '{
@@ -1077,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 '{ @@ -1420,7 +1420,7 @@

Talk to our team

-

For self-serve: create an account β†’

+

For self-serve: create an account β†’

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

Developers