fido_metadata 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.rubocop.yml +196 -0
  4. data/.travis.yml +7 -0
  5. data/CHANGELOG.md +31 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +8 -0
  8. data/Gemfile.lock +75 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +86 -0
  11. data/Rakefile +12 -0
  12. data/bin/console +24 -0
  13. data/bin/rspec +29 -0
  14. data/bin/rubocop +29 -0
  15. data/bin/setup +8 -0
  16. data/fido_metadata.gemspec +41 -0
  17. data/lib/Root.cer +15 -0
  18. data/lib/fido_metadata.rb +19 -0
  19. data/lib/fido_metadata/attributes.rb +37 -0
  20. data/lib/fido_metadata/biometric_accuracy_descriptor.rb +15 -0
  21. data/lib/fido_metadata/biometric_status_report.rb +18 -0
  22. data/lib/fido_metadata/client.rb +110 -0
  23. data/lib/fido_metadata/code_accuracy_descriptor.rb +14 -0
  24. data/lib/fido_metadata/coercer/assumed_value.rb +19 -0
  25. data/lib/fido_metadata/coercer/bit_field.rb +22 -0
  26. data/lib/fido_metadata/coercer/certificates.rb +16 -0
  27. data/lib/fido_metadata/coercer/date.rb +15 -0
  28. data/lib/fido_metadata/coercer/escaped_uri.rb +17 -0
  29. data/lib/fido_metadata/coercer/magic_number.rb +24 -0
  30. data/lib/fido_metadata/coercer/objects.rb +18 -0
  31. data/lib/fido_metadata/coercer/user_verification_details.rb +36 -0
  32. data/lib/fido_metadata/constants.rb +91 -0
  33. data/lib/fido_metadata/entry.rb +25 -0
  34. data/lib/fido_metadata/pattern_accuracy_descriptor.rb +13 -0
  35. data/lib/fido_metadata/refinement/fixed_length_secure_compare.rb +23 -0
  36. data/lib/fido_metadata/statement.rb +65 -0
  37. data/lib/fido_metadata/status_report.rb +20 -0
  38. data/lib/fido_metadata/store.rb +82 -0
  39. data/lib/fido_metadata/table_of_contents.rb +17 -0
  40. data/lib/fido_metadata/test_cache_store.rb +26 -0
  41. data/lib/fido_metadata/verification_method_descriptor.rb +20 -0
  42. data/lib/fido_metadata/version.rb +5 -0
  43. data/lib/fido_metadata/x5c_key_finder.rb +50 -0
  44. metadata +186 -0
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "fido_metadata"
6
+
7
+ # Configure in-memory cache
8
+ require "fido_metadata/test_cache_store"
9
+ FidoMetadata.configure do |config|
10
+ config.metadata_token = ENV["MDS_TOKEN"]
11
+ config.cache_backend = FidoMetadata::TestCacheStore.new
12
+ end
13
+
14
+ unless FidoMetadata.configuration.metadata_token
15
+ puts <<~TOKEN_HINT
16
+ No MDS token configured via the MDS_TOKEN environment variable.
17
+ Set one for this session: FidoMetadata.configuration.metadata_token = 'your token'
18
+ TOKEN_HINT
19
+ end
20
+ puts "Reset the cache via: FidoMetadata.configuration.cache_backend.clear"
21
+
22
+ # Start REPL
23
+ require "pry-byebug"
24
+ Pry.start
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("bundle", __dir__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("rspec-core", "rspec")
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rubocop' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("bundle", __dir__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("rubocop", "rubocop")
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,41 @@
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 "fido_metadata/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "fido_metadata"
9
+ spec.version = FidoMetadata::VERSION
10
+ spec.authors = ["Bart de Water"]
11
+
12
+ spec.summary = "FIDO Alliance Metadata Service client"
13
+ spec.description = "Client for looking up metadata about FIDO authenticators, for use by WebAuthn relying parties"
14
+ spec.homepage = "https://github.com/bdewater/fido_metadata"
15
+ spec.license = "MIT"
16
+
17
+ if spec.respond_to?(:metadata)
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
21
+ end
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
27
+ end
28
+ spec.bindir = "exe"
29
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
30
+ spec.require_paths = ["lib"]
31
+
32
+ spec.required_ruby_version = ">= 2.3"
33
+
34
+ spec.add_dependency "jwt", "~> 2.0"
35
+ spec.add_development_dependency "bundler", "~> 1.17"
36
+ spec.add_development_dependency "pry-byebug"
37
+ spec.add_development_dependency "rake", "~> 10.0"
38
+ spec.add_development_dependency "rspec", "~> 3.8"
39
+ spec.add_development_dependency "rubocop", "0.75.0"
40
+ spec.add_development_dependency "webmock", "~> 3.6"
41
+ end
@@ -0,0 +1,15 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIICQzCCAcigAwIBAgIORqmxkzowRM99NQZJurcwCgYIKoZIzj0EAwMwUzELMAkG
3
+ A1UEBhMCVVMxFjAUBgNVBAoTDUZJRE8gQWxsaWFuY2UxHTAbBgNVBAsTFE1ldGFk
4
+ YXRhIFRPQyBTaWduaW5nMQ0wCwYDVQQDEwRSb290MB4XDTE1MDYxNzAwMDAwMFoX
5
+ DTQ1MDYxNzAwMDAwMFowUzELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUZJRE8gQWxs
6
+ aWFuY2UxHTAbBgNVBAsTFE1ldGFkYXRhIFRPQyBTaWduaW5nMQ0wCwYDVQQDEwRS
7
+ b290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEFEoo+6jdxg6oUuOloqPjK/nVGyY+
8
+ AXCFz1i5JR4OPeFJs+my143ai0p34EX4R1Xxm9xGi9n8F+RxLjLNPHtlkB3X4ims
9
+ rfIx7QcEImx1cMTgu5zUiwxLX1ookVhIRSoso2MwYTAOBgNVHQ8BAf8EBAMCAQYw
10
+ DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU0qUfC6f2YshA1Ni9udeO0VS7vEYw
11
+ HwYDVR0jBBgwFoAU0qUfC6f2YshA1Ni9udeO0VS7vEYwCgYIKoZIzj0EAwMDaQAw
12
+ ZgIxAKulGbSFkDSZusGjbNkAhAkqTkLWo3GrN5nRBNNk2Q4BlG+AvM5q9wa5WciW
13
+ DcMdeQIxAMOEzOFsxX9Bo0h4LOFE5y5H8bdPFYW+l5gy1tQiJv+5NUyM2IBB55XU
14
+ YjdBz56jSA==
15
+ -----END CERTIFICATE-----
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/store"
4
+ require "fido_metadata/version"
5
+
6
+ module FidoMetadata
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
+ attr_accessor :metadata_token
17
+ attr_accessor :cache_backend
18
+ end
19
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FidoMetadata
4
+ module Attributes
5
+ def underscore_name(name)
6
+ name
7
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
8
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
9
+ .downcase
10
+ .to_sym
11
+ end
12
+
13
+ private :underscore_name
14
+
15
+ def json_accessor(name, coercer = nil)
16
+ underscored_name = underscore_name(name)
17
+ attr_accessor underscored_name
18
+
19
+ if coercer
20
+ define_method(:"#{underscored_name}=") do |value|
21
+ coerced_value = coercer.coerce(value)
22
+ instance_variable_set(:"@#{underscored_name}", coerced_value)
23
+ end
24
+ end
25
+ end
26
+
27
+ def from_json(hash = {})
28
+ instance = new
29
+ hash.each do |k, v|
30
+ method_name = :"#{underscore_name(k)}="
31
+ instance.public_send(method_name, v) if instance.respond_to?(method_name)
32
+ end
33
+
34
+ instance
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/attributes"
4
+
5
+ module FidoMetadata
6
+ class BiometricAccuracyDescriptor
7
+ extend Attributes
8
+
9
+ json_accessor("selfAttestedFRR")
10
+ json_accessor("selfAttestedFAR")
11
+ json_accessor("maxTemplates")
12
+ json_accessor("maxRetries")
13
+ json_accessor("blockSlowdown")
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/attributes"
4
+ require "fido_metadata/coercer/date"
5
+
6
+ module FidoMetadata
7
+ class BiometricStatusReport
8
+ extend Attributes
9
+
10
+ json_accessor("certLevel")
11
+ json_accessor("modality")
12
+ json_accessor("effectiveDate", Coercer::Date)
13
+ json_accessor("certificationDescriptor")
14
+ json_accessor("certificateNumber")
15
+ json_accessor("certificationPolicyVersion")
16
+ json_accessor("certificationRequirementsVersion")
17
+ end
18
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "fido_metadata/refinement/fixed_length_secure_compare"
7
+ require "fido_metadata/x5c_key_finder"
8
+ require "fido_metadata/version"
9
+
10
+ module FidoMetadata
11
+ class Client
12
+ class DataIntegrityError < StandardError; end
13
+ class InvalidHashError < DataIntegrityError; end
14
+ class UnverifiedSigningKeyError < DataIntegrityError; end
15
+
16
+ using Refinement::FixedLengthSecureCompare
17
+
18
+ DEFAULT_HEADERS = {
19
+ "Content-Type" => "application/json",
20
+ "User-Agent" => "fido_metadata/#{FidoMetadata::VERSION} (Ruby)"
21
+ }.freeze
22
+ FIDO_ROOT_CERTIFICATES = [OpenSSL::X509::Certificate.new(
23
+ File.read(File.join(__dir__, "..", "Root.cer"))
24
+ )].freeze
25
+
26
+ def initialize(token)
27
+ @token = token
28
+ end
29
+
30
+ def download_toc(uri, trusted_certs: FIDO_ROOT_CERTIFICATES)
31
+ response = get_with_token(uri)
32
+ payload, _ = JWT.decode(response, nil, true, algorithms: ["ES256"]) do |headers|
33
+ jwt_certificates = headers["x5c"].map do |encoded|
34
+ OpenSSL::X509::Certificate.new(Base64.strict_decode64(encoded))
35
+ end
36
+ crls = download_crls(jwt_certificates)
37
+
38
+ begin
39
+ X5cKeyFinder.from(jwt_certificates, trusted_certs, crls)
40
+ rescue JWT::VerificationError => e
41
+ raise(UnverifiedSigningKeyError, e.message)
42
+ end
43
+ end
44
+ payload
45
+ end
46
+
47
+ def download_entry(uri, expected_hash:)
48
+ response = get_with_token(uri)
49
+ decoded_hash = Base64.urlsafe_decode64(expected_hash)
50
+ unless OpenSSL.fixed_length_secure_compare(OpenSSL::Digest::SHA256.digest(response), decoded_hash)
51
+ raise(InvalidHashError)
52
+ end
53
+
54
+ decoded_body = Base64.urlsafe_decode64(response)
55
+ JSON.parse(decoded_body)
56
+ end
57
+
58
+ private
59
+
60
+ def get_with_token(uri)
61
+ if @token && !@token.empty?
62
+ uri.path += "/" unless uri.path.end_with?("/")
63
+ uri.query = "token=#{@token}"
64
+ end
65
+ get(uri)
66
+ end
67
+
68
+ def get(uri)
69
+ get = Net::HTTP::Get.new(uri, DEFAULT_HEADERS)
70
+ response = http(uri).request(get)
71
+ response.value
72
+ response.body
73
+ end
74
+
75
+ def http(uri)
76
+ @http ||= begin
77
+ http = Net::HTTP.new(uri.host, uri.port)
78
+ http.use_ssl = uri.port == 443
79
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
80
+ http.open_timeout = 5
81
+ http.read_timeout = 5
82
+ http
83
+ end
84
+ end
85
+
86
+ def download_crls(certificates)
87
+ uris = extract_crl_distribution_points(certificates)
88
+
89
+ crls = uris.compact.uniq.map do |uri|
90
+ begin
91
+ get(uri)
92
+ rescue Net::ProtoServerError
93
+ # TODO: figure out why test endpoint specifies a missing and unused CRL in the cert chain, and see if this
94
+ # rescue can be removed. If the CRL is used, OpenSSL error 3 (unable to get certificate CRL) will raise.
95
+ nil
96
+ end
97
+ end
98
+ crls.compact.map { |crl| OpenSSL::X509::CRL.new(crl) }
99
+ end
100
+
101
+ def extract_crl_distribution_points(certificates)
102
+ certificates.map do |certificate|
103
+ extension = certificate.extensions.detect { |ext| ext.oid == "crlDistributionPoints" }
104
+ # TODO: replace this with proper parsing of deeply nested ASN1 structures
105
+ match = extension&.value&.match(/URI:(?<uri>\S*)/)
106
+ URI(match[:uri]) if match
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/attributes"
4
+
5
+ module FidoMetadata
6
+ class CodeAccuracyDescriptor
7
+ extend Attributes
8
+
9
+ json_accessor("base")
10
+ json_accessor("minLength")
11
+ json_accessor("maxRetries")
12
+ json_accessor("blockSlowdown")
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FidoMetadata
4
+ module Coercer
5
+ class AssumedValue
6
+ def initialize(assume)
7
+ @assume = assume
8
+ end
9
+
10
+ def coerce(value)
11
+ if value.nil?
12
+ @assume
13
+ else
14
+ value
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FidoMetadata
4
+ module Coercer
5
+ class BitField
6
+ def initialize(mapping, single_value: false)
7
+ @mapping = mapping
8
+ @single_value = single_value
9
+ end
10
+
11
+ def coerce(value)
12
+ results = @mapping.reject { |flag, _constant| flag & value == 0 }.values
13
+
14
+ if @single_value
15
+ results.first
16
+ else
17
+ results
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl/x509"
4
+
5
+ module FidoMetadata
6
+ module Coercer
7
+ module Certificates
8
+ def self.coerce(values)
9
+ return unless values.is_a?(Array)
10
+ return values if values.all? { |value| value.is_a?(OpenSSL::X509::Certificate) }
11
+
12
+ values.map { |value| OpenSSL::X509::Certificate.new(Base64.decode64(value)) }
13
+ end
14
+ end
15
+ end
16
+ end