require "test_helper" class ActionPack::PasskeyTest < ActiveSupport::TestCase setup do @identity = identities(:kevin) @private_key = OpenSSL::PKey::EC.generate("prime256v1") ActionPack::WebAuthn::Current.host = "www.example.com" ActionPack::WebAuthn::Current.origin = "http://www.example.com" @passkey = @identity.passkeys.create!( credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false), public_key: @private_key.public_to_der, sign_count: 0, transports: [ "internal" ] ) end test "authenticate with valid assertion" do challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge assertion = build_assertion(challenge: challenge) result = @passkey.authenticate(assertion, challenge: challenge) assert_equal @passkey, result end test "authenticate returns nil with invalid signature" do challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge assertion = build_assertion(challenge: challenge) assertion[:signature] = Base64.urlsafe_encode64("invalid", padding: false) assert_nil @passkey.authenticate(assertion, challenge: challenge) end test "authenticate updates sign count and backed_up" do challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge assertion = build_assertion(challenge: challenge, sign_count: 5, backed_up: true) @passkey.authenticate(assertion, challenge: challenge) assert_equal 5, @passkey.reload.sign_count assert @passkey.backed_up? end test "to_public_key_credential" do credential = @passkey.to_public_key_credential assert_equal @passkey.credential_id, credential.id assert_equal @passkey.sign_count, credential.sign_count assert_equal @passkey.transports, credential.transports end private def build_assertion(challenge:, sign_count: 1, backed_up: false) origin = ActionPack::WebAuthn::Current.origin client_data_json = { challenge: challenge, origin: origin, type: "webauthn.get" }.to_json authenticator_data = build_authenticator_data(sign_count: sign_count, backed_up: backed_up) signature = sign(authenticator_data, client_data_json) { id: @passkey.credential_id, client_data_json: client_data_json, authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false), signature: Base64.urlsafe_encode64(signature, padding: false) } end def build_authenticator_data(sign_count:, backed_up: false) rp_id_hash = Digest::SHA256.digest(ActionPack::WebAuthn::Current.host) flags = 0x01 | 0x04 # user present + user verified flags |= 0x08 | 0x10 if backed_up # backup eligible + backup state bytes = [] bytes.concat(rp_id_hash.bytes) bytes << flags bytes.concat([ sign_count ].pack("N").bytes) bytes.pack("C*") end def sign(authenticator_data, client_data_json) client_data_hash = Digest::SHA256.digest(client_data_json) signed_data = authenticator_data + client_data_hash @private_key.sign("SHA256", signed_data) end end