identity-vault/app/models/identity/webauthn_credential.rb
Mahad Kalam 5562fe2c06
WebAuthn/passkeys support (#53)
god i hope any of this works
2026-01-13 15:48:21 -05:00

73 lines
2.1 KiB
Ruby

class Identity::WebauthnCredential < ApplicationRecord
belongs_to :identity
has_paper_trail
include PublicActivity::Model
tracked owner: proc { |controller, record| record.identity }, recipient: proc { |controller, record| record.identity }, only: [ :create, :destroy ]
scope :active, -> { where(compromised_at: nil) }
scope :compromised, -> { where.not(compromised_at: nil) }
validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true
validates :sign_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :nickname, length: { maximum: 50 }, allow_blank: true
before_validation :set_initial_sign_count, on: :create
# WebAuthn credential IDs and public keys are binary data that need to be
# base64url encoded for storage and transmission
def webauthn_id
Base64.urlsafe_decode64(external_id)
end
def webauthn_id=(value)
self.external_id = Base64.urlsafe_encode64(value, padding: false)
end
def webauthn_public_key
Base64.urlsafe_decode64(public_key)
end
def webauthn_public_key=(value)
self.public_key = Base64.urlsafe_encode64(value, padding: false)
end
# Increment the sign count after successful authentication
# This helps detect credential cloning attacks
def increment_sign_count!
increment!(:sign_count)
end
# Mark credential as potentially compromised (e.g., sign count anomaly)
def mark_as_compromised!
update!(compromised_at: Time.current)
Rails.logger.warn "WebAuthn credential marked as compromised: id=#{id}, identity_id=#{identity_id}"
end
def compromised?
compromised_at.present?
end
def active?
!compromised?
end
# Human-readable display for the credential
def display_name
nickname.presence || "Passkey created #{created_at.strftime('%b %d, %Y')}"
end
# Class method to get all decoded credential IDs for a collection
# Useful for building WebAuthn allow/exclude lists
def self.raw_credential_ids
pluck(:external_id).map { |id| Base64.urlsafe_decode64(id) }
end
private
def set_initial_sign_count
self.sign_count ||= 0
end
end