From 23f3be4e228d5889ee547bcd2797911d71e15d72 Mon Sep 17 00:00:00 2001 From: padulafacundo Date: Sat, 27 Jun 2020 17:43:25 -0300 Subject: [PATCH 1/3] Support multiple relying parties (#296) * WIP: Relying Party model * fixup! WIP: Relying Party model * refactor: make `WebAuthn.configuration` a wrapper for a `RelyingParty` instance * fixup! refactor: make `WebAuthn.configuration` a wrapper for a `RelyingParty` instance * refactor: delegate Configuration methods * refactor: always pass a RelyingParty * use relying party on when creating options * refactor: remove unnecessary aliases * fix: make reader private * refactor: make parent class AuthenticatorResponse responsible for relying party * fix: post rebase fixes * test: get rid of all keyword 2.7 warnings * fix: rebase trailing diff * fix: make rubocop happy * fix: make AttestationObject#deserialize accept relying_party param * refactor: algorithm validation uses relying party configuration * fix: make RP initializer use finders setter to process params correctly * fix: cache store should recieve origin from server * refactor: CR comments on API flexibility * refactor: CR comments * test: add functional comprehensive test for multiple RPs and a default configuration * refactor: Allow #verify_authentication to be used with or without a block * style: fix rubocop Co-authored-by: Braulio Martinez --- lib/webauthn/attestation_object.rb | 14 +- lib/webauthn/attestation_statement.rb | 4 +- lib/webauthn/attestation_statement/base.rb | 17 +- .../authenticator_assertion_response.rb | 7 +- .../authenticator_attestation_response.rb | 17 +- .../attested_credential_data.rb | 14 +- lib/webauthn/authenticator_response.rb | 11 +- lib/webauthn/configuration.rb | 74 ++-- lib/webauthn/credential.rb | 9 +- lib/webauthn/fake_client.rb | 4 +- lib/webauthn/public_key_credential.rb | 23 +- .../public_key_credential/creation_options.rb | 6 +- lib/webauthn/public_key_credential/options.rb | 15 +- .../public_key_credential/request_options.rb | 2 +- lib/webauthn/relying_party.rb | 117 ++++++ spec/conformance/conformance_cache_store.rb | 4 +- spec/conformance/server.rb | 46 +-- spec/spec_helper.rb | 27 +- ...authenticator_attestation_response_spec.rb | 2 +- spec/webauthn/relying_party_spec.rb | 342 ++++++++++++++++++ 20 files changed, 625 insertions(+), 130 deletions(-) create mode 100644 lib/webauthn/relying_party.rb create mode 100644 spec/webauthn/relying_party_spec.rb diff --git a/lib/webauthn/attestation_object.rb b/lib/webauthn/attestation_object.rb index 63cf93a6..c0f3af1f 100644 --- a/lib/webauthn/attestation_object.rb +++ b/lib/webauthn/attestation_object.rb @@ -10,18 +10,22 @@ module WebAuthn class AttestationObject extend Forwardable - def self.deserialize(attestation_object) - from_map(CBOR.decode(attestation_object)) + def self.deserialize(attestation_object, relying_party) + from_map(CBOR.decode(attestation_object), relying_party) end - def self.from_map(map) + def self.from_map(map, relying_party) new( authenticator_data: WebAuthn::AuthenticatorData.deserialize(map["authData"]), - attestation_statement: WebAuthn::AttestationStatement.from(map["fmt"], map["attStmt"]) + attestation_statement: WebAuthn::AttestationStatement.from( + map["fmt"], + map["attStmt"], + relying_party: relying_party + ) ) end - attr_reader :authenticator_data, :attestation_statement + attr_reader :authenticator_data, :attestation_statement, :relying_party def initialize(authenticator_data:, attestation_statement:) @authenticator_data = authenticator_data diff --git a/lib/webauthn/attestation_statement.rb b/lib/webauthn/attestation_statement.rb index bfd13cf2..7974af6c 100644 --- a/lib/webauthn/attestation_statement.rb +++ b/lib/webauthn/attestation_statement.rb @@ -31,11 +31,11 @@ class FormatNotSupportedError < Error; end ATTESTATION_FORMAT_APPLE => WebAuthn::AttestationStatement::Apple }.freeze - def self.from(format, statement) + def self.from(format, statement, relying_party: WebAuthn.configuration.relying_party) klass = FORMAT_TO_CLASS[format] if klass - klass.new(statement) + klass.new(statement, relying_party) else raise(FormatNotSupportedError, "Unsupported attestation format '#{format}'") end diff --git a/lib/webauthn/attestation_statement/base.rb b/lib/webauthn/attestation_statement/base.rb index bbf0bd1a..5f0c689f 100644 --- a/lib/webauthn/attestation_statement/base.rb +++ b/lib/webauthn/attestation_statement/base.rb @@ -28,8 +28,9 @@ class UnsupportedAlgorithm < Error; end class Base AAGUID_EXTENSION_OID = "1.3.6.1.4.1.45724.1.1.4" - def initialize(statement) + def initialize(statement, relying_party = WebAuthn.configuration.relying_party) @statement = statement + @relying_party = relying_party end def valid?(_authenticator_data, _client_data_hash) @@ -50,7 +51,7 @@ def attestation_certificate_key_id private - attr_reader :statement + attr_reader :statement, :relying_party def matching_aaguid?(attested_credential_data_aaguid) extension = attestation_certificate&.find_extension(AAGUID_EXTENSION_OID) @@ -93,10 +94,10 @@ def attestation_trust_path def trustworthy?(aaguid: nil, attestation_certificate_key_id: nil) if ATTESTATION_TYPES_WITH_ROOT.include?(attestation_type) - configuration.acceptable_attestation_types.include?(attestation_type) && + relying_party.acceptable_attestation_types.include?(attestation_type) && valid_certificate_chain?(aaguid: aaguid, attestation_certificate_key_id: attestation_certificate_key_id) else - configuration.acceptable_attestation_types.include?(attestation_type) + relying_party.acceptable_attestation_types.include?(attestation_type) end end @@ -120,7 +121,7 @@ def attestation_root_certificates_store(aaguid: nil, attestation_certificate_key def root_certificates(aaguid: nil, attestation_certificate_key_id: nil) root_certificates = - configuration.attestation_root_certificates_finders.reduce([]) do |certs, finder| + relying_party.attestation_root_certificates_finders.reduce([]) do |certs, finder| if certs.empty? finder.find( attestation_format: format, @@ -158,14 +159,10 @@ def verification_data(authenticator_data, client_data_hash) def cose_algorithm @cose_algorithm ||= COSE::Algorithm.find(algorithm).tap do |alg| - alg && configuration.algorithms.include?(alg.name) || + alg && relying_party.algorithms.include?(alg.name) || raise(UnsupportedAlgorithm, "Unsupported algorithm #{algorithm}") end end - - def configuration - WebAuthn.configuration - end end end end diff --git a/lib/webauthn/authenticator_assertion_response.rb b/lib/webauthn/authenticator_assertion_response.rb index 44450fd6..1511117c 100644 --- a/lib/webauthn/authenticator_assertion_response.rb +++ b/lib/webauthn/authenticator_assertion_response.rb @@ -10,8 +10,8 @@ class SignatureVerificationError < VerificationError; end class SignCountVerificationError < VerificationError; end class AuthenticatorAssertionResponse < AuthenticatorResponse - def self.from_client(response) - encoder = WebAuthn.configuration.encoder + def self.from_client(response, relying_party: WebAuthn.configuration.relying_party) + encoder = relying_party.encoder user_handle = if response["userHandle"] @@ -22,7 +22,8 @@ def self.from_client(response) authenticator_data: encoder.decode(response["authenticatorData"]), client_data_json: encoder.decode(response["clientDataJSON"]), signature: encoder.decode(response["signature"]), - user_handle: user_handle + user_handle: user_handle, + relying_party: relying_party ) end diff --git a/lib/webauthn/authenticator_attestation_response.rb b/lib/webauthn/authenticator_attestation_response.rb index 652330e6..a5c83aaf 100644 --- a/lib/webauthn/authenticator_attestation_response.rb +++ b/lib/webauthn/authenticator_attestation_response.rb @@ -18,12 +18,13 @@ class AttestedCredentialVerificationError < VerificationError; end class AuthenticatorAttestationResponse < AuthenticatorResponse extend Forwardable - def self.from_client(response) - encoder = WebAuthn.configuration.encoder + def self.from_client(response, relying_party: WebAuthn.configuration.relying_party) + encoder = relying_party.encoder new( attestation_object: encoder.decode(response["attestationObject"]), - client_data_json: encoder.decode(response["clientDataJSON"]) + client_data_json: encoder.decode(response["clientDataJSON"]), + relying_party: relying_party ) end @@ -33,13 +34,14 @@ def initialize(attestation_object:, **options) super(**options) @attestation_object_bytes = attestation_object + @relying_party = relying_party end def verify(expected_challenge, expected_origin = nil, user_verification: nil, rp_id: nil) super verify_item(:attested_credential) - if WebAuthn.configuration.verify_attestation_statement + if relying_party.verify_attestation_statement verify_item(:attestation_statement) end @@ -47,7 +49,7 @@ def verify(expected_challenge, expected_origin = nil, user_verification: nil, rp end def attestation_object - @attestation_object ||= WebAuthn::AttestationObject.deserialize(attestation_object_bytes) + @attestation_object ||= WebAuthn::AttestationObject.deserialize(attestation_object_bytes, relying_party) end def_delegators( @@ -63,14 +65,15 @@ def attestation_object private - attr_reader :attestation_object_bytes + attr_reader :attestation_object_bytes, :relying_party def type WebAuthn::TYPES[:create] end def valid_attested_credential? - attestation_object.valid_attested_credential? + attestation_object.valid_attested_credential? && + relying_party.algorithms.include?(authenticator_data.credential.algorithm) end def valid_attestation_statement? diff --git a/lib/webauthn/authenticator_data/attested_credential_data.rb b/lib/webauthn/authenticator_data/attested_credential_data.rb index 5d31a8f1..cb93f54b 100644 --- a/lib/webauthn/authenticator_data/attested_credential_data.rb +++ b/lib/webauthn/authenticator_data/attested_credential_data.rb @@ -24,7 +24,7 @@ class AttestedCredentialData < BinData::Record # TODO: use keyword_init when we dropped Ruby 2.4 support Credential = - Struct.new(:id, :public_key) do + Struct.new(:id, :public_key, :algorithm) do def public_key_object COSE::Key.deserialize(public_key).to_pkey end @@ -47,7 +47,7 @@ def aaguid def credential @credential ||= if valid? - Credential.new(id, public_key) + Credential.new(id, public_key, algorithm) end end @@ -59,10 +59,16 @@ def length private + def algorithm + COSE::Algorithm.find(cose_key.alg).name + end + def valid_credential_public_key? - cose_key = COSE::Key.deserialize(public_key) + !!cose_key.alg + end - !!cose_key.alg && WebAuthn.configuration.algorithms.include?(COSE::Algorithm.find(cose_key.alg).name) + def cose_key + @cose_key ||= COSE::Key.deserialize(public_key) end def public_key diff --git a/lib/webauthn/authenticator_response.rb b/lib/webauthn/authenticator_response.rb index 1e5be34c..fc20996f 100644 --- a/lib/webauthn/authenticator_response.rb +++ b/lib/webauthn/authenticator_response.rb @@ -19,13 +19,14 @@ class UserPresenceVerificationError < VerificationError; end class UserVerifiedVerificationError < VerificationError; end class AuthenticatorResponse - def initialize(client_data_json:) + def initialize(client_data_json:, relying_party: WebAuthn.configuration.relying_party) @client_data_json = client_data_json + @relying_party = relying_party end def verify(expected_challenge, expected_origin = nil, user_verification: nil, rp_id: nil) - expected_origin ||= WebAuthn.configuration.origin || raise("Unspecified expected origin") - rp_id ||= WebAuthn.configuration.rp_id + expected_origin ||= relying_party.origin || raise("Unspecified expected origin") + rp_id ||= relying_party.id verify_item(:type) verify_item(:token_binding) @@ -34,7 +35,7 @@ def verify(expected_challenge, expected_origin = nil, user_verification: nil, rp verify_item(:authenticator_data) verify_item(:rp_id, rp_id || rp_id_from_origin(expected_origin)) - if !WebAuthn.configuration.silent_authentication + if !relying_party.silent_authentication verify_item(:user_presence) end @@ -57,7 +58,7 @@ def client_data private - attr_reader :client_data_json + attr_reader :client_data_json, :relying_party def verify_item(item, *args) if send("valid_#{item}?", *args) diff --git a/lib/webauthn/configuration.rb b/lib/webauthn/configuration.rb index 7858ccc7..e3048284 100644 --- a/lib/webauthn/configuration.rb +++ b/lib/webauthn/configuration.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -require "openssl" -require "webauthn/encoder" -require "webauthn/error" +require 'forwardable' +require 'webauthn/relying_party' module WebAuthn def self.configuration @@ -13,50 +12,49 @@ def self.configure yield(configuration) end - class RootCertificateFinderNotSupportedError < Error; end - class Configuration - DEFAULT_ALGORITHMS = ["ES256", "PS256", "RS256"].compact.freeze - - attr_accessor :algorithms - attr_accessor :encoding - attr_accessor :origin - attr_accessor :rp_id - attr_accessor :rp_name - attr_accessor :verify_attestation_statement - attr_accessor :credential_options_timeout - attr_accessor :silent_authentication - attr_accessor :acceptable_attestation_types - attr_reader :attestation_root_certificates_finders + extend Forwardable + + def_delegators :@relying_party, + :algorithms, + :algorithms=, + :encoding, + :encoding=, + :origin, + :origin=, + :verify_attestation_statement, + :verify_attestation_statement=, + :credential_options_timeout, + :credential_options_timeout=, + :silent_authentication, + :silent_authentication=, + :acceptable_attestation_types, + :acceptable_attestation_types=, + :attestation_root_certificates_finders, + :attestation_root_certificates_finders=, + :encoder, + :encoder= + + attr_reader :relying_party def initialize - @algorithms = DEFAULT_ALGORITHMS.dup - @encoding = WebAuthn::Encoder::STANDARD_ENCODING - @verify_attestation_statement = true - @credential_options_timeout = 120000 - @silent_authentication = false - @acceptable_attestation_types = ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA', 'AnonCA'] - @attestation_root_certificates_finders = [] + @relying_party = RelyingParty.new end - # This is the user-data encoder. - # Used to decode user input and to encode data provided to the user. - def encoder - @encoder ||= WebAuthn::Encoder.new(encoding) + def rp_name + relying_party.name end - def attestation_root_certificates_finders=(finders) - if !finders.respond_to?(:each) - finders = [finders] - end + def rp_name=(name) + relying_party.name = name + end - finders.each do |finder| - unless finder.respond_to?(:find) - raise RootCertificateFinderNotSupportedError, "Finder must implement `find` method" - end - end + def rp_id + relying_party.id + end - @attestation_root_certificates_finders = finders + def rp_id=(id) + relying_party.id = id end end end diff --git a/lib/webauthn/credential.rb b/lib/webauthn/credential.rb index 91931601..6177b80a 100644 --- a/lib/webauthn/credential.rb +++ b/lib/webauthn/credential.rb @@ -4,6 +4,7 @@ require "webauthn/public_key_credential/request_options" require "webauthn/public_key_credential_with_assertion" require "webauthn/public_key_credential_with_attestation" +require "webauthn/relying_party" module WebAuthn module Credential @@ -15,12 +16,12 @@ def self.options_for_get(**keyword_arguments) WebAuthn::PublicKeyCredential::RequestOptions.new(**keyword_arguments) end - def self.from_create(credential) - WebAuthn::PublicKeyCredentialWithAttestation.from_client(credential) + def self.from_create(credential, relying_party: WebAuthn.configuration.relying_party) + WebAuthn::PublicKeyCredentialWithAttestation.from_client(credential, relying_party: relying_party) end - def self.from_get(credential) - WebAuthn::PublicKeyCredentialWithAssertion.from_client(credential) + def self.from_get(credential, relying_party: WebAuthn.configuration.relying_party) + WebAuthn::PublicKeyCredentialWithAssertion.from_client(credential, relying_party: relying_party) end end end diff --git a/lib/webauthn/fake_client.rb b/lib/webauthn/fake_client.rb index c8efe895..6f0cdf04 100644 --- a/lib/webauthn/fake_client.rb +++ b/lib/webauthn/fake_client.rb @@ -10,7 +10,7 @@ module WebAuthn class FakeClient TYPES = { create: "webauthn.create", get: "webauthn.get" }.freeze - attr_reader :origin, :token_binding + attr_reader :origin, :token_binding, :encoding def initialize( origin = fake_origin, @@ -111,7 +111,7 @@ def get(challenge: fake_challenge, private - attr_reader :authenticator, :encoding + attr_reader :authenticator def data_json_for(method, challenge) data = { diff --git a/lib/webauthn/public_key_credential.rb b/lib/webauthn/public_key_credential.rb index d757c4e1..b33badef 100644 --- a/lib/webauthn/public_key_credential.rb +++ b/lib/webauthn/public_key_credential.rb @@ -6,22 +6,31 @@ module WebAuthn class PublicKeyCredential attr_reader :type, :id, :raw_id, :client_extension_outputs, :response - def self.from_client(credential) + def self.from_client(credential, relying_party: WebAuthn.configuration.relying_party) new( type: credential["type"], id: credential["id"], - raw_id: WebAuthn.configuration.encoder.decode(credential["rawId"]), + raw_id: relying_party.encoder.decode(credential["rawId"]), client_extension_outputs: credential["clientExtensionResults"], - response: response_class.from_client(credential["response"]) + response: response_class.from_client(credential["response"], relying_party: relying_party), + encoder: relying_party.encoder ) end - def initialize(type:, id:, raw_id:, client_extension_outputs: {}, response:) + def initialize( + type:, + id:, + raw_id:, + response:, + client_extension_outputs: {}, + encoder: WebAuthn.configuration.encoder + ) @type = type @id = id @raw_id = raw_id @client_extension_outputs = client_extension_outputs @response = response + @encoder = encoder end def verify(*_args) @@ -41,6 +50,8 @@ def authenticator_extension_outputs private + attr_reader :encoder + def valid_type? type == TYPE_PUBLIC_KEY end @@ -52,9 +63,5 @@ def valid_id? def authenticator_data response&.authenticator_data end - - def encoder - WebAuthn.configuration.encoder - end end end diff --git a/lib/webauthn/public_key_credential/creation_options.rb b/lib/webauthn/public_key_credential/creation_options.rb index 113f7ec7..74d8f6c5 100644 --- a/lib/webauthn/public_key_credential/creation_options.rb +++ b/lib/webauthn/public_key_credential/creation_options.rb @@ -39,8 +39,8 @@ def initialize( @rp = if rp.is_a?(Hash) - rp[:name] ||= configuration.rp_name - rp[:id] ||= configuration.rp_id + rp[:name] ||= relying_party.name + rp[:id] ||= relying_party.id RPEntity.new(**rp) else @@ -76,7 +76,7 @@ def exclude_credentials_from_exclude end def pub_key_cred_params_from_algs - Array(algs || configuration.algorithms).map do |alg| + Array(algs || relying_party.algorithms).map do |alg| alg_id = if alg.is_a?(String) || alg.is_a?(Symbol) COSE::Algorithm.by_name(alg.to_s).id diff --git a/lib/webauthn/public_key_credential/options.rb b/lib/webauthn/public_key_credential/options.rb index 1131c848..69586155 100644 --- a/lib/webauthn/public_key_credential/options.rb +++ b/lib/webauthn/public_key_credential/options.rb @@ -8,10 +8,11 @@ class PublicKeyCredential class Options CHALLENGE_LENGTH = 32 - attr_reader :timeout, :extensions + attr_reader :timeout, :extensions, :relying_party - def initialize(timeout: default_timeout, extensions: nil) - @timeout = timeout + def initialize(timeout: nil, extensions: nil, relying_party: WebAuthn.configuration.relying_party) + @relying_party = relying_party + @timeout = timeout || default_timeout @extensions = extensions end @@ -49,7 +50,7 @@ def attributes end def encoder - WebAuthn.configuration.encoder + relying_party.encoder end def raw_challenge @@ -57,11 +58,7 @@ def raw_challenge end def default_timeout - configuration.credential_options_timeout - end - - def configuration - WebAuthn.configuration + relying_party.credential_options_timeout end def as_public_key_descriptors(ids) diff --git a/lib/webauthn/public_key_credential/request_options.rb b/lib/webauthn/public_key_credential/request_options.rb index d202f940..0a497c21 100644 --- a/lib/webauthn/public_key_credential/request_options.rb +++ b/lib/webauthn/public_key_credential/request_options.rb @@ -10,7 +10,7 @@ class RequestOptions < Options def initialize(rp_id: nil, allow_credentials: nil, allow: nil, user_verification: nil, **keyword_arguments) super(**keyword_arguments) - @rp_id = rp_id || configuration.rp_id + @rp_id = rp_id || relying_party.id @allow_credentials = allow_credentials @allow = allow @user_verification = user_verification diff --git a/lib/webauthn/relying_party.rb b/lib/webauthn/relying_party.rb new file mode 100644 index 00000000..f1a8edf4 --- /dev/null +++ b/lib/webauthn/relying_party.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "openssl" +require "webauthn/credential" +require "webauthn/encoder" +require "webauthn/error" + +module WebAuthn + class RootCertificateFinderNotSupportedError < Error; end + + class RelyingParty + def self.if_pss_supported(algorithm) + OpenSSL::PKey::RSA.instance_methods.include?(:verify_pss) ? algorithm : nil + end + + DEFAULT_ALGORITHMS = ["ES256", "PS256", "RS256"].compact.freeze + + def initialize( + algorithms: DEFAULT_ALGORITHMS.dup, + encoding: WebAuthn::Encoder::STANDARD_ENCODING, + origin: nil, + id: nil, + name: nil, + verify_attestation_statement: true, + credential_options_timeout: 120000, + silent_authentication: false, + acceptable_attestation_types: ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA', 'AnonCA'], + attestation_root_certificates_finders: [] + ) + @algorithms = algorithms + @encoding = encoding + @origin = origin + @id = id + @name = name + @verify_attestation_statement = verify_attestation_statement + @credential_options_timeout = credential_options_timeout + @silent_authentication = silent_authentication + @acceptable_attestation_types = acceptable_attestation_types + self.attestation_root_certificates_finders = attestation_root_certificates_finders + end + + attr_accessor :algorithms, + :encoding, + :origin, + :id, + :name, + :verify_attestation_statement, + :credential_options_timeout, + :silent_authentication, + :acceptable_attestation_types + + attr_reader :attestation_root_certificates_finders + + # This is the user-data encoder. + # Used to decode user input and to encode data provided to the user. + def encoder + @encoder ||= WebAuthn::Encoder.new(encoding) + end + + def attestation_root_certificates_finders=(finders) + if !finders.respond_to?(:each) + finders = [finders] + end + + finders.each do |finder| + unless finder.respond_to?(:find) + raise RootCertificateFinderNotSupportedError, "Finder must implement `find` method" + end + end + + @attestation_root_certificates_finders = finders + end + + def options_for_registration(**keyword_arguments) + WebAuthn::Credential.options_for_create( + **keyword_arguments, + relying_party: self + ) + end + + def verify_registration(raw_credential, challenge, user_verification: nil) + webauthn_credential = WebAuthn::Credential.from_create(raw_credential, relying_party: self) + + if webauthn_credential.verify(challenge, user_verification: user_verification) + webauthn_credential + end + end + + def options_for_authentication(**keyword_arguments) + WebAuthn::Credential.options_for_get( + **keyword_arguments, + relying_party: self + ) + end + + def verify_authentication( + raw_credential, + challenge, + user_verification: nil, + public_key: nil, + sign_count: nil + ) + webauthn_credential = WebAuthn::Credential.from_get(raw_credential, relying_party: self) + + stored_credential = yield(webauthn_credential) if block_given? + + if webauthn_credential.verify( + challenge, + public_key: public_key || stored_credential.public_key, + sign_count: sign_count || stored_credential.sign_count, + user_verification: user_verification + ) + block_given? ? [webauthn_credential, stored_credential] : webauthn_credential + end + end + end +end diff --git a/spec/conformance/conformance_cache_store.rb b/spec/conformance/conformance_cache_store.rb index 7b952b23..3fea9122 100644 --- a/spec/conformance/conformance_cache_store.rb +++ b/spec/conformance/conformance_cache_store.rb @@ -18,12 +18,12 @@ def setup_authenticators end end - def setup_metadata_store + def setup_metadata_store(endpoint) puts("Setting up metadata store TOC") response = Net::HTTP.post( URI("https://mds.certinfra.fidoalliance.org/getEndpoints"), - { endpoint: WebAuthn.configuration.origin }.to_json, + { endpoint: endpoint }.to_json, FidoMetadata::Client::DEFAULT_HEADERS ) diff --git a/spec/conformance/server.rb b/spec/conformance/server.rb index e879109b..b1fded40 100644 --- a/spec/conformance/server.rb +++ b/spec/conformance/server.rb @@ -40,26 +40,28 @@ def self.registered_for(username) host = ENV["HOST"] || "localhost" -WebAuthn.configure do |config| - config.origin = "http://#{host}:#{settings.port}" - config.rp_name = RP_NAME - config.algorithms.concat(%w(ES384 ES512 PS384 PS512 RS384 RS512 RS1)) - config.silent_authentication = true - config.attestation_root_certificates_finders = - MDSFinder.new.tap do |mds| - mds.token = "" - mds.cache_backend = ConformanceCacheStore.new - mds.cache_backend.setup_authenticators - mds.cache_backend.setup_metadata_store - end -end +mds_finder = + MDSFinder.new.tap do |mds| + mds.token = "" + mds.cache_backend = ConformanceCacheStore.new + mds.cache_backend.setup_authenticators + mds.cache_backend.setup_metadata_store("http://#{host}:#{settings.port}") + end + +relying_party = WebAuthn::RelyingParty.new( + origin: "http://#{host}:#{settings.port}", + name: RP_NAME, + algorithms: %w(ES256 ES384 ES512 PS256 PS384 PS512 RS256 RS384 RS512 RS1), + silent_authentication: true, + attestation_root_certificates_finders: mds_finder +) post "/attestation/options" do - options = WebAuthn::Credential.options_for_create( + options = relying_party.options_for_registration( attestation: params["attestation"], authenticator_selection: params["authenticatorSelection"], - exclude: Credential.registered_for(params["username"]).map(&:id), extensions: params["extensions"], + exclude: Credential.registered_for(params["username"]).map(&:id), user: { id: "1", name: params["username"], display_name: params["displayName"] } ) @@ -74,9 +76,8 @@ def self.registered_for(username) end post "/attestation/result" do - webauthn_credential = WebAuthn::Credential.from_create(params) - - webauthn_credential.verify( + webauthn_credential = relying_party.verify_registration( + params, cookies["attestation_challenge"], user_verification: cookies["attestation_user_verification"] == "required" ) @@ -106,10 +107,10 @@ def self.registered_for(username) end post "/assertion/options" do - options = WebAuthn::Credential.options_for_get( - allow: Credential.registered_for(params["username"]).map(&:id), + options = relying_party.options_for_authentication( extensions: params["extensions"], - user_verification: params["userVerification"] + user_verification: params["userVerification"], + allow: Credential.registered_for(params["username"]).map(&:id) ) cookies["assertion_username"] = params["username"] @@ -127,7 +128,8 @@ def self.registered_for(username) uc.id == webauthn_credential.id end - webauthn_credential.verify( + webauthn_credential = relying_party.verify_authentication( + params, cookies["assertion_challenge"], public_key: user_credential.public_key, sign_count: user_credential.sign_count, diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 44b7105c..c1eea54e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -23,16 +23,35 @@ end end -def create_credential(client: WebAuthn::FakeClient.new, rp_id: nil) - rp_id ||= URI.parse(client.origin).host +def create_credential( + client: WebAuthn::FakeClient.new, + rp_id: nil, + relying_party: WebAuthn.configuration.relying_party +) + rp_id ||= relying_party.id || URI.parse(client.origin).host create_result = client.create(rp_id: rp_id) + attestation_object = + if client.encoding + relying_party.encoder.decode(create_result["response"]["attestationObject"]) + else + create_result["response"]["attestationObject"] + end + + client_data_json = + if client.encoding + relying_party.encoder.decode(create_result["response"]["clientDataJSON"]) + else + create_result["response"]["clientDataJSON"] + end + response = WebAuthn::AuthenticatorAttestationResponse .new( - attestation_object: create_result["response"]["attestationObject"], - client_data_json: create_result["response"]["clientDataJSON"] + attestation_object: attestation_object, + client_data_json: client_data_json, + relying_party: relying_party ) credential_public_key = response.credential.public_key diff --git a/spec/webauthn/authenticator_attestation_response_spec.rb b/spec/webauthn/authenticator_attestation_response_spec.rb index 41568f50..3a60b81b 100644 --- a/spec/webauthn/authenticator_attestation_response_spec.rb +++ b/spec/webauthn/authenticator_attestation_response_spec.rb @@ -539,7 +539,7 @@ it "doesn't verify" do expect { attestation_response.verify(original_challenge, origin) - }.to raise_exception(WebAuthn::AuthenticatorDataVerificationError) + }.to raise_exception(WebAuthn::AttestedCredentialVerificationError) end end end diff --git a/spec/webauthn/relying_party_spec.rb b/spec/webauthn/relying_party_spec.rb new file mode 100644 index 00000000..3dcc2938 --- /dev/null +++ b/spec/webauthn/relying_party_spec.rb @@ -0,0 +1,342 @@ +# frozen_string_literal: true + +require "ostruct" +require "spec_helper" +require "webauthn/fake_authenticator" +require "webauthn/fake_client" +require "webauthn/relying_party" + +RSpec.describe "RelyingParty" do + let(:authenticator) { WebAuthn::FakeAuthenticator.new } + + let(:admin_rp) do + WebAuthn::RelyingParty.new( + origin: "https://admin.example.test", + id: 'admin.example.test', + name: 'Admin Application' + ) + end + let(:admin_fake_client) do + WebAuthn::FakeClient.new("https://admin.example.test", authenticator: authenticator) + end + + let(:user) do + OpenStruct.new(id: WebAuthn.generate_user_id, name: 'John Doe', credentials: []) + end + + context "without having any global configuration" do + let(:consumer_rp) do + WebAuthn::RelyingParty.new( + origin: "https://www.example.test", + id: 'example.test', + name: 'Consumer Application' + ) + end + let(:consumer_fake_client) do + WebAuthn::FakeClient.new("https://www.example.test", authenticator: authenticator) + end + + context "instance two relying parties and use them for the registration ceremony" do + it "works when both used for the same user and authenticator" do + options = admin_rp.options_for_registration( + user: user.to_h.slice(:id, :name), + exclude: user.credentials + ) + raw_credential = admin_fake_client.create(challenge: options.challenge, rp_id: admin_rp.id) + webauthn_credential = admin_rp.verify_registration(raw_credential, options.challenge) + + expect(webauthn_credential).to be_truthy + expect(webauthn_credential.id).to be_truthy + expect(webauthn_credential.public_key).to be_truthy + expect(webauthn_credential.sign_count).to eq(0) + + options = consumer_rp.options_for_registration( + user: user.to_h.slice(:id, :name), + exclude: user.credentials + ) + raw_credential = consumer_fake_client.create(challenge: options.challenge, rp_id: consumer_rp.id) + webauthn_credential = consumer_rp.verify_registration(raw_credential, options.challenge) + + expect(webauthn_credential).to be_truthy + expect(webauthn_credential.id).to be_truthy + expect(webauthn_credential.public_key).to be_truthy + expect(webauthn_credential.sign_count).to eq(0) + end + + it "fails if you pass consumer client data to admin relying party" do + options = admin_rp.options_for_registration( + user: user.to_h.slice(:id, :name), + exclude: user.credentials + ) + raw_credential = consumer_fake_client.create(challenge: options.challenge) + + expect do + admin_rp.verify_registration(raw_credential, options.challenge) + end.to raise_error(WebAuthn::OriginVerificationError) + end + end + + context "configuring relying parties and use them for the authentication ceremony" do + let(:admin_credential) do + create_credential(client: admin_fake_client, relying_party: admin_rp) + end + let(:consumer_credential) do + create_credential(client: consumer_fake_client, relying_party: consumer_rp) + end + + before do + user.credentials << OpenStruct.new( + webauthn_id: admin_credential.first, + public_key: admin_rp.encoder.encode(admin_credential[1]), + sign_count: 0 + ) + user.credentials << OpenStruct.new( + webauthn_id: consumer_credential.first, + public_key: consumer_rp.encoder.encode(consumer_credential[1]), + sign_count: 0 + ) + end + + it "works when both used for the same user and authenticator" do + options = admin_rp.options_for_authentication(allow: user.credentials.map(&:webauthn_id)) + + raw_credential = admin_fake_client.get( + challenge: options.challenge, + rp_id: admin_rp.id, + sign_count: 1 + ) + + verified_webauthn_credential, stored_credential = + admin_rp.verify_authentication( + raw_credential, + options.challenge + ) do |webauthn_credential| + user.credentials.find { |c| c.webauthn_id == webauthn_credential.id } + end + + expect(verified_webauthn_credential).to be_truthy + expect(verified_webauthn_credential.id).to be_truthy + expect(verified_webauthn_credential.sign_count).to eq(1) + expect(stored_credential.webauthn_id).to eq(admin_credential.first) + + options = consumer_rp.options_for_authentication(allow: user.credentials.map(&:webauthn_id)) + + raw_credential = consumer_fake_client.get( + challenge: options.challenge, + rp_id: consumer_rp.id, + sign_count: 1 + ) + + verified_webauthn_credential, stored_credential = + consumer_rp.verify_authentication( + raw_credential, + options.challenge + ) do |webauthn_credential| + user.credentials.find { |c| c.webauthn_id == webauthn_credential.id } + end + + expect(verified_webauthn_credential).to be_truthy + expect(verified_webauthn_credential.id).to be_truthy + expect(verified_webauthn_credential.sign_count).to eq(1) + expect(stored_credential.webauthn_id).to eq(consumer_credential.first) + end + + it "fails when you try to authenticate a credential registered for consumer in admin" do + options = admin_rp.options_for_authentication(allow: user.credentials.map(&:webauthn_id)) + + raw_credential = admin_fake_client.get( + challenge: options.challenge, + rp_id: admin_rp.id, + sign_count: 1 + ) + + expect do + admin_rp.verify_authentication( + raw_credential, + options.challenge + ) do + user.credentials.find { |c| c.webauthn_id == consumer_credential.first } + end + end.to raise_error(WebAuthn::SignatureVerificationError) + end + end + end + + context "with a global configuration and a different relying party co-existing" do + let(:global_configuration_client) do + WebAuthn::FakeClient.new(WebAuthn.configuration.origin, authenticator: authenticator) + end + + before do + WebAuthn.configure do |config| + config.origin = "https://www.example.com" + config.rp_name = "Example Consumer page" + end + end + + context "when performing a registragion ceremony" do + it "works when both used for the same user and authenticator" do + options = admin_rp.options_for_registration( + user: user.to_h.slice(:id, :name), + exclude: user.credentials + ) + raw_credential = admin_fake_client.create(challenge: options.challenge, rp_id: admin_rp.id) + webauthn_credential = admin_rp.verify_registration(raw_credential, options.challenge) + + expect(webauthn_credential).to be_truthy + expect(webauthn_credential.id).to be_truthy + expect(webauthn_credential.public_key).to be_truthy + expect(webauthn_credential.sign_count).to eq(0) + + options = WebAuthn.configuration.relying_party.options_for_registration( + user: user.to_h.slice(:id, :name), + exclude: user.credentials + ) + raw_credential = global_configuration_client.create(challenge: options.challenge) + webauthn_credential = + WebAuthn.configuration.relying_party.verify_registration( + raw_credential, + options.challenge + ) + + expect(webauthn_credential).to be_truthy + expect(webauthn_credential.id).to be_truthy + expect(webauthn_credential.public_key).to be_truthy + expect(webauthn_credential.sign_count).to eq(0) + end + end + + context "when performing an authentication ceremony" do + let(:admin_credential) do + create_credential(client: admin_fake_client, relying_party: admin_rp) + end + let(:default_configuration_credential) do + create_credential(client: global_configuration_client) + end + + before do + user.credentials << OpenStruct.new( + webauthn_id: admin_credential.first, + public_key: admin_rp.encoder.encode(admin_credential[1]), + sign_count: 0 + ) + user.credentials << OpenStruct.new( + webauthn_id: default_configuration_credential.first, + public_key: WebAuthn.configuration.encoder.encode(default_configuration_credential[1]), + sign_count: 0 + ) + end + + it "works when both used for the same user and authenticator" do + options = admin_rp.options_for_authentication(allow: user.credentials.map(&:webauthn_id)) + + raw_credential = admin_fake_client.get( + challenge: options.challenge, + rp_id: admin_rp.id, + sign_count: 1 + ) + + verified_webauthn_credential, stored_credential = + admin_rp.verify_authentication( + raw_credential, + options.challenge + ) do |webauthn_credential| + user.credentials.find { |c| c.webauthn_id == webauthn_credential.id } + end + + expect(verified_webauthn_credential).to be_truthy + expect(verified_webauthn_credential.id).to be_truthy + expect(verified_webauthn_credential.sign_count).to eq(1) + expect(stored_credential.webauthn_id).to eq(admin_credential.first) + + options = + WebAuthn.configuration.relying_party.options_for_authentication( + allow: user.credentials.map(&:webauthn_id) + ) + + raw_credential = global_configuration_client.get( + challenge: options.challenge, + sign_count: 1 + ) + + verified_webauthn_credential, stored_credential = + WebAuthn.configuration.relying_party.verify_authentication( + raw_credential, + options.challenge + ) do |webauthn_credential| + user.credentials.find { |c| c.webauthn_id == webauthn_credential.id } + end + + expect(verified_webauthn_credential).to be_truthy + expect(verified_webauthn_credential.id).to be_truthy + expect(verified_webauthn_credential.sign_count).to eq(1) + expect(stored_credential.webauthn_id).to eq(default_configuration_credential.first) + end + end + end + + context "with only a global configuration" do + let(:global_configuration_client) do + WebAuthn::FakeClient.new(WebAuthn.configuration.origin, authenticator: authenticator) + end + + before do + WebAuthn.configure do |config| + config.origin = "https://www.example.com" + config.rp_name = "Example Consumer page" + end + end + + context "when performing a registragion ceremony" do + it "works well when using the former interface" do + options = WebAuthn::Credential.options_for_create( + user: user.to_h.slice(:id, :name), + exclude: user.credentials + ) + raw_credential = global_configuration_client.create(challenge: options.challenge) + webauthn_credential = WebAuthn::Credential.from_create(raw_credential) + webauthn_credential.verify(options.challenge) + + expect(webauthn_credential).to be_truthy + expect(webauthn_credential.id).to be_truthy + expect(webauthn_credential.public_key).to be_truthy + expect(webauthn_credential.sign_count).to eq(0) + end + end + + context "when performing an authentication ceremony" do + let(:default_configuration_credential) do + create_credential(client: global_configuration_client) + end + + before do + user.credentials << OpenStruct.new( + webauthn_id: default_configuration_credential.first, + public_key: WebAuthn.configuration.encoder.encode(default_configuration_credential[1]), + sign_count: 0 + ) + end + + it "works well when using the former interface" do + options = WebAuthn::Credential.options_for_get(allow: user.credentials.map(&:webauthn_id)) + + raw_credential = global_configuration_client.get( + challenge: options.challenge, + sign_count: 1 + ) + + webauthn_credential = WebAuthn::Credential.from_get(raw_credential) + stored_credential = user.credentials.find { |c| c.webauthn_id == webauthn_credential.id } + webauthn_credential.verify( + options.challenge, + public_key: stored_credential.public_key, + sign_count: stored_credential.sign_count + ) + + expect(webauthn_credential).to be_truthy + expect(webauthn_credential.id).to be_truthy + expect(webauthn_credential.sign_count).to eq(1) + end + end + end +end From 365ae4bdad9fef5fed7a051e35463121cc60b432 Mon Sep 17 00:00:00 2001 From: Bart de Water <496367+bdewater@users.noreply.github.com> Date: Thu, 8 Sep 2022 18:57:16 -0400 Subject: [PATCH 2/3] Bump minimum version of Ruby to 2.5 Not sure how 2f54c92a35b2afc8af01de945ca28219177fabe4 passed CI for Ruby 2.4, since lib/webauthn/relying_party.rb uses Hash#slice which was introduced in 2.5. By now 2.4 has been end-of-life for two years and I don't think it's worth any further investigation. --- .github/workflows/build.yml | 1 - .rubocop.yml | 2 +- .../authenticator_data/attested_credential_data.rb | 5 ++--- lib/webauthn/u2f_migrator.rb | 5 ++++- spec/conformance/conformance_cache_store.rb | 10 ++++------ webauthn.gemspec | 2 +- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80f99f99..0d9995fd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,7 +21,6 @@ jobs: - '2.7' - '2.6' - '2.5' - - '2.4' - truffleruby steps: - uses: actions/checkout@v2 diff --git a/.rubocop.yml b/.rubocop.yml index e3137440..2a366acb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,7 +7,7 @@ inherit_mode: - AllowedNames AllCops: - TargetRubyVersion: 2.4 + TargetRubyVersion: 2.5 DisabledByDefault: true NewCops: disable Exclude: diff --git a/lib/webauthn/authenticator_data/attested_credential_data.rb b/lib/webauthn/authenticator_data/attested_credential_data.rb index cb93f54b..f89a3a95 100644 --- a/lib/webauthn/authenticator_data/attested_credential_data.rb +++ b/lib/webauthn/authenticator_data/attested_credential_data.rb @@ -22,9 +22,8 @@ class AttestedCredentialData < BinData::Record count_bytes_remaining :trailing_bytes_length string :trailing_bytes, length: :trailing_bytes_length - # TODO: use keyword_init when we dropped Ruby 2.4 support Credential = - Struct.new(:id, :public_key, :algorithm) do + Struct.new(:id, :public_key, :algorithm, keyword_init: true) do def public_key_object COSE::Key.deserialize(public_key).to_pkey end @@ -47,7 +46,7 @@ def aaguid def credential @credential ||= if valid? - Credential.new(id, public_key, algorithm) + Credential.new(id: id, public_key: public_key, algorithm: algorithm) end end diff --git a/lib/webauthn/u2f_migrator.rb b/lib/webauthn/u2f_migrator.rb index cb04d23e..9dd9dc33 100644 --- a/lib/webauthn/u2f_migrator.rb +++ b/lib/webauthn/u2f_migrator.rb @@ -31,7 +31,10 @@ def credential @credential ||= begin hash = authenticator_data.send(:credential) - WebAuthn::AuthenticatorData::AttestedCredentialData::Credential.new(hash[:id], hash[:public_key].serialize) + WebAuthn::AuthenticatorData::AttestedCredentialData::Credential.new( + id: hash[:id], + public_key: hash[:public_key].serialize + ) end end diff --git a/spec/conformance/conformance_cache_store.rb b/spec/conformance/conformance_cache_store.rb index 3fea9122..6890f158 100644 --- a/spec/conformance/conformance_cache_store.rb +++ b/spec/conformance/conformance_cache_store.rb @@ -34,12 +34,10 @@ def setup_metadata_store(endpoint) json = possible_endpoints.each_with_index do |uri, index| - begin - puts("Trying endpoint #{index}: #{uri}") - break client.download_toc(URI(uri), trusted_certs: conformance_certificates) - rescue FidoMetadata::Client::DataIntegrityError, JWT::VerificationError, Net::HTTPFatalError - nil - end + puts("Trying endpoint #{index}: #{uri}") + break client.download_toc(URI(uri), trusted_certs: conformance_certificates) + rescue FidoMetadata::Client::DataIntegrityError, JWT::VerificationError, Net::HTTPFatalError + nil end if json.is_a?(Hash) && json.keys == ["legalHeader", "no", "nextUpdate", "entries"] diff --git a/webauthn.gemspec b/webauthn.gemspec index 07d6fcb6..1d667838 100644 --- a/webauthn.gemspec +++ b/webauthn.gemspec @@ -31,7 +31,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.required_ruby_version = ">= 2.4" + spec.required_ruby_version = ">= 2.5" spec.add_dependency "android_key_attestation", "~> 0.3.0" spec.add_dependency "awrence", "~> 1.1" From d58add552657e0ccf5671da4117f2f991596b2c4 Mon Sep 17 00:00:00 2001 From: Bart de Water <496367+bdewater@users.noreply.github.com> Date: Thu, 8 Sep 2022 19:45:29 -0400 Subject: [PATCH 3/3] Prepare 3.0.0.alpha2 --- CHANGELOG.md | 13 ++++++++++++- lib/webauthn/version.rb | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b4c840..31376304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [v3.0.0.alpha2] - 2022-09-12 + +### Added + +- Rebased support for multiple relying parties from v3.0.0.alpha1 on top of v2.5.2, the previous alpha version was based on v2.3.0 ([@bdewater]) + +### BREAKING CHANGES + +- Bumped minimum required Ruby version to 2.5 ([@bdewater]) + ## [v3.0.0.alpha1] - 2020-06-27 ### Added @@ -340,7 +350,8 @@ Note: Both additions should help making it compatible with Chrome for Android 70 - `WebAuthn::AuthenticatorAttestationResponse.valid?` can be used to validate fido-u2f attestations returned by the browser - Works with ruby 2.5 -[v3.0.0.alpha1]: https://github.com/cedarcode/webauthn-ruby/compare/2-stable...v3.0.0.alpha1/ +[v3.0.0.alpha2]: https://github.com/cedarcode/webauthn-ruby/compare/2-stable...v3.0.0.alpha2/ +[v3.0.0.alpha1]: https://github.com/cedarcode/webauthn-ruby/compare/v2.3.0...v3.0.0.alpha1 [v2.5.2]: https://github.com/cedarcode/webauthn-ruby/compare/v2.5.1...v2.5.2/ [v2.5.1]: https://github.com/cedarcode/webauthn-ruby/compare/v2.5.0...v2.5.1/ [v2.5.0]: https://github.com/cedarcode/webauthn-ruby/compare/v2.4.1...v2.5.0/ diff --git a/lib/webauthn/version.rb b/lib/webauthn/version.rb index 0a3fb637..47363101 100644 --- a/lib/webauthn/version.rb +++ b/lib/webauthn/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module WebAuthn - VERSION = "2.5.2" + VERSION = "3.0.0.alpha2" end