Skip to content

feat(auth): passkey-only 2FA + session/CSRF/secret hardening#10

Merged
Anton-Horn merged 5 commits into
mainfrom
chore/post-pi-cleanup
May 11, 2026
Merged

feat(auth): passkey-only 2FA + session/CSRF/secret hardening#10
Anton-Horn merged 5 commits into
mainfrom
chore/post-pi-cleanup

Conversation

@Anton-Horn

Copy link
Copy Markdown
Contributor

Summary

Replaces dual-track 2FA (TOTP authenticator app + bcrypt-hashed backup codes + WebAuthn passkeys) with passkeys as the sole second factor, and ships the highest-impact security fixes against the current auth surface in one pass.

The user-facing model is now: password + passkey, nothing else. The Security tab in account settings has a single section.

What's gone

  • server/routes/totp.ts, server/db/queries/totp.ts, web/src/api/totp.ts, web/src/lib/qr-decode.ts
  • users.totp_secret, users.totp_enabled, totp_backup_codes table (migration block drops them from existing DBs)
  • All authenticator-app / QR-code / recovery-code UI in LoginPage, AccountPage, InvitePage, SetupPage, HelpPage
  • otpauth + qrcode deps
  • Temp-token purposes 2fa and 2fa-reenroll

Out of scope by explicit user decision: CredentialsManager's optional totpSecret field on stored credentials stays — it's for the agent to log into external services on the user's behalf, not for authenticating into zero-agent.

Security fixes shipped alongside

  • JWT_SECRET and CREDENTIALS_KEY: refuse to boot if unset or under 32 chars (no derived fallback from DB_PATH)
  • CORS_ORIGIN: required in production, no * fallback
  • JWT moved to HttpOnly; Secure; SameSite=Strict cookie; bearer kept only as a CLI/WS fallback
  • New users.token_version column, embedded as tv claim, verified on every request; bumped on logout and on password reset → instant kill of every existing session for a user
  • New POST /api/auth/logout endpoint
  • CSRF double-submit (csrf cookie + X-CSRF-Token header) middleware on state-changing routes; unauthenticated ceremonies and the pi CLI proxy are exempt
  • Passkey ceremonies use userVerification: required everywhere and are keyed by a random ceremony id (no cross-flow / parallel-ceremony collisions). Added /api/auth/passkey/enroll-options + /enroll-verify for in-flow enrollment when an account requires a passkey but doesn't have one yet
  • Password-reset init returns a fixed { ok: true } shape — no account enumeration
  • Constant-time login: bcrypt vs a dummy hash when username is unknown
  • Rate-limit IP source trusts X-Forwarded-For only when TRUST_PROXY=1
  • /api/setup/complete gated by X-Setup-Token header in production; check + insert wrapped in db.transaction
  • Temp tokens carry a jti; reuse is rejected
  • bcrypt cost bumped 10 → 12 on setup, password reset, invitation acceptance

New env required in production

  • JWT_SECRET (≥32 chars)
  • CREDENTIALS_KEY (≥32 chars)
  • CORS_ORIGIN (exact origin, e.g. https://app.example.com)
  • SETUP_TOKEN (≥16 chars, only required until setup is completed)
  • TRUST_PROXY=1 if behind a reverse proxy

Test plan

  • Server refuses to boot without JWT_SECRET / CREDENTIALS_KEY (verified locally)
  • Server refuses to boot in production without CORS_ORIGIN (verified locally)
  • Migration: existing dev DB with totp_secret / totp_enabled / totp_backup_codes heals on next boot
  • Fresh setup → X-Setup-Token required in prod; SetupPage runs passkey enrollment after admin creation
  • Login with no passkey + admin account → enrolls a passkey before granting the session
  • Login with passkey → completes via WebAuthn with userVerification: required
  • Logout in tab A invalidates an active session in tab B on next request
  • Password reset only via passkey; success bumps token_version
  • Account → Security shows only the passkeys section
  • curl -X POST /api/me from outside the SPA without X-CSRF-Token → 403
  • bun run build (web) green; tsc --noEmit green for server/** and web/**; vitest run — 22 pass, the 2 failures (search.test.ts) are pre-existing and require BRAVE_SEARCH_API_KEY

🤖 Generated with Claude Code

The Build session image workflow targeted runner/docker/session/Dockerfile,
removed in the pi migration. Pi now runs as a child process in the server
container, so the standalone session image is no longer built.

Also rewrites the README architecture, features, and project structure to
match the post-pi layout (no runner service, no S3, no plan mode, no
skills directory).
- server/Dockerfile: drop COPY skills/ and skills-lock.json (skills/ was
  removed in pi-migration; the build was broken on main)
- skills-lock.json: delete the orphan lock file
- server/.ocd-deploy.json:
  - add PI_PROJECTS_ROOT=/app/data/projects so Pi workspaces and chat
    JSONLs sit on the persistent volume (otherwise /var/zero is ephemeral
    and every restart wipes projects + history)
  - bump volume size 10 -> 20 GB to make room for project files
  - drop invalid webhook.paths array (schema only allows a single path)
    and stale skills/ entry; webhook now redeploys on any push to main
- 100+ models -> multi-model (models.json has 10 curated entries)
- drop SOUL.md/MEMORY.md (only HEARTBEAT.md is wired up)
- image gen: drop FLUX-specific naming and 'customizable aspect ratios'
  (handler takes only a prompt, aspect ratio is hardcoded)
- apps: describe what 'zero apps create' actually does (port allocation
  + gateway HTTP proxy) instead of generic 'app deployment'
- 'with any model' tagline -> drop, since model set is configured
Replace dual-track 2FA (TOTP authenticator + backup codes + passkeys)
with passkeys as the sole second factor, and harden the auth surface
end-to-end.

- Remove TOTP + backup-code routes, DB columns, types, and all
  authenticator/QR/recovery UI from login, account, invite, setup
- Move JWT from localStorage to HttpOnly+Secure+SameSite cookie; add
  per-user token_version (bumped on logout + password reset) so
  every existing session can be killed server-side
- Add CSRF double-submit middleware on state-changing routes
- Refuse to boot without JWT_SECRET / CREDENTIALS_KEY (>=32 chars)
- Refuse to boot in production without CORS_ORIGIN (no `*` fallback)
- Gate /api/setup/complete behind X-Setup-Token in production and
  wrap the existence-check + insert in a transaction
- Passkey ceremonies use userVerification: required and are keyed
  by random ceremony id (no cross-flow / parallel-ceremony collisions)
- Generic password-reset-init response shape (no enumeration)
- Constant-time login (bcrypt vs dummy hash on unknown user)
- Trust X-Forwarded-For only when TRUST_PROXY=1
- Temp tokens carry a jti and are single-use
- Bcrypt cost bumped 10 -> 12 on setup, reset, invite-accept
@Anton-Horn Anton-Horn merged commit 5c88a94 into main May 11, 2026
1 check failed
@Anton-Horn Anton-Horn deleted the chore/post-pi-cleanup branch May 11, 2026 10:18
Anton-Horn added a commit that referenced this pull request May 11, 2026
* chore: refresh bun.lock to remove otpauth + qrcode

Lockfile was left stale after #10 removed the otpauth and qrcode
deps from package.json. CI runs --frozen-lockfile and rejected the
mismatch.

* chore: drop dead RegisterPage

Unrouted page still referenced removed registerApi() + old store
.login(); only kept around through web tsconfig globbing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant