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
+20
View File
@@ -466,6 +466,26 @@ func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled
return err
}
// ConsumeTOTPStep records a just-accepted TOTP step and reports whether it was
// fresh. It answers false when the step has already been spent — the same code, or
// an earlier one, being presented a second time.
//
// The guard is in the statement, not in a read-then-write: two logins racing with
// the same stolen code must not both find the old value and both proceed. The row
// moves forward only when the new step is strictly greater, so exactly one of them
// updates a row and the other sees nothing change.
func (db *DB) ConsumeTOTPStep(ctx context.Context, userID string, step int64) (bool, error) {
res, err := db.ExecContext(ctx,
`UPDATE users SET totp_last_step = ? WHERE id = ?
AND (totp_last_step IS NULL OR totp_last_step < ?)`,
step, userID, step)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// InboxID returns the user's Inbox project.
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
var id string