Files
fizzy/lib/action_pack/web_authn/authenticator/response.rb
T
Stanko K.R. 1983014be6 Rely solely on the challange signature for verification
The cookie approach seems like the more secure aproach because it ties the authentication or registration attempt to the user's browser session, but it doesn't work reliably on Chrome for Windows. Also, a simila problem pops up on Chrome for Linux if the session is used instead of a separate cookie. It looks like the browser doesn't propagate the state change through fast enough which results in some requests contaiining the new/updated cookie, and others don't, which results in sporadic failures. Since we use a signed and expiring challange we still get protection from replay attacks and tampering which enables us to omit the cookie entierly and rely on the challange's signature to prove expiration and authenticity. The only thing we lose is the ability to tie and attemp to a single browser session.

See: https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-challengeURL which proposes to add the same challange fetching logic as part of the standard

See: https://github.com/w3c/webauthn/issues/1856 which discusses issues that arise from having expiring challanges (which the spec recommends)

See: https://github.com/OneUptime/oneuptime/security/advisories/GHSA-gjjc-pcwp-c74m which is an explot that can happen if the server isn't able to verify the authenticity of challanges that are sent outside of a cookie
2026-03-25 17:26:49 +01:00

138 lines
4.1 KiB
Ruby

# = 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