1983014be6
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
138 lines
4.1 KiB
Ruby
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
|