# frozen_string_literal: true require "discourse_webauthn" RSpec.describe DiscourseWebauthn::RegistrationService do subject(:service) { described_class.new(current_user, params, **options) } let(:secure_session) { SecureSession.new("tester") } let(:client_data_challenge) { Base64.encode64(challenge) } let(:client_data_webauthn_type) { "webauthn.create" } let(:client_data_origin) { "http://test.localhost" } let(:client_data_param) do { challenge: client_data_challenge, type: client_data_webauthn_type, origin: client_data_origin, } end ## # This attestation object was sourced by manually registering # a key with `navigator.credentials.create` and capturing the # results in localhost. It does not have a user verification # flag set (i.e. it is only usable as 2FA). let(:attestation) do "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQFmvayWc8OPJ4jj4sevfxBmvUglDMZrFalyokYrdnqOVvudC0lQialaGQv72eBzJM2Qn1GfJI7lpBgFJMprisLSlAQIDJiABIVgg+23/BZux7LK0/KQgCiQGtdr51ar+vfTtHWpRtN17gOwiWCBstV918mugVBexg/rdZjTs0wN/upHFoyBiAJCaGVD8OA==" end let(:params) do { clientData: Base64.encode64(client_data_param.to_json), attestation: attestation, name: "My Yubikey", } end ## # The above attestation was generated in localhost; Discourse.current_hostname # returns test.localhost which we do not want let(:options) do { session: secure_session, factor_type: UserSecurityKey.factor_types[:second_factor] } end let(:challenge) { DiscourseWebauthn.stage_challenge(current_user, secure_session).challenge } let(:current_user) { Fabricate(:user) } context "when the client data webauthn type is not webauthn.create" do let(:client_data_webauthn_type) { "webauthn.explode" } it "raises an InvalidTypeError" do expect { service.register_security_key }.to raise_error( DiscourseWebauthn::InvalidTypeError, I18n.t("webauthn.validation.invalid_type_error"), ) end end context "when the decoded challenge does not match the original challenge provided by the server" do let(:client_data_challenge) { Base64.encode64("invalid challenge") } it "raises a ChallengeMismatchError" do expect { service.register_security_key }.to raise_error( DiscourseWebauthn::ChallengeMismatchError, I18n.t("webauthn.validation.challenge_mismatch_error"), ) end end context "when the origin of the client data does not match the server origin" do let(:client_data_origin) { "https://someothersite.com" } it "raises a InvalidOriginError" do expect { service.register_security_key }.to raise_error( DiscourseWebauthn::InvalidOriginError, I18n.t("webauthn.validation.invalid_origin_error"), ) end end context "when the sha256 hash of the relaying party ID does not match the one in attestation.authData" do it "raises a InvalidRelyingPartyIdError" do DiscourseWebauthn.stubs(:rp_id).returns("bad_rp_id") expect { service.register_security_key }.to raise_error( DiscourseWebauthn::InvalidRelyingPartyIdError, I18n.t("webauthn.validation.invalid_relying_party_id_error"), ) end end context "when the public key algorithm is not supported by the server" do before do @original_supported_alg_value = DiscourseWebauthn::SUPPORTED_ALGORITHMS silence_warnings { DiscourseWebauthn::SUPPORTED_ALGORITHMS = [-999] } end it "raises a UnsupportedPublicKeyAlgorithmError" do expect { service.register_security_key }.to raise_error( DiscourseWebauthn::UnsupportedPublicKeyAlgorithmError, I18n.t("webauthn.validation.unsupported_public_key_algorithm_error"), ) end after do silence_warnings { DiscourseWebauthn::SUPPORTED_ALGORITHMS = @original_supported_alg_value } end end context "when the attestation format is not supported" do before do @original_supported_alg_value = DiscourseWebauthn::VALID_ATTESTATION_FORMATS silence_warnings { DiscourseWebauthn::VALID_ATTESTATION_FORMATS = ["err"] } end it "raises a UnsupportedAttestationFormatError" do expect { service.register_security_key }.to raise_error( DiscourseWebauthn::UnsupportedAttestationFormatError, I18n.t("webauthn.validation.unsupported_attestation_format_error"), ) end after do silence_warnings do DiscourseWebauthn::VALID_ATTESTATION_FORMATS = @original_supported_alg_value end end end context "when the credential id is already in use for any user" do it "raises a CredentialIdInUseError" do # register the key to the current user security_key = service.register_security_key # update the key to be on a different user other_user = Fabricate(:user) security_key.update(user: other_user) # error! expect { service.register_security_key }.to raise_error( DiscourseWebauthn::CredentialIdInUseError, I18n.t("webauthn.validation.credential_id_in_use_error"), ) end end context "when the attestation data is malformed" do let(:attestation) do "blah/krrmihjLHmVzzuoMdl2NBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQFmvayWc8OPJ4jj4sevfxBmvUglDMZrFalyokYrdnqOVvudC0lQialaGQv72eBzJM2Qn1GfJI7lpBgFJMprisLSlAQIDJiABIVgg+23/BZux7LK0/KQgCiQGtdr51ar+vfTtHWpRtN17gOwiWCBstV918mugVBexg/rdZjTs0wN/upHFoyBiAJCaGVD8OA==" end it "raises a MalformedAttestationError" do expect { service.register_security_key }.to raise_error( DiscourseWebauthn::MalformedAttestationError, I18n.t("webauthn.validation.malformed_attestation_error"), ) end end context "when the user presence flag is false" do it "raises a UserPresenceError" do # simulate missing user presence by flipping first bit to 0 flags = "00000010" overridenAuthData = service.send(:auth_data) overridenAuthData[32] = [flags].pack("b*") service.instance_variable_set(:@auth_data, overridenAuthData) expect { service.register_security_key }.to raise_error( DiscourseWebauthn::UserPresenceError, I18n.t("webauthn.validation.user_presence_error"), ) end end it "registers a valid second-factor key" do key = service.register_security_key expect(key).to be_a(UserSecurityKey) expect(key.user).to eq(current_user) expect(key.factor_type).to eq(UserSecurityKey.factor_types[:second_factor]) end describe "registering a second factor key as first factor" do let(:options) do { factor_type: UserSecurityKey.factor_types[:first_factor], session: secure_session } end it "does not work since second-factor key does not have the user verification flag" do expect { service.register_security_key }.to raise_error( DiscourseWebauthn::UserVerificationError, I18n.t("webauthn.validation.user_verification_error"), ) end end describe "registering a passkey" do let(:options) do { factor_type: UserSecurityKey.factor_types[:first_factor], session: secure_session } end ## # key registered locally using # - localhost:3000 as the origin (via an origin override in discourse_webauthn.rb) # - frontend webauthn.create has user verification flag enabled let(:attestation) do "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAICRXq4sFZ9XpWZOzfJ8EguJmoEPMzNVyFMUWQfT5u1QzpQECAyYgASFYILjOiAHAwNrXkCk/tmyYRiE87QyV/15wUvhcXhr1JfwtIlggClQywgQvSxTsqV/FSK0cNHTTmuwfzzREqE6eLDmPxmI=" end it "works with a valid key" do key = service.register_security_key expect(key).to be_a(UserSecurityKey) expect(key.user).to eq(current_user) expect(key.factor_type).to eq(UserSecurityKey.factor_types[:first_factor]) end context "when the user verification flag in the key is false" do it "raises a UserVerificationError" do # simulate missing user verification by flipping third bit to 0 flags = "10000010" # correct flag sequence is "10100010" overriden_auth_data = service.send(:auth_data) overriden_auth_data[32] = [flags].pack("b*") service.instance_variable_set(:@auth_data, overriden_auth_data) expect { service.register_security_key }.to raise_error( DiscourseWebauthn::UserVerificationError, I18n.t("webauthn.validation.user_verification_error"), ) end end end end