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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FidoMetadata
6
+ module Coercer
7
+ module Date
8
+ def self.coerce(value)
9
+ return value if value.is_a?(::Date)
10
+
11
+ ::Date.iso8601(value) if value
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module FidoMetadata
6
+ module Coercer
7
+ module EscapedURI
8
+ # The character # is a reserved character and not allowed in URLs, it is replaced by its hex value %x23.
9
+ # https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-metadata-service-v2.0-rd-20180702.html#idl-def-MetadataTOCPayloadEntry
10
+ def self.coerce(value)
11
+ return value if value.is_a?(URI)
12
+
13
+ URI(value.gsub(/%x23/, "#")) if value
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FidoMetadata
4
+ module Coercer
5
+ class MagicNumber
6
+ def initialize(mapping, array: false)
7
+ @mapping = mapping
8
+ @array = array
9
+ end
10
+
11
+ def coerce(values)
12
+ if @array
13
+ return values unless values.all? { |value| value.is_a?(Integer) }
14
+
15
+ values.map { |value| @mapping[value] }.compact
16
+ else
17
+ return values unless values.is_a?(Integer)
18
+
19
+ @mapping[values]
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FidoMetadata
4
+ module Coercer
5
+ class Objects
6
+ def initialize(klass)
7
+ @klass = klass
8
+ end
9
+
10
+ def coerce(values)
11
+ return unless values.is_a?(Array)
12
+ return values if values.all? { |value| value.is_a?(@klass) }
13
+
14
+ values.map { |value| @klass.from_json(value) }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/verification_method_descriptor"
4
+
5
+ module FidoMetadata
6
+ module Coercer
7
+ class UserVerificationDetails
8
+ def self.coerce(values)
9
+ return unless values.is_a?(Array)
10
+ return values if values.all? do |array|
11
+ array.all? do |object|
12
+ object.is_a?(VerificationMethodDescriptor)
13
+ end
14
+ end
15
+
16
+ values.map do |array|
17
+ array.map do |hash|
18
+ object = FidoMetadata::VerificationMethodDescriptor.from_json(hash)
19
+
20
+ if hash["baDesc"]
21
+ object.ba_desc = FidoMetadata::BiometricAccuracyDescriptor.from_json(hash["baDesc"])
22
+ end
23
+ if hash["caDesc"]
24
+ object.ca_desc = FidoMetadata::CodeAccuracyDescriptor.from_json(hash["caDesc"])
25
+ end
26
+ if hash["paDesc"]
27
+ object.pa_desc = FidoMetadata::PatternAccuracyDescriptor.from_json(hash["paDesc"])
28
+ end
29
+
30
+ object
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FidoMetadata
4
+ module Constants
5
+ # https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-registry-v2.0-rd-20180702.html
6
+
7
+ ATTACHMENT_HINTS = {
8
+ 0x0001 => "INTERNAL",
9
+ 0x0002 => "EXTERNAL",
10
+ 0x0004 => "WIRED",
11
+ 0x0008 => "WIRELESS",
12
+ 0x0010 => "NFC",
13
+ 0x0020 => "BLUETOOTH",
14
+ 0x0040 => "NETWORK",
15
+ 0x0080 => "READY",
16
+ 0x0100 => "WIFI_DIRECT",
17
+ }.freeze
18
+
19
+ ATTESTATION_TYPES = {
20
+ 0x3E07 => "BASIC_FULL", # 'Basic' in WebAuthn
21
+ 0x3E08 => "BASIC_SURROGATE", # 'Self' in WebAuthn
22
+ 0x3E09 => "ECDAA",
23
+ 0x3E0A => "ATTCA",
24
+ }.freeze
25
+
26
+ AUTHENTICATION_ALGORITHMS = {
27
+ 0x0001 => "SECP256R1_ECDSA_SHA256_RAW",
28
+ 0x0002 => "SECP256R1_ECDSA_SHA256_DER",
29
+ 0x0003 => "RSASSA_PSS_SHA256_RAW",
30
+ 0x0004 => "RSASSA_PSS_SHA256_DER",
31
+ 0x0005 => "SECP256K1_ECDSA_SHA256_RAW",
32
+ 0x0006 => "SECP256K1_ECDSA_SHA256_DER",
33
+ 0x0007 => "SM2_SM3_RAW",
34
+ 0x0008 => "RSA_EMSA_PKCS1_SHA256_RAW",
35
+ 0x0009 => "RSA_EMSA_PKCS1_SHA256_DER",
36
+ 0x000A => "RSASSA_PSS_SHA384_RAW",
37
+ 0x000B => "RSASSA_PSS_SHA512_RAW",
38
+ 0x000C => "RSASSA_PKCSV15_SHA256_RAW",
39
+ 0x000D => "RSASSA_PKCSV15_SHA384_RAW",
40
+ 0x000E => "RSASSA_PKCSV15_SHA512_RAW",
41
+ 0x000F => "RSASSA_PKCSV15_SHA1_RAW",
42
+ 0x0010 => "SECP384R1_ECDSA_SHA384_RAW",
43
+ 0x0011 => "SECP521R1_ECDSA_SHA512_RAW",
44
+ 0x0012 => "ED25519_EDDSA_SHA256_RAW",
45
+ }.freeze
46
+
47
+ KEY_PROTECTION_TYPES = {
48
+ 0x0001 => "SOFTWARE",
49
+ 0x0002 => "HARDWARE",
50
+ 0x0004 => "TEE",
51
+ 0x0008 => "SECURE_ELEMENT",
52
+ 0x0010 => "REMOTE_HANDLE",
53
+ }.freeze
54
+
55
+ MATCHER_PROTECTION_TYPES = {
56
+ 0x0001 => "SOFTWARE",
57
+ 0x0002 => "TEE",
58
+ 0x0004 => "ON_CHIP",
59
+ }.freeze
60
+
61
+ PUBLIC_KEY_FORMATS = {
62
+ 0x0100 => "ECC_X962_RAW",
63
+ 0x0101 => "ECC_X962_DER",
64
+ 0x0102 => "RSA_2048_RAW",
65
+ 0x0103 => "RSA_2048_DER",
66
+ 0x0104 => "COSE",
67
+ }.freeze
68
+
69
+ TRANSACTION_CONFIRMATION_DISPLAY_TYPES = {
70
+ 0x0001 => "ANY",
71
+ 0x0002 => "PRIVILEGED_SOFTWARE",
72
+ 0x0004 => "TEE",
73
+ 0x0008 => "HARDWARE",
74
+ 0x0010 => "REMOTE",
75
+ }.freeze
76
+
77
+ USER_VERIFICATION_METHODS = {
78
+ 0x00000001 => "PRESENCE",
79
+ 0x00000002 => "FINGERPRINT",
80
+ 0x00000004 => "PASSCODE",
81
+ 0x00000008 => "VOICEPRINT",
82
+ 0x00000010 => "FACEPRINT",
83
+ 0x00000020 => "LOCATION",
84
+ 0x00000040 => "EYEPRINT",
85
+ 0x00000080 => "PATTERN",
86
+ 0x00000100 => "HANDPRINT",
87
+ 0x00000200 => "NONE",
88
+ 0x00000400 => "ALL",
89
+ }.freeze
90
+ end
91
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/attributes"
4
+ require "fido_metadata/biometric_status_report"
5
+ require "fido_metadata/status_report"
6
+ require "fido_metadata/coercer/date"
7
+ require "fido_metadata/coercer/escaped_uri"
8
+ require "fido_metadata/coercer/objects"
9
+
10
+ module FidoMetadata
11
+ class Entry
12
+ extend Attributes
13
+
14
+ json_accessor("aaid")
15
+ json_accessor("aaguid")
16
+ json_accessor("attestationCertificateKeyIdentifiers")
17
+ json_accessor("hash")
18
+ json_accessor("url", Coercer::EscapedURI)
19
+ json_accessor("biometricStatusReports", Coercer::Objects.new(BiometricStatusReport))
20
+ json_accessor("statusReports", Coercer::Objects.new(StatusReport))
21
+ json_accessor("timeOfLastStatusChange", Coercer::Date)
22
+ json_accessor("rogueListURL", Coercer::EscapedURI)
23
+ json_accessor("rogueListHash")
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/attributes"
4
+
5
+ module FidoMetadata
6
+ class PatternAccuracyDescriptor
7
+ extend Attributes
8
+
9
+ json_accessor("minComplexity")
10
+ json_accessor("maxRetries")
11
+ json_accessor("blockSlowdown")
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module FidoMetadata
6
+ module Refinement
7
+ module FixedLengthSecureCompare
8
+ unless OpenSSL.singleton_class.method_defined?(:fixed_length_secure_compare)
9
+ refine OpenSSL.singleton_class do
10
+ def fixed_length_secure_compare(a, b) # rubocop:disable Naming/UncommunicativeMethodParamName
11
+ raise ArgumentError, "inputs must be of equal length" unless a.bytesize == b.bytesize
12
+
13
+ # borrowed from Rack::Utils
14
+ l = a.unpack("C*")
15
+ r, i = 0, -1
16
+ b.each_byte { |v| r |= v ^ l[i += 1] }
17
+ r == 0
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/attributes"
4
+ require "fido_metadata/constants"
5
+ require "fido_metadata/verification_method_descriptor"
6
+ require "fido_metadata/coercer/assumed_value"
7
+ require "fido_metadata/coercer/bit_field"
8
+ require "fido_metadata/coercer/certificates"
9
+ require "fido_metadata/coercer/magic_number"
10
+ require "fido_metadata/coercer/user_verification_details"
11
+
12
+ module FidoMetadata
13
+ class Statement
14
+ extend Attributes
15
+
16
+ json_accessor("legalHeader")
17
+ json_accessor("aaid")
18
+ json_accessor("aaguid")
19
+ json_accessor("attestationCertificateKeyIdentifiers")
20
+ json_accessor("description")
21
+ json_accessor("alternativeDescriptions")
22
+ json_accessor("authenticatorVersion")
23
+ json_accessor("protocolFamily", Coercer::AssumedValue.new("uaf"))
24
+ json_accessor("upv")
25
+ json_accessor("assertionScheme")
26
+ json_accessor("authenticationAlgorithm", Coercer::MagicNumber.new(Constants::AUTHENTICATION_ALGORITHMS))
27
+ json_accessor("authenticationAlgorithms",
28
+ Coercer::MagicNumber.new(Constants::AUTHENTICATION_ALGORITHMS, array: true))
29
+ json_accessor("publicKeyAlgAndEncoding", Coercer::MagicNumber.new(Constants::PUBLIC_KEY_FORMATS))
30
+ json_accessor("publicKeyAlgAndEncodings",
31
+ Coercer::MagicNumber.new(Constants::PUBLIC_KEY_FORMATS, array: true))
32
+ json_accessor("attestationTypes", Coercer::MagicNumber.new(Constants::ATTESTATION_TYPES, array: true))
33
+ json_accessor("userVerificationDetails", Coercer::UserVerificationDetails)
34
+ json_accessor("keyProtection", Coercer::BitField.new(Constants::KEY_PROTECTION_TYPES))
35
+ json_accessor("isKeyRestricted", Coercer::AssumedValue.new(true))
36
+ json_accessor("isFreshUserVerificationRequired", Coercer::AssumedValue.new(true))
37
+ json_accessor("matcherProtection",
38
+ Coercer::BitField.new(Constants::MATCHER_PROTECTION_TYPES, single_value: true))
39
+ json_accessor("cryptoStrength")
40
+ json_accessor("operatingEnv")
41
+ json_accessor("attachmentHint", Coercer::BitField.new(Constants::ATTACHMENT_HINTS))
42
+ json_accessor("isSecondFactorOnly")
43
+ json_accessor("tcDisplay", Coercer::BitField.new(Constants::TRANSACTION_CONFIRMATION_DISPLAY_TYPES))
44
+ json_accessor("tcDisplayContentType")
45
+ json_accessor("tcDisplayPNGCharacteristics")
46
+ json_accessor("attestationRootCertificates")
47
+ json_accessor("ecdaaTrustAnchors")
48
+ json_accessor("icon")
49
+ json_accessor("supportedExtensions")
50
+
51
+ # Lazy load certificates for compatibility ActiveSupport::Cache. Can be removed once we require a version of
52
+ # OpenSSL which includes https://github.com/ruby/openssl/pull/281
53
+ def attestation_root_certificates
54
+ Coercer::Certificates.coerce(@attestation_root_certificates)
55
+ end
56
+
57
+ def trust_store
58
+ trust_store = OpenSSL::X509::Store.new
59
+ attestation_root_certificates.each do |certificate|
60
+ trust_store.add_cert(certificate)
61
+ end
62
+ trust_store
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/attributes"
4
+ require "fido_metadata/coercer/date"
5
+ require "fido_metadata/coercer/escaped_uri"
6
+
7
+ module FidoMetadata
8
+ class StatusReport
9
+ extend Attributes
10
+
11
+ json_accessor("status")
12
+ json_accessor("effectiveDate", Coercer::Date)
13
+ json_accessor("certificate")
14
+ json_accessor("url", Coercer::EscapedURI)
15
+ json_accessor("certificationDescriptor")
16
+ json_accessor("certificateNumber")
17
+ json_accessor("certificationPolicyVersion")
18
+ json_accessor("certificationRequirementsVersion")
19
+ end
20
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/client"
4
+ require "fido_metadata/table_of_contents"
5
+ require "fido_metadata/statement"
6
+
7
+ module FidoMetadata
8
+ class Store
9
+ METADATA_ENDPOINT = URI("https://mds2.fidoalliance.org/")
10
+
11
+ def table_of_contents
12
+ @table_of_contents ||= begin
13
+ key = "metadata_toc"
14
+ toc = cache_backend.read(key)
15
+ return toc if toc
16
+
17
+ json = client.download_toc(METADATA_ENDPOINT)
18
+ toc = FidoMetadata::TableOfContents.from_json(json)
19
+ cache_backend.write(key, toc)
20
+ toc
21
+ end
22
+ end
23
+
24
+ def fetch_entry(aaguid: nil, attestation_certificate_key_id: nil)
25
+ verify_arguments(aaguid: aaguid, attestation_certificate_key_id: attestation_certificate_key_id)
26
+
27
+ if aaguid
28
+ table_of_contents.entries.detect { |entry| entry.aaguid == aaguid }
29
+ elsif attestation_certificate_key_id
30
+ table_of_contents.entries.detect do |entry|
31
+ entry.attestation_certificate_key_identifiers&.detect do |id|
32
+ id == attestation_certificate_key_id
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ def fetch_statement(aaguid: nil, attestation_certificate_key_id: nil)
39
+ verify_arguments(aaguid: aaguid, attestation_certificate_key_id: attestation_certificate_key_id)
40
+
41
+ key = "statement_#{aaguid || attestation_certificate_key_id}"
42
+ statement = cache_backend.read(key)
43
+ return statement if statement
44
+
45
+ entry = if aaguid
46
+ fetch_entry(aaguid: aaguid)
47
+ elsif attestation_certificate_key_id
48
+ fetch_entry(attestation_certificate_key_id: attestation_certificate_key_id)
49
+ end
50
+ return unless entry
51
+
52
+ json = client.download_entry(entry.url, expected_hash: entry.hash)
53
+ statement = FidoMetadata::Statement.from_json(json)
54
+ cache_backend.write(key, statement)
55
+ statement
56
+ end
57
+
58
+ private
59
+
60
+ def verify_arguments(aaguid: nil, attestation_certificate_key_id: nil)
61
+ unless aaguid || attestation_certificate_key_id
62
+ raise ArgumentError, "must pass either aaguid or attestation_certificate_key"
63
+ end
64
+
65
+ if aaguid && attestation_certificate_key_id
66
+ raise ArgumentError, "cannot pass both aaguid and attestation_certificate_key"
67
+ end
68
+ end
69
+
70
+ def cache_backend
71
+ FidoMetadata.configuration.cache_backend || raise("no cache_backend configured")
72
+ end
73
+
74
+ def metadata_token
75
+ FidoMetadata.configuration.metadata_token || raise("no metadata_token configured")
76
+ end
77
+
78
+ def client
79
+ @client ||= FidoMetadata::Client.new(metadata_token)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fido_metadata/attributes"
4
+ require "fido_metadata/entry"
5
+ require "fido_metadata/coercer/date"
6
+ require "fido_metadata/coercer/objects"
7
+
8
+ module FidoMetadata
9
+ class TableOfContents
10
+ extend Attributes
11
+
12
+ json_accessor("legalHeader")
13
+ json_accessor("nextUpdate", Coercer::Date)
14
+ json_accessor("entries", Coercer::Objects.new(Entry))
15
+ json_accessor("no")
16
+ end
17
+ end