- Shipped
- August 3, 2026 at 4:13 AM UTC
- Author
- Kamo
- Commit
- 5afe5d3
The compliance assessment called the absence of any second factor disqualifying on its own, and under the 2025 Security Rule NPRM MFA stops being addressable at all. Single-factor username+password was the only interactive authentication path on the platform. TOTP is verified against the RFC 6238 Appendix B test vectors rather than against values this implementation produced. That distinction is the whole ballgame: a home-grown TOTP that agrees only with itself authenticates nobody's Google Authenticator, and the failure surfaces at rollout with every user locked out. Writing those tests immediately caught that I had passed unix seconds where the HMAC wants a counter — off by a factor of thirty — so generateAt now does the division and no caller has to remember it. Properties that separate real MFA from the appearance of it, each pinned by a test: - Codes are single-use. A TOTP stays valid for its entire 30-second step, so without lastUsedTimeStep an intercepted or shoulder-surfed code works again inside its own window. An earlier step still inside the tolerance is refused too, once a later one has been accepted. - Unconfirmed enrollments do not count. Beginning enrollment and abandoning it before proving possession must not satisfy an MFA requirement. - Lockout is real: a correct code is refused while locked, otherwise the lockout is decorative. - beginEnrollment refuses to replace a confirmed factor. Silently overwriting it would let anyone reaching the endpoint swap the victim's authenticator for their own. - The secret is encrypted at rest and returned exactly once; recovery codes are hashed and single-use. Neither is recoverable by staff, so a lost device means re-enrollment — the correct trade for a credential that bypasses the password. - Verification compares in constant time. String.equals returns on the first differing digit, which leaks how many leading digits were right and collapses a 10^6 space into roughly 60 guesses. Recovery codes are SHA-256 rather than bcrypt deliberately: 80 bits of machine-generated entropy has no dictionary to stretch against, and a slow hash there would only buy a denial-of-service on the login path. Tables already exist in the cluster (KamoInitializer c65a233), so nothing is armed by this push. Enrollment and challenge endpoints are the next step; nothing calls this yet.