Skip to content

MFA recovery codes are case-sensitive at redemption, no hint to the user #14

Closed

Description

@jjbaumert

Summary: Recovery codes are minted uppercase (crates/auth/src/mfa.rs::one_recovery_code) and verified via bcrypt::verify(code, hash) (crates/auth/src/mfa.rs:194-195) over the raw presented string — no case-normalization before hashing or verifying. A user who types their recovery code in lowercase (plausible if they wrote it down and re-typed it, rather than copy-pasting) gets a generic "invalid code" rejection with no indication that case is the problem.

Severity: S3 — user-facing degraded flow with a non-obvious workaround (retype in the exact stored case). The impact is compounded by the redemption route (crates/api/src/routes/mfa.rs:492-506) sharing its failure counter with the MFA challenge endpoint (record_mfa_failure_exhausted, referenced at crates/api/src/routes/mfa.rs:503-506, see also mfa_failure_counter_is_shared_across_challenge_and_recovery at crates/api/tests/test_mfa.rs:542) — a user retrying a case-mismatched code multiple times can lock themselves out of MFA verification entirely, during account recovery, without ever being told why their otherwise-correct code didn't work.

Environment:

  • Deploy: any
  • Commit: 6411ddb
  • User / role: any user completing MFA recovery with a valid but wrong-case code

Repro steps:

  1. Enroll in MFA and obtain a recovery code (minted uppercase, format XXXXX-XXXXX).
  2. At the MFA challenge, submit the same code lowercased.
  3. Observe the redemption is rejected.
  4. Repeat several times (plausible if the user doesn't realize case matters) and observe the shared MFA-failure counter can rate-limit the account.

Expected: Either normalize case before verification (mint and compare case-insensitively), or reject with an error message that hints case sensitivity.

Observed: verify_recovery_code (crates/auth/src/mfa.rs:194-195) does a raw bcrypt compare; a lowercase-typed code fails with the same generic error as a wrong code, and repeated attempts count toward record_mfa_failure_exhausted's shared lockout threshold.

Evidence:

  • Code: crates/auth/src/mfa.rs:168-195 (generate_recovery_codes / one_recovery_code mint uppercase; verify_recovery_code does a raw bcrypt::verify)
  • Code: crates/api/src/routes/mfa.rs:492-506 (redemption route, generic failure path, shared lockout counter)
  • Existing test: recovery_code_bcrypt_roundtrip (crates/auth/src/mfa.rs:300-304) only asserts the exact-case roundtrip and an unrelated wrong-code rejection; it does not cover a same-code-different-case input.

Suspected area: crates/auth/src/mfa.rs::verify_recovery_code (normalize case before compare — the simplest fix, since codes are alphanumeric and minted uppercase already) and/or crates/api/src/routes/mfa.rs (redemption error message).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingverification-findingFinding from a verification run

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions