feat(security): a TOTP code can be used once

RFC 6238 says the verifier must reject a code it has already accepted: a code
stays valid for its whole thirty-second window, so one seen in flight — a
phishing page, a proxy, over the shoulder — could be replayed until it expired.

Tasks now record the counter of the last accepted step (totp_last_step), and a
login accepts a code only when its step is strictly greater. The guard is in the
UPDATE, not a read-then-write, so two logins racing with the same stolen code
cannot both find the old value and both proceed — exactly one moves the row.

auth.TOTPStep is VerifyTOTP that also reports which step matched, walking the
same one-step window; the code entered a little early or late spends the step it
was issued at, not the step it was checked at, so a late-but-valid code is still
single-use. Verified end to end: the same code presented to two logins in one
window is accepted once and refused the second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-08-23 23:34:34 +02:00
co-authored by Claude Opus 5
parent b4038356f0
commit 77994eff09
6 changed files with 161 additions and 1 deletions
+33
View File
@@ -265,6 +265,39 @@ func TestRecoveryCodes(t *testing.T) {
}
}
// TOTPStep returns the step a code matched, and it must be the same step across
// the tolerance window — that number is what makes a code single-use, so a code
// entered a little early or late has to spend the same step, not a new one.
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
secret, _, err := NewTOTPSecret("verdande", "k@example.com")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
code := codeAt(t, secret, now)
want, err := TOTPStep(secret, code, now)
if err != nil {
t.Fatalf("the current code did not match: %v", err)
}
// Verified one step late — the code is still valid, and must report the step it
// was issued at, not the step it was checked at.
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
if err != nil {
t.Fatalf("code rejected one step late: %v", err)
}
if got != want {
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
}
if _, err := TOTPStep(secret, "000000", now); err == nil {
t.Error("a wrong code returned a step")
}
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
}
}
// codeAt produces the code an authenticator app would display at that moment,
// using the same settings VerifyTOTP checks against.
func codeAt(t *testing.T, secret string, now time.Time) string {