Files
clinch/app/models/webauthn_credential.rb
Dan Milne 44892e3301 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>
2026-06-11 20:28:38 +10:00

108 lines
3.2 KiB
Ruby

class WebauthnCredential < ApplicationRecord
belongs_to :user
# Set default authenticator_type if not provided
after_initialize :set_default_authenticator_type, if: :new_record?
# Validations
validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true
validates :sign_count, presence: true, numericality: {greater_than_or_equal_to: 0, only_integer: true}
validates :nickname, presence: true
validates :authenticator_type, inclusion: {in: %w[platform cross-platform]}
# Scopes for querying
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
scope :platform_authenticators, -> { where(authenticator_type: "platform") }
scope :roaming_authenticators, -> { where(authenticator_type: "cross-platform") }
scope :recently_used, -> { where.not(last_used_at: nil).order(last_used_at: :desc) }
scope :never_used, -> { where(last_used_at: nil) }
# Update last used timestamp and sign count after successful authentication
def update_usage!(sign_count:, ip_address: nil, user_agent: nil)
update!(
last_used_at: Time.current,
last_used_ip: ip_address,
sign_count: sign_count,
user_agent: user_agent
)
end
# Check if this is a platform authenticator (built-in device)
def platform_authenticator?
authenticator_type == "platform"
end
# Check if this is a roaming authenticator (USB/NFC/Bluetooth key)
def roaming_authenticator?
authenticator_type == "cross-platform"
end
# Check if this credential is backed up (synced passkeys)
def backed_up?
backup_eligible? && backup_state?
end
# Human readable description
def description
if nickname.present?
"#{nickname} (#{authenticator_type.humanize})"
else
"#{authenticator_type.humanize} Authenticator"
end
end
# 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.zero?
new_sign_count <= sign_count
end
# Format for display in UI
def display_name
nickname || "#{authenticator_type&.humanize} Authenticator"
end
# When was this credential created?
def created_recently?
created_at > 1.week.ago
end
# How long ago was this last used?
def last_used_ago
return "Never" unless last_used_at
time_ago_in_words(last_used_at)
end
private
def set_default_authenticator_type
self.authenticator_type ||= "cross-platform"
end
def time_ago_in_words(time)
seconds = Time.current - time
minutes = seconds / 60
hours = minutes / 60
days = hours / 24
if days > 0
"#{days.floor} day#{"s" if days > 1} ago"
elsif hours > 0
"#{hours.floor} hour#{"s" if hours > 1} ago"
elsif minutes > 0
"#{minutes.floor} minute#{"s" if minutes > 1} ago"
else
"Just now"
end
end
end