diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5ace4600 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2969c38..44e4bbd2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,11 @@ name: build -on: push +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize] jobs: test: @@ -16,6 +20,8 @@ jobs: fail-fast: false matrix: ruby: + - '3.4.0-preview2' + - '3.3' - '3.2' - '3.1' - '3.0' @@ -24,9 +30,21 @@ jobs: - '2.5' - truffleruby steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - run: bundle exec rake + - run: bundle exec rspec + env: + RUBYOPT: ${{ startsWith(matrix.ruby, '3.4') && '--enable=frozen-string-literal' || '' }} + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + - run: bundle exec rubocop -f github diff --git a/.github/workflows/git.yml b/.github/workflows/git.yml index 85622600..340f471e 100644 --- a/.github/workflows/git.yml +++ b/.github/workflows/git.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Block autosquash commits uses: xt0rted/block-autosquash-commits-action@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 26465c64..bda73e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [v3.1.0] - 2023-12-26 + +### Added + +- Add support for optional `authenticator_attachment` in `PublicKeyCredential`. #370 [@8ma10s] + +### Fixed + +- Fix circular require warning between `webauthn/relying_party` and `webauthn/credential`. #389 [@bdewater] +- Correctly verify attestation that contains just a batch certificate that is present in the attestation root certificates. #406 [@santiagorodriguez96] + +### Changed + +- Inlined `base64` implementation. #402 [@olleolleolle] +- Raise a more descriptive error if input `challenge` is `nil` when verifying the `PublicKeyCredential`. #413 [@soartec-lab] + ## [v3.0.0] - 2023-02-15 ### Added @@ -358,6 +374,7 @@ Note: Both additions should help making it compatible with Chrome for Android 70 - `WebAuthn::AuthenticatorAttestationResponse.valid?` can be used to validate fido-u2f attestations returned by the browser - Works with ruby 2.5 +[v3.1.0]: https://github.com/cedarcode/webauthn-ruby/compare/v3.0.0...v3.1.0/ [v3.0.0]: https://github.com/cedarcode/webauthn-ruby/compare/2-stable...v3.0.0/ [v3.0.0.alpha2]: https://github.com/cedarcode/webauthn-ruby/compare/2-stable...v3.0.0.alpha2/ [v3.0.0.alpha1]: https://github.com/cedarcode/webauthn-ruby/compare/v2.3.0...v3.0.0.alpha1 diff --git a/README.md b/README.md index 88f66d51..65fd6f42 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ For the current release version see https://github.com/cedarcode/webauthn-ruby/b ![banner](assets/webauthn-ruby.png) [![Gem](https://img.shields.io/gem/v/webauthn.svg?style=flat-square)](https://rubygems.org/gems/webauthn) -[![Travis](https://img.shields.io/travis/cedarcode/webauthn-ruby/master.svg?style=flat-square)](https://travis-ci.com/cedarcode/webauthn-ruby) +[![Build](https://github.com/cedarcode/webauthn-ruby/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/cedarcode/webauthn-ruby/actions/workflows/build.yml) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-informational.svg?style=flat-square)](https://conventionalcommits.org) [![Join the chat at https://gitter.im/cedarcode/webauthn-ruby](https://badges.gitter.im/cedarcode/webauthn-ruby.svg)](https://gitter.im/cedarcode/webauthn-ruby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) diff --git a/docs/advanced_configuration.md b/docs/advanced_configuration.md index 378467c3..b643119b 100644 --- a/docs/advanced_configuration.md +++ b/docs/advanced_configuration.md @@ -59,7 +59,7 @@ Intead of the [Global Configuration](../README.md#configuration) you place in `c **DISCLAIMER: This API was released on version 3.0.0.alpha1 and is still under evaluation. Although it has been throughly tested and it is fully functional it might be changed until the final release of version 3.0.0.** -The explanation for each ceremony can be found in depth in [Credential Registration](../README.md#credential-registration) and [Credential Authentication](../README.md#credential-authentication) but if you choose this instance based approach to define your WebAuthn configurations and assuming `relying_party` is the result of an instance you get through `WebAuthn::RelytingParty.new(...)` the code in those explanations needs to be updated to: +The explanation for each ceremony can be found in depth in [Credential Registration](../README.md#credential-registration) and [Credential Authentication](../README.md#credential-authentication) but if you choose this instance based approach to define your WebAuthn configurations and assuming `relying_party` is the result of an instance you get through `WebAuthn::RelyingParty.new(...)` the code in those explanations needs to be updated to: ### Credential Registration @@ -73,7 +73,7 @@ end options = relying_party.options_for_registration( user: { id: user.webauthn_id, name: user.name }, - exclude: user.credentials.map { |c| c.webauthn_id } + exclude: user.credentials.map { |c| c.external_id } ) # Store the newly generated challenge somewhere so you can have it @@ -106,7 +106,7 @@ begin # Store Credential ID, Credential Public Key and Sign Count for future authentications user.credentials.create!( - webauthn_id: webauthn_credential.id, + external_id: webauthn_credential.id, public_key: webauthn_credential.public_key, sign_count: webauthn_credential.sign_count ) @@ -120,7 +120,7 @@ end #### Initiation phase ```ruby -options = relying_party.options_for_get(allow: user.credentials.map { |c| c.webauthn_id }) +options = relying_party.options_for_authentication(allow: user.credentials.map { |c| c.webauthn_id }) # Store the newly generated challenge somewhere so you can have it # for the verification phase. @@ -148,9 +148,9 @@ begin webauthn_credential, stored_credential = relying_party.verify_authentication( params[:publicKeyCredential], session[:authentication_challenge] - ) do + ) do |webauthn_credential| # the returned object needs to respond to #public_key and #sign_count - user.credentials.find_by(webauthn_id: webauthn_credential.id) + user.credentials.find_by(external_id: webauthn_credential.id) end # Update the stored credential sign count with the value from `webauthn_credential.sign_count` diff --git a/lib/webauthn.rb b/lib/webauthn.rb index 82249301..ee2bd098 100644 --- a/lib/webauthn.rb +++ b/lib/webauthn.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "webauthn/json_serializer" require "webauthn/configuration" require "webauthn/credential" require "webauthn/credential_creation_options" diff --git a/lib/webauthn/attestation_statement/base.rb b/lib/webauthn/attestation_statement/base.rb index 5f0c689f..6c5e07d4 100644 --- a/lib/webauthn/attestation_statement/base.rb +++ b/lib/webauthn/attestation_statement/base.rb @@ -102,6 +102,15 @@ def trustworthy?(aaguid: nil, attestation_certificate_key_id: nil) end def valid_certificate_chain?(aaguid: nil, attestation_certificate_key_id: nil) + root_certificates = root_certificates( + aaguid: aaguid, + attestation_certificate_key_id: attestation_certificate_key_id + ) + + if certificates&.one? && root_certificates.include?(attestation_certificate) + return true + end + attestation_root_certificates_store( aaguid: aaguid, attestation_certificate_key_id: attestation_certificate_key_id diff --git a/lib/webauthn/authenticator_assertion_response.rb b/lib/webauthn/authenticator_assertion_response.rb index 1511117c..6ae46482 100644 --- a/lib/webauthn/authenticator_assertion_response.rb +++ b/lib/webauthn/authenticator_assertion_response.rb @@ -37,8 +37,22 @@ def initialize(authenticator_data:, signature:, user_handle: nil, **options) @user_handle = user_handle end - def verify(expected_challenge, expected_origin = nil, public_key:, sign_count:, user_verification: nil, rp_id: nil) - super(expected_challenge, expected_origin, user_verification: user_verification, rp_id: rp_id) + def verify( + expected_challenge, + expected_origin = nil, + public_key:, + sign_count:, + user_presence: nil, + user_verification: nil, + rp_id: nil + ) + super( + expected_challenge, + expected_origin, + user_presence: user_presence, + user_verification: user_verification, + rp_id: rp_id + ) verify_item(:signature, WebAuthn::PublicKey.deserialize(public_key)) verify_item(:sign_count, sign_count) diff --git a/lib/webauthn/authenticator_attestation_response.rb b/lib/webauthn/authenticator_attestation_response.rb index a5c83aaf..101832a5 100644 --- a/lib/webauthn/authenticator_attestation_response.rb +++ b/lib/webauthn/authenticator_attestation_response.rb @@ -12,7 +12,6 @@ module WebAuthn class AttestationStatementVerificationError < VerificationError; end - class AttestationTrustworthinessVerificationError < VerificationError; end class AttestedCredentialVerificationError < VerificationError; end class AuthenticatorAttestationResponse < AuthenticatorResponse @@ -23,21 +22,23 @@ def self.from_client(response, relying_party: WebAuthn.configuration.relying_par new( attestation_object: encoder.decode(response["attestationObject"]), + transports: response["transports"], client_data_json: encoder.decode(response["clientDataJSON"]), relying_party: relying_party ) end - attr_reader :attestation_type, :attestation_trust_path + attr_reader :attestation_type, :attestation_trust_path, :transports - def initialize(attestation_object:, **options) + def initialize(attestation_object:, transports: [], **options) super(**options) @attestation_object_bytes = attestation_object + @transports = transports @relying_party = relying_party end - def verify(expected_challenge, expected_origin = nil, user_verification: nil, rp_id: nil) + def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_verification: nil, rp_id: nil) super verify_item(:attested_credential) diff --git a/lib/webauthn/authenticator_response.rb b/lib/webauthn/authenticator_response.rb index fc20996f..1151b5e5 100644 --- a/lib/webauthn/authenticator_response.rb +++ b/lib/webauthn/authenticator_response.rb @@ -24,7 +24,7 @@ def initialize(client_data_json:, relying_party: WebAuthn.configuration.relying_ @relying_party = relying_party end - def verify(expected_challenge, expected_origin = nil, user_verification: nil, rp_id: nil) + def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_verification: nil, rp_id: nil) expected_origin ||= relying_party.origin || raise("Unspecified expected origin") rp_id ||= relying_party.id @@ -35,7 +35,8 @@ def verify(expected_challenge, expected_origin = nil, user_verification: nil, rp verify_item(:authenticator_data) verify_item(:rp_id, rp_id || rp_id_from_origin(expected_origin)) - if !relying_party.silent_authentication + # Fallback to RP configuration unless user_presence is passed in explicitely + if user_presence.nil? && !relying_party.silent_authentication || user_presence verify_item(:user_presence) end diff --git a/lib/webauthn/encoder.rb b/lib/webauthn/encoder.rb index 64838313..8d6bf8b5 100644 --- a/lib/webauthn/encoder.rb +++ b/lib/webauthn/encoder.rb @@ -20,9 +20,12 @@ def initialize(encoding = STANDARD_ENCODING) def encode(data) case encoding when :base64 - Base64.strict_encode64(data) + [data].pack("m0") # Base64.strict_encode64(data) when :base64url - Base64.urlsafe_encode64(data, padding: false) + data = [data].pack("m0") # Base64.urlsafe_encode64(data, padding: false) + data.chomp!("==") or data.chomp!("=") + data.tr!("+/", "-_") + data when nil, false data else @@ -33,9 +36,15 @@ def encode(data) def decode(data) case encoding when :base64 - Base64.strict_decode64(data) + data.unpack1("m0") # Base64.strict_decode64(data) when :base64url - Base64.urlsafe_decode64(data) + if !data.end_with?("=") && data.length % 4 != 0 # Base64.urlsafe_decode64(data) + data = data.ljust((data.length + 3) & ~3, "=") + data.tr!("-_", "+/") + else + data = data.tr("-_", "+/") + end + data.unpack1("m0") when nil, false data else diff --git a/lib/webauthn/fake_authenticator.rb b/lib/webauthn/fake_authenticator.rb index 40b13306..d7fc1433 100644 --- a/lib/webauthn/fake_authenticator.rb +++ b/lib/webauthn/fake_authenticator.rb @@ -20,10 +20,11 @@ def make_credential( backup_eligibility: false, backup_state: false, attested_credential_data: true, + algorithm: nil, sign_count: nil, extensions: nil ) - credential_id, credential_key, credential_sign_count = new_credential + credential_id, credential_key, credential_sign_count = new_credential(algorithm) sign_count ||= credential_sign_count credentials[rp_id] ||= {} @@ -85,7 +86,14 @@ def get_assertion( extensions: extensions ).serialize - signature = credential_key.sign("SHA256", authenticator_data + client_data_hash) + signature_digest_algorithm = + case credential_key + when OpenSSL::PKey::RSA, OpenSSL::PKey::EC + 'SHA256' + when OpenSSL::PKey::PKey + nil + end + signature = credential_key.sign(signature_digest_algorithm, authenticator_data + client_data_hash) credential[:sign_count] += 1 { @@ -102,8 +110,21 @@ def get_assertion( attr_reader :credentials - def new_credential - [SecureRandom.random_bytes(16), OpenSSL::PKey::EC.generate("prime256v1"), 0] + def new_credential(algorithm) + algorithm ||= 'ES256' + credential_key = + case algorithm + when 'ES256' + OpenSSL::PKey::EC.generate('prime256v1') + when 'RS256' + OpenSSL::PKey::RSA.new(2048) + when 'EdDSA' + OpenSSL::PKey.generate_key("ED25519") + else + raise "Unsupported algorithm #{algorithm}" + end + + [SecureRandom.random_bytes(16), credential_key, 0] end def hashed(target) diff --git a/lib/webauthn/fake_authenticator/attestation_object.rb b/lib/webauthn/fake_authenticator/attestation_object.rb index 52e4de5c..01b82352 100644 --- a/lib/webauthn/fake_authenticator/attestation_object.rb +++ b/lib/webauthn/fake_authenticator/attestation_object.rb @@ -61,7 +61,7 @@ def authenticator_data begin credential_data = if attested_credential_data - { id: credential_id, public_key: credential_key.public_key } + { id: credential_id, public_key: credential_public_key } end AuthenticatorData.new( @@ -76,6 +76,15 @@ def authenticator_data ) end end + + def credential_public_key + case credential_key + when OpenSSL::PKey::RSA, OpenSSL::PKey::EC + credential_key.public_key + when OpenSSL::PKey::PKey + OpenSSL::PKey.read(credential_key.public_to_der) + end + end end end end diff --git a/lib/webauthn/fake_authenticator/authenticator_data.rb b/lib/webauthn/fake_authenticator/authenticator_data.rb index dc27e343..ed98cd2f 100644 --- a/lib/webauthn/fake_authenticator/authenticator_data.rb +++ b/lib/webauthn/fake_authenticator/authenticator_data.rb @@ -140,7 +140,9 @@ def cose_credential_public_key key = COSE::Key::EC2.from_pkey(credential[:public_key]) key.alg = alg[key.crv] - + when OpenSSL::PKey::PKey + key = COSE::Key::OKP.from_pkey(credential[:public_key]) + key.alg = -8 end key.serialize diff --git a/lib/webauthn/fake_client.rb b/lib/webauthn/fake_client.rb index dbdad39b..98ae0d45 100644 --- a/lib/webauthn/fake_client.rb +++ b/lib/webauthn/fake_client.rb @@ -32,6 +32,7 @@ def create( backup_eligibility: false, backup_state: false, attested_credential_data: true, + credential_algorithm: nil, extensions: nil ) rp_id ||= URI.parse(origin).host @@ -47,6 +48,7 @@ def create( backup_eligibility: backup_eligibility, backup_state: backup_state, attested_credential_data: attested_credential_data, + algorithm: credential_algorithm, extensions: extensions ) @@ -64,10 +66,12 @@ def create( "type" => "public-key", "id" => internal_encoder.encode(id), "rawId" => encoder.encode(id), + "authenticatorAttachment" => 'platform', "clientExtensionResults" => extensions, "response" => { "attestationObject" => encoder.encode(attestation_object), - "clientDataJSON" => encoder.encode(client_data_json) + "clientDataJSON" => encoder.encode(client_data_json), + "transports" => ["internal"], } } end @@ -108,6 +112,7 @@ def get(challenge: fake_challenge, "id" => internal_encoder.encode(assertion[:credential_id]), "rawId" => encoder.encode(assertion[:credential_id]), "clientExtensionResults" => extensions, + "authenticatorAttachment" => 'platform', "response" => { "clientDataJSON" => encoder.encode(client_data_json), "authenticatorData" => encoder.encode(assertion[:authenticator_data]), diff --git a/lib/webauthn/json_serializer.rb b/lib/webauthn/json_serializer.rb new file mode 100644 index 00000000..124d5dd1 --- /dev/null +++ b/lib/webauthn/json_serializer.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module WebAuthn + module JSONSerializer + # Argument wildcard for Ruby on Rails controller automatic object JSON serialization + def as_json(*) + to_hash_with_camelized_keys + end + + private + + def to_hash_with_camelized_keys + attributes.each_with_object({}) do |attribute_name, hash| + value = send(attribute_name) + + if value.respond_to?(:as_json) + hash[camelize(attribute_name)] = value.as_json + elsif value + hash[camelize(attribute_name)] = deep_camelize_keys(value) + end + end + end + + def deep_camelize_keys(object) + case object + when Hash + object.each_with_object({}) do |(key, value), result| + result[camelize(key)] = deep_camelize_keys(value) + end + when Array + object.map { |element| deep_camelize_keys(element) } + else + object + end + end + + def camelize(term) + first_term, *rest = term.to_s.split('_') + + [first_term, *rest.map(&:capitalize)].join.to_sym + end + end +end diff --git a/lib/webauthn/public_key_credential.rb b/lib/webauthn/public_key_credential.rb index a0e45bd9..5be6c983 100644 --- a/lib/webauthn/public_key_credential.rb +++ b/lib/webauthn/public_key_credential.rb @@ -4,7 +4,9 @@ module WebAuthn class PublicKeyCredential - attr_reader :type, :id, :raw_id, :client_extension_outputs, :response + class InvalidChallengeError < Error; end + + attr_reader :type, :id, :raw_id, :client_extension_outputs, :authenticator_attachment, :response def self.from_client(credential, relying_party: WebAuthn.configuration.relying_party) new( @@ -12,6 +14,7 @@ def self.from_client(credential, relying_party: WebAuthn.configuration.relying_p id: credential["id"], raw_id: relying_party.encoder.decode(credential["rawId"]), client_extension_outputs: credential["clientExtensionResults"], + authenticator_attachment: credential["authenticatorAttachment"], response: response_class.from_client(credential["response"], relying_party: relying_party), relying_party: relying_party ) @@ -22,6 +25,7 @@ def initialize( id:, raw_id:, response:, + authenticator_attachment: nil, client_extension_outputs: {}, relying_party: WebAuthn.configuration.relying_party ) @@ -29,11 +33,18 @@ def initialize( @id = id @raw_id = raw_id @client_extension_outputs = client_extension_outputs + @authenticator_attachment = authenticator_attachment @response = response @relying_party = relying_party end - def verify(*_args) + def verify(challenge, *_args) + unless valid_class?(challenge) + msg = "challenge must be a String. input challenge class: #{challenge.class}" + + raise(InvalidChallengeError, msg) + end + valid_type? || raise("invalid type") valid_id? || raise("invalid id") @@ -68,6 +79,10 @@ def valid_id? raw_id && id && raw_id == WebAuthn.standard_encoder.decode(id) end + def valid_class?(challenge) + challenge.is_a?(String) + end + def authenticator_data response&.authenticator_data end diff --git a/lib/webauthn/public_key_credential/entity.rb b/lib/webauthn/public_key_credential/entity.rb index 7846de16..dc7fab54 100644 --- a/lib/webauthn/public_key_credential/entity.rb +++ b/lib/webauthn/public_key_credential/entity.rb @@ -1,40 +1,18 @@ # frozen_string_literal: true -require "awrence" - module WebAuthn class PublicKeyCredential class Entity + include JSONSerializer + attr_reader :name def initialize(name:) @name = name end - def as_json - to_hash.to_camelback_keys - end - private - def to_hash - hash = {} - - attributes.each do |attribute_name| - value = send(attribute_name) - - if value.respond_to?(:as_json) - value = value.as_json - end - - if value - hash[attribute_name] = value - end - end - - hash - end - def attributes [:name] end diff --git a/lib/webauthn/public_key_credential/options.rb b/lib/webauthn/public_key_credential/options.rb index 5e918e7b..01035534 100644 --- a/lib/webauthn/public_key_credential/options.rb +++ b/lib/webauthn/public_key_credential/options.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true -require "awrence" require "securerandom" module WebAuthn class PublicKeyCredential class Options + include JSONSerializer + CHALLENGE_LENGTH = 32 attr_reader :timeout, :extensions, :relying_party @@ -20,31 +21,8 @@ def challenge encoder.encode(raw_challenge) end - # Argument wildcard for Ruby on Rails controller automatic object JSON serialization - def as_json(*) - to_hash.to_camelback_keys - end - private - def to_hash - hash = {} - - attributes.each do |attribute_name| - value = send(attribute_name) - - if value.respond_to?(:as_json) - value = value.as_json - end - - if value - hash[attribute_name] = value - end - end - - hash - end - def attributes [:challenge, :timeout, :extensions] end diff --git a/lib/webauthn/public_key_credential_with_assertion.rb b/lib/webauthn/public_key_credential_with_assertion.rb index e82e5378..71d70d51 100644 --- a/lib/webauthn/public_key_credential_with_assertion.rb +++ b/lib/webauthn/public_key_credential_with_assertion.rb @@ -9,13 +9,14 @@ def self.response_class WebAuthn::AuthenticatorAssertionResponse end - def verify(challenge, public_key:, sign_count:, user_verification: nil) + def verify(challenge, public_key:, sign_count:, user_presence: nil, user_verification: nil) super response.verify( encoder.decode(challenge), public_key: encoder.decode(public_key), sign_count: sign_count, + user_presence: user_presence, user_verification: user_verification, rp_id: appid_extension_output ? appid : nil ) diff --git a/lib/webauthn/public_key_credential_with_attestation.rb b/lib/webauthn/public_key_credential_with_attestation.rb index 49b9ba8d..95b406b8 100644 --- a/lib/webauthn/public_key_credential_with_attestation.rb +++ b/lib/webauthn/public_key_credential_with_attestation.rb @@ -9,10 +9,10 @@ def self.response_class WebAuthn::AuthenticatorAttestationResponse end - def verify(challenge, user_verification: nil) + def verify(challenge, user_presence: nil, user_verification: nil) super - response.verify(encoder.decode(challenge), user_verification: user_verification) + response.verify(encoder.decode(challenge), user_presence: user_presence, user_verification: user_verification) true end diff --git a/lib/webauthn/relying_party.rb b/lib/webauthn/relying_party.rb index 483a90c2..6a667c48 100644 --- a/lib/webauthn/relying_party.rb +++ b/lib/webauthn/relying_party.rb @@ -81,10 +81,10 @@ def options_for_registration(**keyword_arguments) ) end - def verify_registration(raw_credential, challenge, user_verification: nil) + def verify_registration(raw_credential, challenge, user_presence: nil, user_verification: nil) webauthn_credential = WebAuthn::Credential.from_create(raw_credential, relying_party: self) - if webauthn_credential.verify(challenge, user_verification: user_verification) + if webauthn_credential.verify(challenge, user_presence: user_presence, user_verification: user_verification) webauthn_credential end end @@ -99,6 +99,7 @@ def options_for_authentication(**keyword_arguments) def verify_authentication( raw_credential, challenge, + user_presence: nil, user_verification: nil, public_key: nil, sign_count: nil @@ -111,6 +112,7 @@ def verify_authentication( challenge, public_key: public_key || stored_credential.public_key, sign_count: sign_count || stored_credential.sign_count, + user_presence: user_presence, user_verification: user_verification ) block_given? ? [webauthn_credential, stored_credential] : webauthn_credential diff --git a/lib/webauthn/version.rb b/lib/webauthn/version.rb index 2a20816f..7350745d 100644 --- a/lib/webauthn/version.rb +++ b/lib/webauthn/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module WebAuthn - VERSION = "3.0.0" + VERSION = "3.1.0" end diff --git a/spec/webauthn/attestation_statement/fido_u2f_spec.rb b/spec/webauthn/attestation_statement/fido_u2f_spec.rb index 2dd2ef67..c98f928b 100644 --- a/spec/webauthn/attestation_statement/fido_u2f_spec.rb +++ b/spec/webauthn/attestation_statement/fido_u2f_spec.rb @@ -32,13 +32,13 @@ let(:signature) { attestation_key.sign("SHA256", to_be_signed) } let(:attestation_certificate) do - issue_certificate(root_certificate, root_key, attestation_key).to_der + issue_certificate(root_certificate, root_key, attestation_key) end let(:statement) do WebAuthn::AttestationStatement::FidoU2f.new( "sig" => signature, - "x5c" => [attestation_certificate] + "x5c" => [attestation_certificate.to_der] ) end @@ -56,6 +56,18 @@ expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy end + context 'when the attestation certificate is the only certificate in the certificate chain' do + context "and it's equal to one of the root certificates" do + before do + WebAuthn.configuration.attestation_root_certificates_finders = finder_for(attestation_certificate) + end + + it "works" do + expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy + end + end + end + context "when signature is invalid" do context "because it was signed with a different signing key (self attested)" do let(:signature) { create_ec_key.sign("SHA256", to_be_signed) } @@ -101,7 +113,7 @@ let(:statement) do WebAuthn::AttestationStatement::FidoU2f.new( "sig" => signature, - "x5c" => [attestation_certificate, attestation_certificate] + "x5c" => [attestation_certificate.to_der, attestation_certificate.to_der] ) end diff --git a/spec/webauthn/attestation_statement/packed_spec.rb b/spec/webauthn/attestation_statement/packed_spec.rb index d602d676..ed242b66 100644 --- a/spec/webauthn/attestation_statement/packed_spec.rb +++ b/spec/webauthn/attestation_statement/packed_spec.rb @@ -90,7 +90,6 @@ let(:algorithm) { -7 } let(:attestation_key) { create_ec_key } let(:signature) { attestation_key.sign("SHA256", to_be_signed) } - let(:attestation_certificate_version) { 2 } let(:attestation_certificate_subject) { "/C=UY/O=ACME/OU=Authenticator Attestation/CN=CN" } let(:attestation_certificate_basic_constraints) { "CA:FALSE" } let(:attestation_certificate_start_time) { Time.now - 1 } @@ -103,14 +102,14 @@ root_certificate, root_key, attestation_key, - version: attestation_certificate_version, + version: 2, name: attestation_certificate_subject, not_before: attestation_certificate_start_time, not_after: attestation_certificate_end_time, extensions: [ extension_factory.create_extension("basicConstraints", attestation_certificate_basic_constraints, true), ] - ).to_der + ) end let(:root_key) { create_ec_key } @@ -125,7 +124,7 @@ WebAuthn::AttestationStatement::Packed.new( "alg" => algorithm, "sig" => signature, - "x5c" => [attestation_certificate] + "x5c" => [attestation_certificate.to_der] ) end @@ -137,6 +136,18 @@ expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy end + context 'when the attestation certificate is the only certificate in the certificate chain' do + context "and it's equal to one of the root certificates" do + before do + WebAuthn.configuration.attestation_root_certificates_finders = finder_for(attestation_certificate) + end + + it "works" do + expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy + end + end + end + context "when signature is invalid" do context "because is signed with a different alg" do let(:algorithm) { -36 } @@ -175,7 +186,9 @@ context "when the attestation certificate doesn't meet requirements" do context "because version is invalid" do - let(:attestation_certificate_version) { 1 } + before do + attestation_certificate.version = 1 + end it "fails" do expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy diff --git a/spec/webauthn/attestation_statement/tpm_spec.rb b/spec/webauthn/attestation_statement/tpm_spec.rb index 4340a8b9..169f4f51 100644 --- a/spec/webauthn/attestation_statement/tpm_spec.rb +++ b/spec/webauthn/attestation_statement/tpm_spec.rb @@ -36,7 +36,7 @@ root_certificate, root_key, aik, - version: aik_certificate_version, + version: 2, name: aik_certificate_subject, not_before: aik_certificate_start_time, not_after: aik_certificate_end_time, @@ -49,7 +49,6 @@ end let(:aik) { create_rsa_key } - let(:aik_certificate_version) { 2 } let(:aik_certificate_subject) { "" } let(:aik_certificate_basic_constraints) { "CA:FALSE" } let(:aik_certificate_extended_key_usage) { ::TPM::AIKCertificate::OID_TCG_KP_AIK_CERTIFICATE } @@ -361,7 +360,9 @@ context "when the AIK certificate doesn't meet requirements" do context "because version is invalid" do - let(:aik_certificate_version) { 1 } + before do + aik_certificate.version = 1 + end it "returns false" do expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy diff --git a/spec/webauthn/authenticator_assertion_response_spec.rb b/spec/webauthn/authenticator_assertion_response_spec.rb index 5aac9fd5..b0d3f95d 100644 --- a/spec/webauthn/authenticator_assertion_response_spec.rb +++ b/spec/webauthn/authenticator_assertion_response_spec.rb @@ -103,19 +103,217 @@ end describe "user present validation" do - let(:assertion) { client.get(challenge: original_challenge, user_present: false, user_verified: false) } + context "when user presence flag is off" do + let(:assertion) { client.get(challenge: original_challenge, user_present: false, user_verified: false) } + + context "when silent_authentication is not set" do + context 'when user presence is not set' do + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + + it "is invalid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_falsy + end + end - context "if user flags are off" do - it "doesn't verify" do - expect { - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - }.to raise_exception(WebAuthn::UserPresenceVerificationError) + context 'when user presence is not required' do + it "verifies if user presence is not required" do + expect( + assertion_response.verify( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: false + ) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: false + ) + ).to be_truthy + end + end + + context 'when user presence is required' do + it "doesn't verify" do + expect { + assertion_response.verify( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: true + ) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: true + ) + ).to be_falsy + end + end end - it "is invalid" do - expect( - assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_falsy + context "when silent_authentication is disabled" do + around do |ex| + old_value = WebAuthn.configuration.silent_authentication + WebAuthn.configuration.silent_authentication = false + + ex.run + + WebAuthn.configuration.silent_authentication = old_value + end + + context 'when user presence is not set' do + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + + it "is invalid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_falsy + end + end + + context 'when user presence is not required' do + it "verifies if user presence is not required" do + expect( + assertion_response.verify( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: false + ) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: false + ) + ).to be_truthy + end + end + + context 'when user presence is required' do + it "doesn't verify" do + expect { + assertion_response.verify( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: true + ) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: true + ) + ).to be_falsy + end + end + end + + context "when silent_authentication is enabled" do + around do |ex| + old_value = WebAuthn.configuration.silent_authentication + WebAuthn.configuration.silent_authentication = true + + ex.run + + WebAuthn.configuration.silent_authentication = old_value + end + + context 'when user presence is not set' do + it "verifies if user presence is not required" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context 'when user presence is not required' do + it "verifies if user presence is not required" do + expect( + assertion_response.verify( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: false + ) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: false + ) + ).to be_truthy + end + end + + context 'when user presence is required' do + it "doesn't verify" do + expect { + assertion_response.verify( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: true + ) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0, + user_presence: true + ) + ).to be_falsy + end + end end end end diff --git a/spec/webauthn/authenticator_attestation_response_spec.rb b/spec/webauthn/authenticator_attestation_response_spec.rb index 3a60b81b..eda785d9 100644 --- a/spec/webauthn/authenticator_attestation_response_spec.rb +++ b/spec/webauthn/authenticator_attestation_response_spec.rb @@ -17,6 +17,7 @@ WebAuthn::AuthenticatorAttestationResponse.new( attestation_object: response["attestationObject"], + transports: response["transports"], client_data_json: response["clientDataJSON"] ) end @@ -508,6 +509,82 @@ end end + describe "user presence" do + context "when UP is not set" do + let(:public_key_credential) { client.create(challenge: original_challenge, user_present: false) } + + context "when silent_authentication is not set" do + it "doesn't verify if user presence is not set" do + expect { + attestation_response.verify(original_challenge, origin) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + + it "verifies if user presence is not required" do + expect(attestation_response.verify(original_challenge, origin, user_presence: false)).to be_truthy + end + + it "doesn't verify if user presence is required" do + expect { + attestation_response.verify(original_challenge, origin, user_presence: true) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + end + + context "when silent_authentication is disabled" do + around do |ex| + old_value = WebAuthn.configuration.silent_authentication + WebAuthn.configuration.silent_authentication = false + + ex.run + + WebAuthn.configuration.silent_authentication = old_value + end + + it "doesn't verify if user presence is not set" do + expect { + attestation_response.verify(original_challenge, origin) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + + it "verifies if user presence is not required" do + expect(attestation_response.verify(original_challenge, origin, user_presence: false)).to be_truthy + end + + it "doesn't verify if user presence is required" do + expect { + attestation_response.verify(original_challenge, origin, user_presence: true) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + end + + context "when silent_authentication is enabled" do + around do |ex| + old_value = WebAuthn.configuration.silent_authentication + WebAuthn.configuration.silent_authentication = true + + ex.run + + WebAuthn.configuration.silent_authentication = old_value + end + + it "verifies if user presence is not set" do + expect(attestation_response.verify(original_challenge, origin)).to be_truthy + end + + it "verifies if user presence is not required" do + expect(attestation_response.verify(original_challenge, origin, user_presence: false)).to be_truthy + end + + it "doesn't verify if user presence is required" do + expect { + attestation_response.verify(original_challenge, origin, user_presence: true) + }.to raise_exception(WebAuthn::UserPresenceVerificationError) + end + end + end + end + describe "user verification" do context "when UV is not set" do let(:public_key_credential) { client.create(challenge: original_challenge, user_verified: false) } diff --git a/spec/webauthn/public_key_credential/creation_options_spec.rb b/spec/webauthn/public_key_credential/creation_options_spec.rb index 970ce1f1..ab49a4ae 100644 --- a/spec/webauthn/public_key_credential/creation_options_spec.rb +++ b/spec/webauthn/public_key_credential/creation_options_spec.rb @@ -130,6 +130,31 @@ expect(hash[:challenge]).to be_truthy end + it "has minimum required" do + options = WebAuthn::PublicKeyCredential::CreationOptions.new( + user: { + id: "user-id", + name: "user-name", + } + ) + + hash = options.as_json + + expect(hash[:rp]).to eq({}) + expect(hash[:user]).to eq( + id: "user-id", name: "user-name", displayName: 'user-name' + ) + expect(hash[:pubKeyCredParams]).to eq( + [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -37 }, { type: "public-key", alg: -257 }] + ) + expect(hash[:timeout]).to eq(120_000) + expect(hash[:excludeCredentials]).to be_nil + expect(hash[:authenticatorSelection]).to be_nil + expect(hash[:attestation]).to be_nil + expect(hash[:extensions]).to eq({}) + expect(hash[:challenge]).to be_truthy + end + it "accepts shorthand for exclude_credentials" do options = WebAuthn::PublicKeyCredential::CreationOptions.new(user: { id: "id", name: "name" }, exclude: "id") diff --git a/spec/webauthn/public_key_credential/request_options_spec.rb b/spec/webauthn/public_key_credential/request_options_spec.rb index a503ba8f..ba0e6776 100644 --- a/spec/webauthn/public_key_credential/request_options_spec.rb +++ b/spec/webauthn/public_key_credential/request_options_spec.rb @@ -67,6 +67,19 @@ expect(hash[:challenge]).to be_truthy end + it "has minimum required" do + options = WebAuthn::PublicKeyCredential::RequestOptions.new + + hash = options.as_json + + expect(hash[:rpId]).to be_nil + expect(hash[:timeout]).to eq(120_000) + expect(hash[:allowCredentials]).to eq([]) + expect(hash[:userVerification]).to be_nil + expect(hash[:extensions]).to eq({}) + expect(hash[:challenge]).to be_truthy + end + it "accepts shorthand for allow_credentials" do options = WebAuthn::PublicKeyCredential::RequestOptions.new(allow: "id") diff --git a/spec/webauthn/public_key_credential_with_assertion_spec.rb b/spec/webauthn/public_key_credential_with_assertion_spec.rb index 9bcb3276..34b147c9 100644 --- a/spec/webauthn/public_key_credential_with_assertion_spec.rb +++ b/spec/webauthn/public_key_credential_with_assertion_spec.rb @@ -21,6 +21,7 @@ let(:credential_raw_id) { credential[0] } let(:credential_id) { Base64.urlsafe_encode64(credential_raw_id) } let(:credential_type) { "public-key" } + let(:credential_authenticator_attachment) { 'platform' } let(:credential_public_key) { Base64.urlsafe_encode64(credential[1]) } let(:credential_sign_count) { credential[2] } @@ -39,6 +40,7 @@ type: credential_type, id: credential_id, raw_id: credential_raw_id, + authenticator_attachment: credential_authenticator_attachment, response: assertion_response ) end @@ -117,6 +119,18 @@ end end + context "when challenge class is invalid" do + it "raise error" do + expect do + public_key_credential.verify( + nil, + public_key: credential_public_key, + sign_count: credential_sign_count + ) + end.to raise_error(WebAuthn::PublicKeyCredential::InvalidChallengeError) + end + end + context "when challenge is invalid" do let(:challenge) { Base64.urlsafe_encode64("another challenge") } @@ -338,5 +352,41 @@ end end end + + context "when user_presence" do + context "is not set" do + it "correcly delegates its value to the response" do + expect(assertion_response).to receive(:verify).with(anything, hash_including(user_presence: nil)) + + public_key_credential.verify(challenge, public_key: credential_public_key, sign_count: credential_sign_count) + end + end + + context "is set to false" do + it "correcly delegates its value to the response" do + expect(assertion_response).to receive(:verify).with(anything, hash_including(user_presence: false)) + + public_key_credential.verify( + challenge, + public_key: credential_public_key, + sign_count: credential_sign_count, + user_presence: false + ) + end + end + + context "is set to true" do + it "correcly delegates its value to the response" do + expect(assertion_response).to receive(:verify).with(anything, hash_including(user_presence: true)) + + public_key_credential.verify( + challenge, + public_key: credential_public_key, + sign_count: credential_sign_count, + user_presence: true + ) + end + end + end end end diff --git a/spec/webauthn/public_key_credential_with_attestation_spec.rb b/spec/webauthn/public_key_credential_with_attestation_spec.rb index 9b8bb798..60f195ac 100644 --- a/spec/webauthn/public_key_credential_with_attestation_spec.rb +++ b/spec/webauthn/public_key_credential_with_attestation_spec.rb @@ -15,6 +15,7 @@ type: type, id: id, raw_id: raw_id, + authenticator_attachment: authenticator_attachment, response: attestation_response ) end @@ -22,6 +23,7 @@ let(:type) { "public-key" } let(:id) { Base64.urlsafe_encode64(raw_id) } let(:raw_id) { SecureRandom.random_bytes(16) } + let(:authenticator_attachment) { 'platform' } let(:attestation_response) do response = client.create(challenge: raw_challenge)["response"] @@ -85,7 +87,15 @@ end end - context "when challenge is invalid" do + context "when challenge class is invalid" do + it "raise error" do + expect { + public_key_credential.verify(nil) + }.to raise_error(WebAuthn::PublicKeyCredential::InvalidChallengeError) + end + end + + context "when challenge value is invalid" do it "fails" do expect { public_key_credential.verify(Base64.urlsafe_encode64("another challenge")) @@ -170,5 +180,31 @@ end end end + + context "when user_presence" do + context "is not set" do + it "correcly delegates its value to the response" do + expect(attestation_response).to receive(:verify).with(anything, hash_including(user_presence: nil)) + + public_key_credential.verify(challenge) + end + end + + context "is set to false" do + it "correcly delegates its value to the response" do + expect(attestation_response).to receive(:verify).with(anything, hash_including(user_presence: false)) + + public_key_credential.verify(challenge, user_presence: false) + end + end + + context "is set to true" do + it "correcly delegates its value to the response" do + expect(attestation_response).to receive(:verify).with(anything, hash_including(user_presence: true)) + + public_key_credential.verify(challenge, user_presence: true) + end + end + end end end diff --git a/spec/webauthn/relying_party_spec.rb b/spec/webauthn/relying_party_spec.rb index 3dcc2938..ea9488f7 100644 --- a/spec/webauthn/relying_party_spec.rb +++ b/spec/webauthn/relying_party_spec.rb @@ -24,6 +24,115 @@ OpenStruct.new(id: WebAuthn.generate_user_id, name: 'John Doe', credentials: []) end + describe '#verify_registration' do + let(:options) do + admin_rp.options_for_registration( + user: user.to_h.slice(:id, :name), + exclude: user.credentials + ) + end + let(:raw_credential) do + admin_fake_client.create(challenge: options.challenge, rp_id: admin_rp.id) + end + + context "when user_presence" do + let(:webauthn_credential_mock) { instance_double('WebAuthn::PublicKeyCredentialWithAttestation', verify: true) } + + before do + allow(WebAuthn::Credential).to receive(:from_create).and_return(webauthn_credential_mock) + end + + context "is not set" do + it "correcly delegates its value to the response" do + expect(webauthn_credential_mock).to receive(:verify).with(anything, hash_including(user_presence: nil)) + + admin_rp.verify_registration(raw_credential, options.challenge) + end + end + + context "is set to false" do + it "correcly delegates its value to the response" do + expect(webauthn_credential_mock).to receive(:verify).with(anything, hash_including(user_presence: false)) + + admin_rp.verify_registration(raw_credential, options.challenge, user_presence: false) + end + end + + context "is set to true" do + it "correcly delegates its value to the response" do + expect(webauthn_credential_mock).to receive(:verify).with(anything, hash_including(user_presence: true)) + + admin_rp.verify_registration(raw_credential, options.challenge, user_presence: true) + end + end + end + end + + describe '#verify_authentication' do + let(:options) { admin_rp.options_for_authentication(allow: user.credentials.map(&:webauthn_id)) } + let(:raw_credential) { admin_fake_client.get(challenge: options.challenge, rp_id: admin_rp.id, sign_count: 1) } + + let(:admin_credential) { create_credential(client: admin_fake_client, relying_party: admin_rp) } + let(:admin_credential_public_key) { admin_credential[1] } + + before do + user.credentials << OpenStruct.new( + webauthn_id: admin_credential.first, + public_key: admin_rp.encoder.encode(admin_credential[1]), + sign_count: 0 + ) + end + + context "when user_presence" do + let(:webauthn_credential_mock) { instance_double('WebAuthn::PublicKeyCredentialWithAssertion', verify: true) } + + before do + allow(WebAuthn::Credential).to receive(:from_get).and_return(webauthn_credential_mock) + end + + context "is not set" do + it "correcly delegates its value to the response" do + expect(webauthn_credential_mock).to receive(:verify).with(anything, hash_including(user_presence: nil)) + + admin_rp.verify_authentication( + raw_credential, + options.challenge, + public_key: admin_credential_public_key, + sign_count: 0 + ) + end + end + + context "is set to false" do + it "correcly delegates its value to the response" do + expect(webauthn_credential_mock).to receive(:verify).with(anything, hash_including(user_presence: false)) + + admin_rp.verify_authentication( + raw_credential, + options.challenge, + public_key: admin_credential_public_key, + sign_count: 0, + user_presence: false + ) + end + end + + context "is set to true" do + it "correcly delegates its value to the response" do + expect(webauthn_credential_mock).to receive(:verify).with(anything, hash_including(user_presence: true)) + + admin_rp.verify_authentication( + raw_credential, + options.challenge, + public_key: admin_credential_public_key, + sign_count: 0, + user_presence: true + ) + end + end + end + end + context "without having any global configuration" do let(:consumer_rp) do WebAuthn::RelyingParty.new( diff --git a/webauthn.gemspec b/webauthn.gemspec index 5ef2d721..b1299c39 100644 --- a/webauthn.gemspec +++ b/webauthn.gemspec @@ -34,7 +34,6 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 2.5" spec.add_dependency "android_key_attestation", "~> 0.3.0" - spec.add_dependency "awrence", "~> 1.1" spec.add_dependency "bindata", "~> 2.4" spec.add_dependency "cbor", "~> 0.5.9" spec.add_dependency "cose", "~> 1.1" @@ -42,6 +41,7 @@ Gem::Specification.new do |spec| spec.add_dependency "safety_net_attestation", "~> 0.4.0" spec.add_dependency "tpm-key_attestation", "~> 0.12.0" + spec.add_development_dependency "base64", ">= 0.1.0" spec.add_development_dependency "bundler", ">= 1.17", "< 3.0" spec.add_development_dependency "byebug", "~> 11.0" spec.add_development_dependency "rake", "~> 13.0"