Fix F-01: TOTP enroll must not set required=1 early

- db/accounts.go: add StorePendingTOTP() which writes
  totp_secret_enc and totp_secret_nonce but leaves
  totp_required=0; add comment explaining two-phase flow
- server.go (handleTOTPEnroll): switch from SetTOTP() to
  StorePendingTOTP() so the required flag is only set after
  the user confirms a valid TOTP code via handleTOTPConfirm,
  which still calls SetTOTP()
- server_test.go: TestTOTPEnrollDoesNotRequireTOTP verifies
  that after POST /v1/auth/totp/enroll, TOTPRequired is false
  and the encrypted secret is present; confirms that a
  subsequent login without a TOTP code still succeeds (no
  lockout)
- AUDIT.md: mark F-01 and F-11 as fixed
Security: without this fix an admin who enrolls TOTP but
  abandons before confirmation is permanently locked out
  because totp_required=1 but no confirmed secret exists.
  StorePendingTOTP() keeps the secret pending until the user
  proves possession by confirming a valid code.
This commit is contained in:
2026-03-11 20:18:57 -07:00
parent 4da39475cc
commit 462f706f73
3 changed files with 125 additions and 39 deletions

View File

@@ -656,9 +656,11 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
return
}
// Store the encrypted pending secret. The totp_required flag is NOT set
// yet — it is set only after the user confirms the code.
if err := s.db.SetTOTP(acct.ID, secretEnc, secretNonce); err != nil {
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required
// remains 0 until the user proves possession of the secret by confirming
// a valid code. If the user abandons enrollment the flag stays unset and
// they can still log in with just their password — no lockout.
if err := s.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}