Skip to content

feat: TOTP enrollment, recovery codes, admin reset (#1005 slice 2) - #1008

Merged
milkway merged 4 commits into
mainfrom
feat/1005-mfa-enroll
Jul 18, 2026
Merged

feat: TOTP enrollment, recovery codes, admin reset (#1005 slice 2)#1008
milkway merged 4 commits into
mainfrom
feat/1005-mfa-enroll

Conversation

@milkway

@milkway milkway commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Slice 2 of 4 of the step-up MFA epic (#1005). Stacked on #1007 (slice 1) — review/merge that first. The USER side: enrolling a TOTP factor, recovery codes, encrypted storage, admin visibility/reset. Still no challenge, trusted-device grants, or proxy guard (slices 3–4).

What changed

  • Migration 0029 (dual-dialect): user_mfa (AES-256-GCM secret via RUSCKER_MASTER_KEY; pending→confirmed; a per-enrollment ceremony token; last_used_step reserved for slice-3 replay prevention) + user_mfa_recovery (salted SHA-256 hashes — codes are high-entropy random, argon2 deliberately not used; transactional one-time consumption).
  • Enrollment at /admin/account/mfa (the account-password page pattern): password re-auth → QR (inline SVG, no CDN) + manual base32 key → first valid code confirms (SHA-1/6-digit/30s, ±1 step — authenticator-app compat) → 10 recovery codes shown exactly once. Pending secret lives encrypted in the DB (HA-safe, survives restarts); wrong code keeps the same secret; a confirmed factor is never overwritten without reset; break-glass sessions can't enroll; missing master key fails closed (503 + hint).
  • Admin: user edit page shows factor state + audited Reset 2FA; the paginated users list untouched.
  • Deps (crates.io-verified): totp-rs 5.7.2 (gen_secret+otpauth, no qr feature) and qrcode 0.14.1 (svg only).
  • Audit: mfa.enroll / mfa.reset — never the material. i18n ×4.

Review hardening — 4 real findings by Codex (gpt-5.6 high), 4 rounds to a clean pass

  1. P1 — ceremony binding: any same-user session (e.g. a stolen cookie that never re-authed) could POST a wrong code and receive the setup re-render with the decrypted secret + QR. The pending row now carries a random ceremony token mirrored in a short-lived HttpOnly/SameSite=Strict cookie set only by the password-re-authed /start; /confirm requires it (constant-time compare) before any decrypt/re-render — otherwise a generic 409 with zero secret material.
  2. P2 — confirm race: the confirm UPDATE matched username+pending only, so a racing re-start could get its replacement secret confirmed by a code proving the old one. The UPDATE is now conditional on the ceremony; a stale confirm hits 0 rows and 409s.
  3. P2 — password oracle: /start ran an unthrottled argon2 verify per guess. A dedicated per-username limiter (5/min) now gates the re-auth.
  4. P1 — limiter TOCTOU: allow() was read-only and failures recorded only after the awaited verify — N concurrent requests could all pass first. Both limiters now try_reserve(): prune+check+count in one mutex critical section, before any await.

Validation

  • Full gate green ×4 rounds; postgres-it green vs a real postgres:16 (incl. the new pg begin/confirm/reset round-trip with the ceremony column).
  • 9 integration tests incl. the adversarial pair (hijacked session never sees the secret and can't confirm even with a correct code; a restarted enrollment invalidates the old ceremony) and the oracle test (5 wrong guesses → even the correct password refused, no pending row).
  • Live smoke: full enrollment through the running binary with the confirmation code computed independently in python (RFC 6238 by hand — proving authenticator interop); secret verified absent-in-cleartext in the DB blob; parallel session without the ceremony → 409, no secret; legit confirm → 200 + 10 recovery codes; admin reset audited (mfa.enroll/mfa.reset rows).

Implemented by a Codex (gpt-5.6, high) agent; verified and hardened as above. Slice 3 (challenge + trusted-device grants) follows.

🤖 Generated with Claude Code

@milkway
milkway marked this pull request as ready for review July 18, 2026 03:50
@milkway
milkway changed the base branch from feat/1005-mfa-schema to main July 18, 2026 03:50
milkway and others added 4 commits July 18, 2026 00:50
The user side of the step-up MFA epic. The factor belongs to the user,
enrolled once at /admin/account/mfa; apps opt in via slice 1's policy
fields. Still no challenge/grants/guard (slices 3-4).

- Migration 0029 (dual-dialect): user_mfa (AES-256-GCM secret via the
  RUSCKER_MASTER_KEY, pending→confirmed states, last_used_step
  reserved for slice-3 replay prevention) + user_mfa_recovery (salted
  SHA-256 hashes — codes are high-entropy random, argon2 deliberately
  not needed; one-time transactional consumption).
- Enrollment flow: password re-auth → QR (inline SVG, no CDN) + manual
  base32 key → first valid code confirms (SHA-1/6/30s, ±1 step, per
  authenticator-app compat) → 10 recovery codes shown exactly once.
  Pending secret lives encrypted in the DB (HA-safe, survives
  restarts); a wrong code keeps the same secret; a confirmed factor
  can never be overwritten without reset. Break-glass sessions cannot
  enroll (no password to re-auth). Missing master key fails closed
  (503 + hint). Confirm attempts rate-limited (5/min, 429 +
  Retry-After). Audit: mfa.enroll / mfa.reset, never the material.
- Admin: user edit page shows factor state + audited Reset 2FA;
  paginated list untouched.
- Deps (crates.io-verified): totp-rs 5.7.2 (gen_secret+otpauth, no qr
  feature) + qrcode 0.14.1 (svg only, no image stack).
- i18n x4; integration suite (happy path with a code computed from the
  decrypted secret, wrong password/code, rate limit, 503 without key,
  reset, break-glass, must-change pinning) + pg lifecycle round-trip.

Implemented by a Codex (gpt-5.6, high) agent. Verified: full gate
green; postgres-it green vs a real postgres:16; live smoke end-to-end
with the confirmation code computed INDEPENDENTLY in python (RFC 6238
by hand) — proving real authenticator interop; secret verified
absent-in-cleartext in the DB blob; 5x401→429; reset audited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two codex-review findings, one mechanism:

- P1: while an enrollment was pending, ANY session for the same user —
  e.g. a stolen cookie that never passed the password re-auth — could
  POST a wrong code and receive the setup re-render WITH the decrypted
  secret + QR, then finish enrollment. The pending row now carries a
  random ceremony token, mirrored in a short-lived HttpOnly
  SameSite=Strict cookie scoped to the MFA pages and set only by the
  password-reauthenticated /start. /confirm requires the matching
  cookie (constant-time compare) BEFORE any decrypt/re-render; without
  it: generic 409, zero secret material.
- P2: the confirm UPDATE matched username+pending only, so a racing
  re-start could get its REPLACEMENT secret confirmed by a code that
  proved the old one. The UPDATE is now conditional on the ceremony —
  a stale confirm affects 0 rows and 409s.

Cookie removal sets the issuing Path (the #923 lesson). Tests: the
existing suite now threads the ceremony cookie like a real browser,
plus two adversarial cases (same-user session without the cookie never
sees the secret and cannot confirm even with a correct code; a
restarted enrollment invalidates the old ceremony).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iew r2)

Codex review: /start ran an unthrottled argon2 verify per guess — a
stolen session cookie became an unlimited password oracle (and a CPU
sink). A dedicated per-username limiter (same 5/min shape as the code
limiter) now gates the re-auth: failures count, success clears, and a
tripped window refuses even the correct password with 429 +
Retry-After. Localized via the existing rate-limit message. Test: five
wrong guesses, then the CORRECT password is refused and no pending row
exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lice 2 review r3)

Codex review: allow() was a read-only check and the failure was only
recorded AFTER the awaited argon2/TOTP verification — N concurrent
requests could all pass the check first, unbounding both password
guesses and CPU-heavy verifies (TOCTOU). The limiter now exposes
try_reserve(): prune + check + count in ONE mutex critical section,
called before any await; success still clears the entry so legitimate
flows never accumulate. Applied to both the re-auth and the code
limiters; record_failure removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@milkway
milkway force-pushed the feat/1005-mfa-enroll branch from 555f3d3 to 07d3fb0 Compare July 18, 2026 03:50
@milkway
milkway merged commit 8debb45 into main Jul 18, 2026
6 checks passed
@milkway
milkway deleted the feat/1005-mfa-enroll branch July 18, 2026 03:51
milkway added a commit that referenced this pull request Jul 18, 2026
Two codex-review findings:

- P1 (pg MVCC): under READ COMMITTED the conditional INSERT…SELECT can
  still read the pre-revocation epoch while the revoker's transaction
  is uncommitted, and the revoker's DELETE snapshot then misses the new
  grant. Each grant now RECORDS the epoch it was issued under and
  evaluate() validates it against the live factor row on every read —
  issuance-time races become harmless because an old-epoch grant is
  simply never accepted. Test simulates the slipped-through case.
- P1 (migration immutability): the epoch column was added by editing
  0029 — but 0029 belongs to slice 2 (PR #1008) and may be applied
  before this slice lands; editing it would trip sqlx's applied-
  migration checksum. 0029 restored byte-for-byte (from the slice-2
  tip — first restore came from the wrong commit and dropped the
  ceremony column; caught by the unit suite); security_epoch moved to
  0030 as ALTER TABLE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
milkway added a commit that referenced this pull request Jul 18, 2026
* feat: MFA challenge + trusted-device grants (#1005 slice 3)

The proof machinery the slice-4 proxy guard will consume. No proxy
changes yet.

- Migration 0030 (dual-dialect): user_mfa_grants — opaque 32-byte
  token stored only as salt:SHA-256, bound to user + mfa_verified_at +
  expiry (30-day cap) + the factor it proved (confirmed_at binding: a
  reset/re-enrollment invalidates old grants) + a session-id hash for
  the 0-day semantics.
- Challenge at /admin/account/mfa/challenge: TOTP or recovery code →
  device grant + cookie (__ruscker_mfa_device={id}.{token}, HttpOnly,
  SameSite=Strict, base-path root so slice 4 sees it on /app/*).
  Audit mfa.verify / mfa.recovery_used. Atomic try_reserve limiter.
- Decision API mfa::evaluate → Satisfied | ChallengeRequired |
  EnrollmentRequired: policy → enrollment → cookie → grant → constant-
  time token compare → expiry → factor binding → 0-day session binding
  or proof-age vs the app's window. One proof serves all apps, each
  comparing the age against its own effective_mfa_validity_days().
- TOTP replay prevention: the accepted step (current/±1, derived
  explicitly) is recorded via a conditional last_used_step update —
  covers challenge AND enrollment confirm.
- Revocations: password set/change and MFA reset delete grants inside
  their existing transactions; user deletion cascades; explicit
  "forget this device" / "sign out all devices" buttons audit
  mfa.trusted_device.revoke. Normal logout keeps the device.

Implemented by a Codex (gpt-5.6, high) agent. Verified: full gate
green; postgres-it green vs real postgres:16 (grants round-trip); live
smoke — challenge with a python-computed step+1 code (the enrollment
consumed the current step; replay guard forces strictly-greater),
grant row hash-only, same-code replay 401, password change revoked the
grant, audit trail correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: strip MFA cookies from proxied requests + honest revoke-all label (#1005 slice 3 review)

Two codex-review findings:

- P1: the trusted-device grant cookie is root-scoped (slice 4 needs it
  on /app/*), so browsers send it on every proxied request — and the
  #258 cookie filter didn't know it: the MFA BEARER would have reached
  app containers, letting a compromised app capture it and bypass MFA
  once it also had the password. is_ruscker_cookie now strips the
  whole __ruscker_mfa_ prefix (grant + ceremony) on both the HTTP path
  and the WS handshake. Proxy test: app cookies pass, MFA cookies
  never reach the upstream.
- P2: "Sign out of all devices" only revokes trusted-device MFA
  grants, not login sessions — relabeled ×4 to "Forget all devices"
  with a confirm text that says exactly that (sessions stay active).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: security epoch closes the revocation/challenge race (#1005 slice 3 review r2)

Codex review: a revocation (password set/change, forget-all) that
committed while a challenge was in flight deleted the grants — and the
challenge's independent INSERT then created a fresh grant AFTER,
surviving the revocation. user_mfa now carries a security_epoch bumped
inside every revocation transaction; grant issuance is an INSERT…SELECT
conditional on the epoch read BEFORE the TOTP verification, so the
revocation always wins (the in-flight insert hits 0 rows and the route
asks the user to challenge again). MFA reset deletes the factor row,
which refuses stale issuance the same way. Contract test: stale-epoch
create is refused after a racing password change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: per-grant epoch + immutable 0029 (#1005 slice 3 review r3)

Two codex-review findings:

- P1 (pg MVCC): under READ COMMITTED the conditional INSERT…SELECT can
  still read the pre-revocation epoch while the revoker's transaction
  is uncommitted, and the revoker's DELETE snapshot then misses the new
  grant. Each grant now RECORDS the epoch it was issued under and
  evaluate() validates it against the live factor row on every read —
  issuance-time races become harmless because an old-epoch grant is
  simply never accepted. Test simulates the slipped-through case.
- P1 (migration immutability): the epoch column was added by editing
  0029 — but 0029 belongs to slice 2 (PR #1008) and may be applied
  before this slice lands; editing it would trip sqlx's applied-
  migration checksum. 0029 restored byte-for-byte (from the slice-2
  tip — first restore came from the wrong commit and dropped the
  ceremony column; caught by the unit suite); security_epoch moved to
  0030 as ALTER TABLE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: transactional grant issuance — recovery rollback + cookie rotation (#1005 slice 3 review r4)

Two codex-review findings, one mechanism (db::mfa_grants::issue, one
transaction per dialect: consume recovery → retire previous grant →
epoch-conditional insert → audit):

- P2: the recovery code was consumed BEFORE grant creation — a stale-
  epoch refusal (or any later failure) burned a finite code with
  nothing issued. The spend now lives inside the issuance transaction
  and rolls back with it; the route matches the code first
  (find_recovery_candidate, no consume) and passes the id in. Test:
  stale-epoch refusal leaves the code usable.
- P2: every successful re-challenge left the browser's PREVIOUS grant
  alive while overwriting its cookie — a copied old cookie stayed a
  valid bearer until hard expiry. The issuance transaction now retires
  the cookie's old grant (scoped to the same username). Test: second
  challenge rotates the cookie and the grant count stays at one.

The separate post-issuance audit call is gone — the audit row commits
with the grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: read-order in evaluate + TOTP step inside the issue tx (#1005 slice 3 review r5)

- P1: evaluate() read the factor BEFORE the grant — a stale-epoch grant
  committing between the two reads could match the cached (pre-
  revocation) epoch and return Satisfied. The authoritative factor
  read now happens AFTER the grant fetch, so its epoch is at least as
  fresh as the grant's; the no-cookie path fetches the factor directly
  (one SELECT either way — this is slice 4's hot path).
- P2: record_used_step committed before issue() — a StaleEpoch refusal
  burned the current 30s code, locking the user out until the next
  step. The step consumption is now a conditional UPDATE inside the
  issuance transaction (new Replayed refusal), rolling back with
  everything else. Enrollment-confirm keeps its standalone step guard
  (no issuance to bundle with).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: recovery-code challenges work without the master key (#1005 slice 3 review r6)

Codex review: the blanket master-key check at the top of the challenge
handler also rejected kind=recovery with 503 — but recovery codes only
compare salted hashes; they never decrypt the TOTP secret. A node
restarted without RUSCKER_MASTER_KEY would lock out users holding
valid recovery codes: exactly the emergency path that must survive
misconfiguration. The check now lives inside the TOTP branch alone
(which does decrypt). Test: keyless state — TOTP 503s with the hint,
a valid recovery code still issues a grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: consistent lock order + exclusive grant rotation (#1005 slice 3 review r7)

Two codex-review concurrency findings on Postgres:

- P2 deadlock: a re-challenge locks user_mfa (the TOTP-step UPDATE)
  then the grant row (rotation DELETE), while every revocation locked
  grants then user_mfa (epoch bump) — opposite order, so an overlap
  could deadlock and abort the SECURITY action with a 500. All
  revocation paths (revoke_all + the silent delete_all helpers used by
  password change / MFA reset) now bump the epoch BEFORE deleting
  grants, matching issue()'s user_mfa-then-grants order.
- P2 double grant: two challenges from the same browser carry the same
  previous grant id; the first retired it, the second's DELETE hit 0
  rows but still inserted — leaving TWO live grants for one cookie
  (overwritten bearer valid 30 days). The rotation DELETE is now
  required to affect exactly one row; a 0-row retire returns the new
  Superseded refusal (route → 409 "verified in another tab"). Test:
  concurrent rotation of the same grant leaves exactly one live grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: one grant per browser-session via UPSERT (#1005 slice 3 review r8)

The r7 exclusive-rotation fix introduced a P1 lockout: after a
revocation from another browser, THIS browser keeps its device cookie
but its grant is gone, so the next valid challenge passed the stale id
to issue(), got Superseded, and — since the cookie was never cleared —
every retry looped, locking the user out. Root cause: keying rotation
off the cookie's grant id.

Redesign, keyed off the session instead:
- Migration 0030 gains UNIQUE(username, session_binding).
- issue() UPSERTs the single row for the browser-session
  (ON CONFLICT DO UPDATE id/token/factor/epoch/verified/expires). A
  re-challenge replaces its row (old cookie dies), a stale cookie
  after revocation simply issues fresh (no lockout), and two
  concurrent challenges from one browser converge to ONE row (no
  orphan). replace_grant_id and the Superseded refusal are gone.
- Lock order is now uniformly user_mfa -> grants -> recovery across
  issue()/revoke_all/delete_all/reset, closing the r8 P2 deadlock
  between a recovery challenge and an MFA reset on Postgres (recovery
  consumption moved AFTER the grant UPSERT).
- Tests: the concurrent-rotation test becomes "one grant per session +
  stale-cookie-after-revocation recovers (no lockout)"; eval/revoke
  tests use distinct bindings to model separate browsers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: enrollment establishes the initial device proof (#1005 slice 3, from slice-4 review)

The proxy guard (slice 4) redirects an unenrolled user to enroll; but
enrollment confirmation only recorded the TOTP step — it minted no
grant. The recovery page's "Continue to app" link then hit the guard,
got ChallengeRequired, and the challenge rejected the just-entered code
as a replay, forcing the user to wait a TOTP interval (or spend a
recovery code). Now confirm() issues the browser's initial device grant
(the code IS a valid proof) and sets the device cookie, so enrollment
flows straight into the app. Belongs in slice 3 (issue() lives here);
the fix is what slice 4's guard makes necessary.

- Audited mfa.verify (the device is verified, beside the mfa.enroll
  already written). Non-fatal on failure — recovery codes still render.
- The step was already consumed by enrollment, so issuance passes no
  step/recovery to consume.
- Tests: enrollment asserts the initial grant + cookie; the
  enroll-then-manual-grant tests use distinct bindings / updated counts
  to account for the initial grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: lock the factor row on every issuance + sweep expired grants (#1005 slice 3 review r9)

Two codex-review findings:

- P2 (pg recovery-path epoch race): the recovery-code path has no
  TOTP-step UPDATE, so under READ COMMITTED the epoch-conditional
  INSERT read a snapshot a racing revocation could pass — inserting an
  old-epoch (thus unusable) grant while committing the one-time
  recovery-code spend. issue() now reads+validates the factor row FIRST
  on EVERY path (Postgres: SELECT … FOR UPDATE; SQLite: a serialised
  read), turning a raced revocation into a clean StaleEpoch rollback
  that leaves the recovery code unspent.
- P2 (grant retention): session-only MFA mints a row per login and
  expired rows were only filtered at read time. issue() now sweeps the
  user's expired grants (DELETE … WHERE expires_at < now) so the table
  can't grow unboundedly. Test covers the sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: write-lock the epoch check + parent-before-child lock order (#1005 slice 3 review r10)

Two database-specific concurrency edges, both turning a rare same-user
race into a transient 500/deadlock (never a security or integrity bug):

- P2 SQLite WAL: pool.begin() is deferred, so reading the epoch took a
  read snapshot; a revocation committing before our later write would
  raise SQLITE_BUSY_SNAPSHOT (a 500) instead of a clean recheck. The
  epoch check is now a no-op conditional UPDATE that takes the WRITE
  lock up front — rows_affected==1 means the epoch held under the lock,
  0 means a raced revocation → StaleEpoch.
- P2 Postgres deadlock vs user deletion: issuance locked user_mfa
  first, but the grant INSERT's FK check needs a key-share lock on the
  parent users row, while user deletion locks users then cascades. The
  pg path now locks the parent `users` row (FOR KEY SHARE) BEFORE
  user_mfa, so both follow parent→child order.

This completes the lock-ordering hardening (user→user_mfa→grants→
recovery, parent before child) across issue()/revoke/reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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