feat(2fa): TOTP login with authenticator apps + recovery codes#13
Merged
Conversation
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%.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdnext 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
0002_totp.pyadds 3 nullable columns. Existing users unaffected (NULL = "no 2FA").totp.py— hand-rolled RFC 6238 (HMAC-SHA1, 30s window, ±1 tolerance, constant-time compare). Nopyotpdep. Recovery codes Argon2id-hashed.routers/account.py—GET /totp/status,POST /totp/setup,POST /totp/confirm,POST /totp/disable. Disable always requires a fresh code.POST /sessionsreturns+ "TokenPair | OtpChallengeResponse" +. NewPOST /sessions/otpaccepts the otp_token + 6-digit code (or recovery code).test_totp.py— utility verify + skew, full setup→confirm→disable lifecycle, two-step login + recovery-code consumption.Client
LoginResponse = TokenPair \| OtpChallenge,isOtpChallengetype guard,loginOtp,totpStatus/totpSetup/totpConfirm/totpDisable.twofactor/qrcode.ts— wrapsqrcodenpm lib (BSD-3, ~5KB gzipped).SettingsPage.tsxwith Security section + 3-step setup modal (TotpSetupFlow.tsx) — Scan QR → Confirm code → Save recovery codes.LoginPage.tsxhandles the OTP-challenge response with a phase-2 form. Master password is held in component state across the two phases (wiped afterunlock).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" +— cleanBundle impact
Out of scope (deliberate)
Test plan
🤖 Generated with Claude Code