Skip to content

SCRUM-208 — admin-auth TOTP MFA

Plan ref: AA-9 (docs/11-admin-plane-plan.md), decision D5. Stacked on SCRUM-209.

What exists

Piece Notes
Key file ADMIN_AUTH_TOTP_KEY_PATH (required for serve): 32 random bytes, base64. admin-auth genkey --totp <path> writes one (0600, refuses to overwrite). Secrets are sealed with AES-256-GCM, random nonce, AAD = user id
TOTP RFC 6238 (SHA-1, 30 s, 6 digits, ±1 step), standard library only; replay-proof: a code is accepted only if its step is newer than staff_user.totp_last_step (atomic update)
Policy required for admin, optional for others, root exempt (never challenged, cannot enroll)
Login confirmed factor → {mfa_required, mfa_ticket} with no tokens/cookie; admin without factor → {mfa_enrollment_required, mfa_ticket}
Tickets (mfa_ticket, migration 00003) sha256 stored, 5 min, single use, 5 attempts claimed atomically before any code is checked; consuming a ticket must update exactly one row
POST /admin-auth/mfa/verify TOTP or recovery code → login success body + cookie; audit login.success with {"mfa":…}, mfa.recovery_used
POST /admin-auth/mfa/enroll / confirm by enroll ticket or bearer; confirm stores the factor, issues 10 recovery codes (xxxx-xxxx, sha256 stored), audit mfa.enroll; with a ticket it ends signed in
Metrics staff_mfa_total{result}; staff_login_total gains mfa_required, mfa_enrollment_required
Contract docs/06-auth-identity-contract.md §13.1 updated with enroll/confirm and the enrollment answer

How to verify

cd services/admin_auth
gofmt -l . && go vet ./...
ADMIN_AUTH_TEST_DATABASE_URL=postgres://auth_rw:pw@127.0.0.1:5433/admin_auth_test?sslmode=disable \
  go test -race -count=1 ./...
go run . genkey --totp /tmp/totp.key && ls -l /tmp/totp.key   # -rw-------

Tests: RFC 6238 vectors, window, seal/open and wrong-AAD; root with a factor still gets tokens; challenge carries no Set-Cookie; admin forced to enroll; viewer not; verify issues a session; replayed TOTP refused; recovery code works once; 5 wrong codes exhaust a ticket and even the right code then fails; 20 parallel wrong guesses → exactly 5 counted; expired and enroll-purpose tickets refused; disabled meanwhile → refused; full enroll → confirm → signed in; enroll by bearer; root 403; already enabled 409; secrets sealed per user; metrics; key-file boot checks.

Results at time of writing

  • gofmt, go vet, go test -race with the DB (9 packages): pass.

How it was built

DeepSeek run scoped (Landlock) to services/admin_auth (570 s, ~87k output tokens, reasoning effort low). Claude review: crypto checked line by line; fixed a race where the ticket was read, the code checked, and only then the failure counted / the ticket marked used, so concurrent requests could exceed the 5-attempt limit and two could consume one ticket. Attempts are now claimed in one conditional UPDATE and consumption requires exactly one row; a concurrency regression test was added.

Deploy note: SCRUM-242 must generate the key file and mount it (the service will not start without it).