smart_health_cards_test_kit 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|