Implement WebAuthn protocol
- Add a CBOR decoder - Add public key credential options - Add COSE key decoder - Add authenticator attestation and assertion - Add credentials - Add exclude_credentials
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# = 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.validate!(
|
||||
# challenge: session[:webauthn_challenge],
|
||||
# origin: "https://example.com",
|
||||
# user_verification: :required
|
||||
# )
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::Response
|
||||
# Raised when response validation fails.
|
||||
class InvalidResponseError < StandardError; end
|
||||
|
||||
attr_reader :client_data_json
|
||||
|
||||
def initialize(client_data_json:)
|
||||
@client_data_json = client_data_json
|
||||
end
|
||||
|
||||
def valid?(**args)
|
||||
validate!(**args)
|
||||
true
|
||||
rescue InvalidResponseError
|
||||
false
|
||||
end
|
||||
|
||||
def validate!(challenge:, origin:, user_verification: :preferred)
|
||||
unless challenge_matches?(challenge)
|
||||
raise InvalidResponseError, "Challenge does not match"
|
||||
end
|
||||
|
||||
unless origin_matches?(origin)
|
||||
raise InvalidResponseError, "Origin does not match"
|
||||
end
|
||||
|
||||
if cross_origin?
|
||||
raise InvalidResponseError, "Cross-origin requests are not supported"
|
||||
end
|
||||
|
||||
if token_binding_present?
|
||||
raise InvalidResponseError, "Token binding is not supported"
|
||||
end
|
||||
|
||||
unless relying_party_id_matches?
|
||||
raise InvalidResponseError, "Relying party ID does not match"
|
||||
end
|
||||
|
||||
unless user_present?
|
||||
raise InvalidResponseError, "User presence is required"
|
||||
end
|
||||
|
||||
if user_verification == :required && !user_verified?
|
||||
raise InvalidResponseError, "User verification is required"
|
||||
end
|
||||
end
|
||||
|
||||
def relying_party
|
||||
ActionPack::WebAuthn.relying_party
|
||||
end
|
||||
|
||||
def client_data
|
||||
@client_data ||= JSON.parse(client_data_json)
|
||||
end
|
||||
|
||||
def authenticator_data
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
def challenge_matches?(expected_challenge)
|
||||
ActiveSupport::SecurityUtils.secure_compare(expected_challenge, client_data["challenge"])
|
||||
end
|
||||
|
||||
def origin_matches?(expected_origin)
|
||||
ActiveSupport::SecurityUtils.secure_compare(expected_origin, client_data["origin"])
|
||||
end
|
||||
|
||||
def relying_party_id_matches?
|
||||
ActiveSupport::SecurityUtils.secure_compare(
|
||||
Digest::SHA256.digest(relying_party.id),
|
||||
authenticator_data&.relying_party_id_hash || ""
|
||||
)
|
||||
end
|
||||
|
||||
def cross_origin?
|
||||
client_data["crossOrigin"] == true
|
||||
end
|
||||
|
||||
def token_binding_present?
|
||||
client_data.dig("tokenBinding", "status") == "present"
|
||||
end
|
||||
|
||||
def user_present?
|
||||
authenticator_data&.user_present?
|
||||
end
|
||||
|
||||
def user_verified?
|
||||
authenticator_data&.user_verified?
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user