├── .rspec ├── .github ├── FUNDING.yml ├── dependabot.yml ├── workflows │ ├── git.yml │ └── build.yml └── actions │ ├── install-openssl │ └── action.yml │ └── install-ruby │ └── action.yml ├── spec ├── conformance │ ├── .ruby-version │ ├── conformance_patches.rb │ ├── Gemfile │ ├── README.md │ ├── mds_finder.rb │ ├── MDSROOT.crt │ ├── conformance_cache_store.rb │ ├── Gemfile.lock │ └── server.rb ├── support │ └── roots │ │ ├── android_safetynet_root.crt │ │ ├── microsoft_tpm_root_certificate_authority_2014.cer │ │ ├── feitian_ft_fido_0200.pem │ │ ├── android_key_root.pem │ │ └── yubico_u2f_root.pem ├── webauthn │ ├── credential_request_options_spec.rb │ ├── fake_client_spec.rb │ ├── attestation_statement │ │ ├── none_spec.rb │ │ ├── apple_spec.rb │ │ ├── android_safetynet_spec.rb │ │ ├── fido_u2f_spec.rb │ │ └── android_key_spec.rb │ ├── u2f_migrator_spec.rb │ ├── authenticator_data │ │ └── attested_credential_data_spec.rb │ ├── credential_creation_options_spec.rb │ ├── authenticator_data_spec.rb │ ├── credential_spec.rb │ ├── public_key_credential │ │ ├── request_options_spec.rb │ │ └── creation_options_spec.rb │ ├── public_key_spec.rb │ └── public_key_credential_with_attestation_spec.rb ├── webauthn_spec.rb └── spec_helper.rb ├── assets └── webauthn-ruby.png ├── lib ├── webauthn │ ├── version.rb │ ├── error.rb │ ├── credential_rp_entity.rb │ ├── credential_entity.rb │ ├── credential_options.rb │ ├── public_key_credential │ │ ├── entity.rb │ │ ├── rp_entity.rb │ │ ├── user_entity.rb │ │ ├── options.rb │ │ ├── request_options.rb │ │ └── creation_options.rb │ ├── credential_user_entity.rb │ ├── encoder.rb │ ├── attestation_statement │ │ ├── none.rb │ │ ├── fido_u2f │ │ │ └── public_key.rb │ │ ├── android_safetynet.rb │ │ ├── android_key.rb │ │ ├── apple.rb │ │ ├── fido_u2f.rb │ │ ├── packed.rb │ │ ├── tpm.rb │ │ └── base.rb │ ├── public_key_credential_with_attestation.rb │ ├── credential.rb │ ├── json_serializer.rb │ ├── credential_request_options.rb │ ├── public_key_credential_with_assertion.rb │ ├── client_data.rb │ ├── attestation_object.rb │ ├── encoders.rb │ ├── attestation_statement.rb │ ├── configuration.rb │ ├── public_key.rb │ ├── authenticator_data │ │ └── attested_credential_data.rb │ ├── authenticator_attestation_response.rb │ ├── public_key_credential.rb │ ├── authenticator_assertion_response.rb │ ├── fake_authenticator │ │ ├── attestation_object.rb │ │ └── authenticator_data.rb │ ├── credential_creation_options.rb │ ├── u2f_migrator.rb │ ├── authenticator_data.rb │ ├── fake_authenticator.rb │ ├── authenticator_response.rb │ ├── relying_party.rb │ └── fake_client.rb ├── webauthn.rb └── cose │ └── rsapkcs1_algorithm.rb ├── bin ├── setup └── console ├── Gemfile ├── Rakefile ├── .gitignore ├── SECURITY.md ├── LICENSE.txt ├── CONTRIBUTING.md ├── webauthn.gemspec ├── docs └── u2f_migration.md └── .rubocop.yml /.rspec: -------------------------------------------------------------------------------- 1 | --order rand 2 | --color 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [cedarcode] 2 | -------------------------------------------------------------------------------- /spec/conformance/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.2 2 | -------------------------------------------------------------------------------- /assets/webauthn-ruby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-ruby/HEAD/assets/webauthn-ruby.png -------------------------------------------------------------------------------- /lib/webauthn/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | VERSION = "3.4.3" 5 | end 6 | -------------------------------------------------------------------------------- /lib/webauthn/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | class Error < StandardError; end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/roots/android_safetynet_root.crt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-ruby/HEAD/spec/support/roots/android_safetynet_root.crt -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/support/roots/microsoft_tpm_root_certificate_authority_2014.cer: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-ruby/HEAD/spec/support/roots/microsoft_tpm_root_certificate_authority_2014.cer -------------------------------------------------------------------------------- /lib/webauthn/credential_rp_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/credential_entity" 4 | 5 | module WebAuthn 6 | class CredentialRPEntity < CredentialEntity 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/conformance/conformance_patches.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tpm/key_attestation" 4 | 5 | ::TPM.send(:remove_const, "VENDOR_IDS") 6 | ::TPM::VENDOR_IDS = { "id:FFFFF1D0" => "FIDO Alliance" }.freeze 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in webauthn.gemspec 8 | gemspec 9 | -------------------------------------------------------------------------------- /lib/webauthn/credential_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | class CredentialEntity 5 | attr_reader :name 6 | 7 | def initialize(name:) 8 | @name = name 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rubocop/rake_task" 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new 9 | 10 | task default: [:rubocop, :spec] 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | /Gemfile.lock 14 | /gemfiles/*.gemfile.lock 15 | .byebug_history 16 | /spec/conformance/metadata.zip 17 | -------------------------------------------------------------------------------- /spec/conformance/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | ruby file: '.ruby-version' 6 | 7 | gem "byebug" 8 | gem "fido_metadata", "~> 0.5.0" 9 | gem "puma", "~> 6.6" 10 | gem "rack-contrib" 11 | gem "rackup", "~> 2.2" 12 | gem "rubyzip" 13 | gem "sinatra", "~> 4.0" 14 | gem "sinatra-contrib" 15 | gem "webauthn", path: File.join("..", "..") 16 | gem "webrick", "~> 1.9" 17 | -------------------------------------------------------------------------------- /lib/webauthn/credential_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | module WebAuthn 6 | class CredentialOptions 7 | CHALLENGE_LENGTH = 32 8 | 9 | def challenge 10 | @challenge ||= SecureRandom.random_bytes(CHALLENGE_LENGTH) 11 | end 12 | 13 | def timeout 14 | @timeout = WebAuthn.configuration.credential_options_timeout 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | class PublicKeyCredential 5 | class Entity 6 | include JSONSerializer 7 | 8 | attr_reader :name 9 | 10 | def initialize(name:) 11 | @name = name 12 | end 13 | 14 | private 15 | 16 | def attributes 17 | [:name] 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "webauthn" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /lib/webauthn/credential_user_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/credential_entity" 4 | 5 | module WebAuthn 6 | class CredentialUserEntity < CredentialEntity 7 | attr_reader :id, :display_name 8 | 9 | def initialize(id:, display_name: nil, **keyword_arguments) 10 | super(**keyword_arguments) 11 | 12 | @id = id 13 | @display_name = display_name || name 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/webauthn.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/json_serializer" 4 | require "webauthn/configuration" 5 | require "webauthn/credential" 6 | require "webauthn/credential_creation_options" 7 | require "webauthn/credential_request_options" 8 | require "webauthn/version" 9 | 10 | module WebAuthn 11 | TYPE_PUBLIC_KEY = "public-key" 12 | 13 | def self.generate_user_id 14 | configuration.encoder.encode(SecureRandom.random_bytes(64)) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/webauthn/encoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/encoders" 4 | 5 | module WebAuthn 6 | class Encoder 7 | extend Forwardable 8 | 9 | # https://www.w3.org/TR/webauthn-2/#base64url-encoding 10 | STANDARD_ENCODING = :base64url 11 | 12 | def_delegators :@encoder_klass, :encode, :decode 13 | 14 | def initialize(encoding = STANDARD_ENCODING) 15 | @encoder_klass = Encoders.lookup(encoding) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/rp_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/public_key_credential/entity" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class RPEntity < Entity 8 | attr_reader :id 9 | 10 | def initialize(id: nil, **keyword_arguments) 11 | super(**keyword_arguments) 12 | 13 | @id = id 14 | end 15 | 16 | private 17 | 18 | def attributes 19 | super.concat([:id]) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/none.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/attestation_statement/base" 4 | 5 | module WebAuthn 6 | module AttestationStatement 7 | class None < Base 8 | def valid?(*_args) 9 | if statement == {} && trustworthy? 10 | [WebAuthn::AttestationStatement::ATTESTATION_TYPE_NONE, nil] 11 | else 12 | false 13 | end 14 | end 15 | 16 | private 17 | 18 | def attestation_type 19 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_NONE 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 2.5.z | :white_check_mark: | 8 | | 2.4.z | :white_check_mark: | 9 | | 2.3.z | :white_check_mark: | 10 | | 2.2.z | :x: | 11 | | 2.1.z | :x: | 12 | | 2.0.z | :x: | 13 | | 1.18.z | :white_check_mark: | 14 | | < 1.18 | :x: | 15 | 16 | ## Reporting a Vulnerability 17 | 18 | If you have discovered a security bug, please send an email to security@cedarcode.com 19 | instead of posting to the GitHub issue tracker. 20 | 21 | Thank you! 22 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/user_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/public_key_credential/entity" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class UserEntity < Entity 8 | attr_reader :id, :display_name 9 | 10 | def initialize(id:, display_name: nil, **keyword_arguments) 11 | super(**keyword_arguments) 12 | 13 | @id = id 14 | @display_name = display_name || name 15 | end 16 | 17 | private 18 | 19 | def attributes 20 | super.concat([:id, :display_name]) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/roots/feitian_ft_fido_0200.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIBfjCCASWgAwIBAgIBATAKBggqhkjOPQQDAjAXMRUwEwYDVQQDDAxGVCBGSURP 3 | IDAyMDAwIBcNMTYwNTAxMDAwMDAwWhgPMjA1MDA1MDEwMDAwMDBaMBcxFTATBgNV 4 | BAMMDEZUIEZJRE8gMDIwMDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNBmrRqV 5 | OxztTJVN19vtdqcL7tKQeol2nnM2/yYgvksZnr50SKbVgIEkzHQVOu80LVEE3lVh 6 | eO1HjggxAlT6o4WjYDBeMB0GA1UdDgQWBBRJFWQt1bvG3jM6XgmV/IcjNtO/CzAf 7 | BgNVHSMEGDAWgBRJFWQt1bvG3jM6XgmV/IcjNtO/CzAMBgNVHRMEBTADAQH/MA4G 8 | A1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNHADBEAiAwfPqgIWIUB+QBBaVGsdHy 9 | 0s5RMxlkzpSX/zSyTZmUpQIgB2wJ6nZRM8oX/nA43Rh6SJovM2XwCCH//+LirBAb 10 | B0M= 11 | -----END CERTIFICATE----- 12 | -------------------------------------------------------------------------------- /.github/workflows/git.yml: -------------------------------------------------------------------------------- 1 | # Syntax reference: 2 | # https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions 3 | name: Git Checks 4 | 5 | on: 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | # Fixup commits are OK in pull requests, but should generally be squashed 11 | # before merging to master, e.g. using `git rebase -i --autosquash master`. 12 | # See https://github.com/marketplace/actions/block-autosquash-commits 13 | block-fixup: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v6 18 | - name: Block autosquash commits 19 | uses: xt0rted/block-autosquash-commits-action@v2 20 | with: 21 | repo-token: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential_with_attestation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/authenticator_attestation_response" 4 | require "webauthn/public_key_credential" 5 | 6 | module WebAuthn 7 | class PublicKeyCredentialWithAttestation < PublicKeyCredential 8 | def self.response_class 9 | WebAuthn::AuthenticatorAttestationResponse 10 | end 11 | 12 | def verify(challenge, user_presence: nil, user_verification: nil) 13 | super 14 | 15 | response.verify(encoder.decode(challenge), user_presence: user_presence, user_verification: user_verification) 16 | 17 | true 18 | end 19 | 20 | def public_key 21 | if raw_public_key 22 | encoder.encode(raw_public_key) 23 | end 24 | end 25 | 26 | def raw_public_key 27 | response&.authenticator_data&.credential&.public_key 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/conformance/README.md: -------------------------------------------------------------------------------- 1 | # FIDO2 conformance test server 2 | 3 | This is a [REST API](https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-server-v2.0-rd-20180702.html#transport-binding-profile) 4 | implementation for use with the FIDO2 Conformance Test Tool, which can be obtained after registration at 5 | https://fidoalliance.org/certification/functional-certification/conformance/ 6 | 7 | The code contained herein is _**not**_ representative of a production implementation of a WebAuthn relying party. 8 | 9 | ## Usage 10 | 11 | Install dependencies using Bundler: 12 | ``` 13 | cd spec/conformance 14 | bundle install 15 | ``` 16 | 17 | Start the server: 18 | ``` 19 | bundle exec ruby server.rb 20 | ``` 21 | 22 | Configure the FIDO2 Test Tool to use the following server URL: `http://localhost:4567` and run any of the server tests. 23 | 24 | For running the Metadata Service Tests, click "Download server metadata" and store the file in the same directory as 25 | `server.rb` before starting the server. 26 | -------------------------------------------------------------------------------- /spec/conformance/mds_finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fido_metadata' 4 | 5 | class MDSFinder 6 | extend Forwardable 7 | 8 | def_delegator :fido_metadata_configuration, :cache_backend, :cache_backend 9 | def_delegator :fido_metadata_configuration, :cache_backend=, :cache_backend= 10 | def_delegator :fido_metadata_configuration, :metadata_token, :token 11 | def_delegator :fido_metadata_configuration, :metadata_token=, :token= 12 | 13 | def find(aaguid: nil, attestation_certificate_key_id: nil, **_args) 14 | metadata_statement = 15 | if aaguid 16 | fido_metadata_store.fetch_statement(aaguid: aaguid) 17 | else 18 | fido_metadata_store.fetch_statement(attestation_certificate_key_id: attestation_certificate_key_id) 19 | end 20 | 21 | metadata_statement&.attestation_root_certificates || [] 22 | end 23 | 24 | private 25 | 26 | def fido_metadata_store 27 | @fido_metadata_store ||= FidoMetadata::Store.new 28 | end 29 | 30 | def fido_metadata_configuration 31 | FidoMetadata.configuration 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/webauthn/credential.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/public_key_credential/creation_options" 4 | require "webauthn/public_key_credential/request_options" 5 | require "webauthn/public_key_credential_with_assertion" 6 | require "webauthn/public_key_credential_with_attestation" 7 | 8 | module WebAuthn 9 | module Credential 10 | def self.options_for_create(**keyword_arguments) 11 | WebAuthn::PublicKeyCredential::CreationOptions.new(**keyword_arguments) 12 | end 13 | 14 | def self.options_for_get(**keyword_arguments) 15 | WebAuthn::PublicKeyCredential::RequestOptions.new(**keyword_arguments) 16 | end 17 | 18 | def self.from_create(credential, relying_party: WebAuthn.configuration.relying_party) 19 | WebAuthn::PublicKeyCredentialWithAttestation.from_client(credential, relying_party: relying_party) 20 | end 21 | 22 | def self.from_get(credential, relying_party: WebAuthn.configuration.relying_party) 23 | WebAuthn::PublicKeyCredentialWithAssertion.from_client(credential, relying_party: relying_party) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Gonzalo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/webauthn/credential_request_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "webauthn/credential_request_options" 5 | 6 | RSpec.describe WebAuthn::CredentialRequestOptions do 7 | let(:request_options) { WebAuthn::CredentialRequestOptions.new } 8 | 9 | it "has a challenge" do 10 | expect(request_options.challenge.class).to eq(String) 11 | expect(request_options.challenge.encoding).to eq(Encoding::ASCII_8BIT) 12 | expect(request_options.challenge.length).to eq(32) 13 | end 14 | 15 | it "has allowCredentials param with an empty array" do 16 | expect(request_options.allow_credentials).to match_array([]) 17 | end 18 | 19 | context "client timeout" do 20 | it "has a default client timeout" do 21 | expect(request_options.timeout).to(eq(120000)) 22 | end 23 | 24 | context "when client timeout is configured" do 25 | before do 26 | WebAuthn.configuration.credential_options_timeout = 60000 27 | end 28 | 29 | it "updates the client timeout" do 30 | expect(request_options.timeout).to(eq(60000)) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/webauthn/fake_client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "FakeClient" do 6 | let(:client) { WebAuthn::FakeClient.new } 7 | 8 | context "#get" do 9 | let!(:credential_1) { client.create } 10 | let!(:credential_2) { client.create } 11 | 12 | it "returns the first matching credential when allow_credentials is nil" do 13 | assertion = client.get 14 | expect(assertion["id"]).to eq(credential_1["id"]) 15 | end 16 | 17 | it "returns the matching credential when allow_credentials is passed" do 18 | allow_credentials = [credential_2["id"]] 19 | assertion = client.get(allow_credentials: allow_credentials) 20 | expect(assertion["id"]).to eq(credential_2["id"]) 21 | end 22 | 23 | it "raises an error when no matching allow_credential can be found" do 24 | # base64(abc) is surely not a valid credential id (too short) 25 | allow_credentials = ["YWJj"] 26 | expect { client.get(allow_credentials: allow_credentials) }.to \ 27 | raise_error(RuntimeError, /No matching credentials \(allowed=\["abc"\]\) found for RP/) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/webauthn/json_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | module JSONSerializer 5 | # Argument wildcard for Ruby on Rails controller automatic object JSON serialization 6 | def as_json(*) 7 | deep_camelize_keys(to_hash) 8 | end 9 | 10 | private 11 | 12 | def to_hash 13 | attributes.each_with_object({}) do |attribute_name, hash| 14 | value = send(attribute_name) 15 | 16 | if value.respond_to?(:as_json) 17 | value = value.as_json 18 | end 19 | 20 | if value 21 | hash[attribute_name] = value 22 | end 23 | end 24 | end 25 | 26 | def deep_camelize_keys(object) 27 | case object 28 | when Hash 29 | object.each_with_object({}) do |(key, value), result| 30 | result[camelize(key)] = deep_camelize_keys(value) 31 | end 32 | when Array 33 | object.map { |element| deep_camelize_keys(element) } 34 | else 35 | object 36 | end 37 | end 38 | 39 | def camelize(term) 40 | first_term, *rest = term.to_s.split('_') 41 | 42 | [first_term, *rest.map(&:capitalize)].join.to_sym 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/support/roots/android_key_root.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDFTCCArygAwIBAgIJAPYG0nMp5fspMAoGCCqGSM49BAMCMIHcMT0wOwYDVQQD 3 | DDRGQUtFIEFuZHJvaWQgS2V5c3RvcmUgU29mdHdhcmUgQXR0ZXN0YXRpb24gUm9v 4 | dCBGQUtFMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxs 5 | aWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMSIwIAYDVQQLDBlBdXRo 6 | ZW50aWNhdG9yIEF0dGVzdGF0aW9uMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkx 7 | EjAQBgNVBAcMCVdha2VmaWVsZDAgFw0xOTA0MjUwNTQ5MzJaGA8yMDc0MDEyNjA1 8 | NDkzMlowgdwxPTA7BgNVBAMMNEZBS0UgQW5kcm9pZCBLZXlzdG9yZSBTb2Z0d2Fy 9 | ZSBBdHRlc3RhdGlvbiBSb290IEZBS0UxMTAvBgkqhkiG9w0BCQEWImNvbmZvcm1h 10 | bmNlLXRvb2xzQGZpZG9hbGxpYW5jZS5vcmcxFjAUBgNVBAoMDUZJRE8gQWxsaWFu 11 | Y2UxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xCzAJBgNVBAYT 12 | AlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMFkwEwYHKoZIzj0C 13 | AQYIKoZIzj0DAQcDQgAEaH+BmKuo1XRtjS3UJVLrjh0bNl/lLhU7VX89N8+kuI8r 14 | nZCfXVD5k3qpOFSY6oTtrcZ1aQRSyR4sZ8EC6Hoi7aNjMGEwDwYDVR0TAQH/BAUw 15 | AwEB/zAOBgNVHQ8BAf8EBAMCAoQwHQYDVR0OBBYEFFKaGzLgVqrNUQ/vX4A3Bovy 16 | kSMdMB8GA1UdIwQYMBaAFFKaGzLgVqrNUQ/vX4A3BovykSMdMAoGCCqGSM49BAMC 17 | A0cAMEQCIAfWMAOOjxO6noErB+wtRBmrDruNIApw70G7kBhliXLkAiBM8sYUVClP 18 | sJkCUHI5Wix3ybY31L5J51VS+f1gACNn+Q== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /spec/support/roots/yubico_u2f_root.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZ 3 | dWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAw 4 | MDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290 5 | IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK 6 | AoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk 7 | 5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep 8 | 8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbw 9 | nebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT 10 | 9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXw 11 | LvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJ 12 | hjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAN 13 | BgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4 14 | MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kt 15 | hX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2k 16 | LVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1U 17 | sG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqc 18 | U9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /lib/webauthn/credential_request_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/credential_options" 4 | 5 | module WebAuthn 6 | def self.credential_request_options 7 | warn( 8 | "DEPRECATION WARNING: `WebAuthn.credential_request_options` is deprecated."\ 9 | " Please use `WebAuthn::Credential.options_for_get` instead." 10 | ) 11 | 12 | CredentialRequestOptions.new.to_h 13 | end 14 | 15 | class CredentialRequestOptions < CredentialOptions 16 | attr_accessor :allow_credentials, :extensions, :user_verification 17 | 18 | def initialize(allow_credentials: [], extensions: nil, user_verification: nil) 19 | super() 20 | 21 | @allow_credentials = allow_credentials 22 | @extensions = extensions 23 | @user_verification = user_verification 24 | end 25 | 26 | def to_h 27 | options = { 28 | challenge: challenge, 29 | timeout: timeout, 30 | allowCredentials: allow_credentials 31 | } 32 | 33 | if extensions 34 | options[:extensions] = extensions 35 | end 36 | 37 | if user_verification 38 | options[:userVerification] = user_verification 39 | end 40 | 41 | options 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/fido_u2f/public_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "cose/key/ec2" 5 | require "webauthn/attestation_statement/base" 6 | 7 | module WebAuthn 8 | module AttestationStatement 9 | class FidoU2f < Base 10 | class PublicKey 11 | COORDINATE_LENGTH = 32 12 | UNCOMPRESSED_FORM_INDICATOR = "\x04" 13 | 14 | def self.uncompressed_point?(data) 15 | data.size && 16 | data.length == UNCOMPRESSED_FORM_INDICATOR.length + COORDINATE_LENGTH * 2 && 17 | data[0] == UNCOMPRESSED_FORM_INDICATOR 18 | end 19 | 20 | def initialize(data) 21 | @data = data 22 | end 23 | 24 | def valid? 25 | data.size >= COORDINATE_LENGTH * 2 && 26 | cose_key.x.length == COORDINATE_LENGTH && 27 | cose_key.y.length == COORDINATE_LENGTH && 28 | cose_key.alg == COSE::Algorithm.by_name("ES256").id 29 | end 30 | 31 | def to_uncompressed_point 32 | UNCOMPRESSED_FORM_INDICATOR + cose_key.x + cose_key.y 33 | end 34 | 35 | private 36 | 37 | attr_reader :data 38 | 39 | def cose_key 40 | @cose_key ||= COSE::Key::EC2.deserialize(data) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class Options 8 | include JSONSerializer 9 | 10 | CHALLENGE_LENGTH = 32 11 | 12 | attr_reader :timeout, :extensions, :relying_party 13 | 14 | def initialize(timeout: nil, extensions: nil, relying_party: WebAuthn.configuration.relying_party) 15 | @relying_party = relying_party 16 | @timeout = timeout || default_timeout 17 | @extensions = default_extensions.merge(extensions || {}) 18 | end 19 | 20 | def challenge 21 | encoder.encode(raw_challenge) 22 | end 23 | 24 | private 25 | 26 | def attributes 27 | [:challenge, :timeout, :extensions] 28 | end 29 | 30 | def encoder 31 | relying_party.encoder 32 | end 33 | 34 | def raw_challenge 35 | @raw_challenge ||= SecureRandom.random_bytes(CHALLENGE_LENGTH) 36 | end 37 | 38 | def default_timeout 39 | relying_party.credential_options_timeout 40 | end 41 | 42 | def default_extensions 43 | {} 44 | end 45 | 46 | def as_public_key_descriptors(ids) 47 | Array(ids).map { |id| { type: TYPE_PUBLIC_KEY, id: id } } 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/request_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/public_key_credential/options" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class RequestOptions < Options 8 | attr_accessor :rp_id, :allow, :user_verification 9 | 10 | def initialize(rp_id: nil, allow_credentials: nil, allow: nil, user_verification: nil, **keyword_arguments) 11 | super(**keyword_arguments) 12 | 13 | @rp_id = rp_id || relying_party.id 14 | @allow_credentials = allow_credentials 15 | @allow = allow 16 | @user_verification = user_verification 17 | end 18 | 19 | def allow_credentials 20 | @allow_credentials || allow_credentials_from_allow || [] 21 | end 22 | 23 | private 24 | 25 | def attributes 26 | super.concat([:allow_credentials, :rp_id, :user_verification]) 27 | end 28 | 29 | def default_extensions 30 | extensions = super || {} 31 | 32 | if relying_party.legacy_u2f_appid 33 | extensions.merge!(appid: relying_party.legacy_u2f_appid) 34 | end 35 | 36 | extensions 37 | end 38 | 39 | def allow_credentials_from_allow 40 | if allow 41 | as_public_key_descriptors(allow) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential_with_assertion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/authenticator_assertion_response" 4 | require "webauthn/public_key_credential" 5 | 6 | module WebAuthn 7 | class PublicKeyCredentialWithAssertion < PublicKeyCredential 8 | def self.response_class 9 | WebAuthn::AuthenticatorAssertionResponse 10 | end 11 | 12 | def verify(challenge, public_key:, sign_count:, user_presence: nil, user_verification: nil) 13 | super 14 | 15 | response.verify( 16 | encoder.decode(challenge), 17 | public_key: encoder.decode(public_key), 18 | sign_count: sign_count, 19 | user_presence: user_presence, 20 | user_verification: user_verification, 21 | rp_id: appid_extension_output ? appid : nil 22 | ) 23 | 24 | true 25 | end 26 | 27 | def user_handle 28 | if raw_user_handle 29 | encoder.encode(raw_user_handle) 30 | end 31 | end 32 | 33 | def raw_user_handle 34 | response.user_handle 35 | end 36 | 37 | private 38 | 39 | def appid_extension_output 40 | return if client_extension_outputs.nil? 41 | 42 | client_extension_outputs['appid'] 43 | end 44 | 45 | def appid 46 | URI.parse(relying_party.legacy_u2f_appid || raise("Unspecified legacy U2F AppID")).to_s 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/conformance/MDSROOT.crt: -------------------------------------------------------------------------------- 1 | !!!!!DO NOT DYNAMICALLY FETCH THIS CERTIFICATE!!!!! 2 | !!!!!ADD THIS CERTIFICATE DIRECTLY TO YOUR CERTIFICATE STORAGE OR SOURCE CODE!!!!! 3 | 4 | FIDO Alliance Certification TEST Metadata Service Root Certificate 5 | Expected page status: Valid 6 | CN=FAKE Root FAKE 7 | OU=FAKE Metadata 3 BLOB Signing FAKE 8 | O=FIDO Alliance 9 | C=US 10 | Serial number=04 5A 1C 22 66 A1 4F 3F 1F 4D 29 55 12 23 15 11 | Valid from=01 February 2017 12 | Valid to=31 January 2045 13 | 14 | Base64 15 | -----BEGIN CERTIFICATE----- 16 | MIICaDCCAe6gAwIBAgIPBCqih0DiJLW7+UHXx/o1MAoGCCqGSM49BAMDMGcxCzAJ 17 | BgNVBAYTAlVTMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMScwJQYDVQQLDB5GQUtF 18 | IE1ldGFkYXRhIDMgQkxPQiBST09UIEZBS0UxFzAVBgNVBAMMDkZBS0UgUm9vdCBG 19 | QUtFMB4XDTE3MDIwMTAwMDAwMFoXDTQ1MDEzMTIzNTk1OVowZzELMAkGA1UEBhMC 20 | VVMxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxJzAlBgNVBAsMHkZBS0UgTWV0YWRh 21 | dGEgMyBCTE9CIFJPT1QgRkFLRTEXMBUGA1UEAwwORkFLRSBSb290IEZBS0UwdjAQ 22 | BgcqhkjOPQIBBgUrgQQAIgNiAASKYiz3YltC6+lmxhPKwA1WFZlIqnX8yL5RybSL 23 | TKFAPEQeTD9O6mOz+tg8wcSdnVxHzwnXiQKJwhrav70rKc2ierQi/4QUrdsPes8T 24 | EirZOkCVJurpDFbXZOgs++pa4XmjYDBeMAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8E 25 | BTADAQH/MB0GA1UdDgQWBBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAfBgNVHSMEGDAW 26 | gBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAKBggqhkjOPQQDAwNoADBlAjEA/xFsgri0 27 | xubSa3y3v5ormpPqCwfqn9s0MLBAtzCIgxQ/zkzPKctkiwoPtDzI51KnAjAmeMyg 28 | X2S5Ht8+e+EQnezLJBJXtnkRWY+Zt491wgt/AwSs5PHHMv5QgjELOuMxQBc= 29 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /lib/webauthn/client_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | require "openssl" 5 | require "webauthn/encoder" 6 | require "webauthn/error" 7 | 8 | module WebAuthn 9 | class ClientDataMissingError < Error; end 10 | 11 | class ClientData 12 | VALID_TOKEN_BINDING_STATUSES = ["present", "supported", "not-supported"].freeze 13 | 14 | def initialize(client_data_json) 15 | @client_data_json = client_data_json 16 | end 17 | 18 | def type 19 | data["type"] 20 | end 21 | 22 | def challenge 23 | WebAuthn.standard_encoder.decode(data["challenge"]) 24 | end 25 | 26 | def origin 27 | data["origin"] 28 | end 29 | 30 | def token_binding 31 | data["tokenBinding"] 32 | end 33 | 34 | def cross_origin 35 | data["crossOrigin"] 36 | end 37 | 38 | def top_origin 39 | data["topOrigin"] 40 | end 41 | 42 | def valid_token_binding_format? 43 | if token_binding 44 | token_binding.is_a?(Hash) && VALID_TOKEN_BINDING_STATUSES.include?(token_binding["status"]) 45 | else 46 | true 47 | end 48 | end 49 | 50 | def hash 51 | OpenSSL::Digest::SHA256.digest(client_data_json) 52 | end 53 | 54 | private 55 | 56 | attr_reader :client_data_json 57 | 58 | def data 59 | @data ||= 60 | if client_data_json 61 | JSON.parse(client_data_json) 62 | else 63 | raise ClientDataMissingError, "Client Data JSON is missing" 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cbor" 4 | require "forwardable" 5 | require "openssl" 6 | require "webauthn/attestation_statement" 7 | require "webauthn/authenticator_data" 8 | 9 | module WebAuthn 10 | class AttestationObject 11 | extend Forwardable 12 | 13 | def self.deserialize(attestation_object, relying_party) 14 | from_map(CBOR.decode(attestation_object), relying_party) 15 | end 16 | 17 | def self.from_map(map, relying_party) 18 | new( 19 | authenticator_data: WebAuthn::AuthenticatorData.deserialize(map["authData"]), 20 | attestation_statement: WebAuthn::AttestationStatement.from( 21 | map["fmt"], 22 | map["attStmt"], 23 | relying_party: relying_party 24 | ) 25 | ) 26 | end 27 | 28 | attr_reader :authenticator_data, :attestation_statement, :relying_party 29 | 30 | def initialize(authenticator_data:, attestation_statement:) 31 | @authenticator_data = authenticator_data 32 | @attestation_statement = attestation_statement 33 | end 34 | 35 | def valid_attested_credential? 36 | authenticator_data.attested_credential_data_included? && 37 | authenticator_data.attested_credential_data.valid? 38 | end 39 | 40 | def valid_attestation_statement?(client_data_hash) 41 | attestation_statement.valid?(authenticator_data, client_data_hash) 42 | end 43 | 44 | def_delegators :authenticator_data, :credential, :aaguid 45 | def_delegators :attestation_statement, :attestation_certificate_key_id 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/cose/rsapkcs1_algorithm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose" 4 | require "cose/algorithm/signature_algorithm" 5 | require "cose/error" 6 | require "cose/key/rsa" 7 | require "openssl/signature_algorithm/rsapkcs1" 8 | 9 | class RSAPKCS1Algorithm < COSE::Algorithm::SignatureAlgorithm 10 | attr_reader :hash_function 11 | 12 | def initialize(*args, hash_function:) 13 | super(*args) 14 | 15 | @hash_function = hash_function 16 | end 17 | 18 | private 19 | 20 | def signature_algorithm_class 21 | OpenSSL::SignatureAlgorithm::RSAPKCS1 22 | end 23 | 24 | def valid_key?(key) 25 | to_cose_key(key).is_a?(COSE::Key::RSA) 26 | end 27 | 28 | def to_pkey(key) 29 | case key 30 | when COSE::Key::RSA 31 | key.to_pkey 32 | when OpenSSL::PKey::RSA 33 | key 34 | else 35 | raise(COSE::Error, "Incompatible key for algorithm") 36 | end 37 | end 38 | end 39 | 40 | COSE::Algorithm.register(RSAPKCS1Algorithm.new(-257, "RS256", hash_function: "SHA256")) 41 | COSE::Algorithm.register(RSAPKCS1Algorithm.new(-258, "RS384", hash_function: "SHA384")) 42 | COSE::Algorithm.register(RSAPKCS1Algorithm.new(-259, "RS512", hash_function: "SHA512")) 43 | 44 | # Patch openssl-signature_algorithm gem to support discouraged/deprecated RSA-PKCS#1 with SHA-1 45 | # (RS1 in JOSE/COSE terminology) algorithm needed for WebAuthn. 46 | OpenSSL::SignatureAlgorithm::RSAPKCS1.const_set( 47 | :ACCEPTED_HASH_FUNCTIONS, 48 | OpenSSL::SignatureAlgorithm::RSAPKCS1::ACCEPTED_HASH_FUNCTIONS + ["SHA1"] 49 | ) 50 | COSE::Algorithm.register(RSAPKCS1Algorithm.new(-65535, "RS1", hash_function: "SHA1")) 51 | -------------------------------------------------------------------------------- /lib/webauthn/encoders.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | def self.standard_encoder 5 | @standard_encoder ||= Encoders.lookup(Encoder::STANDARD_ENCODING) 6 | end 7 | 8 | module Encoders 9 | class << self 10 | def lookup(encoding) 11 | case encoding 12 | when :base64 13 | Base64Encoder 14 | when :base64url 15 | Base64UrlEncoder 16 | when nil, false 17 | NullEncoder 18 | else 19 | raise "Unsupported or unknown encoding: #{encoding}" 20 | end 21 | end 22 | end 23 | 24 | class Base64Encoder 25 | def self.encode(data) 26 | [data].pack("m0") # Base64.strict_encode64(data) 27 | end 28 | 29 | def self.decode(data) 30 | data.unpack1("m0") # Base64.strict_decode64(data) 31 | end 32 | end 33 | 34 | class Base64UrlEncoder 35 | def self.encode(data) 36 | data = [data].pack("m0") # Base64.urlsafe_encode64(data, padding: false) 37 | data.chomp!("==") or data.chomp!("=") 38 | data.tr!("+/", "-_") 39 | data 40 | end 41 | 42 | def self.decode(data) 43 | if !data.end_with?("=") && data.length % 4 != 0 # Base64.urlsafe_decode64(data) 44 | data = data.ljust((data.length + 3) & ~3, "=") 45 | end 46 | 47 | data = data.tr("-_", "+/") 48 | data.unpack1("m0") 49 | end 50 | end 51 | 52 | class NullEncoder 53 | def self.encode(data) 54 | data 55 | end 56 | 57 | def self.decode(data) 58 | data 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/none_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "webauthn/attestation_statement/none" 6 | 7 | RSpec.describe "none attestation" do 8 | let(:authenticator_data_bytes) do 9 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 10 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "localhost"), 11 | aaguid: 0.chr * 16, 12 | ).serialize 13 | end 14 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 15 | 16 | describe "#valid?" do 17 | it "returns true if the statement is an empty map" do 18 | expect(WebAuthn::AttestationStatement::None.new({}).valid?(authenticator_data, nil)).to be_truthy 19 | end 20 | 21 | it "returns attestation info" do 22 | attestation_statement = WebAuthn::AttestationStatement::None.new({}) 23 | expect(attestation_statement.valid?(authenticator_data, nil)).to eq( 24 | ["None", nil] 25 | ) 26 | end 27 | 28 | it "returns false if the statement is something else" do 29 | expect(WebAuthn::AttestationStatement::None.new(nil).valid?(authenticator_data, nil)).to be_falsy 30 | expect(WebAuthn::AttestationStatement::None.new("").valid?(authenticator_data, nil)).to be_falsy 31 | expect(WebAuthn::AttestationStatement::None.new([]).valid?(authenticator_data, nil)).to be_falsy 32 | expect(WebAuthn::AttestationStatement::None.new("a" => "b").valid?(authenticator_data, nil)).to be_falsy 33 | end 34 | 35 | it "returns false if None is not among the acceptable attestation types" do 36 | WebAuthn.configuration.acceptable_attestation_types = ['AttCA'] 37 | 38 | expect(WebAuthn::AttestationStatement::None.new({}).valid?(authenticator_data, nil)).to be_falsy 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/attestation_statement/android_key" 4 | require "webauthn/attestation_statement/android_safetynet" 5 | require "webauthn/attestation_statement/apple" 6 | require "webauthn/attestation_statement/fido_u2f" 7 | require "webauthn/attestation_statement/none" 8 | require "webauthn/attestation_statement/packed" 9 | require "webauthn/attestation_statement/tpm" 10 | require "webauthn/error" 11 | 12 | module WebAuthn 13 | module AttestationStatement 14 | class FormatNotSupportedError < Error; end 15 | 16 | ATTESTATION_FORMAT_NONE = "none" 17 | ATTESTATION_FORMAT_FIDO_U2F = "fido-u2f" 18 | ATTESTATION_FORMAT_PACKED = 'packed' 19 | ATTESTATION_FORMAT_ANDROID_SAFETYNET = "android-safetynet" 20 | ATTESTATION_FORMAT_ANDROID_KEY = "android-key" 21 | ATTESTATION_FORMAT_TPM = "tpm" 22 | ATTESTATION_FORMAT_APPLE = "apple" 23 | 24 | FORMAT_TO_CLASS = { 25 | ATTESTATION_FORMAT_NONE => WebAuthn::AttestationStatement::None, 26 | ATTESTATION_FORMAT_FIDO_U2F => WebAuthn::AttestationStatement::FidoU2f, 27 | ATTESTATION_FORMAT_PACKED => WebAuthn::AttestationStatement::Packed, 28 | ATTESTATION_FORMAT_ANDROID_SAFETYNET => WebAuthn::AttestationStatement::AndroidSafetynet, 29 | ATTESTATION_FORMAT_ANDROID_KEY => WebAuthn::AttestationStatement::AndroidKey, 30 | ATTESTATION_FORMAT_TPM => WebAuthn::AttestationStatement::TPM, 31 | ATTESTATION_FORMAT_APPLE => WebAuthn::AttestationStatement::Apple 32 | }.freeze 33 | 34 | def self.from(format, statement, relying_party: WebAuthn.configuration.relying_party) 35 | klass = FORMAT_TO_CLASS[format] 36 | 37 | if klass 38 | klass.new(statement, relying_party) 39 | else 40 | raise(FormatNotSupportedError, "Unsupported attestation format '#{format}'") 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/webauthn/u2f_migrator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/seeds" 5 | require "byebug" 6 | 7 | require "webauthn/u2f_migrator" 8 | 9 | RSpec.describe WebAuthn::U2fMigrator do 10 | subject(:u2f_migrator) do 11 | described_class.new( 12 | app_id: app_id, 13 | certificate: stored_credential[:certificate], 14 | key_handle: stored_credential[:key_handle], 15 | public_key: stored_credential[:public_key], 16 | counter: 41 17 | ) 18 | end 19 | 20 | let(:stored_credential) { seeds[:u2f_migration][:stored_credential] } 21 | let(:app_id) { URI("https://example.org") } 22 | 23 | it "returns the credential ID" do 24 | expect(WebAuthn::Encoders::Base64Encoder.encode(u2f_migrator.credential.id)) 25 | .to eq("pLpuLSz+xDZI19JcXtVlm8GPK3gVOFJ+vUkt4DJWvfQ=") 26 | end 27 | 28 | it "returns the credential public key in COSE format" do 29 | public_key = COSE::Key.deserialize(u2f_migrator.credential.public_key) 30 | 31 | expect(public_key.alg).to eq(-7) 32 | expect(public_key.crv).to eq(1) 33 | expect(public_key.x).to eq(WebAuthn::Encoders::Base64Encoder.decode("sNYt5rMPhvC6x6kBaVE5HC4xhJ4uZGYcvSsTzX1VCK0=")) 34 | expect(public_key.y).to eq(WebAuthn::Encoders::Base64Encoder.decode("UDsL2io1eppLNEdaKOZbZgtImKnj6bvwgg1DSUKX7dA=")) 35 | end 36 | 37 | it "returns the signature counter" do 38 | expect(u2f_migrator.authenticator_data.sign_count).to eq(41) 39 | end 40 | 41 | it "returns the 'Basic or AttCA' attestation type" do 42 | expect(u2f_migrator.attestation_type).to eq("Basic_or_AttCA") 43 | end 44 | 45 | it "returns the attestation certificate" do 46 | certificate = u2f_migrator.attestation_trust_path.first 47 | 48 | expect(certificate.subject.to_s).to eq("/CN=WebAuthn test vectors/O=W3C/OU=Authenticator Attestation/C=AA") 49 | expect(certificate.issuer.to_s).to eq("/CN=WebAuthn test vectors/O=W3C/OU=Authenticator Attestation CA/C=AA") 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/webauthn/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | require 'webauthn/relying_party' 5 | 6 | module WebAuthn 7 | def self.configuration 8 | @configuration ||= Configuration.new 9 | end 10 | 11 | def self.configure 12 | yield(configuration) 13 | end 14 | 15 | class Configuration 16 | extend Forwardable 17 | 18 | def_delegators :@relying_party, 19 | :algorithms, 20 | :algorithms=, 21 | :encoding, 22 | :encoding=, 23 | :origin, 24 | :origin=, 25 | :allowed_origins, 26 | :allowed_origins=, 27 | :allowed_top_origins, 28 | :allowed_top_origins=, 29 | :verify_attestation_statement, 30 | :verify_attestation_statement=, 31 | :verify_cross_origin, 32 | :verify_cross_origin=, 33 | :credential_options_timeout, 34 | :credential_options_timeout=, 35 | :silent_authentication, 36 | :silent_authentication=, 37 | :acceptable_attestation_types, 38 | :acceptable_attestation_types=, 39 | :attestation_root_certificates_finders, 40 | :attestation_root_certificates_finders=, 41 | :encoder, 42 | :encoder=, 43 | :legacy_u2f_appid, 44 | :legacy_u2f_appid= 45 | 46 | attr_reader :relying_party 47 | 48 | def initialize 49 | @relying_party = RelyingParty.new 50 | end 51 | 52 | def rp_name 53 | relying_party.name 54 | end 55 | 56 | def rp_name=(name) 57 | relying_party.name = name 58 | end 59 | 60 | def rp_id 61 | relying_party.id 62 | end 63 | 64 | def rp_id=(id) 65 | relying_party.id = id 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/webauthn/authenticator_data/attested_credential_data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe WebAuthn::AuthenticatorData::AttestedCredentialData do 6 | let(:cose_key_data) { fake_cose_credential_key } 7 | 8 | def raw_attested_credential_data(options = {}) 9 | options = { 10 | aaguid: SecureRandom.random_bytes(16), 11 | id: SecureRandom.random_bytes(16), 12 | public_key: cose_key_data 13 | }.merge(options) 14 | 15 | options[:aaguid] + [options[:id].length].pack("n*") + options[:id] + options[:public_key] 16 | end 17 | 18 | describe "#valid?" do 19 | it "returns false if public key is missing" do 20 | raw_data = raw_attested_credential_data(public_key: CBOR.encode("")) 21 | 22 | attested_credential_data = 23 | WebAuthn::AuthenticatorData::AttestedCredentialData.deserialize(raw_data) 24 | 25 | expect { attested_credential_data.valid? }.to raise_error(COSE::UnknownKeyType) 26 | expect { attested_credential_data.credential }.to raise_error(COSE::UnknownKeyType) 27 | end 28 | 29 | it "returns false if public key doesn't contain the alg parameter" do 30 | raw_data = raw_attested_credential_data(public_key: fake_cose_credential_key(algorithm: nil)) 31 | 32 | attested_credential_data = 33 | WebAuthn::AuthenticatorData::AttestedCredentialData.deserialize(raw_data) 34 | 35 | expect(attested_credential_data.valid?).to be_falsy 36 | expect(attested_credential_data.credential).to eq(nil) 37 | end 38 | 39 | it "returns true if all data is present" do 40 | raw_data = raw_attested_credential_data(id: "this-is-a-credential-id") 41 | attested_credential_data = WebAuthn::AuthenticatorData::AttestedCredentialData.deserialize(raw_data) 42 | 43 | expect(attested_credential_data.valid?).to be_truthy 44 | expect(attested_credential_data.credential.id).to eq("this-is-a-credential-id") 45 | expect(attested_credential_data.credential.public_key).to eq(cose_key_data) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/conformance/conformance_cache_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fido_metadata" 4 | require "fido_metadata/test_cache_store" 5 | require "zip" 6 | 7 | class ConformanceCacheStore < FidoMetadata::TestCacheStore 8 | FILENAME = "metadata.zip" 9 | 10 | def setup_authenticators 11 | if File.exist?(FILENAME) 12 | Zip::File.open(FILENAME).glob("metadataStatements/*.json") do |file| 13 | json = JSON.parse(file.get_input_stream.read) 14 | statement = FidoMetadata::Statement.from_json(json) 15 | identifier = statement.aaguid || statement.attestation_certificate_key_identifiers.first 16 | write("statement_#{identifier}", statement) 17 | end 18 | else 19 | puts("#{FILENAME} not found, this will affect Metadata Service Test results.") 20 | end 21 | end 22 | 23 | def setup_metadata_store(endpoint) 24 | puts("Setting up metadata store TOC") 25 | 26 | response = Net::HTTP.post( 27 | URI("https://mds3.fido.tools/getEndpoints"), 28 | { endpoint: endpoint }.to_json, 29 | FidoMetadata::Client::DEFAULT_HEADERS 30 | ) 31 | 32 | response.value 33 | possible_endpoints = JSON.parse(response.body)["result"] 34 | 35 | client = FidoMetadata::Client.new 36 | 37 | json = 38 | possible_endpoints.each_with_index do |uri, index| 39 | puts("Trying endpoint #{index}: #{uri}") 40 | break client.download_toc(URI(uri), algorithms: ["ES256"], trusted_certs: conformance_certificates) 41 | rescue FidoMetadata::Client::DataIntegrityError, JWT::VerificationError, Net::HTTPFatalError 42 | nil 43 | end 44 | 45 | if json.is_a?(Hash) && json.keys == ["legalHeader", "no", "nextUpdate", "entries"] 46 | puts("TOC setup done!") 47 | toc = FidoMetadata::TableOfContents.from_json(json) 48 | write("metadata_toc", toc) 49 | else 50 | puts("Unable to setup TOC!") 51 | end 52 | end 53 | 54 | private 55 | 56 | def conformance_certificates 57 | file = File.read(File.join(__dir__, "MDSROOT.crt")) 58 | 59 | [OpenSSL::X509::Certificate.new(file)] 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /.github/actions/install-openssl/action.yml: -------------------------------------------------------------------------------- 1 | name: Install OpenSSL 2 | 3 | inputs: 4 | version: 5 | description: 'The version of OpenSSL to install' 6 | required: true 7 | 8 | runs: 9 | using: 'composite' 10 | steps: 11 | - name: Restore cached OpenSSL library 12 | id: cache-openssl-restore 13 | uses: actions/cache/restore@v4 14 | with: 15 | path: ~/openssl 16 | key: openssl-${{ inputs.version }} 17 | 18 | - name: Compile OpenSSL library 19 | if: steps.cache-openssl-restore.outputs.cache-hit != 'true' 20 | shell: bash 21 | run: | 22 | mkdir -p tmp/build-openssl && cd tmp/build-openssl 23 | case ${{ inputs.version }} in 24 | 1.1.*) 25 | OPENSSL_COMMIT=OpenSSL_ 26 | OPENSSL_COMMIT+=$(echo ${{ inputs.version }} | sed -e 's/\./_/g') 27 | git clone -b $OPENSSL_COMMIT --depth 1 https://github.com/openssl/openssl.git . 28 | echo "Git commit: $(git rev-parse HEAD)" 29 | ./Configure --prefix=$HOME/openssl --libdir=lib linux-x86_64 30 | make depend && make -j4 && make install_sw 31 | ;; 32 | 3.*) 33 | OPENSSL_COMMIT=openssl- 34 | OPENSSL_COMMIT+=$(echo ${{ inputs.version }}) 35 | git clone -b $OPENSSL_COMMIT --depth 1 https://github.com/openssl/openssl.git . 36 | echo "Git commit: $(git rev-parse HEAD)" 37 | if [[ ${{ inputs.version }} == 3.5* ]]; then 38 | ./Configure --prefix=$HOME/openssl --libdir=lib enable-fips no-tests no-legacy 39 | else 40 | ./Configure --prefix=$HOME/openssl --libdir=lib enable-fips no-tests 41 | fi 42 | make -j4 && make install_sw && make install_fips 43 | ;; 44 | *) 45 | echo "Don't know how to build OpenSSL ${{ inputs.version }}" 46 | ;; 47 | esac 48 | 49 | - name: Save OpenSSL library cache 50 | if: steps.cache-openssl-restore.outputs.cache-hit != 'true' 51 | id: cache-openssl-save 52 | uses: actions/cache/save@v4 53 | with: 54 | path: ~/openssl 55 | key: ${{ steps.cache-openssl-restore.outputs.cache-primary-key }} 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to webauthn-ruby 2 | 3 | ### How? 4 | 5 | - Creating a new issue to report a bug 6 | - Creating a new issue to suggest a new feature 7 | - Commenting on an existing issue to answer an open question 8 | - Commenting on an existing issue to ask the reporter for more details to aid reproducing the problem 9 | - Improving documentation 10 | - Creating a pull request that fixes an issue (see [beginner friendly issues](https://github.com/cedarcode/webauthn-ruby/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)) 11 | - Creating a pull request that implements a new feature (worth first creating an issue to discuss the suggested feature) 12 | 13 | ### Development 14 | 15 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run the tests and code-style checks. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 16 | 17 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 18 | 19 | ### Styleguide 20 | 21 | #### Ruby 22 | 23 | We use [rubocop](https://rubygems.org/gems/rubocop) to check ruby code style. 24 | 25 | #### Git commit messages 26 | 27 | We try to follow [Conventional Commits](https://conventionalcommits.org) specification since `v1.17.0`. 28 | 29 | On top of `fix` and `feat` types, we also use optional: 30 | 31 | * __build__: Changes that affect the build system or external dependencies 32 | * __ci__: Changes to the CI configuration files and scripts 33 | * __docs__: Documentation only changes 34 | * __perf__: A code change that improves performance 35 | * __refactor__: A code change that neither fixes a bug nor adds a feature 36 | * __style__: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 37 | * __test__: Adding missing tests or correcting existing tests 38 | 39 | Partially inspired in [Angular's Commit Message Guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines). 40 | -------------------------------------------------------------------------------- /webauthn.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "webauthn/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "webauthn" 9 | spec.version = WebAuthn::VERSION 10 | spec.authors = ["Gonzalo Rodriguez", "Braulio Martinez"] 11 | spec.email = ["gonzalo@cedarcode.com", "braulio@cedarcode.com"] 12 | 13 | spec.summary = "WebAuthn ruby server library" 14 | spec.description = 'WebAuthn ruby server library ― Make your application a W3C Web Authentication conformant 15 | Relying Party and allow your users to authenticate with U2F and FIDO2 authenticators.' 16 | spec.homepage = "https://github.com/cedarcode/webauthn-ruby" 17 | spec.license = "MIT" 18 | 19 | spec.metadata = { 20 | "bug_tracker_uri" => "https://github.com/cedarcode/webauthn-ruby/issues", 21 | "changelog_uri" => "https://github.com/cedarcode/webauthn-ruby/blob/master/CHANGELOG.md", 22 | "source_code_uri" => "https://github.com/cedarcode/webauthn-ruby" 23 | } 24 | 25 | spec.files = 26 | `git ls-files -z`.split("\x0").reject do |f| 27 | f.match(%r{^(test|spec|features|assets)/}) 28 | end 29 | 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | spec.required_ruby_version = ">= 2.5" 35 | 36 | spec.add_dependency "android_key_attestation", "~> 0.3.0" 37 | spec.add_dependency "bindata", "~> 2.4" 38 | spec.add_dependency "cbor", "~> 0.5.9" 39 | spec.add_dependency "cose", "~> 1.1" 40 | spec.add_dependency "openssl", ">= 2.2" 41 | spec.add_dependency "safety_net_attestation", "~> 0.5.0" 42 | spec.add_dependency "tpm-key_attestation", "~> 0.14.0" 43 | 44 | spec.add_development_dependency "bundler", ">= 1.17", "< 3.0" 45 | spec.add_development_dependency "byebug", "~> 11.0" 46 | spec.add_development_dependency "rake", "~> 13.0" 47 | spec.add_development_dependency "rspec", "~> 3.8" 48 | spec.add_development_dependency "rubocop", "~> 1" 49 | spec.add_development_dependency "rubocop-rake", "~> 0.5" 50 | spec.add_development_dependency "rubocop-rspec", ">= 2.2", "< 4.0" 51 | end 52 | -------------------------------------------------------------------------------- /lib/webauthn/public_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "cose/error" 5 | require "cose/key" 6 | require "cose/rsapkcs1_algorithm" 7 | require "webauthn/attestation_statement/fido_u2f/public_key" 8 | 9 | module WebAuthn 10 | class PublicKey 11 | class UnsupportedAlgorithm < Error; end 12 | 13 | def self.deserialize(public_key) 14 | cose_key = 15 | if WebAuthn::AttestationStatement::FidoU2f::PublicKey.uncompressed_point?(public_key) 16 | # Gem version v1.11.0 and lower, used to behave so that Credential#public_key 17 | # returned an EC P-256 uncompressed point. 18 | # 19 | # Because of https://github.com/cedarcode/webauthn-ruby/issues/137 this was changed 20 | # and Credential#public_key started returning the unchanged COSE_Key formatted 21 | # credentialPublicKey (as in https://www.w3.org/TR/webauthn/#credentialpublickey). 22 | # 23 | # Given that the credential public key is expected to be stored long-term by the gem 24 | # user and later be passed as the public_key argument in the 25 | # AuthenticatorAssertionResponse.verify call, we then need to support the two formats. 26 | COSE::Key::EC2.new( 27 | alg: COSE::Algorithm.by_name("ES256").id, 28 | crv: 1, 29 | x: public_key[1..32], 30 | y: public_key[33..-1] 31 | ) 32 | else 33 | COSE::Key.deserialize(public_key) 34 | end 35 | 36 | new(cose_key: cose_key) 37 | end 38 | 39 | attr_reader :cose_key 40 | 41 | def initialize(cose_key:) 42 | @cose_key = cose_key 43 | end 44 | 45 | def pkey 46 | @cose_key.to_pkey 47 | end 48 | 49 | def alg 50 | @cose_key.alg 51 | end 52 | 53 | def verify(signature, verification_data) 54 | cose_algorithm.verify(pkey, signature, verification_data) 55 | rescue COSE::Error 56 | false 57 | end 58 | 59 | private 60 | 61 | def cose_algorithm 62 | @cose_algorithm ||= COSE::Algorithm.find(alg) || raise( 63 | UnsupportedAlgorithm, 64 | "The public key algorithm #{alg} is not among the available COSE algorithms" 65 | ) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/android_safetynet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "safety_net_attestation" 4 | require "openssl" 5 | require "webauthn/attestation_statement/base" 6 | 7 | module WebAuthn 8 | module AttestationStatement 9 | # Implements https://www.w3.org/TR/webauthn-1/#sctn-android-safetynet-attestation 10 | class AndroidSafetynet < Base 11 | def valid?(authenticator_data, client_data_hash) 12 | valid_response?(authenticator_data, client_data_hash) && 13 | valid_version? && 14 | cts_profile_match? && 15 | trustworthy?(aaguid: authenticator_data.aaguid) && 16 | [attestation_type, attestation_trust_path] 17 | end 18 | 19 | private 20 | 21 | def valid_response?(authenticator_data, client_data_hash) 22 | nonce = Digest::SHA256.base64digest(authenticator_data.data + client_data_hash) 23 | 24 | begin 25 | attestation_response 26 | .verify(nonce, trusted_certificates: root_certificates(aaguid: authenticator_data.aaguid), time: time) 27 | rescue SafetyNetAttestation::Error 28 | false 29 | end 30 | end 31 | 32 | # TODO: improve once the spec has clarifications https://github.com/w3c/webauthn/issues/968 33 | def valid_version? 34 | !statement["ver"].empty? 35 | end 36 | 37 | def cts_profile_match? 38 | attestation_response.cts_profile_match? 39 | end 40 | 41 | def valid_certificate_chain?(**_) 42 | # Already performed as part of #valid_response? 43 | true 44 | end 45 | 46 | def attestation_type 47 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC 48 | end 49 | 50 | # SafetyNetAttestation returns full chain including root, WebAuthn expects only the x5c certificates 51 | def certificates 52 | attestation_response.certificate_chain[0..-2] 53 | end 54 | 55 | def attestation_response 56 | @attestation_response ||= SafetyNetAttestation::Statement.new(statement["response"]) 57 | end 58 | 59 | def default_root_certificates 60 | SafetyNetAttestation::Statement::GOOGLE_ROOT_CERTIFICATES 61 | end 62 | 63 | def time 64 | Time.now 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_data/attested_credential_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bindata" 4 | require "cose/key" 5 | require "webauthn/error" 6 | 7 | module WebAuthn 8 | class AttestedCredentialDataFormatError < WebAuthn::Error; end 9 | 10 | class AuthenticatorData < BinData::Record 11 | class AttestedCredentialData < BinData::Record 12 | AAGUID_LENGTH = 16 13 | ZEROED_AAGUID = 0.chr * AAGUID_LENGTH 14 | 15 | ID_LENGTH_LENGTH = 2 16 | 17 | endian :big 18 | 19 | string :raw_aaguid, length: AAGUID_LENGTH 20 | bit16 :id_length 21 | string :id, read_length: :id_length 22 | count_bytes_remaining :trailing_bytes_length 23 | string :trailing_bytes, length: :trailing_bytes_length 24 | 25 | Credential = 26 | Struct.new(:id, :public_key, :algorithm, keyword_init: true) do 27 | def public_key_object 28 | COSE::Key.deserialize(public_key).to_pkey 29 | end 30 | end 31 | 32 | def self.deserialize(data) 33 | read(data) 34 | rescue EOFError 35 | raise AttestedCredentialDataFormatError 36 | end 37 | 38 | def valid? 39 | valid_credential_public_key? 40 | end 41 | 42 | def aaguid 43 | raw_aaguid.unpack("H8H4H4H4H12").join("-") 44 | end 45 | 46 | def credential 47 | @credential ||= 48 | if valid? 49 | Credential.new(id: id, public_key: public_key, algorithm: algorithm) 50 | end 51 | end 52 | 53 | def length 54 | if valid? 55 | AAGUID_LENGTH + ID_LENGTH_LENGTH + id_length + public_key_length 56 | end 57 | end 58 | 59 | private 60 | 61 | def algorithm 62 | COSE::Algorithm.find(cose_key.alg).name 63 | end 64 | 65 | def valid_credential_public_key? 66 | !!cose_key.alg 67 | end 68 | 69 | def cose_key 70 | @cose_key ||= COSE::Key.deserialize(public_key) 71 | end 72 | 73 | def public_key 74 | trailing_bytes[0..public_key_length - 1] 75 | end 76 | 77 | def public_key_length 78 | @public_key_length ||= 79 | CBOR.encode(CBOR::Unpacker.new(StringIO.new(trailing_bytes)).each.first).length 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/conformance/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | webauthn (3.4.2) 5 | android_key_attestation (~> 0.3.0) 6 | bindata (~> 2.4) 7 | cbor (~> 0.5.9) 8 | cose (~> 1.1) 9 | openssl (>= 2.2) 10 | safety_net_attestation (~> 0.5.0) 11 | tpm-key_attestation (~> 0.14.0) 12 | 13 | GEM 14 | remote: https://rubygems.org/ 15 | specs: 16 | android_key_attestation (0.3.0) 17 | base64 (0.3.0) 18 | bindata (2.5.1) 19 | byebug (12.0.0) 20 | cbor (0.5.10.1) 21 | cose (1.3.1) 22 | cbor (~> 0.5.9) 23 | openssl-signature_algorithm (~> 1.0) 24 | fido_metadata (0.5.0) 25 | base64 (>= 0.1.0) 26 | jwt (>= 2.0, < 4) 27 | jwt (2.10.2) 28 | base64 29 | logger (1.7.0) 30 | multi_json (1.17.0) 31 | mustermann (3.0.4) 32 | ruby2_keywords (~> 0.0.1) 33 | nio4r (2.7.4) 34 | openssl (3.3.1) 35 | openssl-signature_algorithm (1.3.0) 36 | openssl (> 2.0) 37 | puma (6.6.1) 38 | nio4r (~> 2.0) 39 | rack (3.2.3) 40 | rack-contrib (2.5.0) 41 | rack (< 4) 42 | rack-protection (4.2.1) 43 | base64 (>= 0.1.0) 44 | logger (>= 1.6.0) 45 | rack (>= 3.0.0, < 4) 46 | rack-session (2.1.1) 47 | base64 (>= 0.1.0) 48 | rack (>= 3.0.0) 49 | rackup (2.2.1) 50 | rack (>= 3) 51 | ruby2_keywords (0.0.5) 52 | rubyzip (3.1.0) 53 | safety_net_attestation (0.5.0) 54 | jwt (>= 2.0, < 4.0) 55 | sinatra (4.2.1) 56 | logger (>= 1.6.0) 57 | mustermann (~> 3.0) 58 | rack (>= 3.0.0, < 4) 59 | rack-protection (= 4.2.1) 60 | rack-session (>= 2.0.0, < 3) 61 | tilt (~> 2.0) 62 | sinatra-contrib (4.2.1) 63 | multi_json (>= 0.0.2) 64 | mustermann (~> 3.0) 65 | rack-protection (= 4.2.1) 66 | sinatra (= 4.2.1) 67 | tilt (~> 2.0) 68 | tilt (2.6.1) 69 | tpm-key_attestation (0.14.1) 70 | bindata (~> 2.4) 71 | openssl (> 2.0) 72 | openssl-signature_algorithm (~> 1.0) 73 | webrick (1.9.1) 74 | 75 | PLATFORMS 76 | ruby 77 | 78 | DEPENDENCIES 79 | byebug 80 | fido_metadata (~> 0.5.0) 81 | puma (~> 6.6) 82 | rack-contrib 83 | rackup (~> 2.2) 84 | rubyzip 85 | sinatra (~> 4.0) 86 | sinatra-contrib 87 | webauthn! 88 | webrick (~> 1.9) 89 | 90 | RUBY VERSION 91 | ruby 3.4.2p28 92 | 93 | BUNDLED WITH 94 | 2.6.5 95 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/android_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "android_key_attestation" 4 | require "openssl" 5 | require "webauthn/attestation_statement/base" 6 | 7 | module WebAuthn 8 | module AttestationStatement 9 | class AndroidKey < Base 10 | def valid?(authenticator_data, client_data_hash) 11 | valid_signature?(authenticator_data, client_data_hash) && 12 | matching_public_key?(authenticator_data) && 13 | valid_attestation_challenge?(client_data_hash) && 14 | all_applications_fields_not_set? && 15 | valid_authorization_list_origin? && 16 | valid_authorization_list_purpose? && 17 | trustworthy?(aaguid: authenticator_data.aaguid) && 18 | [attestation_type, attestation_trust_path] 19 | end 20 | 21 | private 22 | 23 | def valid_attestation_challenge?(client_data_hash) 24 | android_key_attestation.verify_challenge(client_data_hash) 25 | rescue AndroidKeyAttestation::ChallengeMismatchError 26 | false 27 | end 28 | 29 | def valid_certificate_chain?(aaguid: nil, **_) 30 | android_key_attestation.verify_certificate_chain(root_certificates: root_certificates(aaguid: aaguid)) 31 | rescue AndroidKeyAttestation::CertificateVerificationError 32 | false 33 | end 34 | 35 | def all_applications_fields_not_set? 36 | !tee_enforced.all_applications && !software_enforced.all_applications 37 | end 38 | 39 | def valid_authorization_list_origin? 40 | tee_enforced.origin == :generated || software_enforced.origin == :generated 41 | end 42 | 43 | def valid_authorization_list_purpose? 44 | tee_enforced.purpose == [:sign] || software_enforced.purpose == [:sign] 45 | end 46 | 47 | def tee_enforced 48 | android_key_attestation.tee_enforced 49 | end 50 | 51 | def software_enforced 52 | android_key_attestation.software_enforced 53 | end 54 | 55 | def attestation_type 56 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC 57 | end 58 | 59 | def default_root_certificates 60 | AndroidKeyAttestation::Statement::GOOGLE_ROOT_CERTIFICATES 61 | end 62 | 63 | def android_key_attestation 64 | @android_key_attestation ||= AndroidKeyAttestation::Statement.new(*certificates) 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: build 9 | 10 | on: 11 | push: 12 | branches: [master] 13 | pull_request: 14 | types: [opened, synchronize] 15 | 16 | jobs: 17 | test: 18 | name: 'Test Ruby ${{ matrix.ruby }} with OpenSSL ${{ matrix.openssl }}' 19 | runs-on: ubuntu-24.04 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | ruby: 24 | - '3.4' 25 | - '3.3' 26 | - '3.2' 27 | - '3.1' 28 | openssl: 29 | - '3.6.0' 30 | - '3.5.4' 31 | - '3.4.3' 32 | - '3.3.5' 33 | - '3.2.6' 34 | - '3.1.8' 35 | - '3.0.18' 36 | - '1.1.1w' 37 | include: 38 | - ruby: truffleruby 39 | - ruby: '3.0' 40 | openssl: '1.1.1w' 41 | - ruby: '2.7' 42 | openssl: '1.1.1w' 43 | - ruby: '2.6' 44 | openssl: '1.1.1w' 45 | - ruby: '2.5' 46 | openssl: '1.1.1w' 47 | 48 | steps: 49 | - uses: actions/checkout@v6 50 | 51 | - name: Install OpenSSL 52 | if: matrix.ruby != 'truffleruby' 53 | uses: ./.github/actions/install-openssl 54 | with: 55 | version: ${{ matrix.openssl }} 56 | 57 | - name: Manually set up Ruby 58 | if: matrix.ruby != 'truffleruby' 59 | uses: ./.github/actions/install-ruby 60 | with: 61 | version: ${{ matrix.ruby }} 62 | openssl-version: ${{ matrix.openssl }} 63 | 64 | - name: Set up Ruby 65 | if: matrix.ruby == 'truffleruby' 66 | uses: ruby/setup-ruby@v1 67 | with: 68 | ruby-version: ${{ matrix.ruby }} 69 | bundler-cache: true 70 | 71 | - run: bundle exec rspec 72 | env: 73 | RUBYOPT: ${{ startsWith(matrix.ruby, '3.4') && '--enable=frozen-string-literal' || '' }} 74 | 75 | lint: 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: actions/checkout@v6 79 | - uses: ruby/setup-ruby@v1 80 | with: 81 | ruby-version: '3.3' 82 | bundler-cache: true 83 | - run: bundle exec rubocop -f github 84 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/apple.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "openssl" 4 | require "webauthn/attestation_statement/base" 5 | 6 | module WebAuthn 7 | module AttestationStatement 8 | class Apple < Base 9 | # Source: https://www.apple.com/certificateauthority/private/ 10 | ROOT_CERTIFICATE = 11 | OpenSSL::X509::Certificate.new(<<~PEM) 12 | -----BEGIN CERTIFICATE----- 13 | MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w 14 | HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ 15 | bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx 16 | NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG 17 | A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49 18 | AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k 19 | xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/ 20 | pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk 21 | 2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA 22 | MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3 23 | jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B 24 | 1bWeT0vT 25 | -----END CERTIFICATE----- 26 | PEM 27 | 28 | NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2" 29 | 30 | def valid?(authenticator_data, client_data_hash) 31 | valid_nonce?(authenticator_data, client_data_hash) && 32 | matching_public_key?(authenticator_data) && 33 | trustworthy? && 34 | [attestation_type, attestation_trust_path] 35 | end 36 | 37 | private 38 | 39 | def valid_nonce?(authenticator_data, client_data_hash) 40 | extension = cred_cert&.find_extension(NONCE_EXTENSION_OID) 41 | 42 | if extension 43 | sequence = OpenSSL::ASN1.decode(extension.value_der) 44 | 45 | sequence.tag == OpenSSL::ASN1::SEQUENCE && 46 | sequence.value.size == 1 && 47 | sequence.value[0].value[0].value == 48 | OpenSSL::Digest::SHA256.digest(authenticator_data.data + client_data_hash) 49 | end 50 | end 51 | 52 | def attestation_type 53 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_ANONCA 54 | end 55 | 56 | def cred_cert 57 | attestation_certificate 58 | end 59 | 60 | def default_root_certificates 61 | [ROOT_CERTIFICATE] 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/creation_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "webauthn/public_key_credential/options" 5 | require "webauthn/public_key_credential/rp_entity" 6 | require "webauthn/public_key_credential/user_entity" 7 | 8 | module WebAuthn 9 | class PublicKeyCredential 10 | class CreationOptions < Options 11 | attr_accessor( 12 | :attestation, 13 | :authenticator_selection, 14 | :exclude, 15 | :algs, 16 | :rp, 17 | :user 18 | ) 19 | 20 | def initialize( 21 | attestation: nil, 22 | authenticator_selection: nil, 23 | exclude_credentials: nil, 24 | exclude: nil, 25 | pub_key_cred_params: nil, 26 | algs: nil, 27 | rp: {}, 28 | user:, 29 | **keyword_arguments 30 | ) 31 | super(**keyword_arguments) 32 | 33 | @attestation = attestation 34 | @authenticator_selection = authenticator_selection 35 | @exclude_credentials = exclude_credentials 36 | @exclude = exclude 37 | @pub_key_cred_params = pub_key_cred_params 38 | @algs = algs 39 | 40 | @rp = 41 | if rp.is_a?(Hash) 42 | rp[:name] ||= relying_party.name 43 | rp[:id] ||= relying_party.id 44 | 45 | RPEntity.new(**rp) 46 | else 47 | rp 48 | end 49 | 50 | @user = 51 | if user.is_a?(Hash) 52 | UserEntity.new(**user) 53 | else 54 | user 55 | end 56 | end 57 | 58 | def exclude_credentials 59 | @exclude_credentials || exclude_credentials_from_exclude 60 | end 61 | 62 | def pub_key_cred_params 63 | @pub_key_cred_params || pub_key_cred_params_from_algs 64 | end 65 | 66 | private 67 | 68 | def attributes 69 | super.concat([:rp, :user, :pub_key_cred_params, :attestation, :authenticator_selection, :exclude_credentials]) 70 | end 71 | 72 | def exclude_credentials_from_exclude 73 | if exclude 74 | as_public_key_descriptors(exclude) 75 | end 76 | end 77 | 78 | def pub_key_cred_params_from_algs 79 | Array(algs || relying_party.algorithms).map do |alg| 80 | alg_id = 81 | if alg.is_a?(String) || alg.is_a?(Symbol) 82 | COSE::Algorithm.by_name(alg.to_s).id 83 | else 84 | alg 85 | end 86 | 87 | { type: TYPE_PUBLIC_KEY, alg: alg_id } 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/apple_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "openssl" 6 | require "webauthn/attestation_statement/apple" 7 | 8 | RSpec.describe "Apple attestation" do 9 | describe "#valid?" do 10 | let(:credential_key) { create_ec_key } 11 | let(:root_key) { create_ec_key } 12 | let(:root_certificate) { create_root_certificate(root_key) } 13 | 14 | let(:cred_cert) do 15 | issue_certificate(root_certificate, root_key, credential_key, extensions: [cred_cert_extension]) 16 | end 17 | 18 | let(:statement) { WebAuthn::AttestationStatement::Apple.new("x5c" => [cred_cert.to_der]) } 19 | 20 | let(:authenticator_data_bytes) do 21 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 22 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 23 | credential: { id: "0".b * 16, public_key: credential_key.public_key } 24 | ).serialize 25 | end 26 | 27 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 28 | let(:client_data_hash) { OpenSSL::Digest::SHA256.digest({}.to_json) } 29 | 30 | let(:nonce) { Digest::SHA256.digest(authenticator_data.data + client_data_hash) } 31 | let(:cred_cert_extension) do 32 | OpenSSL::X509::Extension.new( 33 | "1.2.840.113635.100.8.2", 34 | OpenSSL::ASN1::Sequence.new( 35 | [OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::OctetString.new(nonce)])] 36 | ) 37 | ) 38 | end 39 | 40 | around do |example| 41 | silence_warnings do 42 | original_apple_certificate = WebAuthn::AttestationStatement::Apple::ROOT_CERTIFICATE 43 | WebAuthn::AttestationStatement::Apple::ROOT_CERTIFICATE = root_certificate 44 | example.run 45 | WebAuthn::AttestationStatement::Apple::ROOT_CERTIFICATE = original_apple_certificate 46 | end 47 | end 48 | 49 | it "works if everything's fine" do 50 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 51 | end 52 | 53 | context "when nonce is invalid" do 54 | let(:nonce) { Digest::SHA256.digest("Invalid") } 55 | 56 | it "fails" do 57 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 58 | end 59 | end 60 | 61 | context "when the credential public key is invalid" do 62 | let(:cred_cert) do 63 | issue_certificate(root_certificate, root_key, create_ec_key, extensions: [cred_cert_extension]) 64 | end 65 | 66 | it "fails" do 67 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/fido_u2f.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose" 4 | require "openssl" 5 | require "webauthn/attestation_statement/base" 6 | require "webauthn/attestation_statement/fido_u2f/public_key" 7 | 8 | module WebAuthn 9 | module AttestationStatement 10 | class FidoU2f < Base 11 | VALID_ATTESTATION_CERTIFICATE_COUNT = 1 12 | VALID_ATTESTATION_CERTIFICATE_ALGORITHM = COSE::Algorithm.by_name("ES256") 13 | VALID_ATTESTATION_CERTIFICATE_KEY_CURVE = COSE::Key::Curve.by_name("P-256") 14 | 15 | def valid?(authenticator_data, client_data_hash) 16 | valid_format? && 17 | valid_certificate_public_key? && 18 | valid_credential_public_key?(authenticator_data.credential.public_key) && 19 | valid_aaguid?(authenticator_data.attested_credential_data.raw_aaguid) && 20 | valid_signature?(authenticator_data, client_data_hash) && 21 | trustworthy?(attestation_certificate_key_id: attestation_certificate_key_id) && 22 | [attestation_type, attestation_trust_path] 23 | end 24 | 25 | private 26 | 27 | def valid_format? 28 | !!(raw_certificates && signature) && 29 | raw_certificates.length == VALID_ATTESTATION_CERTIFICATE_COUNT 30 | end 31 | 32 | def valid_certificate_public_key? 33 | certificate_public_key.is_a?(OpenSSL::PKey::EC) && 34 | certificate_public_key.group.curve_name == VALID_ATTESTATION_CERTIFICATE_KEY_CURVE.pkey_name && 35 | certificate_public_key.check_key 36 | end 37 | 38 | def valid_credential_public_key?(public_key_bytes) 39 | public_key_u2f(public_key_bytes).valid? 40 | end 41 | 42 | def certificate_public_key 43 | attestation_certificate.public_key 44 | end 45 | 46 | def valid_aaguid?(attested_credential_data_aaguid) 47 | attested_credential_data_aaguid == WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID 48 | end 49 | 50 | def algorithm 51 | VALID_ATTESTATION_CERTIFICATE_ALGORITHM.id 52 | end 53 | 54 | def verification_data(authenticator_data, client_data_hash) 55 | "\x00" + 56 | authenticator_data.rp_id_hash + 57 | client_data_hash + 58 | authenticator_data.credential.id + 59 | public_key_u2f(authenticator_data.credential.public_key).to_uncompressed_point 60 | end 61 | 62 | def public_key_u2f(cose_key_data) 63 | PublicKey.new(cose_key_data) 64 | end 65 | 66 | def attestation_type 67 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC_OR_ATTCA 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/packed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "openssl" 4 | require "webauthn/attestation_statement/base" 5 | 6 | module WebAuthn 7 | # Implements https://www.w3.org/TR/2018/CR-webauthn-20180807/#packed-attestation 8 | module AttestationStatement 9 | class Packed < Base 10 | # Follows "Verification procedure" 11 | def valid?(authenticator_data, client_data_hash) 12 | valid_format? && 13 | valid_algorithm?(authenticator_data.credential) && 14 | valid_ec_public_keys?(authenticator_data.credential) && 15 | meet_certificate_requirement? && 16 | matching_aaguid?(authenticator_data.attested_credential_data.raw_aaguid) && 17 | valid_signature?(authenticator_data, client_data_hash) && 18 | trustworthy?(aaguid: authenticator_data.aaguid) && 19 | [attestation_type, attestation_trust_path] 20 | end 21 | 22 | private 23 | 24 | def valid_algorithm?(credential) 25 | !self_attestation? || algorithm == COSE::Key.deserialize(credential.public_key).alg 26 | end 27 | 28 | def self_attestation? 29 | !raw_certificates 30 | end 31 | 32 | def valid_format? 33 | algorithm && signature 34 | end 35 | 36 | def valid_ec_public_keys?(credential) 37 | (certificates&.map(&:public_key) || [credential.public_key_object]) 38 | .select { |pkey| pkey.is_a?(OpenSSL::PKey::EC) } 39 | .all? { |pkey| pkey.check_key } 40 | end 41 | 42 | # Check https://www.w3.org/TR/2018/CR-webauthn-20180807/#packed-attestation-cert-requirements 43 | def meet_certificate_requirement? 44 | if attestation_certificate 45 | subject = attestation_certificate.subject.to_a 46 | 47 | attestation_certificate.version == 2 && 48 | subject.assoc('OU')&.at(1) == "Authenticator Attestation" && 49 | attestation_certificate.find_extension('basicConstraints')&.value == 'CA:FALSE' 50 | else 51 | true 52 | end 53 | end 54 | 55 | def attestation_type 56 | if attestation_trust_path 57 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC_OR_ATTCA # FIXME: use metadata if available 58 | else 59 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_SELF 60 | end 61 | end 62 | 63 | def valid_signature?(authenticator_data, client_data_hash) 64 | super( 65 | authenticator_data, 66 | client_data_hash, 67 | attestation_certificate&.public_key || authenticator_data.credential.public_key_object 68 | ) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_attestation_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cbor" 4 | require "forwardable" 5 | require "uri" 6 | require "openssl" 7 | 8 | require "webauthn/attestation_object" 9 | require "webauthn/authenticator_response" 10 | require "webauthn/client_data" 11 | require "webauthn/encoder" 12 | 13 | module WebAuthn 14 | class AttestationStatementVerificationError < VerificationError; end 15 | class AttestedCredentialVerificationError < VerificationError; end 16 | 17 | class AuthenticatorAttestationResponse < AuthenticatorResponse 18 | extend Forwardable 19 | 20 | def self.from_client(response, relying_party: WebAuthn.configuration.relying_party) 21 | encoder = relying_party.encoder 22 | 23 | new( 24 | attestation_object: encoder.decode(response["attestationObject"]), 25 | transports: response["transports"], 26 | client_data_json: encoder.decode(response["clientDataJSON"]), 27 | relying_party: relying_party 28 | ) 29 | end 30 | 31 | attr_reader :attestation_type, :attestation_trust_path, :transports 32 | 33 | def initialize(attestation_object:, transports: [], **options) 34 | super(**options) 35 | 36 | @attestation_object_bytes = attestation_object 37 | @transports = transports 38 | @relying_party = relying_party 39 | end 40 | 41 | def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_verification: nil, rp_id: nil) 42 | super 43 | 44 | verify_item(:attested_credential) 45 | if relying_party.verify_attestation_statement 46 | verify_item(:attestation_statement) 47 | end 48 | 49 | true 50 | end 51 | 52 | def attestation_object 53 | @attestation_object ||= WebAuthn::AttestationObject.deserialize(attestation_object_bytes, relying_party) 54 | end 55 | 56 | def_delegators( 57 | :attestation_object, 58 | :aaguid, 59 | :attestation_statement, 60 | :attestation_certificate_key_id, 61 | :authenticator_data, 62 | :credential 63 | ) 64 | 65 | alias_method :attestation_certificate_key, :attestation_certificate_key_id 66 | 67 | private 68 | 69 | attr_reader :attestation_object_bytes, :relying_party 70 | 71 | def type 72 | WebAuthn::TYPES[:create] 73 | end 74 | 75 | def valid_attested_credential? 76 | attestation_object.valid_attested_credential? && 77 | relying_party.algorithms.include?(authenticator_data.credential.algorithm) 78 | end 79 | 80 | def valid_attestation_statement? 81 | @attestation_type, @attestation_trust_path = attestation_object.valid_attestation_statement?(client_data.hash) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/webauthn/credential_creation_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "webauthn/credential_creation_options" 5 | 6 | RSpec.describe WebAuthn::CredentialCreationOptions do 7 | let(:creation_options) do 8 | WebAuthn::CredentialCreationOptions.new(user_id: "1", user_name: "User", user_display_name: "User Display") 9 | end 10 | 11 | it "has a challenge" do 12 | expect(creation_options.challenge.class).to eq(String) 13 | expect(creation_options.challenge.encoding).to eq(Encoding::ASCII_8BIT) 14 | expect(creation_options.challenge.length).to eq(32) 15 | end 16 | 17 | context "public key params" do 18 | it "has default public key params" do 19 | params = creation_options.pub_key_cred_params 20 | 21 | array = [ 22 | { type: "public-key", alg: -7 }, 23 | { type: "public-key", alg: -37 }, 24 | { type: "public-key", alg: -257 }, 25 | ] 26 | 27 | expect(params).to match_array(array) 28 | end 29 | 30 | context "when extra alg added" do 31 | before do 32 | WebAuthn.configuration.algorithms << "RS1" 33 | end 34 | 35 | it "is added to public key params" do 36 | params = creation_options.pub_key_cred_params 37 | 38 | array = [ 39 | { type: "public-key", alg: -7 }, 40 | { type: "public-key", alg: -37 }, 41 | { type: "public-key", alg: -257 }, 42 | { type: "public-key", alg: -65535 }, 43 | ] 44 | 45 | expect(params).to match_array(array) 46 | end 47 | end 48 | end 49 | 50 | context "Relying Party info" do 51 | it "has relying party name default" do 52 | expect(creation_options.rp.name).to eq("web-server") 53 | end 54 | 55 | context "when configured" do 56 | before do 57 | WebAuthn.configuration.rp_name = "Example Inc." 58 | end 59 | 60 | it "has the configured values" do 61 | expect(creation_options.rp.name).to eq("Example Inc.") 62 | end 63 | end 64 | end 65 | 66 | it "has user info" do 67 | expect(creation_options.user.id).to eq("1") 68 | expect(creation_options.user.name).to eq("User") 69 | expect(creation_options.user.display_name).to eq("User Display") 70 | end 71 | 72 | context "client timeout" do 73 | it "has a default client timeout" do 74 | expect(creation_options.timeout).to(eq(120000)) 75 | end 76 | 77 | context "when client timeout is configured" do 78 | before do 79 | WebAuthn.configuration.credential_options_timeout = 60000 80 | end 81 | 82 | it "updates the client timeout" do 83 | expect(creation_options.timeout).to(eq(60000)) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/encoder" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class InvalidChallengeError < Error; end 8 | 9 | attr_reader :type, :id, :raw_id, :client_extension_outputs, :authenticator_attachment, :response 10 | 11 | def self.from_client(credential, relying_party: WebAuthn.configuration.relying_party) 12 | new( 13 | type: credential["type"], 14 | id: credential["id"], 15 | raw_id: relying_party.encoder.decode(credential["rawId"]), 16 | client_extension_outputs: credential["clientExtensionResults"], 17 | authenticator_attachment: credential["authenticatorAttachment"], 18 | response: response_class.from_client(credential["response"], relying_party: relying_party), 19 | relying_party: relying_party 20 | ) 21 | end 22 | 23 | def initialize( 24 | type:, 25 | id:, 26 | raw_id:, 27 | response:, 28 | authenticator_attachment: nil, 29 | client_extension_outputs: {}, 30 | relying_party: WebAuthn.configuration.relying_party 31 | ) 32 | @type = type 33 | @id = id 34 | @raw_id = raw_id 35 | @client_extension_outputs = client_extension_outputs 36 | @authenticator_attachment = authenticator_attachment 37 | @response = response 38 | @relying_party = relying_party 39 | end 40 | 41 | def verify(challenge, *_args) 42 | unless valid_class?(challenge) 43 | msg = "challenge must be a String. input challenge class: #{challenge.class}" 44 | 45 | raise(InvalidChallengeError, msg) 46 | end 47 | 48 | valid_type? || raise("invalid type") 49 | valid_id? || raise("invalid id") 50 | 51 | true 52 | end 53 | 54 | def sign_count 55 | authenticator_data&.sign_count 56 | end 57 | 58 | def authenticator_extension_outputs 59 | authenticator_data.extension_data if authenticator_data&.extension_data_included? 60 | end 61 | 62 | def backup_eligible? 63 | authenticator_data&.credential_backup_eligible? 64 | end 65 | 66 | def backed_up? 67 | authenticator_data&.credential_backed_up? 68 | end 69 | 70 | private 71 | 72 | attr_reader :relying_party 73 | 74 | def valid_type? 75 | type == TYPE_PUBLIC_KEY 76 | end 77 | 78 | def valid_id? 79 | raw_id && id && raw_id == WebAuthn.standard_encoder.decode(id) 80 | end 81 | 82 | def valid_class?(challenge) 83 | challenge.is_a?(String) 84 | end 85 | 86 | def authenticator_data 87 | response&.authenticator_data 88 | end 89 | 90 | def encoder 91 | relying_party.encoder 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_assertion_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/authenticator_data" 4 | require "webauthn/authenticator_response" 5 | require "webauthn/encoder" 6 | require "webauthn/public_key" 7 | 8 | module WebAuthn 9 | class SignatureVerificationError < VerificationError; end 10 | class SignCountVerificationError < VerificationError; end 11 | 12 | class AuthenticatorAssertionResponse < AuthenticatorResponse 13 | def self.from_client(response, relying_party: WebAuthn.configuration.relying_party) 14 | encoder = relying_party.encoder 15 | 16 | user_handle = 17 | if response["userHandle"] 18 | encoder.decode(response["userHandle"]) 19 | end 20 | 21 | new( 22 | authenticator_data: encoder.decode(response["authenticatorData"]), 23 | client_data_json: encoder.decode(response["clientDataJSON"]), 24 | signature: encoder.decode(response["signature"]), 25 | user_handle: user_handle, 26 | relying_party: relying_party 27 | ) 28 | end 29 | 30 | attr_reader :user_handle 31 | 32 | def initialize(authenticator_data:, signature:, user_handle: nil, **options) 33 | super(**options) 34 | 35 | @authenticator_data_bytes = authenticator_data 36 | @signature = signature 37 | @user_handle = user_handle 38 | end 39 | 40 | def verify( 41 | expected_challenge, 42 | expected_origin = nil, 43 | public_key:, 44 | sign_count:, 45 | user_presence: nil, 46 | user_verification: nil, 47 | rp_id: nil 48 | ) 49 | super( 50 | expected_challenge, 51 | expected_origin, 52 | user_presence: user_presence, 53 | user_verification: user_verification, 54 | rp_id: rp_id 55 | ) 56 | verify_item(:signature, WebAuthn::PublicKey.deserialize(public_key)) 57 | verify_item(:sign_count, sign_count) 58 | 59 | true 60 | end 61 | 62 | def authenticator_data 63 | @authenticator_data ||= WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) 64 | end 65 | 66 | private 67 | 68 | attr_reader :authenticator_data_bytes, :signature 69 | 70 | def valid_signature?(webauthn_public_key) 71 | webauthn_public_key.verify(signature, authenticator_data_bytes + client_data.hash) 72 | end 73 | 74 | def valid_sign_count?(stored_sign_count) 75 | normalized_sign_count = stored_sign_count || 0 76 | if authenticator_data.sign_count.nonzero? || normalized_sign_count.nonzero? 77 | authenticator_data.sign_count > normalized_sign_count 78 | else 79 | true 80 | end 81 | end 82 | 83 | def type 84 | WebAuthn::TYPES[:get] 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/webauthn/fake_authenticator/attestation_object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cbor" 4 | require "webauthn/fake_authenticator/authenticator_data" 5 | 6 | module WebAuthn 7 | class FakeAuthenticator 8 | class AttestationObject 9 | def initialize( 10 | client_data_hash:, 11 | rp_id_hash:, 12 | credential_id:, 13 | credential_key:, 14 | user_present: true, 15 | user_verified: false, 16 | backup_eligibility: false, 17 | backup_state: false, 18 | attested_credential_data: true, 19 | sign_count: 0, 20 | extensions: nil 21 | ) 22 | @client_data_hash = client_data_hash 23 | @rp_id_hash = rp_id_hash 24 | @credential_id = credential_id 25 | @credential_key = credential_key 26 | @user_present = user_present 27 | @user_verified = user_verified 28 | @backup_eligibility = backup_eligibility 29 | @backup_state = backup_state 30 | @attested_credential_data = attested_credential_data 31 | @sign_count = sign_count 32 | @extensions = extensions 33 | end 34 | 35 | def serialize 36 | CBOR.encode( 37 | "fmt" => "none", 38 | "attStmt" => {}, 39 | "authData" => authenticator_data.serialize 40 | ) 41 | end 42 | 43 | private 44 | 45 | attr_reader( 46 | :client_data_hash, 47 | :rp_id_hash, 48 | :credential_id, 49 | :credential_key, 50 | :user_present, 51 | :user_verified, 52 | :backup_eligibility, 53 | :backup_state, 54 | :attested_credential_data, 55 | :sign_count, 56 | :extensions 57 | ) 58 | 59 | def authenticator_data 60 | @authenticator_data ||= 61 | begin 62 | credential_data = 63 | if attested_credential_data 64 | { id: credential_id, public_key: credential_public_key } 65 | end 66 | 67 | AuthenticatorData.new( 68 | rp_id_hash: rp_id_hash, 69 | credential: credential_data, 70 | user_present: user_present, 71 | user_verified: user_verified, 72 | backup_eligibility: backup_eligibility, 73 | backup_state: backup_state, 74 | sign_count: 0, 75 | extensions: extensions 76 | ) 77 | end 78 | end 79 | 80 | def credential_public_key 81 | case credential_key 82 | when OpenSSL::PKey::RSA, OpenSSL::PKey::EC 83 | credential_key.public_key 84 | when OpenSSL::PKey::PKey 85 | OpenSSL::PKey.read(credential_key.public_to_der) 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/tpm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "openssl" 5 | require "tpm/key_attestation" 6 | require "webauthn/attestation_statement/base" 7 | 8 | module WebAuthn 9 | module AttestationStatement 10 | class TPM < Base 11 | TPM_V2 = "2.0" 12 | 13 | COSE_ALG_TO_TPM = { 14 | "RS1" => { signature: ::TPM::ALG_RSASSA, hash: ::TPM::ALG_SHA1 }, 15 | "RS256" => { signature: ::TPM::ALG_RSASSA, hash: ::TPM::ALG_SHA256 }, 16 | "PS256" => { signature: ::TPM::ALG_RSAPSS, hash: ::TPM::ALG_SHA256 }, 17 | "ES256" => { signature: ::TPM::ALG_ECDSA, hash: ::TPM::ALG_SHA256 }, 18 | }.freeze 19 | 20 | def valid?(authenticator_data, client_data_hash) 21 | attestation_type == ATTESTATION_TYPE_ATTCA && 22 | ver == TPM_V2 && 23 | valid_key_attestation?( 24 | authenticator_data.data + client_data_hash, 25 | authenticator_data.credential.public_key_object, 26 | authenticator_data.aaguid 27 | ) && 28 | matching_aaguid?(authenticator_data.attested_credential_data.raw_aaguid) && 29 | trustworthy?(aaguid: authenticator_data.aaguid) && 30 | [attestation_type, attestation_trust_path] 31 | end 32 | 33 | private 34 | 35 | def valid_key_attestation?(certified_extra_data, key, aaguid) 36 | key_attestation = 37 | ::TPM::KeyAttestation.new( 38 | statement["certInfo"], 39 | signature, 40 | statement["pubArea"], 41 | certificates, 42 | OpenSSL::Digest.digest(cose_algorithm.hash_function, certified_extra_data), 43 | signature_algorithm: tpm_algorithm[:signature], 44 | hash_algorithm: tpm_algorithm[:hash], 45 | trusted_certificates: root_certificates(aaguid: aaguid) 46 | ) 47 | 48 | key_attestation.valid? && key_attestation.key && key_attestation.key.to_pem == key.to_pem 49 | end 50 | 51 | def valid_certificate_chain?(**_) 52 | # Already performed as part of #valid_key_attestation? 53 | true 54 | end 55 | 56 | def default_root_certificates 57 | ::TPM::KeyAttestation::TRUSTED_CERTIFICATES 58 | end 59 | 60 | def tpm_algorithm 61 | COSE_ALG_TO_TPM[cose_algorithm.name] || raise("Unsupported algorithm #{cose_algorithm.name}") 62 | end 63 | 64 | def ver 65 | statement["ver"] 66 | end 67 | 68 | def cose_algorithm 69 | @cose_algorithm ||= COSE::Algorithm.find(algorithm) 70 | end 71 | 72 | def attestation_type 73 | if raw_certificates 74 | ATTESTATION_TYPE_ATTCA 75 | else 76 | raise "Attestation type invalid" 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/webauthn/credential_creation_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "webauthn/credential_options" 5 | require "webauthn/credential_rp_entity" 6 | require "webauthn/credential_user_entity" 7 | 8 | module WebAuthn 9 | def self.credential_creation_options(rp_name: nil, user_name: "web-user", display_name: "web-user", user_id: "1") 10 | warn( 11 | "DEPRECATION WARNING: `WebAuthn.credential_creation_options` is deprecated."\ 12 | " Please use `WebAuthn::Credential.options_for_create` instead." 13 | ) 14 | 15 | CredentialCreationOptions.new( 16 | rp_name: rp_name, user_id: user_id, user_name: user_name, user_display_name: display_name 17 | ).to_h 18 | end 19 | 20 | class CredentialCreationOptions < CredentialOptions 21 | DEFAULT_RP_NAME = "web-server" 22 | 23 | attr_accessor :attestation, :authenticator_selection, :exclude_credentials, :extensions 24 | 25 | def initialize( 26 | attestation: nil, 27 | authenticator_selection: nil, 28 | exclude_credentials: nil, 29 | extensions: nil, 30 | user_id:, 31 | user_name:, 32 | user_display_name: nil, 33 | rp_name: nil 34 | ) 35 | super() 36 | 37 | @attestation = attestation 38 | @authenticator_selection = authenticator_selection 39 | @exclude_credentials = exclude_credentials 40 | @extensions = extensions 41 | @user_id = user_id 42 | @user_name = user_name 43 | @user_display_name = user_display_name 44 | @rp_name = rp_name 45 | end 46 | 47 | def to_h 48 | options = { 49 | challenge: challenge, 50 | pubKeyCredParams: pub_key_cred_params, 51 | timeout: timeout, 52 | user: { id: user.id, name: user.name, displayName: user.display_name }, 53 | rp: { name: rp.name } 54 | } 55 | 56 | if attestation 57 | options[:attestation] = attestation 58 | end 59 | 60 | if authenticator_selection 61 | options[:authenticatorSelection] = authenticator_selection 62 | end 63 | 64 | if exclude_credentials 65 | options[:excludeCredentials] = exclude_credentials 66 | end 67 | 68 | if extensions 69 | options[:extensions] = extensions 70 | end 71 | 72 | options 73 | end 74 | 75 | def pub_key_cred_params 76 | configuration.algorithms.map do |alg_name| 77 | { type: "public-key", alg: COSE::Algorithm.by_name(alg_name).id } 78 | end 79 | end 80 | 81 | def rp 82 | @rp ||= CredentialRPEntity.new(name: rp_name || configuration.rp_name || DEFAULT_RP_NAME) 83 | end 84 | 85 | def user 86 | @user ||= CredentialUserEntity.new(id: user_id, name: user_name, display_name: user_display_name) 87 | end 88 | 89 | private 90 | 91 | attr_reader :user_id, :user_name, :user_display_name, :rp_name 92 | 93 | def configuration 94 | WebAuthn.configuration 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/webauthn/u2f_migrator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'webauthn/fake_client' 4 | require 'webauthn/attestation_statement/fido_u2f' 5 | 6 | module WebAuthn 7 | class U2fMigrator 8 | def initialize(app_id:, certificate:, key_handle:, public_key:, counter:) 9 | @app_id = app_id 10 | @certificate = certificate 11 | @key_handle = key_handle 12 | @public_key = public_key 13 | @counter = counter 14 | end 15 | 16 | def authenticator_data 17 | @authenticator_data ||= WebAuthn::FakeAuthenticator::AuthenticatorData.new( 18 | rp_id_hash: OpenSSL::Digest::SHA256.digest(@app_id.to_s), 19 | credential: { 20 | id: credential_id, 21 | public_key: credential_cose_key 22 | }, 23 | sign_count: @counter, 24 | user_present: true, 25 | user_verified: false, 26 | aaguid: WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID 27 | ) 28 | end 29 | 30 | def credential 31 | @credential ||= 32 | begin 33 | hash = authenticator_data.send(:credential) 34 | WebAuthn::AuthenticatorData::AttestedCredentialData::Credential.new( 35 | id: hash[:id], 36 | public_key: hash[:public_key].serialize 37 | ) 38 | end 39 | end 40 | 41 | def attestation_type 42 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC_OR_ATTCA 43 | end 44 | 45 | def attestation_trust_path 46 | @attestation_trust_path ||= [ 47 | OpenSSL::X509::Certificate.new(WebAuthn::Encoders::Base64Encoder.decode(@certificate)) 48 | ] 49 | end 50 | 51 | private 52 | 53 | # https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-client-to-authenticator-protocol-v2.0-rd-20180702.html#u2f-authenticatorMakeCredential-interoperability 54 | # Let credentialId be a credentialIdLength byte array initialized with CTAP1/U2F response key handle bytes. 55 | def credential_id 56 | WebAuthn::Encoders::Base64UrlEncoder.decode(@key_handle) 57 | end 58 | 59 | # Let x9encodedUserPublicKey be the user public key returned in the U2F registration response message [U2FRawMsgs]. 60 | # Let coseEncodedCredentialPublicKey be the result of converting x9encodedUserPublicKey’s value from ANS X9.62 / 61 | # Sec-1 v2 uncompressed curve point representation [SEC1V2] to COSE_Key representation ([RFC8152] Section 7). 62 | def credential_cose_key 63 | decoded_public_key = WebAuthn::Encoders::Base64Encoder.decode(@public_key) 64 | if WebAuthn::AttestationStatement::FidoU2f::PublicKey.uncompressed_point?(decoded_public_key) 65 | COSE::Key::EC2.new( 66 | alg: COSE::Algorithm.by_name("ES256").id, 67 | crv: 1, 68 | x: decoded_public_key[1..32], 69 | y: decoded_public_key[33..-1] 70 | ) 71 | else 72 | raise "expected U2F public key to be in uncompressed point format" 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /.github/actions/install-ruby/action.yml: -------------------------------------------------------------------------------- 1 | name: Install Ruby 2 | 3 | inputs: 4 | version: 5 | description: 'The version of Ruby to install' 6 | required: true 7 | openssl-version: 8 | description: 'The version of OpenSSL used' 9 | required: true 10 | 11 | runs: 12 | using: 'composite' 13 | steps: 14 | - name: Restore cached Ruby installation 15 | id: cache-ruby-restore 16 | uses: actions/cache/restore@v4 17 | with: 18 | path: ~/rubies/ruby-${{ inputs.version }} 19 | key: ruby-${{ inputs.version }}-with-openssl-${{ inputs.openssl-version }} 20 | 21 | - name: Install Ruby 22 | if: steps.cache-ruby-restore.outputs.cache-hit != 'true' 23 | shell: bash 24 | run: | 25 | latest_patch=$(curl -s https://cache.ruby-lang.org/pub/ruby/${{ inputs.version }}/ \ 26 | | grep -oP "ruby-${{ inputs.version }}\.\d+\.tar\.xz" \ 27 | | grep -oP "\d+(?=\.tar\.xz)" \ 28 | | sort -V | tail -n 1) 29 | wget https://cache.ruby-lang.org/pub/ruby/${{ inputs.version }}/ruby-${{ inputs.version }}.${latest_patch}.tar.xz 30 | tar -xJvf ruby-${{ inputs.version }}.${latest_patch}.tar.xz 31 | cd ruby-${{ inputs.version }}.${latest_patch} 32 | ./configure --prefix=$HOME/rubies/ruby-${{ inputs.version }} --with-openssl-dir=$HOME/openssl 33 | make 34 | make install 35 | 36 | - name: Update PATH 37 | shell: bash 38 | run: | 39 | echo "~/rubies/ruby-${{ inputs.version }}/bin" >> $GITHUB_PATH 40 | 41 | - name: Install Bundler 42 | shell: bash 43 | run: | 44 | case ${{ inputs.version }} in 45 | 2.7* | 3.*) 46 | echo "Skipping Bundler installation for Ruby ${{ inputs.version }}" 47 | ;; 48 | 2.5* | 2.6*) 49 | gem install bundler -v '~> 2.3.0' 50 | ;; 51 | *) 52 | echo "Don't know how to install Bundler for Ruby ${{ inputs.version }}" 53 | ;; 54 | esac 55 | 56 | - name: Save Ruby installation cache 57 | if: steps.cache-ruby-restore.outputs.cache-hit != 'true' 58 | id: cache-ruby-save 59 | uses: actions/cache/save@v4 60 | with: 61 | path: ~/rubies/ruby-${{ inputs.version }} 62 | key: ${{ steps.cache-ruby-restore.outputs.cache-primary-key }} 63 | 64 | - name: Cache Bundler Install 65 | id: cache-bundler-restore 66 | uses: actions/cache/restore@v4 67 | env: 68 | GEMFILE: ${{ env.BUNDLE_GEMFILE || 'Gemfile' }} 69 | with: 70 | path: ~/bundler/cache 71 | key: bundler-ruby-${{ inputs.version }}-${{ inputs.openssl-version }}-${{ hashFiles(env.Gemfile, 'webauthn.gemspec') }} 72 | 73 | - name: Install dependencies 74 | shell: bash 75 | run: | 76 | bundle config set --local path ~/bundler/cache 77 | bundle install 78 | 79 | - name: Save Bundler Install cache 80 | id: cache-bundler-save 81 | uses: actions/cache/save@v4 82 | with: 83 | path: ~/bundler/cache 84 | key: ${{ steps.cache-bundler-restore.outputs.cache-primary-key }} 85 | -------------------------------------------------------------------------------- /spec/webauthn_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe WebAuthn do 6 | it "has a version number" do 7 | expect(WebAuthn::VERSION).not_to be nil 8 | end 9 | 10 | describe "#credential_creation_options" do 11 | before do 12 | @credential_creation_options = silence_warnings { WebAuthn.credential_creation_options } 13 | end 14 | 15 | it "has a 32 byte length challenge" do 16 | expect(@credential_creation_options[:challenge].length).to eq(32) 17 | end 18 | 19 | it "has public key params" do 20 | params = @credential_creation_options[:pubKeyCredParams] 21 | 22 | array = [ 23 | { type: "public-key", alg: -7 }, 24 | { type: "public-key", alg: -37 }, 25 | { type: "public-key", alg: -257 }, 26 | ] 27 | 28 | expect(params).to match_array(array) 29 | end 30 | 31 | it "has user info" do 32 | user_info = @credential_creation_options[:user] 33 | expect(user_info[:name]).to eq("web-user") 34 | expect(user_info[:displayName]).to eq("web-user") 35 | expect(user_info[:id]).to eq("1") 36 | end 37 | 38 | context "Relying Party info" do 39 | it "has relying party name default" do 40 | expect(@credential_creation_options[:rp][:name]).to eq("web-server") 41 | end 42 | 43 | context "when configured" do 44 | before do 45 | WebAuthn.configuration.rp_name = "Example Inc." 46 | end 47 | 48 | it "has the configured values" do 49 | creation_options = silence_warnings { WebAuthn.credential_creation_options } 50 | 51 | expect(creation_options[:rp][:name]).to eq("Example Inc.") 52 | end 53 | end 54 | end 55 | end 56 | 57 | describe "#credential_request_options" do 58 | let(:credential_request_options) { silence_warnings { WebAuthn.credential_request_options } } 59 | 60 | it "has a 32 byte length challenge" do 61 | expect(credential_request_options[:challenge].length).to eq(32) 62 | end 63 | 64 | it "has allowCredentials param with an empty array" do 65 | expect(credential_request_options[:allowCredentials]).to match_array([]) 66 | end 67 | end 68 | 69 | describe "#generate_user_id" do 70 | let(:user_id) { WebAuthn.generate_user_id } 71 | let(:encoder) { WebAuthn::Encoder.new(encoding) } 72 | 73 | before do 74 | WebAuthn.configuration.encoding = encoding 75 | end 76 | 77 | context "when encoding is base64url" do 78 | let(:encoding) { :base64url } 79 | 80 | it "is encoded" do 81 | expect(user_id.class).to eq(String) 82 | expect(user_id.encoding).not_to eq(Encoding::BINARY) 83 | end 84 | 85 | it "is 64 bytes long" do 86 | expect(encoder.decode(user_id).length).to eq(64) 87 | end 88 | end 89 | 90 | context "when encoding is base64" do 91 | let(:encoding) { :base64 } 92 | 93 | it "is encoded" do 94 | expect(user_id.class).to eq(String) 95 | expect(user_id.encoding).not_to eq(Encoding::BINARY) 96 | end 97 | 98 | it "is 64 bytes long" do 99 | expect(encoder.decode(user_id).length).to eq(64) 100 | end 101 | end 102 | 103 | context "when not encoding" do 104 | let(:encoding) { false } 105 | 106 | it "is not encoded" do 107 | expect(user_id.class).to eq(String) 108 | expect(user_id.encoding).to eq(Encoding::BINARY) 109 | end 110 | 111 | it "is 64 bytes long" do 112 | expect(user_id.length).to eq(64) 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/android_safetynet_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "jwt" 6 | require "openssl" 7 | require "webauthn/attestation_statement/android_safetynet" 8 | 9 | RSpec.describe WebAuthn::AttestationStatement::AndroidSafetynet do 10 | describe "#valid?" do 11 | let(:statement) { described_class.new("ver" => version, "response" => response) } 12 | let(:version) { "2.0" } 13 | 14 | let(:response) do 15 | JWT.encode( 16 | payload, 17 | attestation_key, 18 | "RS256", 19 | x5c: [WebAuthn::Encoders::Base64Encoder.encode(leaf_certificate.to_der)] 20 | ) 21 | end 22 | 23 | let(:payload) do 24 | { "nonce" => nonce, "ctsProfileMatch" => cts_profile_match, "timestampMs" => timestamp.to_i * 1000 } 25 | end 26 | let(:timestamp) { Time.now } 27 | let(:cts_profile_match) { true } 28 | let(:nonce) do 29 | WebAuthn::Encoders::Base64Encoder.encode( 30 | OpenSSL::Digest::SHA256.digest(authenticator_data_bytes + client_data_hash) 31 | ) 32 | end 33 | let(:attestation_key) { create_rsa_key(2048) } 34 | 35 | let(:leaf_certificate) do 36 | issue_certificate(root_certificate, root_key, attestation_key, name: "CN=attest.android.com") 37 | end 38 | 39 | let(:root_key) { create_ec_key } 40 | let(:root_certificate) { create_root_certificate(root_key) } 41 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 42 | 43 | let(:authenticator_data_bytes) do 44 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 45 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 46 | credential: { id: "0".b * 16, public_key: credential_key.public_key }, 47 | ).serialize 48 | end 49 | 50 | let(:credential_key) { create_rsa_key } 51 | let(:client_data_hash) { OpenSSL::Digest::SHA256.digest({}.to_json) } 52 | 53 | let(:google_certificates) { [root_certificate] } 54 | 55 | around do |example| 56 | silence_warnings do 57 | original_google_certificates = SafetyNetAttestation::Statement::GOOGLE_ROOT_CERTIFICATES 58 | SafetyNetAttestation::Statement::GOOGLE_ROOT_CERTIFICATES = google_certificates 59 | example.run 60 | SafetyNetAttestation::Statement::GOOGLE_ROOT_CERTIFICATES = original_google_certificates 61 | end 62 | end 63 | 64 | it "returns true when everything's in place" do 65 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 66 | end 67 | 68 | context "when nonce is not set to the base64 of the SHA256 of authData + clientDataHash" do 69 | let(:nonce) { WebAuthn::Encoders::Base64Encoder.encode(OpenSSL::Digest.digest("SHA256", "something else")) } 70 | 71 | it "returns false" do 72 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 73 | end 74 | end 75 | 76 | context "when ctsProfileMatch is not true" do 77 | let(:cts_profile_match) { false } 78 | 79 | it "returns false" do 80 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 81 | end 82 | end 83 | 84 | context "when the attestation certificate is not signed by Google" do 85 | let(:google_certificates) { [create_root_certificate(create_ec_key)] } 86 | 87 | it "fails" do 88 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 89 | end 90 | 91 | it "returns true if they are configured" do 92 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for(root_certificate) 93 | 94 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bindata" 4 | require "webauthn/authenticator_data/attested_credential_data" 5 | require "webauthn/error" 6 | 7 | module WebAuthn 8 | class AuthenticatorDataFormatError < WebAuthn::Error; end 9 | 10 | class AuthenticatorData < BinData::Record 11 | RP_ID_HASH_LENGTH = 32 12 | FLAGS_LENGTH = 1 13 | SIGN_COUNT_LENGTH = 4 14 | 15 | endian :big 16 | 17 | count_bytes_remaining :data_length 18 | string :rp_id_hash, length: RP_ID_HASH_LENGTH 19 | struct :flags do 20 | bit1 :extension_data_included 21 | bit1 :attested_credential_data_included 22 | bit1 :reserved_for_future_use_2 23 | bit1 :backup_state 24 | bit1 :backup_eligibility 25 | bit1 :user_verified 26 | bit1 :reserved_for_future_use_1 27 | bit1 :user_present 28 | end 29 | bit32 :sign_count 30 | count_bytes_remaining :trailing_bytes_length 31 | string :trailing_bytes, length: :trailing_bytes_length 32 | 33 | def self.deserialize(data) 34 | read(data) 35 | rescue EOFError 36 | raise AuthenticatorDataFormatError 37 | end 38 | 39 | def data 40 | to_binary_s 41 | end 42 | 43 | def valid? 44 | (!attested_credential_data_included? || attested_credential_data.valid?) && 45 | (!extension_data_included? || extension_data) && 46 | valid_length? 47 | end 48 | 49 | def user_flagged? 50 | user_present? || user_verified? 51 | end 52 | 53 | def user_present? 54 | flags.user_present == 1 55 | end 56 | 57 | def user_verified? 58 | flags.user_verified == 1 59 | end 60 | 61 | def credential_backup_eligible? 62 | flags.backup_eligibility == 1 63 | end 64 | 65 | def credential_backed_up? 66 | flags.backup_state == 1 67 | end 68 | 69 | def attested_credential_data_included? 70 | flags.attested_credential_data_included == 1 71 | end 72 | 73 | def extension_data_included? 74 | flags.extension_data_included == 1 75 | end 76 | 77 | def credential 78 | if attested_credential_data_included? 79 | attested_credential_data.credential 80 | end 81 | end 82 | 83 | def attested_credential_data 84 | @attested_credential_data ||= 85 | AttestedCredentialData.deserialize(trailing_bytes) 86 | rescue AttestedCredentialDataFormatError 87 | raise AuthenticatorDataFormatError 88 | end 89 | 90 | def extension_data 91 | @extension_data ||= CBOR.decode(raw_extension_data) 92 | end 93 | 94 | def aaguid 95 | raw_aaguid = attested_credential_data.raw_aaguid 96 | 97 | unless raw_aaguid == WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID 98 | attested_credential_data.aaguid 99 | end 100 | end 101 | 102 | private 103 | 104 | def valid_length? 105 | data_length == base_length + attested_credential_data_length + extension_data_length 106 | end 107 | 108 | def raw_extension_data 109 | if extension_data_included? 110 | if attested_credential_data_included? 111 | trailing_bytes[attested_credential_data.length..-1] 112 | else 113 | trailing_bytes.snapshot 114 | end 115 | end 116 | end 117 | 118 | def attested_credential_data_length 119 | if attested_credential_data_included? 120 | attested_credential_data.length 121 | else 122 | 0 123 | end 124 | end 125 | 126 | def extension_data_length 127 | if extension_data_included? 128 | raw_extension_data.length 129 | else 130 | 0 131 | end 132 | end 133 | 134 | def base_length 135 | RP_ID_HASH_LENGTH + FLAGS_LENGTH + SIGN_COUNT_LENGTH 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /spec/webauthn/authenticator_data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe WebAuthn::AuthenticatorData do 6 | let(:serialized_authenticator_data) do 7 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 8 | rp_id_hash: rp_id_hash, 9 | sign_count: sign_count, 10 | user_present: user_present, 11 | user_verified: user_verified, 12 | backup_eligibility: backup_eligibility, 13 | backup_state: backup_state, 14 | ).serialize 15 | end 16 | 17 | let(:rp_id_hash) { OpenSSL::Digest.digest("SHA256", "localhost") } 18 | let(:sign_count) { 42 } 19 | let(:user_present) { true } 20 | let(:user_verified) { false } 21 | let(:backup_eligibility) { false } 22 | let(:backup_state) { false } 23 | 24 | let(:authenticator_data) { described_class.deserialize(serialized_authenticator_data) } 25 | 26 | describe "#valid?" do 27 | it "returns true" do 28 | expect(authenticator_data.valid?).to be_truthy 29 | end 30 | 31 | it "returns false if leftover bytes" do 32 | data = WebAuthn::FakeAuthenticator::AuthenticatorData.new( 33 | rp_id_hash: rp_id_hash, 34 | sign_count: sign_count, 35 | user_present: user_present, 36 | user_verified: user_verified, 37 | extensions: nil 38 | ).serialize 39 | 40 | authenticator_data = WebAuthn::AuthenticatorData.deserialize(data + CBOR.encode("k" => "v")) 41 | 42 | expect(authenticator_data.valid?).to be_falsy 43 | end 44 | end 45 | 46 | describe "#rp_id_hash" do 47 | subject { authenticator_data.rp_id_hash } 48 | 49 | it { is_expected.to eq(rp_id_hash) } 50 | end 51 | 52 | describe "#sign_count" do 53 | subject { authenticator_data.sign_count } 54 | 55 | it { is_expected.to eq(42) } 56 | end 57 | 58 | describe "#user_present?" do 59 | subject { authenticator_data.user_present? } 60 | 61 | context "when UP flag is set" do 62 | let(:user_present) { true } 63 | 64 | it { is_expected.to be_truthy } 65 | end 66 | 67 | context "when UP flag is not set" do 68 | let(:user_present) { false } 69 | 70 | it { is_expected.to be_falsy } 71 | end 72 | end 73 | 74 | describe "#user_verified?" do 75 | subject { authenticator_data.user_verified? } 76 | 77 | context "when UV flag is set" do 78 | let(:user_verified) { true } 79 | 80 | it { is_expected.to be_truthy } 81 | end 82 | 83 | context "when UV flag is not set" do 84 | let(:user_verified) { false } 85 | 86 | it { is_expected.to be_falsy } 87 | end 88 | end 89 | 90 | describe "#user_flagged?" do 91 | subject { authenticator_data.user_flagged? } 92 | 93 | context "when both UP and UV flag are set" do 94 | let(:user_present) { true } 95 | let(:user_verified) { true } 96 | 97 | it { is_expected.to be_truthy } 98 | end 99 | 100 | context "when only UP is set" do 101 | let(:user_present) { true } 102 | let(:user_verified) { false } 103 | 104 | it { is_expected.to be_truthy } 105 | end 106 | 107 | context "when only UV flag is set" do 108 | let(:user_present) { false } 109 | let(:user_verified) { true } 110 | 111 | it { is_expected.to be_truthy } 112 | end 113 | 114 | context "when both UP and UV flag are not set" do 115 | let(:user_present) { false } 116 | let(:user_verified) { false } 117 | 118 | it { is_expected.to be_falsy } 119 | end 120 | end 121 | 122 | describe "#credential_backup_eligible?" do 123 | subject { authenticator_data.credential_backup_eligible? } 124 | 125 | context "when BE flag is set" do 126 | let(:backup_eligibility) { true } 127 | 128 | it { is_expected.to be_truthy } 129 | end 130 | 131 | context "when BE flag is not set" do 132 | let(:backup_eligibility) { false } 133 | 134 | it { is_expected.to be_falsy } 135 | end 136 | end 137 | 138 | describe "#credential_backed_up?" do 139 | subject { authenticator_data.credential_backed_up? } 140 | 141 | context "when BS flag is set" do 142 | let(:backup_state) { true } 143 | 144 | it { is_expected.to be_truthy } 145 | end 146 | 147 | context "when BS flag is not set" do 148 | let(:backup_state) { false } 149 | 150 | it { is_expected.to be_falsy } 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/webauthn/fake_authenticator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cbor" 4 | require "openssl" 5 | require "securerandom" 6 | require "webauthn/fake_authenticator/attestation_object" 7 | require "webauthn/fake_authenticator/authenticator_data" 8 | 9 | module WebAuthn 10 | class FakeAuthenticator 11 | def initialize 12 | @credentials = {} 13 | end 14 | 15 | def make_credential( 16 | rp_id:, 17 | client_data_hash:, 18 | user_present: true, 19 | user_verified: false, 20 | backup_eligibility: false, 21 | backup_state: false, 22 | attested_credential_data: true, 23 | algorithm: nil, 24 | sign_count: nil, 25 | extensions: nil 26 | ) 27 | credential_id, credential_key, credential_sign_count = new_credential(algorithm) 28 | sign_count ||= credential_sign_count 29 | 30 | credentials[rp_id] ||= {} 31 | credentials[rp_id][credential_id] = { 32 | credential_key: credential_key, 33 | sign_count: sign_count + 1 34 | } 35 | 36 | AttestationObject.new( 37 | client_data_hash: client_data_hash, 38 | rp_id_hash: hashed(rp_id), 39 | credential_id: credential_id, 40 | credential_key: credential_key, 41 | user_present: user_present, 42 | user_verified: user_verified, 43 | backup_eligibility: backup_eligibility, 44 | backup_state: backup_state, 45 | attested_credential_data: attested_credential_data, 46 | sign_count: sign_count, 47 | extensions: extensions 48 | ).serialize 49 | end 50 | 51 | def get_assertion( 52 | rp_id:, 53 | client_data_hash:, 54 | user_present: true, 55 | user_verified: false, 56 | backup_eligibility: false, 57 | backup_state: false, 58 | aaguid: AuthenticatorData::AAGUID, 59 | sign_count: nil, 60 | extensions: nil, 61 | allow_credentials: nil 62 | ) 63 | credential_options = credentials[rp_id] 64 | 65 | if credential_options 66 | allow_credentials ||= credential_options.keys 67 | credential_id = (credential_options.keys & allow_credentials).first 68 | unless credential_id 69 | raise "No matching credentials (allowed=#{allow_credentials}) " \ 70 | "found for RP #{rp_id} among credentials=#{credential_options}" 71 | end 72 | 73 | credential = credential_options[credential_id] 74 | credential_key = credential[:credential_key] 75 | credential_sign_count = credential[:sign_count] 76 | 77 | authenticator_data = AuthenticatorData.new( 78 | rp_id_hash: hashed(rp_id), 79 | user_present: user_present, 80 | user_verified: user_verified, 81 | backup_eligibility: backup_eligibility, 82 | backup_state: backup_state, 83 | aaguid: aaguid, 84 | credential: nil, 85 | sign_count: sign_count || credential_sign_count, 86 | extensions: extensions 87 | ).serialize 88 | 89 | signature_digest_algorithm = 90 | case credential_key 91 | when OpenSSL::PKey::RSA, OpenSSL::PKey::EC 92 | 'SHA256' 93 | when OpenSSL::PKey::PKey 94 | nil 95 | end 96 | signature = credential_key.sign(signature_digest_algorithm, authenticator_data + client_data_hash) 97 | credential[:sign_count] += 1 98 | 99 | { 100 | credential_id: credential_id, 101 | authenticator_data: authenticator_data, 102 | signature: signature 103 | } 104 | else 105 | raise "No credentials found for RP #{rp_id}" 106 | end 107 | end 108 | 109 | private 110 | 111 | attr_reader :credentials 112 | 113 | def new_credential(algorithm) 114 | algorithm ||= 'ES256' 115 | credential_key = 116 | case algorithm 117 | when 'ES256' 118 | OpenSSL::PKey::EC.generate('prime256v1') 119 | when 'RS256' 120 | OpenSSL::PKey::RSA.new(2048) 121 | when 'EdDSA' 122 | OpenSSL::PKey.generate_key("ED25519") 123 | else 124 | raise "Unsupported algorithm #{algorithm}" 125 | end 126 | 127 | [SecureRandom.random_bytes(16), credential_key, 0] 128 | end 129 | 130 | def hashed(target) 131 | OpenSSL::Digest::SHA256.digest(target) 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/webauthn/fake_authenticator/authenticator_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/key" 4 | require "cbor" 5 | require "securerandom" 6 | 7 | module WebAuthn 8 | class FakeAuthenticator 9 | class AuthenticatorData 10 | AAGUID = SecureRandom.random_bytes(16) 11 | 12 | attr_reader :sign_count 13 | 14 | def initialize( 15 | rp_id_hash:, 16 | credential: { 17 | id: SecureRandom.random_bytes(16), 18 | public_key: OpenSSL::PKey::EC.generate("prime256v1").public_key 19 | }, 20 | sign_count: 0, 21 | user_present: true, 22 | user_verified: !user_present, 23 | backup_eligibility: false, 24 | backup_state: false, 25 | aaguid: AAGUID, 26 | extensions: { "fakeExtension" => "fakeExtensionValue" } 27 | ) 28 | @rp_id_hash = rp_id_hash 29 | @credential = credential 30 | @sign_count = sign_count 31 | @user_present = user_present 32 | @user_verified = user_verified 33 | @backup_eligibility = backup_eligibility 34 | @backup_state = backup_state 35 | @aaguid = aaguid 36 | @extensions = extensions 37 | end 38 | 39 | def serialize 40 | rp_id_hash + flags + serialized_sign_count + attested_credential_data + extension_data 41 | end 42 | 43 | private 44 | 45 | attr_reader :rp_id_hash, 46 | :credential, 47 | :user_present, 48 | :user_verified, 49 | :extensions, 50 | :backup_eligibility, 51 | :backup_state 52 | 53 | def flags 54 | [ 55 | [ 56 | bit(:user_present), 57 | reserved_for_future_use_bit, 58 | bit(:user_verified), 59 | bit(:backup_eligibility), 60 | bit(:backup_state), 61 | reserved_for_future_use_bit, 62 | attested_credential_data_included_bit, 63 | extension_data_included_bit 64 | ].join 65 | ].pack("b*") 66 | end 67 | 68 | def serialized_sign_count 69 | [sign_count].pack('L>') 70 | end 71 | 72 | def attested_credential_data 73 | @attested_credential_data ||= 74 | if credential 75 | @aaguid + 76 | [credential[:id].length].pack("n*") + 77 | credential[:id] + 78 | cose_credential_public_key 79 | else 80 | "" 81 | end 82 | end 83 | 84 | def extension_data 85 | if extensions 86 | CBOR.encode(extensions) 87 | else 88 | "" 89 | end 90 | end 91 | 92 | def bit(flag) 93 | if context[flag] 94 | "1" 95 | else 96 | "0" 97 | end 98 | end 99 | 100 | def attested_credential_data_included_bit 101 | if attested_credential_data.empty? 102 | "0" 103 | else 104 | "1" 105 | end 106 | end 107 | 108 | def extension_data_included_bit 109 | if extension_data.empty? 110 | "0" 111 | else 112 | "1" 113 | end 114 | end 115 | 116 | def reserved_for_future_use_bit 117 | "0" 118 | end 119 | 120 | def context 121 | { 122 | user_present: user_present, 123 | user_verified: user_verified, 124 | backup_eligibility: backup_eligibility, 125 | backup_state: backup_state 126 | } 127 | end 128 | 129 | def cose_credential_public_key 130 | case credential[:public_key] 131 | when OpenSSL::PKey::RSA 132 | key = COSE::Key::RSA.from_pkey(credential[:public_key]) 133 | key.alg = -257 134 | when OpenSSL::PKey::EC::Point 135 | alg = { 136 | COSE::Key::Curve.by_name("P-256").id => -7, 137 | COSE::Key::Curve.by_name("P-384").id => -35, 138 | COSE::Key::Curve.by_name("P-521").id => -36 139 | } 140 | 141 | key = COSE::Key::EC2.from_pkey(credential[:public_key]) 142 | key.alg = alg[key.crv] 143 | when OpenSSL::PKey::PKey 144 | key = COSE::Key::OKP.from_pkey(credential[:public_key]) 145 | key.alg = -8 146 | end 147 | 148 | key.serialize 149 | end 150 | 151 | def key_bytes(public_key) 152 | public_key.to_bn.to_s(2) 153 | end 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/authenticator_data" 4 | require "webauthn/client_data" 5 | require "webauthn/error" 6 | 7 | module WebAuthn 8 | TYPES = { create: "webauthn.create", get: "webauthn.get" }.freeze 9 | 10 | class VerificationError < Error; end 11 | 12 | class AuthenticatorDataVerificationError < VerificationError; end 13 | class ChallengeVerificationError < VerificationError; end 14 | class OriginVerificationError < VerificationError; end 15 | class RpIdVerificationError < VerificationError; end 16 | class TokenBindingVerificationError < VerificationError; end 17 | class TopOriginVerificationError < VerificationError; end 18 | class TypeVerificationError < VerificationError; end 19 | class UserPresenceVerificationError < VerificationError; end 20 | class UserVerifiedVerificationError < VerificationError; end 21 | 22 | class AuthenticatorResponse 23 | def initialize(client_data_json:, relying_party: WebAuthn.configuration.relying_party) 24 | @client_data_json = client_data_json 25 | @relying_party = relying_party 26 | end 27 | 28 | def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_verification: nil, rp_id: nil) 29 | expected_origin ||= relying_party.allowed_origins || raise("Unspecified expected origin") 30 | 31 | rp_id ||= relying_party.id 32 | 33 | verify_item(:type) 34 | verify_item(:token_binding) 35 | verify_item(:challenge, expected_challenge) 36 | verify_item(:origin, expected_origin) 37 | verify_item(:top_origin) if needs_top_origin_verification? 38 | verify_item(:authenticator_data) 39 | 40 | verify_item( 41 | :rp_id, 42 | rp_id || rp_id_from_origin(expected_origin) 43 | ) 44 | 45 | # Fallback to RP configuration unless user_presence is passed in explicitely 46 | if user_presence.nil? && !relying_party.silent_authentication || user_presence 47 | verify_item(:user_presence) 48 | end 49 | 50 | if user_verification 51 | verify_item(:user_verified) 52 | end 53 | 54 | true 55 | end 56 | 57 | def valid?(*args, **keyword_arguments) 58 | verify(*args, **keyword_arguments) 59 | rescue WebAuthn::VerificationError 60 | false 61 | end 62 | 63 | def client_data 64 | @client_data ||= WebAuthn::ClientData.new(client_data_json) 65 | end 66 | 67 | private 68 | 69 | attr_reader :client_data_json, :relying_party 70 | 71 | def verify_item(item, *args) 72 | if send("valid_#{item}?", *args) 73 | true 74 | else 75 | camelized_item = item.to_s.split('_').map { |w| w.capitalize }.join 76 | error_const_name = "WebAuthn::#{camelized_item}VerificationError" 77 | raise Object.const_get(error_const_name) 78 | end 79 | end 80 | 81 | def valid_type? 82 | client_data.type == type 83 | end 84 | 85 | def valid_token_binding? 86 | client_data.valid_token_binding_format? 87 | end 88 | 89 | def valid_top_origin? 90 | return false unless client_data.cross_origin 91 | 92 | relying_party.allowed_top_origins&.include?(client_data.top_origin) 93 | end 94 | 95 | def valid_challenge?(expected_challenge) 96 | OpenSSL.secure_compare(client_data.challenge, expected_challenge) 97 | end 98 | 99 | def valid_origin?(expected_origin) 100 | return false unless expected_origin 101 | 102 | expected_origin.include?(client_data.origin) 103 | end 104 | 105 | def valid_rp_id?(rp_id) 106 | return false unless rp_id 107 | 108 | OpenSSL::Digest::SHA256.digest(rp_id) == authenticator_data.rp_id_hash 109 | end 110 | 111 | def valid_authenticator_data? 112 | authenticator_data.valid? 113 | rescue WebAuthn::AuthenticatorDataFormatError 114 | false 115 | end 116 | 117 | def valid_user_presence? 118 | authenticator_data.user_flagged? 119 | end 120 | 121 | def valid_user_verified? 122 | authenticator_data.user_verified? 123 | end 124 | 125 | def rp_id_from_origin(expected_origin) 126 | URI.parse(expected_origin.first).host if expected_origin.size == 1 127 | end 128 | 129 | def type 130 | raise NotImplementedError, "Please define #type method in subclass" 131 | end 132 | 133 | def needs_top_origin_verification? 134 | relying_party.verify_cross_origin && (client_data.cross_origin || client_data.top_origin) 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/webauthn/credential_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "webauthn/configuration" 6 | require "webauthn/credential" 7 | 8 | RSpec.describe "Credential" do 9 | let(:origin) { fake_origin } 10 | 11 | before do 12 | WebAuthn.configuration.allowed_origins = [origin] 13 | end 14 | 15 | describe ".from_create" do 16 | let(:challenge) do 17 | WebAuthn::Credential.options_for_create(user: { id: "1", name: "User" }).challenge 18 | end 19 | 20 | let(:client) { WebAuthn::FakeClient.new(origin) } 21 | 22 | before do 23 | WebAuthn.configuration.encoding = encoding 24 | end 25 | 26 | context "when encoding is base64url" do 27 | let(:encoding) { :base64url } 28 | 29 | it "works" do 30 | credential = WebAuthn::Credential.from_create(client.create(challenge: challenge)) 31 | 32 | expect(credential.verify(challenge)).to be_truthy 33 | 34 | expect(credential.id).not_to be_empty 35 | expect(credential.public_key).not_to be_empty 36 | expect(credential.public_key.class).to eq(String) 37 | expect(credential.public_key.encoding).not_to eq(Encoding::BINARY) 38 | expect(credential.sign_count).to eq(0) 39 | end 40 | end 41 | 42 | context "when encoding is base64" do 43 | let(:encoding) { :base64 } 44 | 45 | it "works" do 46 | credential = WebAuthn::Credential.from_create(client.create(challenge: challenge)) 47 | 48 | expect(credential.verify(challenge)).to be_truthy 49 | 50 | expect(credential.id).not_to be_empty 51 | expect(credential.public_key).not_to be_empty 52 | expect(credential.public_key.class).to eq(String) 53 | expect(credential.public_key.encoding).not_to eq(Encoding::BINARY) 54 | expect(credential.sign_count).to eq(0) 55 | end 56 | end 57 | 58 | context "when not encoding" do 59 | let(:encoding) { false } 60 | 61 | it "works" do 62 | credential = WebAuthn::Credential.from_create(client.create(challenge: challenge)) 63 | 64 | expect(credential.verify(challenge)).to be_truthy 65 | 66 | expect(credential.id).not_to be_empty 67 | expect(credential.public_key).not_to be_empty 68 | expect(credential.public_key.class).to eq(String) 69 | expect(credential.public_key.encoding).to eq(Encoding::BINARY) 70 | expect(credential.sign_count).to eq(0) 71 | end 72 | end 73 | end 74 | 75 | describe ".from_get" do 76 | let(:challenge) do 77 | WebAuthn::Credential.options_for_get.challenge 78 | end 79 | 80 | let(:client) { WebAuthn::FakeClient.new(origin) } 81 | 82 | let(:credential_from_create) do 83 | WebAuthn::Credential.from_create(created_credential) 84 | end 85 | 86 | let(:created_credential) { client.create } 87 | 88 | let(:public_key) { credential_from_create.public_key } 89 | let(:sign_count) { credential_from_create.sign_count } 90 | 91 | before do 92 | WebAuthn.configuration.encoding = encoding 93 | 94 | # Client needs to have a created credential before getting one 95 | created_credential 96 | end 97 | 98 | context "when encoding is base64url" do 99 | let(:encoding) { :base64url } 100 | 101 | it "works" do 102 | credential = WebAuthn::Credential.from_get(client.get(challenge: challenge)) 103 | 104 | expect(credential.verify(challenge, public_key: public_key, sign_count: sign_count)).to be_truthy 105 | 106 | expect(credential.id).not_to be_empty 107 | expect(credential.user_handle).to be_nil 108 | expect(credential.sign_count).to eq(1) 109 | end 110 | end 111 | 112 | context "when encoding is base64" do 113 | let(:encoding) { :base64 } 114 | 115 | it "works" do 116 | credential = WebAuthn::Credential.from_get(client.get(challenge: challenge)) 117 | 118 | expect(credential.verify(challenge, public_key: public_key, sign_count: sign_count)).to be_truthy 119 | 120 | expect(credential.id).not_to be_empty 121 | expect(credential.user_handle).to be_nil 122 | expect(credential.sign_count).to eq(1) 123 | end 124 | end 125 | 126 | context "when not encoding" do 127 | let(:encoding) { false } 128 | 129 | it "works" do 130 | credential = WebAuthn::Credential.from_get(client.get(challenge: challenge)) 131 | 132 | expect(credential.verify(challenge, public_key: public_key, sign_count: sign_count)).to be_truthy 133 | 134 | expect(credential.id).not_to be_empty 135 | expect(credential.user_handle).to be_nil 136 | expect(credential.sign_count).to eq(1) 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /spec/webauthn/public_key_credential/request_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "webauthn/public_key_credential/request_options" 5 | 6 | RSpec.describe WebAuthn::PublicKeyCredential::RequestOptions do 7 | let(:request_options) { WebAuthn::PublicKeyCredential::RequestOptions.new } 8 | 9 | it "has a challenge" do 10 | expect(request_options.challenge.class).to eq(String) 11 | expect(request_options.challenge.encoding).to eq(Encoding::ASCII) 12 | expect(request_options.challenge.length).to be >= 32 13 | end 14 | 15 | it "has allowCredentials param with an empty array" do 16 | expect(request_options.allow_credentials).to match_array([]) 17 | end 18 | 19 | context "client timeout" do 20 | it "has a default client timeout" do 21 | expect(request_options.timeout).to(eq(120000)) 22 | end 23 | 24 | context "when client timeout is configured" do 25 | before do 26 | WebAuthn.configuration.credential_options_timeout = 60000 27 | end 28 | 29 | it "updates the client timeout" do 30 | expect(request_options.timeout).to(eq(60000)) 31 | end 32 | end 33 | end 34 | 35 | context "Relying Party info" do 36 | it "has relying party name default to nothing" do 37 | expect(request_options.rp_id).to eq(nil) 38 | end 39 | 40 | context "when configured" do 41 | before do 42 | WebAuthn.configuration.rp_id = "example.com" 43 | end 44 | 45 | it "has the configured values" do 46 | expect(request_options.rp_id).to eq("example.com") 47 | end 48 | end 49 | end 50 | 51 | it "has everything" do 52 | options = WebAuthn::PublicKeyCredential::RequestOptions.new( 53 | rp_id: "rp-id", 54 | timeout: 10_000, 55 | allow_credentials: [{ type: "public-key", id: "credential-id", transports: ["usb", "nfc"] }], 56 | user_verification: "required", 57 | extensions: { whatever: "whatever" }, 58 | ) 59 | 60 | hash = options.as_json 61 | 62 | expect(hash[:rpId]).to eq("rp-id") 63 | expect(hash[:timeout]).to eq(10_000) 64 | expect(hash[:allowCredentials]).to eq([{ type: "public-key", id: "credential-id", transports: ["usb", "nfc"] }]) 65 | expect(hash[:userVerification]).to eq("required") 66 | expect(hash[:extensions]).to eq(whatever: "whatever") 67 | expect(hash[:challenge]).to be_truthy 68 | end 69 | 70 | it "has minimum required" do 71 | options = WebAuthn::PublicKeyCredential::RequestOptions.new 72 | 73 | hash = options.as_json 74 | 75 | expect(hash[:timeout]).to eq(120_000) 76 | expect(hash[:allowCredentials]).to eq([]) 77 | expect(hash[:extensions]).to eq({}) 78 | expect(hash[:challenge]).to be_truthy 79 | expect(hash).not_to have_key(:userVerification) 80 | expect(hash).not_to have_key(:rpId) 81 | end 82 | 83 | it "accepts shorthand for allow_credentials" do 84 | options = WebAuthn::PublicKeyCredential::RequestOptions.new(allow: "id") 85 | 86 | expect(options.allow).to eq("id") 87 | expect(options.allow_credentials).to eq([{ type: "public-key", id: "id" }]) 88 | expect(options.as_json[:allowCredentials]).to eq([{ type: "public-key", id: "id" }]) 89 | end 90 | 91 | context "when legacy_u2f_appid" do 92 | context "is set in the configuration" do 93 | before do 94 | WebAuthn.configuration.legacy_u2f_appid = "https://u2f-login.example.com" 95 | end 96 | 97 | context "and appid extension is not requested in the options" do 98 | it "automatically adds it with the value in the configuration" do 99 | expect(request_options.extensions).not_to be_empty 100 | expect(request_options.extensions[:appid]).to eq("https://u2f-login.example.com") 101 | end 102 | end 103 | 104 | context "and appid extension is requested in the options" do 105 | let(:request_options) do 106 | WebAuthn::PublicKeyCredential::RequestOptions.new( 107 | extensions: { appid: "https://another-login.example.com" } 108 | ) 109 | end 110 | 111 | it "leaves the value that was originally requested" do 112 | expect(request_options.extensions).not_to be_empty 113 | expect(request_options.extensions[:appid]).to eq("https://another-login.example.com") 114 | end 115 | end 116 | end 117 | 118 | context "is not set in the configuration" do 119 | context "and appid extension is not requested in the options" do 120 | it "does not adds it automatically" do 121 | expect(request_options.extensions).to be_empty 122 | end 123 | end 124 | 125 | context "and appid extension is requested in the options" do 126 | let(:request_options) do 127 | WebAuthn::PublicKeyCredential::RequestOptions.new( 128 | extensions: { appid: "https://another-login.example.com" } 129 | ) 130 | end 131 | 132 | it "leaves the value that was originally requested" do 133 | expect(request_options.extensions).not_to be_empty 134 | expect(request_options.extensions[:appid]).to eq("https://another-login.example.com") 135 | end 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/conformance/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | require "webauthn" 5 | require "sinatra" 6 | require "rack/contrib" 7 | require "sinatra/cookies" 8 | require "byebug" 9 | 10 | use Rack::PostBodyContentTypeParser 11 | set show_exceptions: false 12 | 13 | require_relative 'mds_finder' 14 | require_relative 'conformance_cache_store' 15 | require_relative "conformance_patches" 16 | 17 | RP_NAME = "webauthn-ruby #{WebAuthn::VERSION} conformance test server" 18 | 19 | UNACCEPTABLE_STATUSES = [ 20 | "USER_VERIFICATION_BYPASS", 21 | "ATTESTATION_KEY_COMPROMISE", 22 | "USER_KEY_REMOTE_COMPROMISE", 23 | "USER_KEY_PHYSICAL_COMPROMISE", 24 | "REVOKED" 25 | ].freeze 26 | 27 | Credential = 28 | Struct.new(:id, :public_key, :sign_count) do 29 | @credentials = {} 30 | 31 | def self.register(username, id:, public_key:, sign_count:) 32 | @credentials[username] ||= [] 33 | @credentials[username] << Credential.new(id, public_key, sign_count) 34 | end 35 | 36 | def self.registered_for(username) 37 | @credentials[username] || [] 38 | end 39 | end 40 | 41 | host = ENV["HOST"] || "localhost" 42 | 43 | mds_finder = 44 | MDSFinder.new.tap do |mds| 45 | mds.cache_backend = ConformanceCacheStore.new 46 | mds.cache_backend.setup_authenticators 47 | mds.cache_backend.setup_metadata_store("http://#{host}:#{settings.port}") 48 | end 49 | 50 | relying_party = WebAuthn::RelyingParty.new( 51 | origin: "http://#{host}:#{settings.port}", 52 | name: RP_NAME, 53 | algorithms: %w(ES256 ES384 ES512 PS256 PS384 PS512 RS256 RS384 RS512 RS1 EdDSA), 54 | silent_authentication: true, 55 | attestation_root_certificates_finders: mds_finder 56 | ) 57 | 58 | post "/attestation/options" do 59 | options = relying_party.options_for_registration( 60 | attestation: params["attestation"], 61 | authenticator_selection: params["authenticatorSelection"], 62 | extensions: params["extensions"], 63 | exclude: Credential.registered_for(params["username"]).map(&:id), 64 | user: { id: "1", name: params["username"], display_name: params["displayName"] } 65 | ) 66 | 67 | cookies["attestation_username"] = params["username"] 68 | cookies["attestation_challenge"] = options.challenge 69 | 70 | if params["authenticatorSelection"] && params["authenticatorSelection"]["userVerification"] 71 | cookies["attestation_user_verification"] = params["authenticatorSelection"]["userVerification"] 72 | end 73 | 74 | render_ok(options.as_json) 75 | end 76 | 77 | post "/attestation/result" do 78 | webauthn_credential = relying_party.verify_registration( 79 | params, 80 | cookies["attestation_challenge"], 81 | user_verification: cookies["attestation_user_verification"] == "required" 82 | ) 83 | 84 | if (aaguid = webauthn_credential.response.aaguid) 85 | metadata_entry = fido_metadata_store.fetch_entry(aaguid: aaguid) 86 | 87 | if metadata_entry 88 | if metadata_entry.status_reports.any? { |status_report| UNACCEPTABLE_STATUSES.include?(status_report.status) } 89 | raise("bad authenticator status") 90 | end 91 | end 92 | end 93 | 94 | Credential.register( 95 | cookies["attestation_username"], 96 | id: webauthn_credential.id, 97 | public_key: webauthn_credential.public_key, 98 | sign_count: webauthn_credential.sign_count, 99 | ) 100 | 101 | cookies["attestation_challenge"] = nil 102 | cookies["attestation_username"] = nil 103 | cookies["attestation_user_verification"] = nil 104 | 105 | render_ok 106 | end 107 | 108 | post "/assertion/options" do 109 | options = relying_party.options_for_authentication( 110 | extensions: params["extensions"], 111 | user_verification: params["userVerification"], 112 | allow: Credential.registered_for(params["username"]).map(&:id) 113 | ) 114 | 115 | cookies["assertion_username"] = params["username"] 116 | cookies["assertion_user_verification"] = params["userVerification"] 117 | cookies["assertion_challenge"] = options.challenge 118 | 119 | render_ok(options.as_json) 120 | end 121 | 122 | post "/assertion/result" do 123 | webauthn_credential = WebAuthn::Credential.from_get(params) 124 | 125 | user_credential = 126 | Credential.registered_for(cookies["assertion_username"]).detect do |uc| 127 | uc.id == webauthn_credential.id 128 | end 129 | 130 | webauthn_credential = relying_party.verify_authentication( 131 | params, 132 | cookies["assertion_challenge"], 133 | public_key: user_credential.public_key, 134 | sign_count: user_credential.sign_count, 135 | user_verification: cookies["assertion_user_verification"] == "required" 136 | ) 137 | 138 | user_credential.sign_count = webauthn_credential.sign_count 139 | cookies["assertion_challenge"] = nil 140 | cookies["assertion_username"] = nil 141 | cookies["assertion_user_verification"] = nil 142 | 143 | render_ok 144 | end 145 | 146 | error 500 do 147 | error = env["sinatra.error"] 148 | render_error(<<~MSG) 149 | #{error.class}: #{error.message} 150 | #{error.backtrace.take(10).join("\n")} 151 | MSG 152 | end 153 | 154 | def render_ok(params = {}) 155 | JSON.dump({ status: "ok", errorMessage: "" }.merge!(params)) 156 | end 157 | 158 | def render_error(message) 159 | JSON.dump(status: "error", errorMessage: message) 160 | end 161 | 162 | def fido_metadata_store 163 | @fido_metadata_store ||= FidoMetadata::Store.new 164 | end 165 | -------------------------------------------------------------------------------- /docs/u2f_migration.md: -------------------------------------------------------------------------------- 1 | # Migrating from U2F to WebAuthn 2 | 3 | The Chromium team [recommends](https://groups.google.com/a/chromium.org/forum/#!msg/security-dev/BGWA1d7a6rI/W2avestmBAAJ) 4 | application developers to switch from the U2F API to the WebAuthn API. This document describes how a Ruby application 5 | using the [u2f gem by Castle](https://github.com/castle/ruby-u2f) can migrate existing credentials so that their users 6 | do not experience interruption or need to re-register their security keys. 7 | 8 | Note that the migration is one-way: credentials registered using WebAuthn cannot be made compatible with the U2F API. 9 | It is recommended to successfully migrate authorization flows before migrating registration flows. 10 | 11 | ## Migrate registered U2F credentials 12 | 13 | Assuming you have a registered credential per the u2f gem readme, base64 urlsafe encoded in a database: 14 | 15 | ```ruby 16 | # This domain will be used in all code examples. It's a single-facet app but a multi-facet AppID 17 | # (e.g. https://example.com/app-id.json) will work as well. 18 | domain = URI("https://login.example.com") 19 | 20 | u2f_registration = U2F::U2F.new(domain.to_s).register!(u2f_challenge, u2f_register_response) 21 | # => # 26 | ``` 27 | 28 | The `U2fMigrator` class quacks like `WebAuthn::AuthenticatorAttestationResponse` and can be used similarly as documented 29 | in the [registration verification phase](https://github.com/cedarcode/webauthn-ruby/blob/master/README.md#verification-phase). 30 | Of course a `verify` instance method is not implemented, as there is no real interaction with an authenticator. 31 | 32 | The migrator can be used to convert credentials in real time during authentication while keeping them stored in the U2F 33 | format, and in a backfill task to store credentials in the new format, depending on how you are approaching your 34 | migration. 35 | 36 | ```ruby 37 | require "webauthn/u2f_migrator" 38 | 39 | migrated_credential = WebAuthn::U2fMigrator.new( 40 | app_id: domain, 41 | certificate: u2f_registration.certificate, 42 | key_handle: u2f_registration.key_handle, 43 | public_key: u2f_registration.public_key, 44 | counter: u2f_registration.counter 45 | ) 46 | migrated_credential.credential.id 47 | # => "\x99\xB5LE83I>q.\xE9\x9C\x90l\xED'\xD5E[\xAB\xDE9\xB7\xCD!\x85\x92\x9F{\x13\xA8\x86" 48 | migrated_credential.credential.public_key 49 | # => "\xA5\x03& \x01!X \xE2P^Q`\xF9\x97\xD9*n<\x14\xDA\xB6a\xEEoK\x03\xACpMb\xED\x8B\x06E\"#!\xED\xC6\x01\x02\"X #C\x97\xAD C\x000\xE7\xD1\xD4%\xCFh\x83\xCD\x9E\xCB\xBC,\"\x1F>\xF6SZ\xA1U\xAB7\xBE\xEB" 50 | migrated_credential.authenticator_data.sign_count 51 | # => 41 52 | ``` 53 | 54 | ## Authenticate migrated U2F credentials 55 | 56 | Following the documentation on the [authentication initiation](https://github.com/cedarcode/webauthn-ruby/blob/master/README.md#initiation-phase-1), 57 | you need to specify the [FIDO AppID extension](https://www.w3.org/TR/webauthn/#sctn-appid-extension) for U2F migratedq 58 | credentials. The WebAuthn standard explains: 59 | 60 | > The FIDO APIs use an alternative identifier for Relying Parties called an _AppID_, and any credentials created using 61 | > those APIs will be scoped to that identifier. Without this extension, they would need to be re-registered in order to 62 | > be scoped to an RP ID. 63 | 64 | For the earlier given example `domain` this means: 65 | - FIDO AppID: `https://login.example.com` 66 | - Valid RP IDs: `login.example.com` (default) and `example.com` 67 | 68 | You can request the use of the `appid` extension by setting the AppID in the configuration, like this: 69 | 70 | ```ruby 71 | WebAuthn.configure do |config| 72 | config.legacy_u2f_appid = "https://login.example.com" 73 | end 74 | ``` 75 | 76 | By doing this, the `appid` extension will be automatically requested when generating the options for get: 77 | 78 | ```ruby 79 | options = WebAuthn::Credential.options_for_get 80 | ``` 81 | 82 | On the frontend, in the resolved value from `navigator.credentials.get({ "publicKey": credentialRequestOptions })` add 83 | a call to [getClientExtensionResults()](https://www.w3.org/TR/webauthn/#dom-publickeycredential-getclientextensionresults) 84 | and send its result to your backend alongside the `id`/`rawId` and `response` values. If the authenticator used the AppID 85 | extension, the returned value will contain `{ "appid": true }`. 86 | 87 | During authentication verification phase, if you followed the [verification phase documentation](https://github.com/cedarcode/webauthn-ruby#verification-phase-1) and have set the AppID in the config, the method `PublicKeyCredentialWithAssertion#verify` will be smart enough to determine if it should use the AppID or the RP ID to verify the WebAuthn credential, depending on the output of the `appid` client extension: 88 | 89 | > If true, the AppID was used and thus, when verifying an assertion, the Relying Party MUST expect the `rpIdHash` to be 90 | > the hash of the _AppID_, not the RP ID. 91 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "webauthn" 5 | require "cbor" 6 | 7 | require "byebug" 8 | require "webauthn/fake_client" 9 | 10 | RSpec.configure do |config| 11 | # Enable flags like --only-failures and --next-failure 12 | config.example_status_persistence_file_path = ".rspec_status" 13 | 14 | # Disable RSpec exposing methods globally on `Module` and `main` 15 | config.disable_monkey_patching! 16 | 17 | config.expect_with :rspec do |c| 18 | c.syntax = :expect 19 | end 20 | 21 | config.after do 22 | WebAuthn.instance_variable_set(:@configuration, nil) 23 | end 24 | end 25 | 26 | def create_credential( 27 | client: WebAuthn::FakeClient.new, 28 | rp_id: nil, 29 | relying_party: WebAuthn.configuration.relying_party 30 | ) 31 | rp_id ||= relying_party.id || URI.parse(client.origin).host 32 | 33 | create_result = client.create(rp_id: rp_id) 34 | 35 | attestation_object = 36 | if client.encoding 37 | relying_party.encoder.decode(create_result["response"]["attestationObject"]) 38 | else 39 | create_result["response"]["attestationObject"] 40 | end 41 | 42 | client_data_json = 43 | if client.encoding 44 | relying_party.encoder.decode(create_result["response"]["clientDataJSON"]) 45 | else 46 | create_result["response"]["clientDataJSON"] 47 | end 48 | 49 | response = 50 | WebAuthn::AuthenticatorAttestationResponse 51 | .new( 52 | attestation_object: attestation_object, 53 | client_data_json: client_data_json, 54 | relying_party: relying_party 55 | ) 56 | 57 | credential_public_key = response.credential.public_key 58 | 59 | [create_result["id"], credential_public_key, response.authenticator_data.sign_count] 60 | end 61 | 62 | def fake_origin 63 | "http://localhost" 64 | end 65 | 66 | def fake_top_origin 67 | "http://localhost.org" 68 | end 69 | 70 | def fake_challenge 71 | SecureRandom.random_bytes(32) 72 | end 73 | 74 | def fake_cose_credential_key(algorithm: -7, x_coordinate: nil, y_coordinate: nil) 75 | crv_p256 = 1 76 | 77 | COSE::Key::EC2.new( 78 | alg: algorithm, 79 | crv: crv_p256, 80 | x: x_coordinate || SecureRandom.random_bytes(32), 81 | y: y_coordinate || SecureRandom.random_bytes(32) 82 | ).serialize 83 | end 84 | 85 | def key_bytes(public_key) 86 | public_key.to_bn.to_s(2) 87 | end 88 | 89 | # Borrowed from activesupport 90 | def silence_warnings 91 | old_verbose, $VERBOSE = $VERBOSE, nil 92 | yield 93 | ensure 94 | $VERBOSE = old_verbose 95 | end 96 | 97 | class RootCertificateFinder 98 | def initialize(certificate, return_empty) 99 | @certificate = certificate 100 | @return_empty = return_empty 101 | end 102 | 103 | def find(*) 104 | if @return_empty 105 | [] 106 | elsif @certificate.is_a?(OpenSSL::X509::Certificate) 107 | [@certificate] 108 | else 109 | certificate_path = File.expand_path( 110 | File.join(__dir__, 'support', 'roots', @certificate) 111 | ) 112 | [OpenSSL::X509::Certificate.new(File.read(certificate_path))] 113 | end 114 | end 115 | end 116 | 117 | def finder_for(certificate_file, return_empty: false) 118 | RootCertificateFinder.new(certificate_file, return_empty) 119 | end 120 | 121 | # NOTE: Use 2048 or more in real life! We use 1024 here just for making the test fast. 122 | def create_rsa_key(key_bits = 1024) 123 | OpenSSL::PKey::RSA.new(key_bits) 124 | end 125 | 126 | def create_ec_key 127 | OpenSSL::PKey::EC.generate("prime256v1") 128 | end 129 | 130 | X509_V3 = 2 131 | 132 | def create_root_certificate(key, not_before: Time.now - 1, not_after: Time.now + 60) 133 | certificate = OpenSSL::X509::Certificate.new 134 | 135 | certificate.version = X509_V3 136 | certificate.subject = OpenSSL::X509::Name.parse("CN=Root-#{rand(1_000_000)}") 137 | certificate.issuer = certificate.subject 138 | certificate.public_key = key 139 | certificate.not_before = not_before 140 | certificate.not_after = not_after 141 | 142 | extension_factory = OpenSSL::X509::ExtensionFactory.new 143 | extension_factory.subject_certificate = certificate 144 | extension_factory.issuer_certificate = certificate 145 | 146 | certificate.extensions = [ 147 | extension_factory.create_extension("basicConstraints", "CA:TRUE", true), 148 | extension_factory.create_extension("keyUsage", "keyCertSign,cRLSign", true), 149 | ] 150 | 151 | certificate.sign(key, "SHA256") 152 | 153 | certificate 154 | end 155 | 156 | def issue_certificate( 157 | ca_certificate, 158 | ca_key, 159 | key, 160 | version: X509_V3, 161 | name: "CN=Cert-#{rand(1_000_000)}", 162 | not_before: Time.now - 1, 163 | not_after: Time.now + 60, 164 | extensions: nil 165 | ) 166 | certificate = OpenSSL::X509::Certificate.new 167 | 168 | certificate.version = version 169 | certificate.subject = OpenSSL::X509::Name.parse(name) 170 | certificate.issuer = ca_certificate.subject 171 | certificate.not_before = not_before 172 | certificate.not_after = not_after 173 | certificate.public_key = key 174 | 175 | if extensions 176 | certificate.extensions = extensions 177 | end 178 | 179 | certificate.sign(ca_key, "SHA256") 180 | 181 | certificate 182 | end 183 | 184 | def fake_certificate_chain_validation_time(attestation_statement, time) 185 | allow(attestation_statement).to receive(:attestation_root_certificates_store) 186 | .and_wrap_original do |m, *_args, **kwargs| 187 | store = m.call(**kwargs) 188 | store.time = time 189 | store 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/webauthn/relying_party.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "openssl" 4 | require "webauthn/credential" 5 | require "webauthn/encoder" 6 | require "webauthn/error" 7 | 8 | module WebAuthn 9 | class RootCertificateFinderNotSupportedError < Error; end 10 | 11 | class RelyingParty 12 | DEFAULT_ALGORITHMS = ["ES256", "PS256", "RS256"].compact.freeze 13 | 14 | def self.if_pss_supported(algorithm) 15 | OpenSSL::PKey::RSA.instance_methods.include?(:verify_pss) ? algorithm : nil 16 | end 17 | 18 | def initialize( 19 | algorithms: DEFAULT_ALGORITHMS.dup, 20 | encoding: WebAuthn::Encoder::STANDARD_ENCODING, 21 | allowed_origins: nil, 22 | allowed_top_origins: nil, 23 | origin: nil, 24 | id: nil, 25 | name: nil, 26 | verify_attestation_statement: true, 27 | verify_cross_origin: false, 28 | credential_options_timeout: 120000, 29 | silent_authentication: false, 30 | acceptable_attestation_types: ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA', 'AnonCA'], 31 | attestation_root_certificates_finders: [], 32 | legacy_u2f_appid: nil 33 | ) 34 | @algorithms = algorithms 35 | @encoding = encoding 36 | @allowed_origins = allowed_origins 37 | @allowed_top_origins = allowed_top_origins 38 | @id = id 39 | @name = name 40 | @verify_attestation_statement = verify_attestation_statement 41 | @verify_cross_origin = verify_cross_origin 42 | @credential_options_timeout = credential_options_timeout 43 | @silent_authentication = silent_authentication 44 | @acceptable_attestation_types = acceptable_attestation_types 45 | @legacy_u2f_appid = legacy_u2f_appid 46 | self.origin = origin 47 | self.attestation_root_certificates_finders = attestation_root_certificates_finders 48 | end 49 | 50 | attr_accessor :algorithms, 51 | :encoding, 52 | :allowed_origins, 53 | :allowed_top_origins, 54 | :id, 55 | :name, 56 | :verify_attestation_statement, 57 | :verify_cross_origin, 58 | :credential_options_timeout, 59 | :silent_authentication, 60 | :acceptable_attestation_types, 61 | :legacy_u2f_appid 62 | 63 | attr_reader :attestation_root_certificates_finders 64 | 65 | # This is the user-data encoder. 66 | # Used to decode user input and to encode data provided to the user. 67 | def encoder 68 | @encoder ||= WebAuthn::Encoder.new(encoding) 69 | end 70 | 71 | def attestation_root_certificates_finders=(finders) 72 | if !finders.respond_to?(:each) 73 | finders = [finders] 74 | end 75 | 76 | finders.each do |finder| 77 | unless finder.respond_to?(:find) 78 | raise RootCertificateFinderNotSupportedError, "Finder must implement `find` method" 79 | end 80 | end 81 | 82 | @attestation_root_certificates_finders = finders 83 | end 84 | 85 | def options_for_registration(**keyword_arguments) 86 | WebAuthn::Credential.options_for_create( 87 | **keyword_arguments, 88 | relying_party: self 89 | ) 90 | end 91 | 92 | def verify_registration(raw_credential, challenge, user_presence: nil, user_verification: nil) 93 | webauthn_credential = WebAuthn::Credential.from_create(raw_credential, relying_party: self) 94 | 95 | if webauthn_credential.verify(challenge, user_presence: user_presence, user_verification: user_verification) 96 | webauthn_credential 97 | end 98 | end 99 | 100 | def options_for_authentication(**keyword_arguments) 101 | WebAuthn::Credential.options_for_get( 102 | **keyword_arguments, 103 | relying_party: self 104 | ) 105 | end 106 | 107 | def verify_authentication( 108 | raw_credential, 109 | challenge, 110 | user_presence: nil, 111 | user_verification: nil, 112 | public_key: nil, 113 | sign_count: nil 114 | ) 115 | webauthn_credential = WebAuthn::Credential.from_get(raw_credential, relying_party: self) 116 | 117 | stored_credential = yield(webauthn_credential) if block_given? 118 | 119 | if webauthn_credential.verify( 120 | challenge, 121 | public_key: public_key || stored_credential.public_key, 122 | sign_count: sign_count || stored_credential.sign_count, 123 | user_presence: user_presence, 124 | user_verification: user_verification 125 | ) 126 | block_given? ? [webauthn_credential, stored_credential] : webauthn_credential 127 | end 128 | end 129 | 130 | # DEPRECATED: This method will be removed in future. 131 | def origin 132 | warn( 133 | "DEPRECATION WARNING: `WebAuthn.origin` is deprecated and returns `nil` " \ 134 | "when `WebAuthn.allowed_origins` contains more than one origin. " \ 135 | "It will be removed in future. Please use `WebAuthn.allowed_origins` instead." 136 | ) 137 | 138 | allowed_origins.first if allowed_origins&.size == 1 139 | end 140 | 141 | # DEPRECATED: This method will be removed in future. 142 | def origin=(new_origin) 143 | return if new_origin.nil? 144 | 145 | warn( 146 | "DEPRECATION WARNING: `WebAuthn.origin=` is deprecated and will be removed in future. "\ 147 | "Please use `WebAuthn.allowed_origins=` instead "\ 148 | "that also allows configuring multiple origins per Relying Party" 149 | ) 150 | 151 | @allowed_origins ||= Array(new_origin) # rubocop:disable Naming/MemoizedInstanceVariableName 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/webauthn/fake_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "openssl" 4 | require "securerandom" 5 | require "webauthn/authenticator_data" 6 | require "webauthn/encoder" 7 | require "webauthn/fake_authenticator" 8 | 9 | module WebAuthn 10 | class FakeClient 11 | TYPES = { create: "webauthn.create", get: "webauthn.get" }.freeze 12 | 13 | attr_reader :origin, :cross_origin, :top_origin, :token_binding, :encoding 14 | 15 | def initialize( 16 | origin = fake_origin, 17 | cross_origin: nil, 18 | top_origin: nil, 19 | token_binding: nil, 20 | authenticator: WebAuthn::FakeAuthenticator.new, 21 | encoding: WebAuthn.configuration.encoding 22 | ) 23 | @origin = origin 24 | @cross_origin = cross_origin 25 | @top_origin = top_origin 26 | @token_binding = token_binding 27 | @authenticator = authenticator 28 | @encoding = encoding 29 | end 30 | 31 | def create( 32 | challenge: fake_challenge, 33 | rp_id: nil, 34 | user_present: true, 35 | user_verified: false, 36 | backup_eligibility: false, 37 | backup_state: false, 38 | attested_credential_data: true, 39 | credential_algorithm: nil, 40 | extensions: nil 41 | ) 42 | rp_id ||= URI.parse(origin).host 43 | 44 | client_data_json = data_json_for(:create, encoder.decode(challenge)) 45 | client_data_hash = hashed(client_data_json) 46 | 47 | attestation_object = authenticator.make_credential( 48 | rp_id: rp_id, 49 | client_data_hash: client_data_hash, 50 | user_present: user_present, 51 | user_verified: user_verified, 52 | backup_eligibility: backup_eligibility, 53 | backup_state: backup_state, 54 | attested_credential_data: attested_credential_data, 55 | algorithm: credential_algorithm, 56 | extensions: extensions 57 | ) 58 | 59 | id = 60 | if attested_credential_data 61 | WebAuthn::AuthenticatorData 62 | .deserialize(CBOR.decode(attestation_object)["authData"]) 63 | .attested_credential_data 64 | .id 65 | else 66 | "id-for-pk-without-attested-credential-data" 67 | end 68 | 69 | { 70 | "type" => "public-key", 71 | "id" => internal_encoder.encode(id), 72 | "rawId" => encoder.encode(id), 73 | "authenticatorAttachment" => 'platform', 74 | "clientExtensionResults" => extensions, 75 | "response" => { 76 | "attestationObject" => encoder.encode(attestation_object), 77 | "clientDataJSON" => encoder.encode(client_data_json), 78 | "transports" => ["internal"], 79 | } 80 | } 81 | end 82 | 83 | def get(challenge: fake_challenge, 84 | rp_id: nil, 85 | user_present: true, 86 | user_verified: false, 87 | backup_eligibility: false, 88 | backup_state: true, 89 | sign_count: nil, 90 | extensions: nil, 91 | user_handle: nil, 92 | allow_credentials: nil) 93 | rp_id ||= URI.parse(origin).host 94 | 95 | client_data_json = data_json_for(:get, encoder.decode(challenge)) 96 | client_data_hash = hashed(client_data_json) 97 | 98 | if allow_credentials 99 | allow_credentials = allow_credentials.map { |credential| encoder.decode(credential) } 100 | end 101 | 102 | assertion = authenticator.get_assertion( 103 | rp_id: rp_id, 104 | client_data_hash: client_data_hash, 105 | user_present: user_present, 106 | user_verified: user_verified, 107 | backup_eligibility: backup_eligibility, 108 | backup_state: backup_state, 109 | sign_count: sign_count, 110 | extensions: extensions, 111 | allow_credentials: allow_credentials 112 | ) 113 | 114 | { 115 | "type" => "public-key", 116 | "id" => internal_encoder.encode(assertion[:credential_id]), 117 | "rawId" => encoder.encode(assertion[:credential_id]), 118 | "clientExtensionResults" => extensions, 119 | "authenticatorAttachment" => 'platform', 120 | "response" => { 121 | "clientDataJSON" => encoder.encode(client_data_json), 122 | "authenticatorData" => encoder.encode(assertion[:authenticator_data]), 123 | "signature" => encoder.encode(assertion[:signature]), 124 | "userHandle" => user_handle ? encoder.encode(user_handle) : nil 125 | } 126 | } 127 | end 128 | 129 | private 130 | 131 | attr_reader :authenticator 132 | 133 | def data_json_for(method, challenge) 134 | data = { 135 | type: type_for(method), 136 | challenge: internal_encoder.encode(challenge), 137 | origin: origin 138 | } 139 | 140 | if token_binding 141 | data[:tokenBinding] = token_binding 142 | end 143 | 144 | if cross_origin 145 | data[:crossOrigin] = cross_origin 146 | end 147 | 148 | if top_origin 149 | data[:topOrigin] = top_origin 150 | end 151 | 152 | data.to_json 153 | end 154 | 155 | def encoder 156 | @encoder ||= WebAuthn::Encoder.new(encoding) 157 | end 158 | 159 | def internal_encoder 160 | WebAuthn.standard_encoder 161 | end 162 | 163 | def hashed(data) 164 | OpenSSL::Digest::SHA256.digest(data) 165 | end 166 | 167 | def fake_challenge 168 | encoder.encode(SecureRandom.random_bytes(32)) 169 | end 170 | 171 | def fake_origin 172 | "http://localhost#{rand(1000)}.test" 173 | end 174 | 175 | def type_for(method) 176 | TYPES[method] 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-rspec 3 | - rubocop-rake 4 | 5 | inherit_mode: 6 | merge: 7 | - AllowedNames 8 | 9 | AllCops: 10 | TargetRubyVersion: 2.5 11 | DisabledByDefault: true 12 | NewCops: disable 13 | Exclude: 14 | - "gemfiles/**/*" 15 | - "vendor/**/*" 16 | 17 | Bundler: 18 | Enabled: true 19 | 20 | Gemspec: 21 | Enabled: true 22 | 23 | Layout: 24 | Enabled: true 25 | 26 | Layout/ClassStructure: 27 | Enabled: true 28 | 29 | Layout/EmptyLineBetweenDefs: 30 | AllowAdjacentOneLineDefs: true 31 | 32 | Layout/EmptyLinesAroundAttributeAccessor: 33 | Enabled: true 34 | 35 | Layout/FirstMethodArgumentLineBreak: 36 | Enabled: true 37 | 38 | Layout/LineLength: 39 | Max: 120 40 | Exclude: 41 | - spec/support/seeds.rb 42 | 43 | Layout/MultilineAssignmentLayout: 44 | Enabled: true 45 | 46 | Layout/MultilineMethodArgumentLineBreaks: 47 | Enabled: true 48 | 49 | Layout/SpaceAroundMethodCallOperator: 50 | Enabled: true 51 | 52 | Lint: 53 | Enabled: true 54 | 55 | Lint/DeprecatedOpenSSLConstant: 56 | Enabled: true 57 | 58 | Lint/MixedRegexpCaptureTypes: 59 | Enabled: true 60 | 61 | Lint/RaiseException: 62 | Enabled: true 63 | 64 | Lint/StructNewOverride: 65 | Enabled: true 66 | 67 | Lint/BinaryOperatorWithIdenticalOperands: 68 | Enabled: true 69 | 70 | Lint/DuplicateElsifCondition: 71 | Enabled: true 72 | 73 | Lint/DuplicateRescueException: 74 | Enabled: true 75 | 76 | Lint/EmptyConditionalBody: 77 | Enabled: true 78 | 79 | Lint/FloatComparison: 80 | Enabled: true 81 | 82 | Lint/MissingSuper: 83 | Enabled: true 84 | 85 | Lint/OutOfRangeRegexpRef: 86 | Enabled: true 87 | 88 | Lint/SelfAssignment: 89 | Enabled: true 90 | 91 | Lint/TopLevelReturnWithArgument: 92 | Enabled: true 93 | 94 | Lint/UnreachableLoop: 95 | Enabled: true 96 | 97 | Naming: 98 | Enabled: true 99 | 100 | Naming/VariableNumber: 101 | Enabled: false 102 | 103 | RSpec/Be: 104 | Enabled: true 105 | 106 | RSpec/BeforeAfterAll: 107 | Enabled: true 108 | 109 | RSpec/EmptyExampleGroup: 110 | Enabled: true 111 | 112 | RSpec/EmptyLineAfterExample: 113 | Enabled: true 114 | 115 | RSpec/EmptyLineAfterExampleGroup: 116 | Enabled: true 117 | 118 | RSpec/EmptyLineAfterFinalLet: 119 | Enabled: true 120 | 121 | RSpec/EmptyLineAfterHook: 122 | Enabled: true 123 | 124 | RSpec/EmptyLineAfterSubject: 125 | Enabled: true 126 | 127 | RSpec/HookArgument: 128 | Enabled: true 129 | 130 | RSpec/LeadingSubject: 131 | Enabled: true 132 | 133 | RSpec/NamedSubject: 134 | Enabled: true 135 | 136 | RSpec/ScatteredLet: 137 | Enabled: true 138 | 139 | RSpec/ScatteredSetup: 140 | Enabled: true 141 | 142 | Naming/MethodParameterName: 143 | AllowedNames: 144 | - rp 145 | 146 | Security: 147 | Enabled: true 148 | 149 | Style/BlockComments: 150 | Enabled: true 151 | 152 | Style/CaseEquality: 153 | Enabled: true 154 | 155 | Style/ClassAndModuleChildren: 156 | Enabled: true 157 | 158 | Style/ClassMethods: 159 | Enabled: true 160 | 161 | Style/ClassVars: 162 | Enabled: true 163 | 164 | Style/CommentAnnotation: 165 | Enabled: true 166 | 167 | Style/ConditionalAssignment: 168 | Enabled: true 169 | 170 | Style/DefWithParentheses: 171 | Enabled: true 172 | 173 | Style/Dir: 174 | Enabled: true 175 | 176 | Style/EachForSimpleLoop: 177 | Enabled: true 178 | 179 | Style/EachWithObject: 180 | Enabled: true 181 | 182 | Style/EmptyBlockParameter: 183 | Enabled: true 184 | 185 | Style/EmptyCaseCondition: 186 | Enabled: true 187 | 188 | Style/EmptyElse: 189 | Enabled: true 190 | 191 | Style/EmptyLambdaParameter: 192 | Enabled: true 193 | 194 | Style/EmptyLiteral: 195 | Enabled: true 196 | 197 | Style/EvenOdd: 198 | Enabled: true 199 | 200 | Style/ExpandPathArguments: 201 | Enabled: true 202 | 203 | Style/For: 204 | Enabled: true 205 | 206 | Style/FrozenStringLiteralComment: 207 | Enabled: true 208 | 209 | Style/GlobalVars: 210 | Enabled: true 211 | 212 | Style/HashSyntax: 213 | Enabled: true 214 | 215 | Style/IdenticalConditionalBranches: 216 | Enabled: true 217 | 218 | Style/IfInsideElse: 219 | Enabled: true 220 | 221 | Style/InverseMethods: 222 | Enabled: true 223 | 224 | Style/MethodCallWithoutArgsParentheses: 225 | Enabled: true 226 | 227 | Style/MethodDefParentheses: 228 | Enabled: true 229 | 230 | Style/MultilineMemoization: 231 | Enabled: true 232 | 233 | Style/MutableConstant: 234 | Enabled: true 235 | 236 | Style/NestedParenthesizedCalls: 237 | Enabled: true 238 | 239 | Style/OptionalArguments: 240 | Enabled: true 241 | 242 | Style/ParenthesesAroundCondition: 243 | Enabled: true 244 | 245 | Style/RedundantBegin: 246 | Enabled: true 247 | 248 | Style/RedundantConditional: 249 | Enabled: true 250 | 251 | Style/RedundantException: 252 | Enabled: true 253 | 254 | Style/RedundantFreeze: 255 | Enabled: true 256 | 257 | Style/RedundantInterpolation: 258 | Enabled: true 259 | 260 | Style/RedundantParentheses: 261 | Enabled: true 262 | 263 | Style/RedundantPercentQ: 264 | Enabled: true 265 | 266 | Style/RedundantReturn: 267 | Enabled: true 268 | 269 | Style/RedundantSelf: 270 | Enabled: true 271 | 272 | Style/Semicolon: 273 | Enabled: true 274 | 275 | Style/SingleLineMethods: 276 | Enabled: true 277 | 278 | Style/SpecialGlobalVars: 279 | Enabled: true 280 | 281 | Style/SymbolLiteral: 282 | Enabled: true 283 | 284 | Style/TrailingBodyOnClass: 285 | Enabled: true 286 | 287 | Style/TrailingBodyOnMethodDefinition: 288 | Enabled: true 289 | 290 | Style/TrailingBodyOnModule: 291 | Enabled: true 292 | 293 | Style/TrailingMethodEndStatement: 294 | Enabled: true 295 | 296 | Style/TrivialAccessors: 297 | Enabled: true 298 | 299 | Style/UnpackFirst: 300 | Enabled: true 301 | 302 | Style/YodaCondition: 303 | Enabled: true 304 | 305 | Style/ZeroLengthPredicate: 306 | Enabled: true 307 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "cose/error" 5 | require "cose/rsapkcs1_algorithm" 6 | require "openssl" 7 | require "webauthn/authenticator_data/attested_credential_data" 8 | require "webauthn/error" 9 | 10 | module WebAuthn 11 | module AttestationStatement 12 | class UnsupportedAlgorithm < Error; end 13 | 14 | ATTESTATION_TYPE_NONE = "None" 15 | ATTESTATION_TYPE_BASIC = "Basic" 16 | ATTESTATION_TYPE_SELF = "Self" 17 | ATTESTATION_TYPE_ATTCA = "AttCA" 18 | ATTESTATION_TYPE_BASIC_OR_ATTCA = "Basic_or_AttCA" 19 | ATTESTATION_TYPE_ANONCA = "AnonCA" 20 | 21 | ATTESTATION_TYPES_WITH_ROOT = [ 22 | ATTESTATION_TYPE_BASIC, 23 | ATTESTATION_TYPE_BASIC_OR_ATTCA, 24 | ATTESTATION_TYPE_ATTCA, 25 | ATTESTATION_TYPE_ANONCA 26 | ].freeze 27 | 28 | class Base 29 | AAGUID_EXTENSION_OID = "1.3.6.1.4.1.45724.1.1.4" 30 | 31 | def initialize(statement, relying_party = WebAuthn.configuration.relying_party) 32 | @statement = statement 33 | @relying_party = relying_party 34 | end 35 | 36 | def valid?(_authenticator_data, _client_data_hash) 37 | raise NotImplementedError 38 | end 39 | 40 | def format 41 | WebAuthn::AttestationStatement::FORMAT_TO_CLASS.key(self.class) 42 | end 43 | 44 | def attestation_certificate 45 | certificates&.first 46 | end 47 | 48 | def attestation_certificate_key_id 49 | attestation_certificate.subject_key_identifier&.unpack1("H*") 50 | end 51 | 52 | private 53 | 54 | attr_reader :statement, :relying_party 55 | 56 | def matching_aaguid?(attested_credential_data_aaguid) 57 | extension = attestation_certificate&.find_extension(AAGUID_EXTENSION_OID) 58 | if extension 59 | aaguid_value = OpenSSL::ASN1.decode(extension.value_der).value 60 | aaguid_value == attested_credential_data_aaguid 61 | else 62 | true 63 | end 64 | end 65 | 66 | def matching_public_key?(authenticator_data) 67 | attestation_certificate.public_key.to_der == authenticator_data.credential.public_key_object.to_der 68 | end 69 | 70 | def certificates 71 | @certificates ||= 72 | raw_certificates&.map do |raw_certificate| 73 | OpenSSL::X509::Certificate.new(raw_certificate) 74 | end 75 | end 76 | 77 | def algorithm 78 | statement["alg"] 79 | end 80 | 81 | def raw_certificates 82 | statement["x5c"] 83 | end 84 | 85 | def signature 86 | statement["sig"] 87 | end 88 | 89 | def attestation_trust_path 90 | if certificates&.any? 91 | certificates 92 | end 93 | end 94 | 95 | def trustworthy?(aaguid: nil, attestation_certificate_key_id: nil) 96 | if ATTESTATION_TYPES_WITH_ROOT.include?(attestation_type) 97 | relying_party.acceptable_attestation_types.include?(attestation_type) && 98 | valid_certificate_chain?(aaguid: aaguid, attestation_certificate_key_id: attestation_certificate_key_id) 99 | else 100 | relying_party.acceptable_attestation_types.include?(attestation_type) 101 | end 102 | end 103 | 104 | def valid_certificate_chain?(aaguid: nil, attestation_certificate_key_id: nil) 105 | root_certificates = root_certificates( 106 | aaguid: aaguid, 107 | attestation_certificate_key_id: attestation_certificate_key_id 108 | ) 109 | 110 | if certificates&.one? && root_certificates.include?(attestation_certificate) 111 | return true 112 | end 113 | 114 | attestation_root_certificates_store( 115 | aaguid: aaguid, 116 | attestation_certificate_key_id: attestation_certificate_key_id 117 | ).verify(attestation_certificate, attestation_trust_path) 118 | end 119 | 120 | def attestation_root_certificates_store(aaguid: nil, attestation_certificate_key_id: nil) 121 | OpenSSL::X509::Store.new.tap do |store| 122 | root_certificates( 123 | aaguid: aaguid, 124 | attestation_certificate_key_id: attestation_certificate_key_id 125 | ).each do |cert| 126 | store.add_cert(cert) 127 | end 128 | end 129 | end 130 | 131 | def root_certificates(aaguid: nil, attestation_certificate_key_id: nil) 132 | root_certificates = 133 | relying_party.attestation_root_certificates_finders.reduce([]) do |certs, finder| 134 | if certs.empty? 135 | finder.find( 136 | attestation_format: format, 137 | aaguid: aaguid, 138 | attestation_certificate_key_id: attestation_certificate_key_id 139 | ) || [] 140 | else 141 | certs 142 | end 143 | end 144 | 145 | if root_certificates.empty? && respond_to?(:default_root_certificates, true) 146 | default_root_certificates 147 | else 148 | root_certificates 149 | end 150 | end 151 | 152 | def valid_signature?(authenticator_data, client_data_hash, public_key = attestation_certificate.public_key) 153 | raise("Incompatible algorithm and key") unless cose_algorithm.compatible_key?(public_key) 154 | 155 | cose_algorithm.verify( 156 | public_key, 157 | signature, 158 | verification_data(authenticator_data, client_data_hash) 159 | ) 160 | rescue COSE::Error 161 | false 162 | end 163 | 164 | def verification_data(authenticator_data, client_data_hash) 165 | authenticator_data.data + client_data_hash 166 | end 167 | 168 | def cose_algorithm 169 | @cose_algorithm ||= 170 | COSE::Algorithm.find(algorithm).tap do |alg| 171 | alg && relying_party.algorithms.include?(alg.name) || 172 | raise(UnsupportedAlgorithm, "Unsupported algorithm #{algorithm}") 173 | end 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/fido_u2f_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "json" 6 | require "openssl" 7 | require "webauthn/attestation_statement/fido_u2f" 8 | 9 | RSpec.describe "FidoU2f attestation" do 10 | describe "#valid?" do 11 | let(:credential_public_key) { create_ec_key.public_key } 12 | let(:client_data_hash) { OpenSSL::Digest::SHA256.digest({}.to_json) } 13 | 14 | let(:authenticator_data_bytes) do 15 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 16 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 17 | credential: { id: "0".b * 16, public_key: credential_public_key }, 18 | aaguid: WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID 19 | ).serialize 20 | end 21 | 22 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 23 | let(:to_be_signed) do 24 | "\x00" + 25 | authenticator_data.rp_id_hash + 26 | client_data_hash + 27 | authenticator_data.credential.id + 28 | credential_public_key.to_bn.to_s(2) 29 | end 30 | 31 | let(:attestation_key) { create_ec_key } 32 | let(:signature) { attestation_key.sign("SHA256", to_be_signed) } 33 | 34 | let(:attestation_certificate) do 35 | issue_certificate(root_certificate, root_key, attestation_key) 36 | end 37 | 38 | let(:statement) do 39 | WebAuthn::AttestationStatement::FidoU2f.new( 40 | "sig" => signature, 41 | "x5c" => [attestation_certificate.to_der] 42 | ) 43 | end 44 | 45 | let(:root_key) { create_ec_key } 46 | 47 | let(:root_certificate) do 48 | create_root_certificate(root_key) 49 | end 50 | 51 | before do 52 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for(root_certificate) 53 | end 54 | 55 | it "works if everything's fine" do 56 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 57 | end 58 | 59 | context 'when the attestation certificate is the only certificate in the certificate chain' do 60 | context "and it's equal to one of the root certificates" do 61 | before do 62 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for(attestation_certificate) 63 | end 64 | 65 | it "works" do 66 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 67 | end 68 | end 69 | end 70 | 71 | context "when signature is invalid" do 72 | context "because it was signed with a different signing key (self attested)" do 73 | let(:signature) { create_ec_key.sign("SHA256", to_be_signed) } 74 | 75 | it "fails" do 76 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 77 | end 78 | end 79 | 80 | context "because it was signed over different data" do 81 | let(:to_be_signed) { "other data" } 82 | 83 | it "fails" do 84 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 85 | end 86 | end 87 | 88 | context "because it is corrupted" do 89 | let(:signature) { "corrupted signature".b } 90 | 91 | it "fails" do 92 | expect { statement.valid?(authenticator_data, client_data_hash) }.to raise_error(OpenSSL::PKey::PKeyError) 93 | end 94 | end 95 | end 96 | 97 | context "when the attested credential public key is invalid" do 98 | context "because the coordinates are longer than expected" do 99 | let(:credential_public_key) do 100 | WebAuthn.configuration.algorithms << "ES384" 101 | 102 | OpenSSL::PKey::EC.generate("secp384r1").public_key 103 | end 104 | 105 | it "fails" do 106 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 107 | end 108 | end 109 | end 110 | 111 | context "when the attestation certificate is invalid" do 112 | context "because there are too many" do 113 | let(:statement) do 114 | WebAuthn::AttestationStatement::FidoU2f.new( 115 | "sig" => signature, 116 | "x5c" => [attestation_certificate.to_der, attestation_certificate.to_der] 117 | ) 118 | end 119 | 120 | it "fails" do 121 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 122 | end 123 | end 124 | 125 | context "because it is not of the correct type" do 126 | let(:attestation_key) { create_rsa_key } 127 | 128 | it "fails" do 129 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 130 | end 131 | end 132 | 133 | context "because it is not of the correct curve" do 134 | let(:attestation_key) { OpenSSL::PKey::EC.generate("secp384r1") } 135 | 136 | it "fails" do 137 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 138 | end 139 | end 140 | end 141 | 142 | context "when the AAGUID is invalid" do 143 | let(:authenticator_data_bytes) do 144 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 145 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 146 | credential: { id: "0".b * 16, public_key: credential_public_key }, 147 | aaguid: SecureRandom.random_bytes(16) 148 | ).serialize 149 | end 150 | 151 | it "fails" do 152 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 153 | end 154 | end 155 | 156 | context "when the certificate chain is invalid" do 157 | context "when finder doesn't have correct certificate" do 158 | before do 159 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for( 160 | nil, 161 | return_empty: true 162 | ) 163 | end 164 | 165 | it "returns false" do 166 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 167 | end 168 | end 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /spec/webauthn/public_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "webauthn/public_key" 6 | require "support/seeds" 7 | require "cose" 8 | require "openssl" 9 | 10 | RSpec.describe "PublicKey" do 11 | let(:uncompressed_point_public_key) do 12 | WebAuthn::Encoders::Base64Encoder.decode(seeds[:u2f_migration][:stored_credential][:public_key]) 13 | end 14 | let(:cose_public_key) do 15 | WebAuthn::Encoders::Base64UrlEncoder.decode( 16 | "pQECAyYgASFYIPJKd_-Rl0QtQwbLggjGC_EbUFIMriCkdc2yuaukkBuNIlggaBsBjCwnMzFL7OUGJNm4b-HVpFNUa_NbsHGARuYKHfU" 17 | ) 18 | end 19 | let(:webauthn_public_key) { WebAuthn::PublicKey.deserialize(public_key) } 20 | 21 | describe ".deserialize" do 22 | context "when invalid public key" do 23 | let(:public_key) { 'invalidinvalid' } 24 | 25 | it "should fail" do 26 | expect { webauthn_public_key }.to raise_error(COSE::MalformedKeyError) 27 | end 28 | end 29 | end 30 | 31 | describe "#pkey" do 32 | let(:pkey) { webauthn_public_key.pkey } 33 | 34 | context "when public key stored in uncompressed point format" do 35 | let(:public_key) { uncompressed_point_public_key } 36 | 37 | it "should return ssl pkey" do 38 | expect(pkey).to be_instance_of(OpenSSL::PKey::EC) 39 | end 40 | end 41 | 42 | context "when public key stored in cose format" do 43 | let(:public_key) { cose_public_key } 44 | 45 | it "should return ssl pkey" do 46 | expect(pkey).to be_instance_of(OpenSSL::PKey::EC) 47 | end 48 | end 49 | end 50 | 51 | describe "#cose_key" do 52 | let(:cose_key) { webauthn_public_key.cose_key } 53 | 54 | context "when public key stored in uncompressed point format" do 55 | let(:public_key) { uncompressed_point_public_key } 56 | 57 | it "should return EC2 cose key" do 58 | expect(cose_key).to be_instance_of(COSE::Key::EC2) 59 | end 60 | end 61 | 62 | context "when public key stored in cose format" do 63 | let(:public_key) { cose_public_key } 64 | 65 | it "should return cose key" do 66 | expect(cose_key).to be_a(COSE::Key::Base) 67 | end 68 | end 69 | end 70 | 71 | describe "#alg" do 72 | let(:alg) { webauthn_public_key.alg } 73 | 74 | context "when public key stored in uncompressed point format" do 75 | let(:public_key) { uncompressed_point_public_key } 76 | 77 | it "should return ES256 cose algorithm id" do 78 | expect(alg).to eq(COSE::Algorithm.by_name("ES256").id) 79 | end 80 | end 81 | 82 | context "when public key stored in cose format" do 83 | let(:public_key) { cose_public_key } 84 | 85 | it "should return cose algorithm id" do 86 | expect(alg).to be_a(Integer) 87 | end 88 | end 89 | end 90 | 91 | describe "#verify" do 92 | context "when public key stored in uncompressed point format" do 93 | let(:public_key) { uncompressed_point_public_key } 94 | 95 | context "when signature was signed with public key" do 96 | let(:signature) do 97 | WebAuthn::Encoders::Base64Encoder.decode(seeds[:u2f_migration][:assertion][:response][:signature]) 98 | end 99 | let(:authenticator_data) do 100 | WebAuthn::Encoders::Base64Encoder.decode(seeds[:u2f_migration][:assertion][:response][:authenticator_data]) 101 | end 102 | let(:client_data_hash) do 103 | WebAuthn::ClientData.new( 104 | WebAuthn::Encoders::Base64Encoder.decode(seeds[:u2f_migration][:assertion][:response][:client_data_json]) 105 | ).hash 106 | end 107 | let(:verification_data) { authenticator_data + client_data_hash } 108 | 109 | it "should verify" do 110 | expect( 111 | webauthn_public_key.verify(signature, verification_data) 112 | ).to be_truthy 113 | end 114 | end 115 | end 116 | 117 | context "when public key stored in cose format" do 118 | let(:signature) { key.sign(hash_algorithm, to_be_signed) } 119 | let(:to_be_signed) { "data" } 120 | let(:hash_algorithm) do 121 | COSE::Algorithm.find("ES256").hash_function 122 | end 123 | let(:cose_key) do 124 | cose_key = COSE::Key::EC2.from_pkey(key.public_key) 125 | cose_key.alg = -7 126 | 127 | cose_key 128 | end 129 | let(:key) { OpenSSL::PKey::EC.generate("prime256v1") } 130 | let(:webauthn_public_key) { WebAuthn::PublicKey.new(cose_key: cose_key) } 131 | 132 | it "works" do 133 | expect(webauthn_public_key.verify(signature, to_be_signed)).to be_truthy 134 | end 135 | 136 | context "when it was signed using a different hash algorithm" do 137 | let(:hash_algorithm) { "SHA1" } 138 | 139 | it "fails" do 140 | expect(webauthn_public_key.verify(signature, to_be_signed)).to be_falsy 141 | end 142 | end 143 | 144 | context "when it was signed with a different key" do 145 | let(:signature) do 146 | OpenSSL::PKey::EC.generate("prime256v1").sign( 147 | hash_algorithm, 148 | to_be_signed 149 | ) 150 | end 151 | 152 | it "fails" do 153 | expect(webauthn_public_key.verify(signature, to_be_signed)).to be_falsy 154 | end 155 | end 156 | 157 | context "when it was signed over different data" do 158 | let(:signature) { key.sign(hash_algorithm, "different data") } 159 | 160 | it "fails" do 161 | expect(webauthn_public_key.verify(signature, to_be_signed)).to be_falsy 162 | end 163 | end 164 | 165 | context "when public key algorithm is not in COSE" do 166 | let(:cose_key) do 167 | cose_key = COSE::Key::EC2.from_pkey(key.public_key) 168 | cose_key.alg = -1 169 | 170 | cose_key 171 | end 172 | 173 | it "fails" do 174 | expect { webauthn_public_key.verify(signature, to_be_signed) }.to( 175 | raise_error( 176 | WebAuthn::PublicKey::UnsupportedAlgorithm, 177 | "The public key algorithm -1 is not among the available COSE algorithms" 178 | ) 179 | ) 180 | end 181 | end 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /spec/webauthn/public_key_credential/creation_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "webauthn/public_key_credential/creation_options" 5 | 6 | RSpec.describe WebAuthn::PublicKeyCredential::CreationOptions do 7 | let(:user_id) { WebAuthn.generate_user_id } 8 | let(:creation_options) do 9 | WebAuthn::PublicKeyCredential::CreationOptions.new( 10 | user: { id: user_id, name: "User", display_name: "User Display" } 11 | ) 12 | end 13 | 14 | it "has a challenge" do 15 | expect(creation_options.challenge.class).to eq(String) 16 | expect(creation_options.challenge.encoding).to eq(Encoding::ASCII) 17 | expect(creation_options.challenge.length).to be >= 32 18 | end 19 | 20 | context "public key params" do 21 | it "has default public key params" do 22 | params = creation_options.pub_key_cred_params 23 | 24 | array = [ 25 | { type: "public-key", alg: -7 }, 26 | { type: "public-key", alg: -37 }, 27 | { type: "public-key", alg: -257 }, 28 | ] 29 | 30 | expect(params).to match_array(array) 31 | end 32 | 33 | context "when extra alg added" do 34 | before do 35 | WebAuthn.configuration.algorithms << "RS1" 36 | end 37 | 38 | it "is added to public key params" do 39 | params = creation_options.pub_key_cred_params 40 | 41 | array = [ 42 | { type: "public-key", alg: -7 }, 43 | { type: "public-key", alg: -37 }, 44 | { type: "public-key", alg: -257 }, 45 | { type: "public-key", alg: -65535 }, 46 | ] 47 | 48 | expect(params).to match_array(array) 49 | end 50 | end 51 | end 52 | 53 | context "Relying Party info" do 54 | it "has relying party name default to nothing" do 55 | expect(creation_options.rp.name).to eq(nil) 56 | end 57 | 58 | context "when configured" do 59 | before do 60 | WebAuthn.configuration.rp_name = "Example Inc." 61 | WebAuthn.configuration.rp_id = "example.com" 62 | end 63 | 64 | it "has the configured values" do 65 | expect(creation_options.rp.name).to eq("Example Inc.") 66 | expect(creation_options.rp.id).to eq("example.com") 67 | end 68 | end 69 | end 70 | 71 | it "has user info" do 72 | expect(creation_options.user.id).to eq(user_id) 73 | expect(creation_options.user.name).to eq("User") 74 | expect(creation_options.user.display_name).to eq("User Display") 75 | end 76 | 77 | context "client timeout" do 78 | it "has a default client timeout" do 79 | expect(creation_options.timeout).to(eq(120000)) 80 | end 81 | 82 | context "when client timeout is configured" do 83 | before do 84 | WebAuthn.configuration.credential_options_timeout = 60000 85 | end 86 | 87 | it "updates the client timeout" do 88 | expect(creation_options.timeout).to(eq(60000)) 89 | end 90 | end 91 | end 92 | 93 | it "has everything" do 94 | options = WebAuthn::PublicKeyCredential::CreationOptions.new( 95 | rp: { 96 | id: "rp-id", 97 | name: "rp-name" 98 | }, 99 | user: { 100 | id: "user-id", 101 | name: "user-name", 102 | display_name: "user-display-name" 103 | }, 104 | pub_key_cred_params: [{ type: "public-key", alg: -7 }], 105 | timeout: 10_000, 106 | exclude_credentials: [{ type: "public-key", id: "credential-id", transports: ["usb", "nfc"] }], 107 | authenticator_selection: { 108 | authenticator_attachment: "cross-platform", 109 | resident_key: "required", 110 | user_verification: "required" 111 | }, 112 | attestation: "direct", 113 | extensions: { whatever: "whatever" }, 114 | ) 115 | 116 | hash = options.as_json 117 | 118 | expect(hash[:rp]).to eq(id: "rp-id", name: "rp-name") 119 | expect(hash[:user]).to eq( 120 | id: "user-id", name: "user-name", displayName: "user-display-name" 121 | ) 122 | expect(hash[:pubKeyCredParams]).to eq([{ type: "public-key", alg: -7 }]) 123 | expect(hash[:timeout]).to eq(10_000) 124 | expect(hash[:excludeCredentials]).to eq([{ type: "public-key", id: "credential-id", transports: ["usb", "nfc"] }]) 125 | expect(hash[:authenticatorSelection]).to eq( 126 | authenticatorAttachment: "cross-platform", residentKey: "required", userVerification: "required" 127 | ) 128 | expect(hash[:attestation]).to eq("direct") 129 | expect(hash[:extensions]).to eq(whatever: "whatever") 130 | expect(hash[:challenge]).to be_truthy 131 | end 132 | 133 | it "has minimum required" do 134 | options = WebAuthn::PublicKeyCredential::CreationOptions.new( 135 | user: { 136 | id: "user-id", 137 | name: "user-name", 138 | } 139 | ) 140 | 141 | hash = options.as_json 142 | 143 | expect(hash[:rp]).to eq({}) 144 | expect(hash[:user]).to eq( 145 | id: "user-id", name: "user-name", displayName: 'user-name' 146 | ) 147 | expect(hash[:pubKeyCredParams]).to eq( 148 | [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -37 }, { type: "public-key", alg: -257 }] 149 | ) 150 | expect(hash[:timeout]).to eq(120_000) 151 | expect(hash[:challenge]).to be_truthy 152 | expect(hash[:extensions]).to eq({}) 153 | expect(hash).not_to have_key(:excludeCredentials) 154 | expect(hash).not_to have_key(:authenticatorSelection) 155 | expect(hash).not_to have_key(:attestation) 156 | end 157 | 158 | it "accepts shorthand for exclude_credentials" do 159 | options = WebAuthn::PublicKeyCredential::CreationOptions.new(user: { id: "id", name: "name" }, exclude: "id") 160 | 161 | expect(options.exclude).to eq("id") 162 | expect(options.exclude_credentials).to eq([{ type: "public-key", id: "id" }]) 163 | expect(options.as_json[:excludeCredentials]).to eq([{ type: "public-key", id: "id" }]) 164 | end 165 | 166 | it "accepts alg name for pub_key_cred_params" do 167 | options = WebAuthn::PublicKeyCredential::CreationOptions.new(user: { id: "id", name: "name" }, algs: "RS256") 168 | 169 | expect(options.algs).to eq("RS256") 170 | expect(options.pub_key_cred_params).to eq([{ type: "public-key", alg: -257 }]) 171 | expect(options.as_json[:pubKeyCredParams]).to eq([{ type: "public-key", alg: -257 }]) 172 | end 173 | 174 | it "accepts alg id for pub_key_cred_params" do 175 | options = WebAuthn::PublicKeyCredential::CreationOptions.new(user: { id: "id", name: "name" }, algs: -257) 176 | 177 | expect(options.algs).to eq(-257) 178 | expect(options.pub_key_cred_params).to eq([{ type: "public-key", alg: -257 }]) 179 | expect(options.as_json[:pubKeyCredParams]).to eq([{ type: "public-key", alg: -257 }]) 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /spec/webauthn/public_key_credential_with_attestation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "securerandom" 6 | require "webauthn/authenticator_attestation_response" 7 | require "webauthn/configuration" 8 | require "webauthn/public_key_credential_with_attestation" 9 | 10 | RSpec.describe "PublicKeyCredentialWithAttestation" do 11 | describe "#verify" do 12 | let(:public_key_credential) do 13 | WebAuthn::PublicKeyCredentialWithAttestation.new( 14 | type: type, 15 | id: id, 16 | raw_id: raw_id, 17 | authenticator_attachment: authenticator_attachment, 18 | response: attestation_response 19 | ) 20 | end 21 | 22 | let(:type) { "public-key" } 23 | let(:id) { WebAuthn::Encoders::Base64UrlEncoder.encode(raw_id) } 24 | let(:raw_id) { SecureRandom.random_bytes(16) } 25 | let(:authenticator_attachment) { 'platform' } 26 | 27 | let(:attestation_response) do 28 | response = client.create(challenge: raw_challenge)["response"] 29 | 30 | WebAuthn::AuthenticatorAttestationResponse.new( 31 | attestation_object: response["attestationObject"], 32 | client_data_json: response["clientDataJSON"] 33 | ) 34 | end 35 | 36 | let(:client) { WebAuthn::FakeClient.new(origin, encoding: false) } 37 | let(:challenge) { WebAuthn::Encoders::Base64UrlEncoder.encode(raw_challenge) } 38 | let(:raw_challenge) { fake_challenge } 39 | let(:origin) { fake_origin } 40 | 41 | before do 42 | WebAuthn.configuration.allowed_origins = [origin] 43 | end 44 | 45 | it "works" do 46 | expect(public_key_credential.verify(challenge)).to be_truthy 47 | 48 | expect(public_key_credential.id).not_to be_empty 49 | expect(public_key_credential.public_key).not_to be_empty 50 | expect(public_key_credential.sign_count).to eq(0) 51 | end 52 | 53 | context "when type is invalid" do 54 | context "because it is missing" do 55 | let(:type) { nil } 56 | 57 | it "fails" do 58 | expect { public_key_credential.verify(challenge) }.to raise_error(RuntimeError) 59 | end 60 | end 61 | 62 | context "because it is something else" do 63 | let(:type) { "password" } 64 | 65 | it "fails" do 66 | expect { public_key_credential.verify(challenge) }.to raise_error(RuntimeError) 67 | end 68 | end 69 | end 70 | 71 | context "when id is invalid" do 72 | context "because it is missing" do 73 | let(:id) { nil } 74 | 75 | it "fails" do 76 | expect { public_key_credential.verify(challenge) }.to raise_error(RuntimeError) 77 | end 78 | end 79 | 80 | context "because it is not the base64url of raw id" do 81 | let(:id) { WebAuthn::Encoders::Base64UrlEncoder.encode(raw_id + "a") } 82 | 83 | it "fails" do 84 | expect { public_key_credential.verify(challenge) }.to raise_error(RuntimeError) 85 | end 86 | end 87 | end 88 | 89 | context "when challenge class is invalid" do 90 | it "raise error" do 91 | expect { 92 | public_key_credential.verify(nil) 93 | }.to raise_error(WebAuthn::PublicKeyCredential::InvalidChallengeError) 94 | end 95 | end 96 | 97 | context "when challenge value is invalid" do 98 | it "fails" do 99 | expect { 100 | public_key_credential.verify(WebAuthn::Encoders::Base64UrlEncoder.encode("another challenge")) 101 | }.to raise_error(WebAuthn::ChallengeVerificationError) 102 | end 103 | end 104 | 105 | context "when clientExtensionResults" do 106 | context "are not received" do 107 | let(:public_key_credential) do 108 | WebAuthn::PublicKeyCredentialWithAttestation.new( 109 | type: type, 110 | id: id, 111 | raw_id: raw_id, 112 | client_extension_outputs: nil, 113 | response: attestation_response 114 | ) 115 | end 116 | 117 | it "works" do 118 | expect(public_key_credential.verify(challenge)).to be_truthy 119 | 120 | expect(public_key_credential.client_extension_outputs).to be_nil 121 | end 122 | end 123 | 124 | context "are received" do 125 | let(:public_key_credential) do 126 | WebAuthn::PublicKeyCredentialWithAttestation.new( 127 | type: type, 128 | id: id, 129 | raw_id: raw_id, 130 | client_extension_outputs: { "appid" => "true" }, 131 | response: attestation_response 132 | ) 133 | end 134 | 135 | it "works" do 136 | expect(public_key_credential.verify(challenge)).to be_truthy 137 | 138 | expect(public_key_credential.client_extension_outputs).to eq({ "appid" => "true" }) 139 | end 140 | end 141 | end 142 | 143 | context "when authentication extension input" do 144 | context "is not received" do 145 | let(:attestation_response) do 146 | response = client.create(challenge: raw_challenge, extensions: nil)["response"] 147 | 148 | WebAuthn::AuthenticatorAttestationResponse.new( 149 | attestation_object: response["attestationObject"], 150 | client_data_json: response["clientDataJSON"] 151 | ) 152 | end 153 | 154 | it "works" do 155 | expect(public_key_credential.verify(challenge)).to be_truthy 156 | 157 | expect(public_key_credential.authenticator_extension_outputs).to be_nil 158 | end 159 | end 160 | 161 | context "is received" do 162 | let(:attestation_response) do 163 | response = client.create( 164 | challenge: raw_challenge, 165 | extensions: { "txAuthSimple" => "Could you please verify yourself?" } 166 | )["response"] 167 | 168 | WebAuthn::AuthenticatorAttestationResponse.new( 169 | attestation_object: response["attestationObject"], 170 | client_data_json: response["clientDataJSON"] 171 | ) 172 | end 173 | 174 | it "works" do 175 | expect(public_key_credential.verify(challenge)).to be_truthy 176 | 177 | expect(public_key_credential.authenticator_extension_outputs) 178 | .to eq({ "txAuthSimple" => "Could you please verify yourself?" }) 179 | end 180 | end 181 | end 182 | 183 | context "when user_presence" do 184 | context "is not set" do 185 | it "correcly delegates its value to the response" do 186 | expect(attestation_response).to receive(:verify).with(anything, hash_including(user_presence: nil)) 187 | 188 | public_key_credential.verify(challenge) 189 | end 190 | end 191 | 192 | context "is set to false" do 193 | it "correcly delegates its value to the response" do 194 | expect(attestation_response).to receive(:verify).with(anything, hash_including(user_presence: false)) 195 | 196 | public_key_credential.verify(challenge, user_presence: false) 197 | end 198 | end 199 | 200 | context "is set to true" do 201 | it "correcly delegates its value to the response" do 202 | expect(attestation_response).to receive(:verify).with(anything, hash_including(user_presence: true)) 203 | 204 | public_key_credential.verify(challenge, user_presence: true) 205 | end 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/android_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "json" 6 | require "openssl" 7 | require "webauthn/attestation_statement/android_key" 8 | 9 | RSpec.describe "AndroidKey attestation" do 10 | describe "#valid?" do 11 | let(:credential_key) { create_ec_key } 12 | let(:client_data_hash) { OpenSSL::Digest::SHA256.digest({}.to_json) } 13 | 14 | let(:authenticator_data_bytes) do 15 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 16 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 17 | credential: { id: "0".b * 16, public_key: credential_key.public_key }, 18 | ).serialize 19 | end 20 | 21 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 22 | let(:to_be_signed) { authenticator_data.data + client_data_hash } 23 | 24 | let(:algorithm) { -7 } 25 | let(:attestation_key) { credential_key } 26 | let(:signature) { attestation_key.sign("SHA256", to_be_signed) } 27 | let(:attestation_certificate_attestation_challenge) { OpenSSL::ASN1::OctetString.new(client_data_hash) } 28 | let(:attestation_certificate_purpose) { OpenSSL::ASN1::Set.new([OpenSSL::ASN1::Integer.new(2)], 1, :EXPLICIT) } 29 | let(:attestation_certificate_origin) { OpenSSL::ASN1::Integer.new(0, 702, :EXPLICIT) } 30 | 31 | let(:attestation_certificate_tee_enforced) do 32 | OpenSSL::ASN1::Sequence.new([attestation_certificate_purpose, attestation_certificate_origin]) 33 | end 34 | 35 | let(:attestation_certificate_software_enforced) { OpenSSL::ASN1::Sequence.new([]) } 36 | 37 | let(:attestation_certificate_extension) do 38 | OpenSSL::ASN1::Sequence.new( 39 | [ 40 | OpenSSL::ASN1::Integer.new(3), 41 | OpenSSL::ASN1::Integer.new(0), 42 | OpenSSL::ASN1::Integer.new(0), 43 | OpenSSL::ASN1::Integer.new(0), 44 | attestation_certificate_attestation_challenge, 45 | OpenSSL::ASN1::OctetString.new(""), 46 | attestation_certificate_software_enforced, 47 | attestation_certificate_tee_enforced 48 | ] 49 | ).to_der 50 | end 51 | 52 | let(:attestation_certificate_extensions) do 53 | [OpenSSL::X509::Extension.new("1.3.6.1.4.1.11129.2.1.17", attestation_certificate_extension, false)] 54 | end 55 | 56 | let(:attestation_certificate) do 57 | issue_certificate( 58 | root_certificate, 59 | root_key, 60 | attestation_key, 61 | extensions: attestation_certificate_extensions 62 | ).to_der 63 | end 64 | 65 | let(:statement) do 66 | WebAuthn::AttestationStatement::AndroidKey.new( 67 | "alg" => algorithm, 68 | "sig" => signature, 69 | "x5c" => [attestation_certificate] 70 | ) 71 | end 72 | 73 | let(:root_key) { create_ec_key } 74 | let(:root_certificate) { create_root_certificate(root_key) } 75 | let(:google_certificates) { [root_certificate] } 76 | 77 | around do |example| 78 | silence_warnings do 79 | original_google_certificates = AndroidKeyAttestation::Statement::GOOGLE_ROOT_CERTIFICATES 80 | AndroidKeyAttestation::Statement::GOOGLE_ROOT_CERTIFICATES = google_certificates 81 | example.run 82 | AndroidKeyAttestation::Statement::GOOGLE_ROOT_CERTIFICATES = original_google_certificates 83 | end 84 | end 85 | 86 | it "works if everything's fine" do 87 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 88 | end 89 | 90 | context "when RSA algorithm" do 91 | let(:algorithm) { -257 } 92 | let(:credential_key) { create_rsa_key } 93 | 94 | it "works" do 95 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 96 | end 97 | end 98 | 99 | context "when signature is invalid" do 100 | context "because is signed with a different alg" do 101 | let(:algorithm) { -36 } 102 | 103 | it "fails" do 104 | expect { 105 | statement.valid?(authenticator_data, client_data_hash) 106 | }.to raise_error("Unsupported algorithm -36") 107 | end 108 | end 109 | 110 | context "because it was signed with a different key" do 111 | let(:signature) { create_ec_key.sign("SHA256", to_be_signed) } 112 | 113 | it "fails" do 114 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 115 | end 116 | end 117 | 118 | context "because it was signed over different data" do 119 | let(:to_be_signed) { "other data" } 120 | 121 | it "fails" do 122 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 123 | end 124 | end 125 | 126 | context "because it is corrupted" do 127 | let(:signature) { "corrupted signature".b } 128 | 129 | it "fails" do 130 | expect { statement.valid?(authenticator_data, client_data_hash) }.to raise_error(OpenSSL::PKey::PKeyError) 131 | end 132 | end 133 | end 134 | 135 | context "when the attestation key doesn't match the credential key" do 136 | let(:attestation_key) { create_ec_key } 137 | 138 | it "fails" do 139 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 140 | end 141 | end 142 | 143 | context "when the attestation certificate doesn't meet requirements" do 144 | context "because attestationChallenge is invalid" do 145 | let(:attestation_certificate_attestation_challenge) { OpenSSL::ASN1::OctetString.new(client_data_hash[0..-2]) } 146 | 147 | it "fails" do 148 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 149 | end 150 | end 151 | 152 | context "because allApplications field is present teeEnforced" do 153 | let(:attestation_certificate_tee_enforced) do 154 | OpenSSL::ASN1::Sequence.new( 155 | [ 156 | attestation_certificate_purpose, 157 | attestation_certificate_origin, 158 | OpenSSL::ASN1::Null.new(nil, 600, :EXPLICIT) 159 | ] 160 | ) 161 | end 162 | 163 | it "fails" do 164 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 165 | end 166 | end 167 | 168 | context "because allApplications field is present softwareEnforced" do 169 | let(:attestation_certificate_software_enforced) do 170 | OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Null.new(nil, 600, :EXPLICIT)]) 171 | end 172 | 173 | it "fails" do 174 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 175 | end 176 | end 177 | 178 | context "because AuthorizationList.purpose is invalid" do 179 | let(:attestation_certificate_purpose) { OpenSSL::ASN1::Set.new([OpenSSL::ASN1::Integer.new(3)], 1, :EXPLICIT) } 180 | 181 | it "fails" do 182 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 183 | end 184 | end 185 | 186 | context "because AuthorizationList.origin is invalid" do 187 | let(:attestation_certificate_origin) { OpenSSL::ASN1::Integer.new(1, 702, :EXPLICIT) } 188 | 189 | it "fails" do 190 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 191 | end 192 | end 193 | end 194 | 195 | context "when the attestation certificate is not signed by Google" do 196 | let(:google_certificates) { [create_root_certificate(create_ec_key)] } 197 | 198 | it "fails" do 199 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 200 | end 201 | 202 | it "returns true if they are configured" do 203 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for(root_certificate) 204 | 205 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 206 | end 207 | end 208 | end 209 | end 210 | --------------------------------------------------------------------------------