Prevent TOTP code replay within the drift window

verify_totp called ROTP without `after:`, so a captured 6-digit code stayed
valid for the full ~90s drift window and could be replayed in a separate
sign-in. Add a last_otp_at column, pass it as ROTP's `after:`, and persist the
matched timestep on success so a code (or any earlier one) cannot be reused.

Also fixes a latent bug surfaced by the new replay path: enable_totp! did
`self.backup_codes = generate_backup_codes`, reassigning backup_codes to the
plaintext return value (generate_backup_codes already stores the BCrypt hashes
internally). That stored backup codes in plaintext and broke verification.
enable_totp! is test-only today, but it is public and backup_codes is not
encrypted, so this is a real footgun. Now it just calls generate_backup_codes.

Rewrites the mislabeled "TOTP code cannot be reused" test to actually assert
that replaying an accepted code is rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-11 08:10:34 +10:00
parent 84ed462f40
commit f38ac2ecc8
4 changed files with 29 additions and 7 deletions

View File

@@ -63,7 +63,10 @@ class User < ApplicationRecord
def enable_totp!
require "rotp"
self.totp_secret = ROTP::Base32.random
self.backup_codes = generate_backup_codes
# generate_backup_codes assigns the BCrypt hashes to self.backup_codes and
# returns the plaintext codes for display. Do NOT reassign backup_codes to the
# return value — that would store the plaintext codes and break verification.
generate_backup_codes
save!
end
@@ -86,7 +89,13 @@ class User < ApplicationRecord
require "rotp"
totp = ROTP::TOTP.new(totp_secret)
totp.verify(code, drift_behind: 30, drift_ahead: 30)
# Pass `after:` so a code can only be accepted once: ROTP rejects any timestep
# at or before the last accepted one, closing the ~90s drift-window replay.
verified_at = totp.verify(code, drift_behind: 30, drift_ahead: 30, after: last_otp_at)
return false unless verified_at
update_column(:last_otp_at, verified_at)
true
end
# Console/debug helper: get current TOTP code