diff --git a/README.md b/README.md index 65fd6f42..478eea5d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,17 @@ WebAuthn.configure do |config| # the User Agent during registration and authentication ceremonies. config.origin = "https://auth.example.com" + # For the case when a relying party has multiple origins + # (e.g. different subdomains, native client sending android:apk-key-hash:... like origins in clientDataJson, etc...), + # you can set the `allowed_origins` instead of a single `origin` above + # + # config.allowed_origins = [ + # "https://auth.example.com", + # "android:apk-key-hash:blablablablablalblalla" + # ] + # + # Note: in this case setting config.rp_id is mandatory + # Relying Party name for display purposes config.rp_name = "Example Inc." diff --git a/lib/webauthn/authenticator_response.rb b/lib/webauthn/authenticator_response.rb index 1151b5e5..b822f866 100644 --- a/lib/webauthn/authenticator_response.rb +++ b/lib/webauthn/authenticator_response.rb @@ -25,7 +25,10 @@ def initialize(client_data_json:, relying_party: WebAuthn.configuration.relying_ end def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_verification: nil, rp_id: nil) - expected_origin ||= relying_party.origin || raise("Unspecified expected origin") + expected_origin ||= relying_party.allowed_origins || + [relying_party.origin] || + raise("Unspecified expected origin") + rp_id ||= relying_party.id verify_item(:type) @@ -33,7 +36,14 @@ def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_v verify_item(:challenge, expected_challenge) verify_item(:origin, expected_origin) verify_item(:authenticator_data) - verify_item(:rp_id, rp_id || rp_id_from_origin(expected_origin)) + + # NOTE: we are trying to guess from 'expected_origin' only in case it's a single origin + # (array that contains a single element) + # rp_id should either be explicitly set or guessed from only a single origin + verify_item( + :rp_id, + rp_id || rp_id_from_origin(expected_origin) + ) # Fallback to RP configuration unless user_presence is passed in explicitely if user_presence.nil? && !relying_party.silent_authentication || user_presence @@ -83,11 +93,21 @@ def valid_challenge?(expected_challenge) OpenSSL.secure_compare(client_data.challenge, expected_challenge) end + # @return [Boolean] + # @param [Array] expected_origin + # Validate if one of the allowed origins configured for RP is matching the one received from client def valid_origin?(expected_origin) - expected_origin && (client_data.origin == expected_origin) + return false unless expected_origin + + expected_origin.include?(client_data.origin) end + # @return [Boolean] + # @param [String] rp_id + # Validate if RP ID is matching the one received from client def valid_rp_id?(rp_id) + return false unless rp_id + OpenSSL::Digest::SHA256.digest(rp_id) == authenticator_data.rp_id_hash end @@ -105,8 +125,16 @@ def valid_user_verified? authenticator_data.user_verified? end + # @return [String, nil] + # @param [String, Array, nil] expected_origin + # Extract RP ID from origin in case rp_id is not provided explicitly def rp_id_from_origin(expected_origin) - URI.parse(expected_origin).host + case expected_origin + when Array + URI.parse(expected_origin.first).host if expected_origin.size == 1 + when String + URI.parse(expected_origin).host + end end def type diff --git a/lib/webauthn/client_data.rb b/lib/webauthn/client_data.rb index 0d31c964..ee5aefed 100644 --- a/lib/webauthn/client_data.rb +++ b/lib/webauthn/client_data.rb @@ -49,12 +49,10 @@ def hash def data @data ||= - begin - if client_data_json - JSON.parse(client_data_json) - else - raise ClientDataMissingError, "Client Data JSON is missing" - end + if client_data_json + JSON.parse(client_data_json) + else + raise ClientDataMissingError, "Client Data JSON is missing" end end end diff --git a/lib/webauthn/configuration.rb b/lib/webauthn/configuration.rb index 93db1a1e..522bef8a 100644 --- a/lib/webauthn/configuration.rb +++ b/lib/webauthn/configuration.rb @@ -22,6 +22,8 @@ class Configuration :encoding=, :origin, :origin=, + :allowed_origins, + :allowed_origins=, :verify_attestation_statement, :verify_attestation_statement=, :credential_options_timeout, diff --git a/lib/webauthn/relying_party.rb b/lib/webauthn/relying_party.rb index 6a667c48..7435f74a 100644 --- a/lib/webauthn/relying_party.rb +++ b/lib/webauthn/relying_party.rb @@ -9,15 +9,16 @@ module WebAuthn class RootCertificateFinderNotSupportedError < Error; end class RelyingParty + DEFAULT_ALGORITHMS = ["ES256", "PS256", "RS256"].compact.freeze + 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, + allowed_origins: nil, origin: nil, id: nil, name: nil, @@ -30,7 +31,7 @@ def initialize( ) @algorithms = algorithms @encoding = encoding - @origin = origin + @allowed_origins = allowed_origins @id = id @name = name @verify_attestation_statement = verify_attestation_statement @@ -38,12 +39,13 @@ def initialize( @silent_authentication = silent_authentication @acceptable_attestation_types = acceptable_attestation_types @legacy_u2f_appid = legacy_u2f_appid + self.origin = origin self.attestation_root_certificates_finders = attestation_root_certificates_finders end attr_accessor :algorithms, :encoding, - :origin, + :allowed_origins, :id, :name, :verify_attestation_statement, @@ -52,7 +54,7 @@ def initialize( :acceptable_attestation_types, :legacy_u2f_appid - attr_reader :attestation_root_certificates_finders + attr_reader :attestation_root_certificates_finders, :origin # This is the user-data encoder. # Used to decode user input and to encode data provided to the user. @@ -118,5 +120,18 @@ def verify_authentication( block_given? ? [webauthn_credential, stored_credential] : webauthn_credential end end + + # DEPRECATED: This method will be removed in future. + def origin=(new_origin) + unless new_origin.nil? + warn( + "DEPRECATION WARNING: `WebAuthn.origin` is deprecated and will be removed in future. "\ + "Please use `WebAuthn.allowed_origins` instead "\ + "that also allows configuring multiple origins per Relying Party" + ) + end + + @origin = new_origin + end end end diff --git a/spec/webauthn/authenticator_attestation_response_spec.rb b/spec/webauthn/authenticator_attestation_response_spec.rb index eda785d9..8620b21e 100644 --- a/spec/webauthn/authenticator_attestation_response_spec.rb +++ b/spec/webauthn/authenticator_attestation_response_spec.rb @@ -24,11 +24,7 @@ let(:public_key_credential) { client.create(challenge: original_challenge) } - before do - WebAuthn.configuration.origin = origin - end - - context "when everything's in place" do + shared_examples "a valid attestation response" do it "verifies" do expect(attestation_response.verify(original_challenge)).to be_truthy end @@ -36,16 +32,83 @@ it "is valid" do expect(attestation_response.valid?(original_challenge)).to be_truthy end + end - # TODO: let FakeClient#create recieve a fixed credential - # https://github.com/cedarcode/webauthn-ruby/pull/302#discussion_r365338434 - it "returns the credential" do - credential = attestation_response.credential + context "when everything's in place" do + context "when there is a single origin" do + before do + WebAuthn.configuration.origin = origin + end + + it_behaves_like "a valid attestation response" + + # TODO: let FakeClient#create recieve a fixed credential + # https://github.com/cedarcode/webauthn-ruby/pull/302#discussion_r365338434 + it "returns the credential" do + credential = attestation_response.credential + + expect(credential.id.class).to eq(BinData::String) + expect(credential.id.encoding).to eq(Encoding::BINARY) + expect(credential.public_key.class).to eq(String) + expect(credential.public_key.encoding).to be(Encoding::BINARY) + end + end + + context "when there are multiple allowed origins" do + let(:allowed_origins) do + [ + fake_origin, + "android:apk-key-hash:blablablablablalblalla" + ] + end + + before do + WebAuthn.configuration.allowed_origins = allowed_origins + end + + context "when rp_id is set explicitly" do + before do + WebAuthn.configuration.rp_id = "localhost" + end + + it_behaves_like "a valid attestation response" + + # TODO: let FakeClient#create recieve a fixed credential + # https://github.com/cedarcode/webauthn-ruby/pull/302#discussion_r365338434 + it "returns the credential" do + credential = attestation_response.credential + + expect(credential.id.class).to eq(BinData::String) + expect(credential.id.encoding).to eq(Encoding::BINARY) + expect(credential.public_key.class).to eq(String) + expect(credential.public_key.encoding).to be(Encoding::BINARY) + end + end + + context "when rp_id is not set explicitly" do + before do + WebAuthn.configuration.rp_id = nil + end + + it "raises error" do + expect { attestation_response.verify(original_challenge) }.to raise_error(WebAuthn::RpIdVerificationError) + end - expect(credential.id.class).to eq(BinData::String) - expect(credential.id.encoding).to eq(Encoding::BINARY) - expect(credential.public_key.class).to eq(String) - expect(credential.public_key.encoding).to be(Encoding::BINARY) + it "is not valid" do + expect(attestation_response.valid?(original_challenge)).to be_falsey + end + + # TODO: let FakeClient#create recieve a fixed credential + # https://github.com/cedarcode/webauthn-ruby/pull/302#discussion_r365338434 + it "returns the credential" do + credential = attestation_response.credential + + expect(credential.id.class).to eq(BinData::String) + expect(credential.id.encoding).to eq(Encoding::BINARY) + expect(credential.public_key.class).to eq(String) + expect(credential.public_key.encoding).to be(Encoding::BINARY) + end + end end end @@ -54,44 +117,76 @@ Base64.strict_decode64(seeds[:security_key_direct][:credential_creation_options][:challenge]) end - let(:origin) { "http://localhost:3000" } + context "when there is a single origin" do + let(:origin) { "http://localhost:3000" } - let(:attestation_response) do - response = seeds[:security_key_direct][:authenticator_attestation_response] + let(:attestation_response) do + response = seeds[:security_key_direct][:authenticator_attestation_response] - WebAuthn::AuthenticatorAttestationResponse.new( - attestation_object: Base64.strict_decode64(response[:attestation_object]), - client_data_json: Base64.strict_decode64(response[:client_data_json]) - ) - end + WebAuthn::AuthenticatorAttestationResponse.new( + attestation_object: Base64.strict_decode64(response[:attestation_object]), + client_data_json: Base64.strict_decode64(response[:client_data_json]) + ) + end - before do - WebAuthn.configuration.attestation_root_certificates_finders = finder_for('feitian_ft_fido_0200.pem') - end + before do + WebAuthn.configuration.attestation_root_certificates_finders = finder_for('feitian_ft_fido_0200.pem') + WebAuthn.configuration.origin = origin + end - it "verifies" do - expect(attestation_response.verify(original_challenge)).to be_truthy - end + it_behaves_like "a valid attestation response" - it "is valid" do - expect(attestation_response.valid?(original_challenge)).to eq(true) - end + it "returns attestation info" do + attestation_response.valid?(original_challenge) - it "returns attestation info" do - attestation_response.valid?(original_challenge) + expect(attestation_response.attestation_type).to eq("Basic_or_AttCA") + expect(attestation_response.attestation_trust_path).to all(be_kind_of(OpenSSL::X509::Certificate)) + end - expect(attestation_response.attestation_type).to eq("Basic_or_AttCA") - expect(attestation_response.attestation_trust_path).to all(be_kind_of(OpenSSL::X509::Certificate)) - end + it "returns the credential" do + expect(attestation_response.credential.id.length).to be >= 16 + end - it "returns the credential" do - expect(attestation_response.credential.id.length).to be >= 16 + it "returns the attestation certificate key" do + expect(attestation_response.attestation_certificate_key_id).to( + eq("f4b64a68c334e901b8e23c6e66e6866c31931f5d") + ) + end end - it "returns the attestation certificate key" do - expect(attestation_response.attestation_certificate_key_id).to( - eq("f4b64a68c334e901b8e23c6e66e6866c31931f5d") - ) + context "when there are multiple allowed origins" do + let(:allowed_origins) do + [ + fake_origin, + "android:apk-key-hash:blablablablablalblalla" + ] + end + + before do + WebAuthn.configuration.allowed_origins = allowed_origins + end + + context "when rp_id is set explicitly" do + before do + WebAuthn.configuration.rp_id = "localhost" + end + + it_behaves_like "a valid attestation response" + end + + context "when rp_id is not set explicitly" do + before do + WebAuthn.configuration.rp_id = nil + end + + it "raises error" do + expect { attestation_response.verify(original_challenge) }.to raise_error(WebAuthn::RpIdVerificationError) + end + + it "is not valid" do + expect(attestation_response.valid?(original_challenge)).to be_falsey + end + end end end @@ -113,13 +208,11 @@ ) end - it "verifies" do - expect(attestation_response.verify(original_challenge)).to be_truthy + before do + WebAuthn.configuration.origin = origin end - it "is valid" do - expect(attestation_response.valid?(original_challenge)).to eq(true) - end + it_behaves_like "a valid attestation response" it "returns attestation info" do attestation_response.valid?(original_challenge) @@ -157,15 +250,10 @@ before do WebAuthn.configuration.attestation_root_certificates_finders = finder_for('yubico_u2f_root.pem') + WebAuthn.configuration.origin = origin end - it "verifies" do - expect(attestation_response.verify(original_challenge)).to be_truthy - end - - it "is valid" do - expect(attestation_response.valid?(original_challenge)).to eq(true) - end + it_behaves_like "a valid attestation response" it "returns attestation info" do attestation_response.valid?(original_challenge) @@ -198,6 +286,7 @@ end before do + WebAuthn.configuration.origin = origin WebAuthn.configure do |config| config.algorithms.concat(%w(RS1)) end @@ -258,16 +347,11 @@ end before do + WebAuthn.configuration.origin = origin allow(attestation_response.attestation_statement).to receive(:time).and_return(time) end - it "verifies" do - expect(attestation_response.verify(original_challenge)).to be_truthy - end - - it "is valid" do - expect(attestation_response.valid?(original_challenge)).to eq(true) - end + it_behaves_like "a valid attestation response" it "returns attestation info" do attestation_response.valid?(original_challenge) @@ -286,8 +370,6 @@ end context "when android-key attestation" do - let(:origin) { seeds[:android_key_direct][:origin] } - let(:original_challenge) do Base64.urlsafe_decode64(seeds[:android_key_direct][:credential_creation_options][:challenge]) end @@ -305,27 +387,80 @@ WebAuthn.configuration.attestation_root_certificates_finders = finder_for('android_key_root.pem') end - it "verifies" do - expect(attestation_response.verify(original_challenge)).to be_truthy - end + context "when there is a single origin" do + let(:origin) { seeds[:android_key_direct][:origin] } - it "is valid" do - expect(attestation_response.valid?(original_challenge)).to eq(true) - end + before do + WebAuthn.configuration.origin = origin + end - it "returns attestation info" do - attestation_response.valid?(original_challenge) + it_behaves_like "a valid attestation response" - expect(attestation_response.attestation_type).to eq("Basic") - expect(attestation_response.attestation_trust_path).to all(be_kind_of(OpenSSL::X509::Certificate)) - end + it "returns attestation info" do + attestation_response.valid?(original_challenge) - it "returns the credential" do - expect(attestation_response.credential.id.length).to be >= 16 + expect(attestation_response.attestation_type).to eq("Basic") + expect(attestation_response.attestation_trust_path).to all(be_kind_of(OpenSSL::X509::Certificate)) + end + + it "returns the credential" do + expect(attestation_response.credential.id.length).to be >= 16 + end + + it "returns the AAGUID" do + expect(attestation_response.aaguid).to eq("550e4b54-aa47-409f-9a95-1ab76c130131") + end end - it "returns the AAGUID" do - expect(attestation_response.aaguid).to eq("550e4b54-aa47-409f-9a95-1ab76c130131") + context "when there are multiple allowed origins" do + let(:allowed_origins) do + [ + seeds[:android_key_direct][:origin], + "android:apk-key-hash:blablablablablalblalla", + "localhost" + ] + end + + before do + WebAuthn.configuration.allowed_origins = allowed_origins + end + + context "when rp_id is set explicitly" do + before do + WebAuthn.configuration.rp_id = "localhost" + end + + it_behaves_like "a valid attestation response" + + it "returns attestation info" do + attestation_response.valid?(original_challenge) + + expect(attestation_response.attestation_type).to eq("Basic") + expect(attestation_response.attestation_trust_path).to all(be_kind_of(OpenSSL::X509::Certificate)) + end + + it "returns the credential" do + expect(attestation_response.credential.id.length).to be >= 16 + end + + it "returns the AAGUID" do + expect(attestation_response.aaguid).to eq("550e4b54-aa47-409f-9a95-1ab76c130131") + end + end + + context "when rp_id is not set explicitly" do + before do + WebAuthn.configuration.rp_id = nil + end + + it "raises error" do + expect { attestation_response.verify(original_challenge) }.to raise_error(WebAuthn::RpIdVerificationError) + end + + it "is not valid" do + expect(attestation_response.valid?(original_challenge)).to be_falsey + end + end end end @@ -346,18 +481,14 @@ end before do + WebAuthn.configuration.origin = origin + # Apple credential certificate expires after 3 days apparently. # Seed data was obtained 22nd Feb 2021, so we are simulating validation within that 3 day timeframe fake_certificate_chain_validation_time(attestation_response.attestation_statement, Time.parse("2021-02-23")) end - it "verifies" do - expect(attestation_response.verify(original_challenge)).to be_truthy - end - - it "is valid" do - expect(attestation_response.valid?(original_challenge)).to eq(true) - end + it_behaves_like "a valid attestation response" it "returns attestation info" do attestation_response.valid?(original_challenge) @@ -371,15 +502,21 @@ end end - it "returns user-friendly error if no client data received" do - attestation_response = WebAuthn::AuthenticatorAttestationResponse.new( - attestation_object: "", - client_data_json: nil - ) + context "when no client data received" do + before do + WebAuthn.configuration.origin = origin + end + + it "returns user-friendly error if no client data received" do + attestation_response = WebAuthn::AuthenticatorAttestationResponse.new( + attestation_object: "", + client_data_json: nil + ) - expect { - attestation_response.valid?("", "") - }.to raise_exception(WebAuthn::ClientDataMissingError) + expect { + attestation_response.valid?("", "") + }.to raise_exception(WebAuthn::ClientDataMissingError) + end end describe "origin validation" do @@ -396,16 +533,14 @@ ) end + before do + WebAuthn.configuration.origin = origin + end + context "matches the default one" do let(:actual_origin) { "http://localhost" } - it "verifies" do - expect(attestation_response.verify(original_challenge)).to be_truthy - end - - it "is valid" do - expect(attestation_response.valid?(original_challenge)).to be_truthy - end + it_behaves_like "a valid attestation response" end context "doesn't match the default one" do @@ -436,16 +571,14 @@ ) end + before do + WebAuthn.configuration.origin = origin + end + context "matches the default one" do let(:rp_id) { "localhost" } - it "verifies" do - expect(attestation_response.verify(original_challenge)).to be_truthy - end - - it "is valid" do - expect(attestation_response.valid?(original_challenge)).to be_truthy - end + it_behaves_like "a valid attestation response" end context "doesn't match the default one" do @@ -469,19 +602,17 @@ WebAuthn.configuration.rp_id = rp_id end - it "verifies" do - expect(attestation_response.verify(original_challenge)).to be_truthy - end - - it "is valid" do - expect(attestation_response.valid?(original_challenge)).to be_truthy - end + it_behaves_like "a valid attestation response" end end describe "tokenBinding validation" do let(:client) { WebAuthn::FakeClient.new(origin, token_binding: token_binding, encoding: false) } + before do + WebAuthn.configuration.origin = origin + end + context "it has stuff" do let(:token_binding) { { status: "supported" } } @@ -586,6 +717,10 @@ end describe "user verification" do + before do + WebAuthn.configuration.origin = origin + end + context "when UV is not set" do let(:public_key_credential) { client.create(challenge: original_challenge, user_verified: false) } @@ -598,6 +733,10 @@ end describe "attested credential data verification" do + before do + WebAuthn.configuration.origin = origin + end + context "when AT is not set" do let(:public_key_credential) { client.create(challenge: original_challenge, attested_credential_data: false) } @@ -640,6 +779,7 @@ before do attestation_response.attestation_statement.instance_variable_get(:@statement)["sig"] = "corrupted signature".b + WebAuthn.configuration.origin = origin end context "when verification is set to true" do @@ -647,7 +787,7 @@ WebAuthn.configuration.verify_attestation_statement = true end - it "verifies the attestation statement" do + it "raises error" do expect { attestation_response.verify(original_challenge) }.to raise_error(OpenSSL::PKey::PKeyError) end end