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.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/lib/smart_health_cards_test_kit/fhir_operation_group.rb +105 -0
- data/lib/smart_health_cards_test_kit/file_download_group.rb +108 -0
- data/lib/smart_health_cards_test_kit/health_card.rb +33 -0
- data/lib/smart_health_cards_test_kit/javascript/jsQR.js +10100 -0
- data/lib/smart_health_cards_test_kit/javascript/qr-scanner-worker.min.js +98 -0
- data/lib/smart_health_cards_test_kit/javascript/qr-scanner.min.js +31 -0
- data/lib/smart_health_cards_test_kit/qr_code_group.rb +107 -0
- data/lib/smart_health_cards_test_kit/shc_fhir_validation.rb +39 -0
- data/lib/smart_health_cards_test_kit/shc_header_verification.rb +36 -0
- data/lib/smart_health_cards_test_kit/shc_payload_verification.rb +123 -0
- data/lib/smart_health_cards_test_kit/shc_signature_verification.rb +95 -0
- data/lib/smart_health_cards_test_kit/utils/chunking_utils.rb +71 -0
- data/lib/smart_health_cards_test_kit/utils/encoding.rb +26 -0
- data/lib/smart_health_cards_test_kit/utils/jws.rb +132 -0
- data/lib/smart_health_cards_test_kit/utils/key.rb +82 -0
- data/lib/smart_health_cards_test_kit/utils/key_set.rb +88 -0
- data/lib/smart_health_cards_test_kit/utils/private_key.rb +53 -0
- data/lib/smart_health_cards_test_kit/utils/public_key.rb +33 -0
- data/lib/smart_health_cards_test_kit/utils/verification.rb +41 -0
- data/lib/smart_health_cards_test_kit/utils/verifier.rb +71 -0
- data/lib/smart_health_cards_test_kit/version.rb +3 -0
- data/lib/smart_health_cards_test_kit/views/scan_qr_code.html +207 -0
- data/lib/smart_health_cards_test_kit/views/upload_qr_code.html +130 -0
- data/lib/smart_health_cards_test_kit.rb +67 -0
- 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
|