fido_metadata 0.1.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 (40) 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/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +8 -0
  7. data/Gemfile.lock +77 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +90 -0
  10. data/Rakefile +12 -0
  11. data/bin/console +24 -0
  12. data/bin/rspec +29 -0
  13. data/bin/rubocop +29 -0
  14. data/bin/setup +8 -0
  15. data/fido_metadata.gemspec +42 -0
  16. data/lib/Root.cer +15 -0
  17. data/lib/fido_metadata.rb +19 -0
  18. data/lib/fido_metadata/attributes.rb +37 -0
  19. data/lib/fido_metadata/biometric_accuracy_descriptor.rb +15 -0
  20. data/lib/fido_metadata/biometric_status_report.rb +18 -0
  21. data/lib/fido_metadata/client.rb +120 -0
  22. data/lib/fido_metadata/code_accuracy_descriptor.rb +14 -0
  23. data/lib/fido_metadata/coercer/assumed_value.rb +19 -0
  24. data/lib/fido_metadata/coercer/bit_field.rb +22 -0
  25. data/lib/fido_metadata/coercer/certificates.rb +16 -0
  26. data/lib/fido_metadata/coercer/date.rb +15 -0
  27. data/lib/fido_metadata/coercer/escaped_uri.rb +17 -0
  28. data/lib/fido_metadata/coercer/magic_number.rb +24 -0
  29. data/lib/fido_metadata/coercer/objects.rb +18 -0
  30. data/lib/fido_metadata/coercer/user_verification_details.rb +36 -0
  31. data/lib/fido_metadata/constants.rb +91 -0
  32. data/lib/fido_metadata/entry.rb +25 -0
  33. data/lib/fido_metadata/pattern_accuracy_descriptor.rb +13 -0
  34. data/lib/fido_metadata/statement.rb +57 -0
  35. data/lib/fido_metadata/status_report.rb +20 -0
  36. data/lib/fido_metadata/store.rb +82 -0
  37. data/lib/fido_metadata/table_of_contents.rb +17 -0
  38. data/lib/fido_metadata/verification_method_descriptor.rb +20 -0
  39. data/lib/fido_metadata/version.rb +5 -0
  40. metadata +196 -0
data/Rakefile ADDED
@@ -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
data/bin/console ADDED
@@ -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_relative "../spec/support/test_cache_store"
9
+ FidoMetadata.configure do |config|
10
+ config.metadata_token = ENV["MDS_TOKEN"]
11
+ config.cache_backend = TestCacheStore.new
12
+ end
13
+
14
+ unless FidoMetadata.configuration.metadata_token
15
+ puts <<~EOS
16
+ No MDS token configured via the MDS_TOKEN environment variable.
17
+ Set one for this session: FidoMetadata.configuration.metadata_token = 'your token'
18
+ EOS
19
+ end
20
+ puts "Reset the cache via: FidoMetadata.configuration.cache_backend.clear"
21
+
22
+ # Start REPL
23
+ require "pry-byebug"
24
+ Pry.start
data/bin/rspec ADDED
@@ -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")
data/bin/rubocop ADDED
@@ -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")
data/bin/setup ADDED
@@ -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,42 @@
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_dependency "securecompare", "~> 1.0"
36
+ spec.add_development_dependency "bundler", "~> 1.17"
37
+ spec.add_development_dependency "pry-byebug"
38
+ spec.add_development_dependency "rake", "~> 10.0"
39
+ spec.add_development_dependency "rspec", "~> 3.8"
40
+ spec.add_development_dependency "rubocop", "0.75.0"
41
+ spec.add_development_dependency "webmock", "~> 3.6"
42
+ end
data/lib/Root.cer ADDED
@@ -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,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "securecompare"
7
+
8
+ module FidoMetadata
9
+ class Client
10
+ class DataIntegrityError < StandardError; end
11
+ class InvalidHashError < DataIntegrityError; end
12
+ class UnverifiedSigningKeyError < DataIntegrityError; end
13
+
14
+ DEFAULT_HEADERS = {
15
+ "Content-Type" => "application/json",
16
+ "User-Agent" => "fido_metadata/#{FidoMetadata::VERSION} (Ruby)"
17
+ }.freeze
18
+
19
+ def self.fido_trust_store
20
+ store = OpenSSL::X509::Store.new
21
+ store.purpose = OpenSSL::X509::PURPOSE_ANY
22
+ store.flags = OpenSSL::X509::V_FLAG_CRL_CHECK | OpenSSL::X509::V_FLAG_CRL_CHECK_ALL
23
+ file = File.read(File.join(__dir__, "..", "Root.cer"))
24
+ store.add_cert(OpenSSL::X509::Certificate.new(file))
25
+ end
26
+
27
+ def initialize(token)
28
+ @token = token
29
+ end
30
+
31
+ def download_toc(uri, trust_store: self.class.fido_trust_store)
32
+ response = get_with_token(uri)
33
+ payload, _ = JWT.decode(response, nil, true, algorithms: ["ES256"]) do |headers|
34
+ verified_public_key(headers["x5c"], trust_store)
35
+ end
36
+ payload
37
+ end
38
+
39
+ def download_entry(uri, expected_hash:)
40
+ response = get_with_token(uri)
41
+ unless SecureCompare.compare(OpenSSL::Digest::SHA256.digest(response), Base64.urlsafe_decode64(expected_hash))
42
+ raise(InvalidHashError)
43
+ end
44
+
45
+ decoded_body = Base64.urlsafe_decode64(response)
46
+ JSON.parse(decoded_body)
47
+ end
48
+
49
+ private
50
+
51
+ def get_with_token(uri)
52
+ if @token && !@token.empty?
53
+ uri.path += "/" unless uri.path.end_with?("/")
54
+ uri.query = "token=#{@token}"
55
+ end
56
+ get(uri)
57
+ end
58
+
59
+ def get(uri)
60
+ get = Net::HTTP::Get.new(uri, DEFAULT_HEADERS)
61
+ response = http(uri).request(get)
62
+ response.value
63
+ response.body
64
+ end
65
+
66
+ def http(uri)
67
+ @http ||= begin
68
+ http = Net::HTTP.new(uri.host, uri.port)
69
+ http.use_ssl = uri.port == 443
70
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
71
+ http.open_timeout = 5
72
+ http.read_timeout = 5
73
+ http
74
+ end
75
+ end
76
+
77
+ def verified_public_key(x5c, trust_store)
78
+ certificates = x5c.map do |encoded|
79
+ OpenSSL::X509::Certificate.new(Base64.strict_decode64(encoded))
80
+ end
81
+ leaf_certificate = certificates[0]
82
+ chain_certificates = certificates[1..-1]
83
+
84
+ crls = download_crls(certificates)
85
+ crls.each do |crl|
86
+ trust_store.add_crl(crl)
87
+ end
88
+
89
+ if trust_store.verify(leaf_certificate, chain_certificates)
90
+ leaf_certificate.public_key
91
+ else
92
+ raise(UnverifiedSigningKeyError, "OpenSSL error #{trust_store.error} (#{trust_store.error_string})")
93
+ end
94
+ end
95
+
96
+ def download_crls(certificates)
97
+ uris = extract_crl_distribution_points(certificates)
98
+
99
+ crls = uris.compact.uniq.map do |uri|
100
+ begin
101
+ get(uri)
102
+ rescue Net::ProtoServerError
103
+ # TODO: figure out why test endpoint specifies a missing and unused CRL in the cert chain, and see if this
104
+ # rescue can be removed. If the CRL is used, OpenSSL error 3 (unable to get certificate CRL) will raise.
105
+ nil
106
+ end
107
+ end
108
+ crls.compact.map { |crl| OpenSSL::X509::CRL.new(crl) }
109
+ end
110
+
111
+ def extract_crl_distribution_points(certificates)
112
+ certificates.map do |certificate|
113
+ extension = certificate.extensions.detect { |ext| ext.oid == "crlDistributionPoints" }
114
+ # TODO: replace this with proper parsing of deeply nested ASN1 structures
115
+ match = extension&.value&.match(/URI:(?<uri>\S*)/)
116
+ URI(match[:uri]) if match
117
+ end
118
+ end
119
+ end
120
+ 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