smart_health_cards_test_kit 0.9.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 (27) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/lib/smart_health_cards_test_kit/fhir_operation_group.rb +105 -0
  4. data/lib/smart_health_cards_test_kit/file_download_group.rb +108 -0
  5. data/lib/smart_health_cards_test_kit/health_card.rb +33 -0
  6. data/lib/smart_health_cards_test_kit/javascript/jsQR.js +10100 -0
  7. data/lib/smart_health_cards_test_kit/javascript/qr-scanner-worker.min.js +98 -0
  8. data/lib/smart_health_cards_test_kit/javascript/qr-scanner.min.js +31 -0
  9. data/lib/smart_health_cards_test_kit/qr_code_group.rb +107 -0
  10. data/lib/smart_health_cards_test_kit/shc_fhir_validation.rb +39 -0
  11. data/lib/smart_health_cards_test_kit/shc_header_verification.rb +36 -0
  12. data/lib/smart_health_cards_test_kit/shc_payload_verification.rb +123 -0
  13. data/lib/smart_health_cards_test_kit/shc_signature_verification.rb +95 -0
  14. data/lib/smart_health_cards_test_kit/utils/chunking_utils.rb +71 -0
  15. data/lib/smart_health_cards_test_kit/utils/encoding.rb +26 -0
  16. data/lib/smart_health_cards_test_kit/utils/jws.rb +132 -0
  17. data/lib/smart_health_cards_test_kit/utils/key.rb +82 -0
  18. data/lib/smart_health_cards_test_kit/utils/key_set.rb +88 -0
  19. data/lib/smart_health_cards_test_kit/utils/private_key.rb +53 -0
  20. data/lib/smart_health_cards_test_kit/utils/public_key.rb +33 -0
  21. data/lib/smart_health_cards_test_kit/utils/verification.rb +41 -0
  22. data/lib/smart_health_cards_test_kit/utils/verifier.rb +71 -0
  23. data/lib/smart_health_cards_test_kit/version.rb +3 -0
  24. data/lib/smart_health_cards_test_kit/views/scan_qr_code.html +207 -0
  25. data/lib/smart_health_cards_test_kit/views/upload_qr_code.html +130 -0
  26. data/lib/smart_health_cards_test_kit.rb +67 -0
  27. metadata +168 -0
@@ -0,0 +1,95 @@
1
+ require_relative 'utils/verifier'
2
+
3
+ module SmartHealthCardsTestKit
4
+ class SHCSignatureVerification < Inferno::Test
5
+ include HealthCard
6
+
7
+ id :shc_signature_verification_test
8
+ title 'Verifiable Credential JWS payload has correct JWS signature'
9
+ description %(
10
+ Each public key used to verify signatures is represented as a JSON Web Key
11
+ (see [RFC7517](https://tools.ietf.org/html/rfc7517)), with some of its properties encoded using
12
+ base64url (see section 5 of [RFC4648](https://tools.ietf.org/html/rfc4648#section-5)):
13
+
14
+ * SHALL have "kty": "EC", "use": "sig", and "alg": "ES256"
15
+ * SHALL have "kid" equal to the base64url-encoded SHA-256 JWK Thumbprint of the key
16
+ (see [RFC7638](https://tools.ietf.org/html/rfc7638))
17
+ * SHALL have "crv": "P-256", and "x", "y" equal to the base64url-encoded values for the public Elliptic
18
+ Curve point coordinates (see [RFC7518](https://tools.ietf.org/html/rfc7518#section-6.2))
19
+ * SHALL NOT have the Elliptic Curve private key parameter "d"
20
+ * If the issuer has an X.509 certificate for the public key, SHALL have "x5c" equal to an array of one
21
+ or more base64-encoded (not base64url-encoded) DER representations of the public certificate or
22
+ certificate chain (see [RFC7517](https://tools.ietf.org/html/rfc7517#section-4.7)). The public key
23
+ listed in the first certificate in the "x5c" array SHALL match the public key specified by the "crv",
24
+ "x", and "y" parameters of the same JWK entry. If the issuer has more than one certificate for the same
25
+ public key (e.g. participation in more than one trust community), then a separate JWK entry is used for
26
+ each certificate with all JWK parameter values identical except "x5c".
27
+
28
+ Issuers SHALL publish their public keys as JSON Web Key Sets (see
29
+ [RFC7517](https://tools.ietf.org/html/rfc7517#section-4.7)), available at
30
+ <<iss value from JWS>> + /.well-known/jwks.json, with Cross-Origin Resource Sharing (CORS) enabled, using
31
+ TLS version 1.2 following the IETF BCP 195 recommendations or TLS version 1.3 (with any configuration).
32
+
33
+ The URL at <<iss value from JWS>> SHALL use the https scheme and SHALL NOT include a trailing /.
34
+ For example, https://smarthealth.cards/examples/issuer is a valid iss value
35
+ (https://smarthealth.cards/examples/issuer/ is not).
36
+ )
37
+
38
+ input :credential_strings
39
+
40
+ run do
41
+ skip_if credential_strings.blank?, 'No Verifiable Credentials received'
42
+
43
+ credential_strings.split(',').each do |credential|
44
+
45
+ jws = SmartHealthCardsTestKit::Utils::JWS.from_jws(credential)
46
+ payload = payload_from_jws(jws)
47
+ iss = payload['iss']
48
+
49
+ assert iss.present?, 'Credential contains no `iss`'
50
+ warning { assert iss.start_with?('https://'), "`iss` SHALL use the `https` scheme: #{iss}" }
51
+ assert !iss.end_with?('/'), "`iss` SHALL NOT include a trailing `/`: #{iss}"
52
+
53
+ key_set_url = "#{iss}/.well-known/jwks.json"
54
+
55
+ get(key_set_url)
56
+
57
+ assert_response_status(200)
58
+ assert_valid_json(response[:body])
59
+
60
+ cors_header = request.response_header('Control-Allow-Origin')
61
+ warning do
62
+ assert cors_header.present?,
63
+ 'No CORS header received. Issuers SHALL publish their public keys with CORS enabled'
64
+ assert cors_header.value == '*',
65
+ "Expected CORS header value of `*`, but actual value was `#{cors_header.value}`"
66
+ end
67
+
68
+ key_set = JSON.parse(response[:body])
69
+
70
+ public_key = key_set['keys'].find { |key| key['kid'] == jws.kid }
71
+ key_object = SmartHealthCardsTestKit::Utils::Key.from_jwk(public_key)
72
+
73
+ assert public_key.present?, "Key set did not contain a key with a `kid` of #{jws.kid}"
74
+
75
+ assert public_key['kty'] == 'EC', "Key had a `kty` value of `#{public_key['kty']}` instead of `EC`"
76
+ assert public_key['use'] == 'sig', "Key had a `use` value of `#{public_key['use']}` instead of `sig`"
77
+ assert public_key['alg'] == 'ES256', "Key had an `alg` value of `#{public_key['alg']}` instead of `ES256`"
78
+ assert public_key['crv'] == 'P-256', "Key had a `crv` value of `#{public_key['crv']}` instead of `P-256`"
79
+ assert !public_key.include?('d'), 'Key SHALL NOT have the private key parameter `d`'
80
+ assert public_key['kid'] == key_object.kid,
81
+ "'kid' SHALL be equal to the base64url-encoded SHA-256 JWK Thumbprint of the key. " \
82
+ "Received: '#{public_key['kid']}', Expected: '#{key_object.kid}'"
83
+
84
+ verifier = SmartHealthCardsTestKit::Utils::Verifier.new(keys: key_object, resolve_keys: false)
85
+
86
+ begin
87
+ assert verifier.verify(jws), 'JWS signature invalid'
88
+ rescue StandardError => e
89
+ assert false, "Error decoding credential: #{e.message}"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartHealthCardsTestKit
4
+ module Utils
5
+ # Split up a JWS into chunks if encoded size is above QR Code Size constraint
6
+ module ChunkingUtils
7
+ extend self
8
+ MAX_SINGLE_JWS_SIZE = 1195
9
+ MAX_CHUNK_SIZE = 1191
10
+
11
+ def split_jws(jws)
12
+ if jws.length <= MAX_SINGLE_JWS_SIZE
13
+ [jws]
14
+ else
15
+ chunk_count = (jws.length / MAX_CHUNK_SIZE.to_f).ceil
16
+ chunk_size = (jws.length / chunk_count.to_f).ceil
17
+ jws.scan(/.{1,#{chunk_size}}/)
18
+ end
19
+ end
20
+
21
+ # Splits jws into chunks and converts each string into numeric
22
+ def jws_to_qr_chunks(jws)
23
+ chunks = split_jws(jws.to_s).map { |c| convert_jws_to_numeric(c) }
24
+
25
+ # if 1 chunk, attach prefix shc:/
26
+ # if multiple chunks, attach prefix shc:/$orderNumber/$totalChunkCount
27
+ if chunks.length == 1
28
+ chunks[0] = "shc:/#{chunks[0]}"
29
+ else
30
+ chunks.map!.with_index(1) { |ch, i| "shc:/#{i}/#{chunks.length}/#{ch}" }
31
+ end
32
+ chunks
33
+ end
34
+
35
+ # Assemble jws from qr code chunks
36
+ def qr_chunks_to_jws(qr_chunks)
37
+ if qr_chunks.length == 1
38
+ # Strip off shc:/ and convert numeric jws
39
+ numeric_jws = qr_chunks[0].delete_prefix('shc:/')
40
+ convert_numeric_jws numeric_jws
41
+ else
42
+ ordered_qr_chunks = strip_prefix_and_sort qr_chunks
43
+ ordered_qr_chunks.map { |c| convert_numeric_jws(c) }.join
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # Each character "c" of the jws is converted into a sequence of two digits by taking c.ord - 45
50
+ def convert_jws_to_numeric(jws)
51
+ jws.chars.map { |c| format('%02d', c.ord - 45) }.join
52
+ end
53
+
54
+ def convert_numeric_jws(numeric_jws)
55
+ result_jws = ''.dup
56
+ numeric_jws.chars.each_slice(2) do |a, b|
57
+ result_jws << ((a + b).to_i + 45).chr
58
+ end
59
+ result_jws
60
+ end
61
+
62
+ def strip_prefix_and_sort(qr_chunks)
63
+ # Multiple QR codes are prefixed with 'shc:/C/N' where C is the index and N is the total number of chunks
64
+ # Sorts chunks by C
65
+ sorted_chunks = qr_chunks.sort_by { |c| c[%r{/(.*?)/}, 1].to_i }
66
+ # Strip prefix
67
+ sorted_chunks.map { |c| c.sub(%r{shc:/(.*?)/(.*?)/}, '') }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module SmartHealthCardsTestKit
6
+ module Utils
7
+ # Encoding utilities for producing JWS
8
+ #
9
+ # @see https://datatracker.ietf.org/doc/html/rfc7515#appendix-A.3.1
10
+ module Encoding
11
+ # Encodes the provided data using url safe base64 without padding
12
+ # @param data [String] the data to be encoded
13
+ # @return [String] the encoded data
14
+ def encode(data)
15
+ Base64.urlsafe_encode64(data, padding: false).gsub("\n", '')
16
+ end
17
+
18
+ # Decodes the provided data using url safe base 64
19
+ # @param data [String] the data to be decoded
20
+ # @return [String] the decoded data
21
+ def decode(data)
22
+ Base64.urlsafe_decode64(data)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'encoding'
3
+ require_relative 'private_key'
4
+ require_relative 'public_key'
5
+
6
+ module SmartHealthCardsTestKit
7
+ module Utils
8
+ # Create JWS from a payload
9
+ class JWS
10
+ class << self
11
+ include Encoding
12
+
13
+ # Creates a JWS from a String representation, or returns the SmartHealthCardsTestKit::Utils::JWS object
14
+ # that was passed in
15
+ # @param jws [String, SmartHealthCardsTestKit::Utils::JWS] the JWS string, or a JWS object
16
+ # @param public_key [SmartHealthCardsTestKit::Utils::PublicKey] the public key associated with the JWS
17
+ # @param key [SmartHealthCardsTestKit::Utils::PrivateKey] the private key associated with the JWS
18
+ # @return [SmartHealthCardsTestKit::Utils::JWS] A new JWS object, or the JWS object that was passed in
19
+ def from_jws(jws, public_key: nil, key: nil)
20
+ return jws if jws.is_a?(SmartHealthCardsTestKit::Utils::JWS) && public_key.nil? && key.nil?
21
+
22
+ unless jws.is_a?(SmartHealthCardsTestKit::Utils::JWS) || jws.is_a?(String)
23
+ raise ArgumentError,
24
+ 'Expected either a SmartHealthCardsTestKit::Utils::JWS or String'
25
+ end
26
+
27
+ header, payload, signature = jws.to_s.split('.').map { |entry| decode(entry) }
28
+ header = JSON.parse(header)
29
+ JWS.new(header: header, payload: payload, signature: signature,
30
+ public_key: public_key, key: key)
31
+ end
32
+ end
33
+
34
+ attr_reader :key, :public_key, :payload
35
+ attr_writer :signature
36
+ attr_accessor :header
37
+
38
+ # Create a new JWS
39
+
40
+ def initialize(header: nil, payload: nil, signature: nil, key: nil, public_key: nil)
41
+ # Not using accessors because they reset the signature which requires both a key and a payload
42
+ @header = header
43
+ @payload = payload
44
+ @signature = signature if signature
45
+ @key = key
46
+ @public_key = public_key || key&.public_key
47
+ end
48
+
49
+ # The kid value from the JWS header, used to identify the key to use to verify
50
+ # @return [String]
51
+ def kid
52
+ header['kid']
53
+ end
54
+
55
+ # Set the private key used for signing issued health cards
56
+ #
57
+ # @param key [SmartHealthCardsTestKit::Utils::PrivateKey, nil] the private key used for signing issued health cards
58
+ def key=(key)
59
+ PrivateKey.enforce_valid_key_type!(key, allow_nil: true)
60
+
61
+ @key = key
62
+
63
+ # If it's a new private key then the public key and signature should be updated
64
+ return if @key.nil?
65
+
66
+ reset_signature
67
+ self.public_key = @key.public_key
68
+ end
69
+
70
+ # Set the public key used for signing issued health cards
71
+ #
72
+ # @param public_key [SmartHealthCardsTestKit::Utils::PublicKey, nil] the private key used for signing issued health cards
73
+ def public_key=(public_key)
74
+ PublicKey.enforce_valid_key_type!(public_key, allow_nil: true)
75
+
76
+ @public_key = public_key
77
+ end
78
+
79
+ # Set the JWS payload. Setting a new payload will result in the a new signature
80
+ # @param new_payload [Object]
81
+ def payload=(new_payload)
82
+ @payload = new_payload
83
+ reset_signature
84
+ end
85
+
86
+ # The signature component of the card
87
+ #
88
+ # @return [String] the unencoded signature
89
+ def signature
90
+ return @signature if @signature
91
+
92
+ raise MissingPrivateKeyError unless key
93
+
94
+ @signature ||= key.sign(jws_signing_input)
95
+ end
96
+
97
+ # Export the card to a JWS String
98
+ # @return [String] the JWS
99
+ def to_s
100
+ [JSON.generate(header), payload, signature].map { |entry| JWS.encode(entry) }.join('.')
101
+ end
102
+
103
+ # # Verify the digital signature on the jws
104
+ # #
105
+ # # @return [Boolean]
106
+ def verify
107
+ raise MissingPublicKeyError unless public_key
108
+
109
+ public_key.verify(jws_signing_input, signature)
110
+ end
111
+
112
+ private
113
+
114
+ def jws_signing_input
115
+ "#{JWS.encode(@header.to_json)}.#{encoded_payload}"
116
+ end
117
+
118
+ def encoded_payload
119
+ JWS.encode(payload)
120
+ end
121
+
122
+ # Resets the signature
123
+ #
124
+ # This method is primarily used when an attribute that affects
125
+ # the signature is changed (e.g. the private key changes, the payload changes)
126
+ def reset_signature
127
+ @signature = nil
128
+ signature if key && payload
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ module SmartHealthCardsTestKit
7
+ module Utils
8
+ # Methods to generate signing keys and jwk
9
+ class Key
10
+ BASE = { kty: 'EC', crv: 'P-256' }.freeze
11
+ DIGEST = OpenSSL::Digest.new('SHA256')
12
+
13
+ # Checks if obj is the the correct key type or nil
14
+ # @param obj Object that should be of same type as caller or nil
15
+ # @param allow_nil Allow/Disallow key to be nil
16
+ def self.enforce_valid_key_type!(obj, allow_nil: false)
17
+ raise InvalidKeyError.new(self, obj) unless obj.is_a?(self) || (allow_nil && obj.nil?)
18
+ end
19
+
20
+ # Create a key from a JWK
21
+ #
22
+ # @param jwk_key [Hash] The JWK represented by a Hash
23
+ # @return [SmartHealthCardsTestKit::Utils::Key] The key represented by the JWK
24
+ def self.from_jwk(jwk_key)
25
+ jwk_key = jwk_key.transform_keys(&:to_sym)
26
+ curvename = 'prime256v1'
27
+ group = OpenSSL::PKey::EC::Group.new(curvename)
28
+ public_key_bn = ['04'].pack('H*') + Base64.urlsafe_decode64(jwk_key[:x]) + Base64.urlsafe_decode64(jwk_key[:y])
29
+ point = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new(public_key_bn, 2))
30
+
31
+ asn1 = OpenSSL::ASN1::Sequence([
32
+ OpenSSL::ASN1::Sequence([
33
+ OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
34
+ OpenSSL::ASN1::ObjectId(curvename)
35
+ ]),
36
+ OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
37
+ ])
38
+
39
+ key = OpenSSL::PKey::EC.new(asn1.to_der)
40
+ key.private_key = OpenSSL::BN.new(Base64.urlsafe_decode64(jwk_key[:d]), 2) if jwk_key[:d]
41
+
42
+ key.private_key? ? SmartHealthCardsTestKit::Utils::PrivateKey.new(key) : SmartHealthCardsTestKit::Utils::PublicKey.new(key)
43
+ end
44
+
45
+ def initialize(ec_key)
46
+ @key = ec_key
47
+ end
48
+
49
+ def group
50
+ @key.group
51
+ end
52
+
53
+ def to_json(*_args)
54
+ to_jwk.to_json
55
+ end
56
+
57
+ def to_jwk
58
+ coordinates.merge(kid: kid, use: 'sig', alg: 'ES256')
59
+ end
60
+
61
+ def kid
62
+ Base64.urlsafe_encode64(DIGEST.digest(public_coordinates.to_json), padding: false)
63
+ end
64
+
65
+ def public_coordinates
66
+ coordinates.slice(:crv, :kty, :x, :y)
67
+ end
68
+
69
+ def coordinates
70
+ return @coordinates if @coordinates
71
+
72
+ key_binary = @key.public_key.to_bn.to_s(2)
73
+ coords = { x: key_binary[1, key_binary.length / 2],
74
+ y: key_binary[key_binary.length / 2 + 1, key_binary.length] }
75
+ coords[:d] = @key.private_key.to_s(2) if @key.private_key?
76
+ @coordinates = coords.transform_values do |val|
77
+ Base64.urlsafe_encode64(val, padding: false)
78
+ end.merge(BASE)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module SmartHealthCardsTestKit
6
+ module Utils
7
+ # A set of keys used for signing or verifying HealthCards
8
+ class KeySet
9
+ extend Forwardable
10
+
11
+ def_delegator :keys, :empty?
12
+
13
+ # Create a KeySet from a JWKS
14
+ #
15
+ # @param jwks [String] the JWKS as a string
16
+ # @return [SmartHealthCardsTestKit::Utils::KeySet]
17
+ def self.from_jwks(jwks)
18
+ jwks = JSON.parse(jwks)
19
+ keys = jwks['keys'].map { |jwk| SmartHealthCardsTestKit::Utils::Key.from_jwk(jwk) }
20
+ KeySet.new(keys)
21
+ end
22
+
23
+ # Create a new KeySet
24
+ #
25
+ # @param keys [SmartHealthCardsTestKit::Utils::Key, Array<SmartHealthCardsTestKit::Utils::Key>, nil] the initial keys
26
+ def initialize(keys = nil)
27
+ @key_map = {}
28
+ add_keys(keys) unless keys.nil?
29
+ end
30
+
31
+ # The contained keys
32
+ #
33
+ # @return [Array]
34
+ def keys
35
+ @key_map.values
36
+ end
37
+
38
+ # Returns the keys as a JWK
39
+ #
40
+ # @return JSON string in JWK format
41
+ def to_jwk
42
+ { keys: keys.map(&:to_jwk) }.to_json
43
+ end
44
+
45
+ # Retrieves a key from the keyset with a kid
46
+ # that matches the parameter
47
+ # @param kid [String] a Base64 encoded kid from a JWS or Key
48
+ # @return [Payload::Key] a key with a matching kid or nil if not found
49
+ def find_key(kid)
50
+ @key_map[kid]
51
+ end
52
+
53
+ # Add keys to KeySet
54
+ #
55
+ # Keys are added based on the key kid
56
+ #
57
+ # @param new_keys [SmartHealthCardsTestKit::Utils::Key, Array<SmartHealthCardsTestKit::Utils::Key>, SmartHealthCardsTestKit::Utils::KeySet] the initial keys
58
+ def add_keys(new_keys)
59
+ if new_keys.is_a? KeySet
60
+ add_keys(new_keys.keys)
61
+ else
62
+ [*new_keys].each { |new_key| @key_map[new_key.kid] = new_key }
63
+ end
64
+ end
65
+
66
+ # Remove keys from KeySet
67
+ #
68
+ # Keys are remove based on the key kid
69
+ #
70
+ # @param new_keys [SmartHealthCardsTestKit::Utils::Key, Array<SmartHealthCardsTestKit::Utils::Key>, SmartHealthCardsTestKit::Utils::KeySet] the initial keys
71
+ def remove_keys(removed_keys)
72
+ if removed_keys.is_a? KeySet
73
+ remove_keys(removed_keys.keys)
74
+ else
75
+ [*removed_keys].each { |removed_key| @key_map.delete(removed_key.kid) }
76
+ end
77
+ end
78
+
79
+ # Check if key is included in the KeySet
80
+ #
81
+ # @param key [SmartHealthCardsTestKit::Utils::Key]
82
+ # @return [Boolean]
83
+ def include?(key)
84
+ !@key_map[key.kid].nil?
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'key'
3
+
4
+ module SmartHealthCardsTestKit
5
+ module Utils
6
+ # A key used for signing JWS
7
+ class PrivateKey < Key
8
+ def self.from_file(path)
9
+ pem = OpenSSL::PKey::EC.new(File.read(path))
10
+ PrivateKey.new(pem)
11
+ end
12
+
13
+ def self.load_from_or_create_from_file(path)
14
+ if File.exist?(path)
15
+ from_file(path)
16
+ else
17
+ generate_key(file_path: path)
18
+ end
19
+ end
20
+
21
+ def self.generate_key(file_path: nil)
22
+ key = OpenSSL::PKey::EC.generate('prime256v1')
23
+ File.write(file_path, key.to_pem) if file_path
24
+ PrivateKey.new(key)
25
+ end
26
+
27
+ def sign(payload)
28
+ asn1_to_raw(@key.sign(OpenSSL::Digest.new('SHA256'), payload), self)
29
+ end
30
+
31
+ def public_key
32
+ return @public_key if @public_key
33
+
34
+ pub = OpenSSL::PKey::EC.new('prime256v1')
35
+ pub.public_key = @key.public_key
36
+ @public_key = PublicKey.new(pub)
37
+ end
38
+
39
+ private
40
+
41
+ # Convert the ASN.1 Representation into the raw signature
42
+ #
43
+ # Adapted from ruby-jwt and json-jwt gems. More info here:
44
+ # https://github.com/nov/json-jwt/issues/21
45
+ # https://github.com/jwt/ruby-jwt/pull/87
46
+ # https://github.com/jwt/ruby-jwt/issues/84
47
+ def asn1_to_raw(signature, private_key)
48
+ byte_size = (private_key.group.degree + 7) / 8
49
+ OpenSSL::ASN1.decode(signature).value.map { |value| value.value.to_s(2).rjust(byte_size, "\x00") }.join
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartHealthCardsTestKit
4
+ module Utils
5
+ # A key used for verifying JWS
6
+ class PublicKey < Key
7
+ def self.from_json(json)
8
+ # TODO
9
+ end
10
+
11
+ def verify(payload, signature)
12
+ @key.verify(OpenSSL::Digest.new('SHA256'), raw_to_asn1(signature, self), payload)
13
+ end
14
+
15
+ private
16
+
17
+ # Convert the raw signature into the ASN.1 Representation
18
+ #
19
+ # Adapted from ruby-jwt and json-jwt gems. More info here:
20
+ # https://github.com/nov/json-jwt/issues/21
21
+ # https://github.com/jwt/ruby-jwt/pull/87
22
+ # https://github.com/jwt/ruby-jwt/issues/84
23
+ def raw_to_asn1(signature, key)
24
+ byte_size = (key.group.degree + 7) / 8
25
+ sig_bytes = signature[0..(byte_size - 1)]
26
+ sig_char = signature[byte_size..] || ''
27
+ OpenSSL::ASN1::Sequence.new([sig_bytes, sig_char].map do |int|
28
+ OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(int, 2))
29
+ end).to_der
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartHealthCardsTestKit
4
+ module Utils
5
+ # Logic for verifying a Payload JWS
6
+ module Verification
7
+ # Verify Health Card with given KeySet
8
+ #
9
+ # @param verifiable [SmartHealthCardsTestKit::Utils::JWS, String] the health card to verify
10
+ # @param key_set [SmartHealthCardsTestKit::Utils::KeySet, nil] the KeySet from which keys should be taken or added
11
+ # @param resolve_keys [Boolean] if keys should be resolved
12
+ # @return [Boolean]
13
+ def verify_using_key_set(verifiable, key_set = nil, resolve_keys: true)
14
+ jws = JWS.from_jws(verifiable)
15
+ key_set ||= SmartHealthCardsTestKit::Utils::KeySet.new
16
+ key_set.add_keys(resolve_key(jws)) if resolve_keys && key_set.find_key(jws.kid).nil?
17
+
18
+ key = key_set.find_key(jws.kid)
19
+ unless key
20
+ raise MissingPublicKeyError,
21
+ 'Verifier does not contain public key that is able to verify this signature'
22
+ end
23
+
24
+ jws.public_key = key
25
+ jws.verify
26
+ end
27
+
28
+ # Resolve a key
29
+ # @param jws [SmartHealthCardsTestKit::Utils::JWS, String] The JWS for which to resolve keys
30
+ # @return [SmartHealthCardsTestKit::Utils::KeySet]
31
+ def resolve_key(jws)
32
+ jwks_uri = URI("#{HealthCard.new(jws.to_s).issuer}/.well-known/jwks.json")
33
+ res = Net::HTTP.get(jwks_uri)
34
+ SmartHealthCardsTestKit::Utils::KeySet.from_jwks(res)
35
+ # Handle response if key is malformed or unreachable
36
+ rescue StandardError => e
37
+ raise ArgumentError, "Unable resolve a valid key from uri #{jwks_uri}: #{e.message}"
38
+ end
39
+ end
40
+ end
41
+ end