Skip to content

feat(2fa): TOTP login with authenticator apps + recovery codes#13

Merged
valehdba merged 2 commits into
mainfrom
feat/2fa-totp
May 10, 2026
Merged

feat(2fa): TOTP login with authenticator apps + recovery codes#13
valehdba merged 2 commits into
mainfrom
feat/2fa-totp

Conversation

@valehdba
Copy link
Copy Markdown
Owner

Summary

Adds standard Google Authenticator–style 2FA. Setup, confirm, two-step login, and disable all flow through a clean pattern that can't be bypassed by a stolen access token.

165 tests total (was 133): +20 server pytest tests, +5 web vitest tests.

Trade-off, surfaced clearly in docs

TOTP cannot be zero-knowledge — RFC 6238 requires the verifier to know the shared secret. Vault contents stay zero-knowledge (still encrypted with the master key the server never sees), but login auth widens by one secret. The widening is documented in docs/SECURITY.md next to the existing "what the server can see" table.

A breach that leaks both the auth_key hash and the TOTP secret still does not enable vault decryption — that requires the master password.

Server

Layer What's new
Schema 0002_totp.py adds 3 nullable columns. Existing users unaffected (NULL = "no 2FA").
TOTP utils totp.py — hand-rolled RFC 6238 (HMAC-SHA1, 30s window, ±1 tolerance, constant-time compare). No pyotp dep. Recovery codes Argon2id-hashed.
Account router routers/account.pyGET /totp/status, POST /totp/setup, POST /totp/confirm, POST /totp/disable. Disable always requires a fresh code.
Sessions POST /sessions returns + "TokenPair | OtpChallengeResponse" + . New POST /sessions/otp accepts the otp_token + 6-digit code (or recovery code).
Tests 20 new tests in test_totp.py — utility verify + skew, full setup→confirm→disable lifecycle, two-step login + recovery-code consumption.

Client

Layer What's new
API LoginResponse = TokenPair \| OtpChallenge, isOtpChallenge type guard, loginOtp, totpStatus/totpSetup/totpConfirm/totpDisable.
2FA module twofactor/qrcode.ts — wraps qrcode npm lib (BSD-3, ~5KB gzipped).
Settings New SettingsPage.tsx with Security section + 3-step setup modal (TotpSetupFlow.tsx) — Scan QR → Confirm code → Save recovery codes.
Login LoginPage.tsx handles the OTP-challenge response with a phase-2 form. Master password is held in component state across the two phases (wiped after unlock).
Sidebar "Settings" link added to the user-card footer.
Tests 5 new tests in twofactor.test.ts.

Verification

  • + "pytest -q" + 52 passed, 84.24% coverage (above 80% gate)
  • + "npm test --workspace=@passman/web" + 113 passed in 8 test files
  • + "npm run typecheck --workspaces" + — clean across core / web / extension
  • + "npm run build --workspace=@passman/web" + — clean
  • + "npm run build --workspace=@passman/extension" + — clean
  • Live-rendered the login page via Vite + Claude Preview to confirm the no-2FA path still works; the Settings route correctly redirects to /login when there's no session.

Bundle impact

  • Web JS: 82.04 → 94.43 KB gzipped (+12 KB, almost entirely the qrcode lib)
  • Web CSS: 5.54 → 5.92 KB gzipped (+0.38 KB)

Out of scope (deliberate)

  • WebAuthn / passkeys — the zero-knowledge-friendly second factor (server holds only the public key). Tracked as a parallel path; this PR keeps Google-Authenticator-style TOTP as the v1.
  • 2FA-required policy — admin-enforced 2FA across all users, mandatory enrollment. v1 is opt-in per user.
  • Hardware OTP keys (YubiKey OTP, not WebAuthn) — niche; passes through the same TOTP flow if the user enrolls them as an authenticator.

Test plan

  • All unit tests pass
  • Typecheck across all workspaces
  • Build clean
  • Manual: register a fresh user, log in, open Settings → Set up 2FA, scan with Google Authenticator, confirm with first code, verify recovery codes display
  • Manual: log out, log in again — verify the OTP step now appears, enter a real code, verify it lands in the vault
  • Manual: try a wrong OTP — verify the 401 message
  • Manual: use a recovery code on phase-2 → verify success and that the count decrements (Settings shows "9 remaining")
  • Manual: try the same recovery code again → verify it's rejected
  • Manual: disable 2FA from Settings with a current code, verify Status flips to "Not enabled"

🤖 Generated with Claude Code

valehdba and others added 2 commits May 10, 2026 20:50
Adds standard Google-Authenticator-style 2FA. Setup, confirm, login,
and disable all flow through a clean two-step pattern that can't be
bypassed by a stolen access token. +20 server tests (52 total) +
+5 web tests (113 total).

## Trade-off, surfaced clearly in docs

TOTP cannot be zero-knowledge — RFC 6238 requires the verifier to know
the shared secret. Vault contents stay zero-knowledge (still encrypted
with the master key the server never sees), but login auth widens by
one secret. The widening is documented in `docs/SECURITY.md` next to
the existing "what the server can see" table. Disabling 2FA always
requires a fresh code (TOTP or recovery), so token theft alone can't
downgrade the auth posture.

## Server

### Schema (`0002_totp.py`)
- `users.totp_secret` BYTEA NULL — RFC 4226/6238 shared secret
- `users.totp_enabled` BOOL NOT NULL DEFAULT false — flipped on confirm
- `users.totp_recovery_hashes` TEXT NULL — JSON list of Argon2id-hashed codes

NULL on existing rows = "no 2FA configured", so existing users are
unaffected.

### `passman/totp.py` — pure utilities
Hand-rolled RFC 6238 (HMAC-SHA1, 30s window, 6 digits, ±1 window
tolerance). No `pyotp` dep; ~50 lines of well-trodden code wrapped with
constant-time `hmac.compare_digest` over the candidate windows.

Recovery codes: 10 by default, `xxxx-xxxx` formatting, Argon2id-hashed
via `argon2-cffi` (already a dep). `consume_recovery_code` is single-use
by design — it strips the matched hash and returns the new JSON list.

### `passman/routers/account.py` — new
- `GET  /api/account/totp/status` — { enabled, recovery_codes_remaining }
- `POST /api/account/totp/setup` — generates secret, returns
  provisioning URI + base32 fallback. Idempotent on partial-setup.
- `POST /api/account/totp/confirm` — verify first code → enable +
  return plaintext recovery codes (shown to user once, server keeps
  only hashes).
- `POST /api/account/totp/disable` — current OTP OR recovery code
  required.

### `passman/routers/sessions.py` — modified
- `POST /api/sessions` now returns `TokenPair | OtpChallengeResponse`
  depending on whether the user has 2FA enabled. The challenge is a
  short-lived (5 min) JWT carrying only `sub` + `type=otp_challenge`.
- `POST /api/sessions/otp` — phase-2: accepts the otp_token + a code
  (TOTP or recovery), issues the full token pair on match.
- Shared `_issue_token_pair` helper deduplicates the token-pair minting
  so the no-2FA and after-OTP paths can't drift.

### Tests (`tests/test_totp.py`, +20)
Pure-function tests (verify with skew, recovery-code consume-once),
endpoint tests (setup/confirm/disable lifecycle, status reporting),
and full two-step login tests including recovery-code consumption.

### `errors.py` — small change
`InvalidCredentialsError` now takes an optional message arg (defaults
to "Invalid credentials") so the new endpoints can return helpful
messages without rewriting every existing call site.

## Client

### API client (`api/client.ts`)
- `LoginResponse` is now `TokenPair | OtpChallenge`
- `isOtpChallenge` type guard
- `loginOtp(otp_token, code)` — phase-2
- `totpStatus` / `totpSetup` / `totpConfirm` / `totpDisable`

### `twofactor/qrcode.ts` — new
Wraps the `qrcode` npm library (BSD-3, ~5KB gzipped). Renders the
otpauth:// URI as inline SVG so the QR can be injected via
`dangerouslySetInnerHTML` without touching the strict CSP.

### `pages/SettingsPage.tsx` + `pages/settings/TotpSetupFlow.tsx` — new
Settings is a fresh route. v1 hosts only the Security section (status
+ enable/disable). Setup is a 3-step modal:
  1. Scan QR (with manual-entry fallback)
  2. Confirm the first code
  3. Save recovery codes (copy / download / "I've saved them")

The recovery-codes step is one-way — once shown, they're gone from the
server forever. The modal blocks `Esc` close on that step to prevent
accidental abandonment.

### `pages/LoginPage.tsx` — modified
Two-step login when the server replies with an OTP challenge. The
master password + KDF params are held in component state across the
two phases so the post-OTP `unlock` call doesn't need to re-prompt.
State is wiped as soon as the vault key is derived.

### Sidebar
"Settings" link added next to Export in the user-card footer.

### Tests (`tests/twofactor.test.ts`, +5)
- `isOtpChallenge` discrimination over `LoginResponse`
- `formatSecretForDisplay` (4-char block grouping, whitespace stripping)

## Deps
- Web: `qrcode@^1.5.4` + `@types/qrcode@^1.5.6` (dev)

## Bundle impact
- Web JS: 82.04 → 94.43 KB gzipped (+12 KB — qrcode lib)
- Web CSS: 5.54 → 5.92 KB gzipped (+0.38 KB)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…order

- Em-dash → "to" in totp.py constants block (RUF003)
- Bare-except in consume_recovery_code now logs at warning then continues (S112)
- totp_enabled column declaration split to fit 100-col line (E501)
- Auto-fixes from `ruff check --fix`: import order, `Iterable` from
  collections.abc instead of typing, `from alembic import op` ordering,
  `from .totp import verify as verify_totp` split into two `from`s

All 52 server tests still pass; coverage 84.19%.
Comment thread server/alembic/env.py Dismissed
@valehdba valehdba merged commit 5d57d8c into main May 10, 2026
8 of 9 checks passed
valehdba added a commit that referenced this pull request May 10, 2026
The PR #13 squash-merge produced a state of `_issue_token_pair`'s
signature that violates ruff format's line-folding rule (the args fit
on a single 100-col line, so ruff prefers them flat — the multi-line
form was an artifact of how the two fix commits combined).

Ruff format clean again. 52 server tests still pass.
valehdba added a commit that referenced this pull request May 10, 2026
The PR #13 squash-merge produced a state of `_issue_token_pair`'s
signature that violates ruff format's line-folding rule (the args fit
on a single 100-col line, so ruff prefers them flat — the multi-line
form was an artifact of how the two fix commits combined).

Ruff format clean again. 52 server tests still pass.
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.

2 participants