fix(2fa): accept the previous TOTP step and consume every code #418
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/BUNYIP-428-totp-step-window-and-single-use"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
A TOTP code read near the end of a 30s step was rejected:
check_codeverified withskew = 0, so only the step current at the instant the request reached the server was accepted. The real tolerance was not "30 seconds" but "whatever is left of the current step", which averages 15s and is routinely under 2s once typing and network latency are counted; the reported HAR shows a submission 0.321s past a boundary failing while the next code succeeded. Separately, no verification path recorded that a code had been consumed, so an accepted code stayed valid for the rest of its step and could complete a second login after logout (RFC 6238 section 5.2).check_codenow matches the current step and the immediately preceding one, backward only, and returns the matched step. The tolerance is applied outsideTOTP::newbecause totp_rs'sskewis symmetric and would also accept a FUTURE code, letting a client with a fast clock authenticate early. A new migration addslast_used_step BIGINTtouser_totp, and every accepted code claims its step through a guarded conditional UPDATE that only fires when the step is strictly newer than the last consumed one; zero rows updated is returned as a plain verification failure, so a replay is indistinguishable from a wrong code (same status, same message, same per-account failure counter) and a concurrent double-submit has exactly one winner. The claim lives in the service, so it covers the login challenge, setup confirm, re-key confirm and every sensitive-op step-up without touching a handler.Widening the window to two live codes takes the per-guess probability from 1e-6 to 2e-6, still negligible against the unchanged 5-failures-per-900s per-account cap, and single use strictly reduces the surface relative to today, where an observed code stayed replayable for the rest of its step.
Unit tests cover previous-step acceptance, two-steps-ago and future-step rejection, and replay at the same step and across the accepted pair. A new env-gated integration test proves the real SQL guard against Postgres, including the concurrent case.
e2e/lib/login.tsdrops the "sleep when under 5s remain" workaround and instead resubmits once after waiting out the step, which is what a single-use collision between parallel workers on the shared E2E account now looks like.#BUNYIP-428