Make WebAuthn clone detection actually block, and fix false positives

Two problems with sign-count clone detection:

- suspicious_sign_count? flagged the case where both the stored and presented
  counts are 0. Most synced passkeys (Apple/Google) report 0 every time, so every
  legitimate sign-in was flagged — drowning real signals in noise. Per WebAuthn
  §6.1.1 a 0 counter means "no counter"; only flag when BOTH counts are non-zero
  and the new one does not advance.

- On a suspicious count the controller only logged a warning and then continued
  to authenticate and overwrite the stored counter. A cloned credential therefore
  worked indefinitely. webauthn_verify now rejects the sign-in (no session, no
  counter update) and emails the user via a new SecurityMailer#suspicious_passkey_used.

Tests cover the corrected classification (synced/first-use/normal vs equal/
decreasing) and the new alert email.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-11 20:28:38 +10:00
parent 24266872f9
commit 44892e3301
7 changed files with 87 additions and 8 deletions

View File

@@ -52,13 +52,17 @@ class WebauthnCredential < ApplicationRecord
end
end
# Check if sign count is suspicious (clone detection)
# Check if sign count is suspicious (clone detection).
#
# Per WebAuthn §6.1.1, a signature counter of 0 means the authenticator does
# not implement a counter (true of most synced passkeys — Apple/Google report
# 0 every time), so it cannot be used for clone detection. Only when BOTH the
# stored and presented counts are non-zero does a non-increasing value signal
# a possible clone.
def suspicious_sign_count?(new_sign_count)
return false if sign_count.zero? && new_sign_count > 0 # First use
return false if new_sign_count > sign_count # Normal increment
return false if sign_count.zero? || new_sign_count.zero?
# Sign count didn't increase - possible clone
true
new_sign_count <= sign_count
end
# Format for display in UI