# = Action Pack WebAuthn Authenticator Response # # Abstract base class for WebAuthn authenticator responses. Provides common # validation logic for both registration (attestation) and authentication # (assertion) ceremonies. # # This class should not be instantiated directly. Use AttestationResponse for # registration or AssertionResponse for authentication. # # == Validation # # The +validate!+ method performs security checks required by the WebAuthn # specification: # # * Challenge verification - ensures the response matches the server-generated challenge # * Origin verification - ensures the response comes from the expected origin # * User verification - optionally requires biometric or PIN verification # # == Example # # response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new( # client_data_json: client_data_json, # authenticator_data: authenticator_data, # signature: signature, # credential: credential, # origin: "https://example.com", # user_verification: :required # ) # # response.validate! # class ActionPack::WebAuthn::Authenticator::Response include ActiveModel::Validations attr_reader :client_data_json attr_accessor :origin, :user_verification validate :challenge_must_be_present validate :challenge_must_not_be_expired validate :origin_must_match validate :must_not_be_cross_origin validate :must_not_have_token_binding validate :relying_party_id_must_match validate :user_must_be_present validate :user_must_be_verified_when_required def initialize(client_data_json:, origin: nil, user_verification: :preferred) @client_data_json = client_data_json @origin = origin @user_verification = user_verification.to_sym end def validate! super rescue ActiveModel::ValidationError raise ActionPack::WebAuthn::InvalidResponseError, errors.full_messages.join(", ") end # Returns the RelyingParty used for RP ID validation. def relying_party ActionPack::WebAuthn.relying_party end # Parses the client data JSON string into a Hash. Raises # +InvalidResponseError+ if the JSON is malformed. def client_data @client_data ||= JSON.parse(client_data_json) rescue JSON::ParserError raise ActionPack::WebAuthn::InvalidResponseError, "Client data is not valid JSON" end def authenticator_data nil end private def challenge_must_be_present if client_data["challenge"].blank? errors.add(:base, "Challenge missing") end end def challenge_must_not_be_expired return if errors.any? signed_message = Base64.urlsafe_decode64(client_data["challenge"]) unless ActionPack::WebAuthn.challenge_verifier.verified(signed_message) errors.add(:base, "Challenge has expired") end rescue ArgumentError errors.add(:base, "Challenge is invalid") end def origin_must_match if origin.blank? errors.add(:base, "Origin missing") elsif client_data["origin"].blank? errors.add(:base, "Origin missing in client data") elsif !ActiveSupport::SecurityUtils.secure_compare(origin.to_s, client_data["origin"].to_s) errors.add(:base, "Origin does not match") end end def must_not_be_cross_origin if client_data["crossOrigin"] == true errors.add(:base, "Cross-origin requests are not supported") end end def must_not_have_token_binding if client_data.dig("tokenBinding", "status") == "present" errors.add(:base, "Token binding is not supported") end end def relying_party_id_must_match unless ActiveSupport::SecurityUtils.secure_compare( Digest::SHA256.digest(relying_party.id), authenticator_data&.relying_party_id_hash || "" ) errors.add(:base, "Relying party ID does not match") end end def user_must_be_present unless authenticator_data&.user_present? errors.add(:base, "User presence is required") end end def user_must_be_verified_when_required if user_verification == :required && !authenticator_data&.user_verified? errors.add(:base, "User verification is required") end end end