TOTP second factor — §164.312(d)

Featurekamo-shared-library
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.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing