health_cards 0.0.1 → 0.0.2
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 +4 -4
- data/lib/health_cards.rb +6 -2
- data/lib/health_cards/attribute_filters.rb +92 -0
- data/lib/health_cards/card_types.rb +32 -0
- data/lib/health_cards/chunk.rb +92 -0
- data/lib/health_cards/{chunking.rb → chunking_utils.rb} +14 -14
- data/lib/health_cards/covid_health_card.rb +7 -2
- data/lib/health_cards/{exceptions.rb → errors.rb} +37 -13
- data/lib/health_cards/exporter.rb +44 -13
- data/lib/health_cards/health_card.rb +78 -98
- data/lib/health_cards/importer.rb +24 -7
- data/lib/health_cards/jws.rb +2 -2
- data/lib/health_cards/key.rb +2 -2
- data/lib/health_cards/qr_codes.rb +36 -0
- data/lib/health_cards/verification.rb +9 -2
- data/lib/health_cards/version.rb +1 -1
- metadata +27 -11
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c6e94c49814ad651b6377277252fef1276ca99fa3b0704171f31571a986da62e
|
4
|
+
data.tar.gz: 9a3a9b0e599a196d70d498089b23efb2479cece350f3bd1cb430ffbe4f22a32b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 39f9f579182341ec4124c991962ab8c7fcd8a09e5ced6e456a7f61248bc53c4a80b4b65a8eed2ba7756ecb29f8a68c8c8625017aad43a4cf38baa62ff3334194
|
7
|
+
data.tar.gz: c9c486b517e6c4e07299662700c50e53be4286b870abeccc1114d042f216ba0489069770a1c574cdae3b6e852a07738749901109c84644ebba42869baf150b3f
|
data/lib/health_cards.rb
CHANGED
@@ -9,8 +9,12 @@ require 'health_cards/key_set'
|
|
9
9
|
require 'health_cards/private_key'
|
10
10
|
require 'health_cards/public_key'
|
11
11
|
require 'health_cards/health_card'
|
12
|
-
require 'health_cards/
|
13
|
-
require 'health_cards/
|
12
|
+
require 'health_cards/attribute_filters'
|
13
|
+
require 'health_cards/card_types'
|
14
|
+
require 'health_cards/chunking_utils'
|
15
|
+
require 'health_cards/errors'
|
16
|
+
require 'health_cards/qr_codes'
|
17
|
+
require 'health_cards/chunk'
|
14
18
|
require 'health_cards/verifier'
|
15
19
|
require 'health_cards/importer'
|
16
20
|
require 'health_cards/covid_health_card'
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HealthCards
|
4
|
+
# Handles behavior related to removing disallowed attributes from FHIR Resources
|
5
|
+
module AttributeFilters
|
6
|
+
ALL_FHIR_RESOURCES = :fhir_resource
|
7
|
+
|
8
|
+
def self.included(base)
|
9
|
+
base.extend ClassMethods
|
10
|
+
end
|
11
|
+
|
12
|
+
# Class level methods for HealthCard class specific settings
|
13
|
+
module ClassMethods
|
14
|
+
# Define allowed attributes for this HealthCard class
|
15
|
+
# @param type [Class] Scopes the attributes to a spefic class. Must be a subclass of FHIR::Model
|
16
|
+
# @param attributes [Array] An array of string with the attribute names that will be passed through
|
17
|
+
# when data is minimized
|
18
|
+
def allow(type:, attributes: [])
|
19
|
+
allowable[type] = attributes
|
20
|
+
end
|
21
|
+
|
22
|
+
# Define disallowed attributes for this HealthCard class
|
23
|
+
# @param type [Class] Scopes the attributes to a spefic class. If not used will default to all FHIR resources.
|
24
|
+
# To apply a rule to all FHIR types (resources and types), use FHIR::Model as the type
|
25
|
+
# @param attributes [Array] An array of string with the attribute names that will be passed through
|
26
|
+
# when data is minimized
|
27
|
+
def disallow(type: ALL_FHIR_RESOURCES, attributes: [])
|
28
|
+
disallowable[type] ||= []
|
29
|
+
disallowable[type].concat(attributes)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Define disallowed attributes for this HealthCard class
|
33
|
+
# @return [Hash] A hash of FHIR::Model subclasses and attributes that will pass through minimization
|
34
|
+
def disallowable
|
35
|
+
return @disallowable if @disallowable
|
36
|
+
|
37
|
+
@disallowable = parent_disallowables
|
38
|
+
end
|
39
|
+
|
40
|
+
# Define allowed attributes for this HealthCard class
|
41
|
+
# @return [Hash] A hash of FHIR::Model subclasses and attributes that will pass through minimization
|
42
|
+
def allowable
|
43
|
+
return @allowable if @allowable
|
44
|
+
|
45
|
+
@allowable = parent_allowables
|
46
|
+
end
|
47
|
+
|
48
|
+
protected
|
49
|
+
|
50
|
+
def parent_allowables(base = {})
|
51
|
+
self < HealthCards::HealthCard ? base.merge(superclass.allowable) : base
|
52
|
+
end
|
53
|
+
|
54
|
+
def parent_disallowables(base = {})
|
55
|
+
self < HealthCards::HealthCard ? base.merge(superclass.disallowable) : base
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def handle_allowable(resource)
|
60
|
+
class_allowables = self.class.allowable[resource.class]
|
61
|
+
|
62
|
+
return unless class_allowables
|
63
|
+
|
64
|
+
allowed = resource.to_hash.select! { |att| class_allowables.include?(att) }
|
65
|
+
|
66
|
+
resource.from_hash(allowed)
|
67
|
+
end
|
68
|
+
|
69
|
+
def handle_disallowable(resource)
|
70
|
+
class_disallowable = find_subclass_keys(self.class.disallowable, resource)
|
71
|
+
|
72
|
+
return if class_disallowable.empty?
|
73
|
+
|
74
|
+
all_disallowed = class_disallowable.map do |disallowed_class|
|
75
|
+
self.class.disallowable[disallowed_class]
|
76
|
+
end.flatten.uniq
|
77
|
+
|
78
|
+
allowed = resource.to_hash.delete_if { |att| all_disallowed.include?(att) }
|
79
|
+
|
80
|
+
resource.from_hash(allowed)
|
81
|
+
end
|
82
|
+
|
83
|
+
protected
|
84
|
+
|
85
|
+
def find_subclass_keys(hash, resource)
|
86
|
+
subclasses = hash.keys.filter { |class_key| class_key.is_a?(Class) && resource.class <= class_key }
|
87
|
+
# No great way to determine if this is an actual FHIR resource
|
88
|
+
subclasses << ALL_FHIR_RESOURCES if resource.respond_to?(:resourceType)
|
89
|
+
subclasses
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HealthCards
|
4
|
+
# Handles behavior related to support types by Healthcard subclasses
|
5
|
+
module CardTypes
|
6
|
+
VC_TYPE = [
|
7
|
+
'https://smarthealth.cards#health-card'
|
8
|
+
].freeze
|
9
|
+
|
10
|
+
# Additional type claims this HealthCard class supports
|
11
|
+
# @param types [String, Array] A string or array of string representing the additional type claims or nil
|
12
|
+
# if used as a getter
|
13
|
+
# @return [Array] the additional types added by this classes
|
14
|
+
def additional_types(*add_types)
|
15
|
+
types.concat(add_types) unless add_types.nil?
|
16
|
+
types - VC_TYPE
|
17
|
+
end
|
18
|
+
|
19
|
+
# Type claims supported by this HealthCard subclass
|
20
|
+
# @return [Array] an array of Strings with all the supported type claims
|
21
|
+
def types
|
22
|
+
@types ||= VC_TYPE.dup
|
23
|
+
end
|
24
|
+
|
25
|
+
# Check if this class supports the given type claim(s)
|
26
|
+
# @param type [Array, String] A type as defined by the SMART Health Cards framework
|
27
|
+
# @return [Boolean] Whether or not the type param is included in the types supported by the HealthCard (sub)class
|
28
|
+
def supports_type?(*type)
|
29
|
+
!types.intersection(type.flatten).empty?
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rubocop:disable Lint/MissingSuper
|
4
|
+
module HealthCards
|
5
|
+
# Represents a single QRCode in a sequence. This class is a shim to the RQRCode library
|
6
|
+
# to enable multimode encoding
|
7
|
+
class Chunk < RQRCode::QRCode
|
8
|
+
attr_reader :ordinal
|
9
|
+
|
10
|
+
def data
|
11
|
+
@qrcode.data
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize(ordinal: 1, input: nil)
|
15
|
+
@ordinal = ordinal
|
16
|
+
@qrcode = ChunkCore.new(input)
|
17
|
+
end
|
18
|
+
|
19
|
+
def image
|
20
|
+
as_png(
|
21
|
+
border_modules: 1,
|
22
|
+
module_px_size: 2
|
23
|
+
)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# RQRCodeCore shim for to enable multimode encoding
|
28
|
+
class ChunkCore < RQRCodeCore::QRCode
|
29
|
+
attr_accessor :data
|
30
|
+
|
31
|
+
def initialize(input)
|
32
|
+
@data = input
|
33
|
+
@error_correct_level = 1
|
34
|
+
@version = 22
|
35
|
+
@module_count = @version * 4 + RQRCodeCore::QRPOSITIONPATTERNLENGTH
|
36
|
+
@modules = Array.new(@module_count)
|
37
|
+
@data_list = SHCQRCode.new(@data)
|
38
|
+
@data_cache = nil
|
39
|
+
make
|
40
|
+
end
|
41
|
+
|
42
|
+
# RQRCodeCore data shim for multimode encoding
|
43
|
+
class SHCQRCode
|
44
|
+
SINGLE_REGEX = %r{shc:/}.freeze
|
45
|
+
MULTI_REGEX = %r{shc:/[0-9]*/[0-9]*/}.freeze
|
46
|
+
|
47
|
+
def initialize(data)
|
48
|
+
@data = data
|
49
|
+
end
|
50
|
+
|
51
|
+
def write(buffer)
|
52
|
+
multi = MULTI_REGEX.match(@data)
|
53
|
+
prefix = multi ? multi.to_s : SINGLE_REGEX.match(@data).to_s
|
54
|
+
|
55
|
+
buffer.byte_encoding_start(prefix.length)
|
56
|
+
|
57
|
+
prefix.each_byte do |b|
|
58
|
+
buffer.put(b, 8)
|
59
|
+
end
|
60
|
+
|
61
|
+
num_content = @data.delete_prefix(prefix)
|
62
|
+
|
63
|
+
buffer.numeric_encoding_start(num_content.length)
|
64
|
+
|
65
|
+
num_content.size.times do |i|
|
66
|
+
next unless (i % 3).zero?
|
67
|
+
|
68
|
+
chars = num_content[i, 3]
|
69
|
+
bit_length = get_bit_length(chars.length)
|
70
|
+
buffer.put(get_code(chars), bit_length)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
NUMBER_LENGTH = {
|
77
|
+
3 => 10,
|
78
|
+
2 => 7,
|
79
|
+
1 => 4
|
80
|
+
}.freeze
|
81
|
+
|
82
|
+
def get_bit_length(length)
|
83
|
+
NUMBER_LENGTH[length]
|
84
|
+
end
|
85
|
+
|
86
|
+
def get_code(chars)
|
87
|
+
chars.to_i
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
# rubocop:enable Lint/MissingSuper
|
@@ -2,12 +2,12 @@
|
|
2
2
|
|
3
3
|
module HealthCards
|
4
4
|
# Split up a JWS into chunks if encoded size is above QR Code Size constraint
|
5
|
-
module
|
5
|
+
module ChunkingUtils
|
6
6
|
extend self
|
7
7
|
MAX_SINGLE_JWS_SIZE = 1195
|
8
8
|
MAX_CHUNK_SIZE = 1191
|
9
9
|
|
10
|
-
def
|
10
|
+
def split_jws(jws)
|
11
11
|
if jws.length <= MAX_SINGLE_JWS_SIZE
|
12
12
|
[jws]
|
13
13
|
else
|
@@ -18,13 +18,21 @@ module HealthCards
|
|
18
18
|
end
|
19
19
|
|
20
20
|
# Splits jws into chunks and converts each string into numeric
|
21
|
-
def
|
22
|
-
|
23
|
-
|
21
|
+
def jws_to_qr_chunks(jws)
|
22
|
+
chunks = split_jws(jws.to_s).map { |c| convert_jws_to_numeric(c) }
|
23
|
+
|
24
|
+
# if 1 chunk, attach prefix shc:/
|
25
|
+
# if multiple chunks, attach prefix shc:/$orderNumber/$totalChunkCount
|
26
|
+
if chunks.length == 1
|
27
|
+
chunks[0] = "shc:/#{chunks[0]}"
|
28
|
+
else
|
29
|
+
chunks.map!.with_index(1) { |ch, i| "shc:/#{i}/#{chunks.length}/#{ch}" }
|
30
|
+
end
|
31
|
+
chunks
|
24
32
|
end
|
25
33
|
|
26
34
|
# Assemble jws from qr code chunks
|
27
|
-
def
|
35
|
+
def qr_chunks_to_jws(qr_chunks)
|
28
36
|
if qr_chunks.length == 1
|
29
37
|
# Strip off shc:/ and convert numeric jws
|
30
38
|
numeric_jws = qr_chunks[0].delete_prefix('shc:/')
|
@@ -35,14 +43,6 @@ module HealthCards
|
|
35
43
|
end
|
36
44
|
end
|
37
45
|
|
38
|
-
def get_payload_from_qr(qr_chunks)
|
39
|
-
jws = assemble_jws qr_chunks
|
40
|
-
|
41
|
-
# Get JWS payload, then decode and inflate
|
42
|
-
message = Base64.urlsafe_decode64 jws.split('.')[1]
|
43
|
-
JSON.parse(Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(message))
|
44
|
-
end
|
45
|
-
|
46
46
|
private
|
47
47
|
|
48
48
|
# Each character "c" of the jws is converted into a sequence of two digits by taking c.ord - 45
|
@@ -1,12 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'health_cards/attribute_filters'
|
4
|
+
require 'health_cards/card_types'
|
5
|
+
|
3
6
|
module HealthCards
|
4
7
|
# Implements HealthCard for use with COVID Vaccination IG
|
5
8
|
class COVIDHealthCard < HealthCards::HealthCard
|
6
9
|
fhir_version '4.0.1'
|
10
|
+
|
7
11
|
additional_types 'https://smarthealth.cards#covid19'
|
12
|
+
additional_types 'https://smarthealth.cards#immunization'
|
8
13
|
|
9
|
-
allow FHIR::Patient, %w[name birthDate]
|
10
|
-
allow FHIR::Immunization, %w[status vaccineCode patient occurrenceDateTime]
|
14
|
+
allow type: FHIR::Patient, attributes: %w[name birthDate]
|
15
|
+
allow type: FHIR::Immunization, attributes: %w[status vaccineCode patient occurrenceDateTime]
|
11
16
|
end
|
12
17
|
end
|
@@ -1,45 +1,69 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module HealthCards
|
4
|
+
class HealthCardsError < StandardError; end
|
5
|
+
|
6
|
+
class JWSError < HealthCardsError; end
|
7
|
+
|
8
|
+
class HealthCardError < HealthCardsError; end
|
9
|
+
|
10
|
+
# Errors related to JWS
|
11
|
+
|
4
12
|
# Exception thrown when a private key is expected or required
|
5
|
-
class
|
13
|
+
class MissingPrivateKeyError < JWSError
|
6
14
|
def initialize(msg = 'Missing private key')
|
7
15
|
super(msg)
|
8
16
|
end
|
9
17
|
end
|
10
18
|
|
11
19
|
# Exception thrown when a public key is expected or required
|
12
|
-
class
|
20
|
+
class MissingPublicKeyError < JWSError
|
13
21
|
def initialize(msg = 'Missing public key')
|
14
22
|
super(msg)
|
15
23
|
end
|
16
24
|
end
|
17
25
|
|
26
|
+
class UnresolvableKeySetError < JWSError; end
|
27
|
+
|
28
|
+
# Errors related to HealthCard / Bundle
|
29
|
+
|
18
30
|
# Exception thrown when an invalid payload is provided
|
19
|
-
class
|
20
|
-
def initialize(msg = 'Bundle
|
31
|
+
class InvalidPayloadError < HealthCardError
|
32
|
+
def initialize(msg = 'Bundle is not a valid FHIR Bundle')
|
21
33
|
super(msg)
|
22
34
|
end
|
23
35
|
end
|
24
36
|
|
25
37
|
# Exception thrown when verifiable credential JSON does not include a locatable FHIR Bundle
|
26
|
-
class
|
38
|
+
class InvalidCredentialError < HealthCardError
|
27
39
|
def initialize(msg = 'Unable to locate FHIR Bundle in credential')
|
28
40
|
super(msg)
|
29
41
|
end
|
30
42
|
end
|
31
43
|
|
32
|
-
# Exception thrown when an invalid key (public or private) is provided
|
33
|
-
class InvalidKeyException < ArgumentError
|
34
|
-
def initialize(expected_class, actual_obj)
|
35
|
-
super("Expected an instance of #{expected_class} but was #{actual_obj.class}")
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
44
|
# Exception thrown when a reference in a bundle in unresolvable
|
40
|
-
class
|
45
|
+
class InvalidBundleReferenceError < HealthCardError
|
41
46
|
def initialize(url)
|
42
47
|
super("Unable to resolve url (#{url}) within bundle")
|
43
48
|
end
|
44
49
|
end
|
50
|
+
|
51
|
+
# Error thrown when FHIR Parameters are invalid
|
52
|
+
class InvalidParametersError < HealthCardError
|
53
|
+
attr_reader :code
|
54
|
+
|
55
|
+
def initialize(code: nil, message: nil)
|
56
|
+
@code = code
|
57
|
+
super(message)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Other errors
|
62
|
+
|
63
|
+
# Exception thrown when an invalid key (public or private) is provided
|
64
|
+
class InvalidKeyError < HealthCardsError
|
65
|
+
def initialize(expected_class, actual_obj)
|
66
|
+
super("Expected an instance of #{expected_class} but was #{actual_obj.class}")
|
67
|
+
end
|
68
|
+
end
|
45
69
|
end
|
@@ -3,21 +3,52 @@
|
|
3
3
|
module HealthCards
|
4
4
|
# Converts a JWS to formats needed by endpoints (e.g. $issue-health-card, download and qr code)
|
5
5
|
module Exporter
|
6
|
-
|
6
|
+
class << self
|
7
|
+
# Export JWS for file download
|
8
|
+
# @param [Array<JWS, String>] An array of JWS objects to be exported
|
9
|
+
# @return [String] JSON string containing file download contents
|
10
|
+
def download(jws)
|
11
|
+
{ verifiableCredential: jws.map(&:to_s) }.to_json
|
12
|
+
end
|
7
13
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
+
# Export JWS for $issue-health-card endpoint
|
15
|
+
# @param [FHIR::Parameters] A FHIR::Parameters object
|
16
|
+
# @yields [types] An array of strings representing the types in the FHIR::Parameters.
|
17
|
+
# Expects block to return JWS instances for those types
|
18
|
+
# @return [String] JSON string containing a FHIR Parameters resource
|
19
|
+
def issue(fhir_params)
|
20
|
+
*jws = yield extract_types!(fhir_params)
|
21
|
+
|
22
|
+
params = jws.compact.map { |j| FHIR::Parameters::Parameter.new(name: 'verifiableCredential', valueString: j) }
|
23
|
+
|
24
|
+
FHIR::Parameters.new(parameter: params).to_json
|
25
|
+
end
|
26
|
+
|
27
|
+
def qr_codes(jws)
|
28
|
+
QRCodes.from_jws(jws)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def extract_types!(fhir_params)
|
34
|
+
if fhir_params.nil?
|
35
|
+
code = 'structure'
|
36
|
+
err_msg = 'No Parameters found'
|
37
|
+
elsif !fhir_params.valid?
|
38
|
+
code = 'invalid'
|
39
|
+
err_msg = fhir_params.validate.to_s
|
40
|
+
else
|
41
|
+
types = fhir_params.parameter.map(&:valueUri).compact
|
42
|
+
if types.empty?
|
43
|
+
code = 'required'
|
44
|
+
err_msg = 'Invalid Parameter: Expected valueUri'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
raise InvalidParametersError.new(code: code, message: err_msg) if code
|
14
49
|
|
15
|
-
|
16
|
-
|
17
|
-
# @return [String] JSON string containing a FHIR Parameters resource
|
18
|
-
def self.issue(jws)
|
19
|
-
params = jws.map { |j| FHIR::Parameters::Parameter.new(name: 'verifiableCredential', valueString: j) }
|
20
|
-
FHIR::Parameters.new(parameter: params).to_json
|
50
|
+
types
|
51
|
+
end
|
21
52
|
end
|
22
53
|
end
|
23
54
|
end
|
@@ -1,14 +1,41 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'json/minify'
|
4
3
|
require 'zlib'
|
4
|
+
require 'uri'
|
5
|
+
|
6
|
+
require 'health_cards/attribute_filters'
|
7
|
+
require 'health_cards/card_types'
|
5
8
|
|
6
9
|
module HealthCards
|
7
10
|
# A HealthCard which implements the credential claims specified by https://smarthealth.cards/
|
8
11
|
class HealthCard
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
+
include HealthCards::AttributeFilters
|
13
|
+
extend HealthCards::CardTypes
|
14
|
+
|
15
|
+
FHIR_REF_REGEX = %r{((http|https)://([A-Za-z0-9\-\\.:%$]*/)+)?(
|
16
|
+
Account|ActivityDefinition|AdverseEvent|AllergyIntolerance|Appointment|AppointmentResponse|AuditEvent|Basic|
|
17
|
+
Binary|BiologicallyDerivedProduct|BodyStructure|Bundle|CapabilityStatement|CarePlan|CareTeam|CatalogEntry|
|
18
|
+
ChargeItem|ChargeItemDefinition|Claim|ClaimResponse|ClinicalImpression|CodeSystem|Communication|
|
19
|
+
CommunicationRequest|CompartmentDefinition|Composition|ConceptMap|Condition|Consent|Contract|Coverage|
|
20
|
+
CoverageEligibilityRequest|CoverageEligibilityResponse|DetectedIssue|Device|DeviceDefinition|DeviceMetric
|
21
|
+
|DeviceRequest|DeviceUseStatement|DiagnosticReport|DocumentManifest|DocumentReference|EffectEvidenceSynthesis|
|
22
|
+
Encounter|Endpoint|EnrollmentRequest|EnrollmentResponse|EpisodeOfCare|EventDefinition|Evidence|EvidenceVariable|
|
23
|
+
ExampleScenario|ExplanationOfBenefit|FamilyMemberHistory|Flag|Goal|GraphDefinition|Group|GuidanceResponse|
|
24
|
+
HealthcareService|ImagingStudy|Immunization|ImmunizationEvaluation|ImmunizationRecommendation|
|
25
|
+
ImplementationGuide|InsurancePlan|Invoice|Library|Linkage|List|Location|Measure|MeasureReport|Media|Medication|
|
26
|
+
MedicationAdministration|MedicationDispense|MedicationKnowledge|MedicationRequest|MedicationStatement|
|
27
|
+
MedicinalProduct|MedicinalProductAuthorization|MedicinalProductContraindication|MedicinalProductIndication|
|
28
|
+
MedicinalProductIngredient|MedicinalProductInteraction|MedicinalProductManufactured|MedicinalProductPackaged|
|
29
|
+
MedicinalProductPharmaceutical|MedicinalProductUndesirableEffect|MessageDefinition|MessageHeader|
|
30
|
+
MolecularSequence|NamingSystem|NutritionOrder|Observation|ObservationDefinition|OperationDefinition|
|
31
|
+
OperationOutcome|Organization|OrganizationAffiliation|Patient|PaymentNotice|PaymentReconciliation|Person|
|
32
|
+
PlanDefinition|Practitioner|PractitionerRole|Procedure|Provenance|Questionnaire|QuestionnaireResponse|
|
33
|
+
RelatedPerson|RequestGroup|ResearchDefinition|ResearchElementDefinition|ResearchStudy|ResearchSubject|
|
34
|
+
RiskAssessment|RiskEvidenceSynthesis|Schedule|SearchParameter|ServiceRequest|Slot|Specimen|SpecimenDefinition|
|
35
|
+
StructureDefinition|StructureMap|Subscription|Substance|SubstanceNucleicAcid|SubstancePolymer|SubstanceProtein|
|
36
|
+
SubstanceReferenceInformation|SubstanceSourceMaterial|SubstanceSpecification|SupplyDelivery|SupplyRequest|Task|
|
37
|
+
TerminologyCapabilities|TestReport|TestScript|ValueSet|VerificationResult|VisionPrescription)/
|
38
|
+
[A-Za-z0-9\-.]{1,64}(/_history/[A-Za-z0-9\-.]{1,64})?}x.freeze
|
12
39
|
|
13
40
|
attr_reader :issuer, :nbf, :bundle
|
14
41
|
|
@@ -30,7 +57,7 @@ module HealthCards
|
|
30
57
|
json = decompress_payload(payload)
|
31
58
|
bundle_hash = json.dig('vc', 'credentialSubject', 'fhirBundle')
|
32
59
|
|
33
|
-
raise HealthCards::
|
60
|
+
raise HealthCards::InvalidCredentialError unless bundle_hash
|
34
61
|
|
35
62
|
bundle = FHIR::Bundle.new(bundle_hash)
|
36
63
|
new(issuer: json['iss'], bundle: bundle)
|
@@ -51,21 +78,6 @@ module HealthCards
|
|
51
78
|
Zlib::Deflate.new(nil, -Zlib::MAX_WBITS).deflate(payload.to_s, Zlib::FINISH)
|
52
79
|
end
|
53
80
|
|
54
|
-
# Define allowed attributes for this HealthCard class
|
55
|
-
# @param klass [Class] Scopes the attributes to a spefic class. Must be a subclass of FHIR::Model
|
56
|
-
# @param attributes [Array] An array of string with the attribute names that will be passed through
|
57
|
-
# when data is minimized
|
58
|
-
def allow(klass, attributes)
|
59
|
-
resource_type = klass.name.split('::').last
|
60
|
-
allowable[resource_type] = attributes
|
61
|
-
end
|
62
|
-
|
63
|
-
# Define allowed attributes for this HealthCard class
|
64
|
-
# @return [Hash] A hash of FHIR::Model subclasses and attributes that will pass through minimization
|
65
|
-
def allowable
|
66
|
-
@allowable ||= {}
|
67
|
-
end
|
68
|
-
|
69
81
|
# Sets/Gets the fhir version that will be passed through to the credential created by an instnace of
|
70
82
|
# this HealthCard (sub)class
|
71
83
|
# @param ver [String] FHIR Version supported by this HealthCard (sub)class. Leaving this param out
|
@@ -76,36 +88,20 @@ module HealthCards
|
|
76
88
|
@fhir_version ||= ver unless ver.nil?
|
77
89
|
@fhir_version
|
78
90
|
end
|
91
|
+
end
|
79
92
|
|
80
|
-
|
81
|
-
# @param types [String, Array] A string or array of string representing the additional type claims or nil
|
82
|
-
# if used as a getter
|
83
|
-
# @return [Array] the additional types added by this classes
|
84
|
-
def additional_types(*add_types)
|
85
|
-
types.concat(add_types) unless add_types.nil?
|
86
|
-
types - VC_TYPE
|
87
|
-
end
|
88
|
-
|
89
|
-
# Type claims supported by this HealthCard subclass
|
90
|
-
# @return [Array] an array of Strings with all the supported type claims
|
91
|
-
def types
|
92
|
-
@types ||= VC_TYPE.dup
|
93
|
-
end
|
93
|
+
allow type: FHIR::Meta, attributes: %w[security]
|
94
94
|
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
def supports_type?(*type)
|
99
|
-
!types.intersection(type).empty?
|
100
|
-
end
|
101
|
-
end
|
95
|
+
disallow attributes: %w[id text]
|
96
|
+
disallow type: FHIR::CodeableConcept, attributes: %w[text]
|
97
|
+
disallow type: FHIR::Coding, attributes: %w[display]
|
102
98
|
|
103
99
|
# Create a HealthCard
|
104
100
|
#
|
105
101
|
# @param bundle [FHIR::Bundle] VerifiableCredential containing a fhir bundle
|
106
102
|
# @param issuer [String] The url from the Issuer of the HealthCard
|
107
103
|
def initialize(bundle:, issuer: nil)
|
108
|
-
raise
|
104
|
+
raise InvalidPayloadError unless bundle.is_a?(FHIR::Bundle) # && bundle.valid?
|
109
105
|
|
110
106
|
@issuer = issuer
|
111
107
|
@bundle = bundle
|
@@ -121,10 +117,9 @@ module HealthCards
|
|
121
117
|
type: self.class.types,
|
122
118
|
credentialSubject: {
|
123
119
|
fhirVersion: self.class.fhir_version,
|
124
|
-
fhirBundle: strip_fhir_bundle
|
120
|
+
fhirBundle: strip_fhir_bundle.to_hash
|
125
121
|
}
|
126
122
|
}
|
127
|
-
|
128
123
|
}
|
129
124
|
end
|
130
125
|
|
@@ -138,7 +133,7 @@ module HealthCards
|
|
138
133
|
# A minified JSON string matching the VC structure specified by https://smarthealth.cards/#health-cards-are-encoded-as-compact-serialization-json-web-signatures-jws
|
139
134
|
# @return [String] JSON string
|
140
135
|
def to_json(*_args)
|
141
|
-
|
136
|
+
to_hash.to_json
|
142
137
|
end
|
143
138
|
|
144
139
|
# Processes the bundle according to https://smarthealth.cards/#health-cards-are-small and returns
|
@@ -146,78 +141,63 @@ module HealthCards
|
|
146
141
|
# @return [Hash] A hash with the same content as the FHIR::Bundle, processed accoding
|
147
142
|
# to SMART Health Cards framework and any constraints created by subclasses
|
148
143
|
def strip_fhir_bundle
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
144
|
+
return [] unless bundle.entry
|
145
|
+
|
146
|
+
new_bundle = duplicate_bundle
|
147
|
+
url_map = redefine_uris(new_bundle)
|
148
|
+
|
149
|
+
new_bundle.entry.each do |entry|
|
150
|
+
entry.each_element do |value, metadata, _|
|
151
|
+
case metadata['type']
|
152
|
+
when 'Reference'
|
153
|
+
value.reference = process_reference(url_map, entry, value)
|
154
|
+
when 'Resource'
|
155
|
+
value.meta = nil unless value.meta&.security
|
156
|
+
end
|
157
|
+
|
158
|
+
handle_allowable(value)
|
159
|
+
handle_disallowable(value)
|
160
|
+
end
|
154
161
|
end
|
155
|
-
|
162
|
+
|
163
|
+
new_bundle
|
156
164
|
end
|
157
165
|
|
158
166
|
private
|
159
167
|
|
160
|
-
def
|
168
|
+
def duplicate_bundle
|
169
|
+
FHIR::Bundle.new(bundle.to_hash)
|
170
|
+
end
|
171
|
+
|
172
|
+
def redefine_uris(bundle)
|
161
173
|
url_map = {}
|
162
174
|
resource_count = 0
|
163
|
-
|
164
|
-
old_url = entry
|
175
|
+
bundle.entry.each do |entry|
|
176
|
+
old_url = entry.fullUrl
|
165
177
|
new_url = "resource:#{resource_count}"
|
166
178
|
url_map[old_url] = new_url
|
167
|
-
entry
|
179
|
+
entry.fullUrl = new_url
|
168
180
|
resource_count += 1
|
169
181
|
end
|
170
|
-
|
182
|
+
url_map
|
171
183
|
end
|
172
184
|
|
173
|
-
def
|
174
|
-
|
175
|
-
|
185
|
+
def process_reference(url_map, entry, ref)
|
186
|
+
entry_url = URI(url_map.key(entry.fullUrl))
|
187
|
+
ref_url = ref.reference
|
176
188
|
|
177
|
-
|
178
|
-
resource.delete('text')
|
179
|
-
if resource.dig('meta', 'security')
|
180
|
-
resource['meta'] = resource['meta'].slice('security')
|
181
|
-
else
|
182
|
-
resource.delete('meta')
|
183
|
-
end
|
184
|
-
handle_allowable(resource)
|
185
|
-
update_nested_elements(resource)
|
186
|
-
end
|
187
|
-
end
|
189
|
+
return unless ref_url
|
188
190
|
|
189
|
-
|
190
|
-
allowable = self.class.allowable[resource['resourceType']]
|
191
|
-
return resource unless allowable
|
191
|
+
return url_map[ref_url] if url_map[ref_url]
|
192
192
|
|
193
|
-
|
194
|
-
|
195
|
-
end
|
193
|
+
fhir_base_url = FHIR_REF_REGEX.match(entry_url.to_s)[1]
|
194
|
+
full_url = URI.join(fhir_base_url, ref_url).to_s
|
196
195
|
|
197
|
-
|
198
|
-
new_url = @url_map.key?(url) ? @url_map[url] : @url_map["#{issuer}/#{url}"]
|
199
|
-
raise InvalidBundleReferenceException, url unless new_url
|
196
|
+
new_url = url_map[full_url]
|
200
197
|
|
201
|
-
new_url
|
202
|
-
end
|
203
|
-
|
204
|
-
def update_nested_elements(hash)
|
205
|
-
hash.map { |k, v| update_nested_element(hash, k, v) }
|
206
|
-
end
|
198
|
+
raise InvalidBundleReferenceError, full_url unless new_url
|
207
199
|
|
208
|
-
|
209
|
-
case value
|
210
|
-
when String
|
211
|
-
hash[attribute_name] = process_url(value) if attribute_name == 'reference'
|
212
|
-
when Hash
|
213
|
-
value.delete('text') if attribute_name.include?('CodeableConcept') || value.key?('coding')
|
214
|
-
update_nested_elements(value)
|
215
|
-
when Array
|
216
|
-
value.each do |member_element|
|
217
|
-
member_element.delete('display') if attribute_name == 'coding'
|
218
|
-
update_nested_elements(member_element) if member_element.is_a?(Hash)
|
219
|
-
end
|
220
|
-
end
|
200
|
+
new_url
|
221
201
|
end
|
222
202
|
end
|
223
203
|
end
|
@@ -3,23 +3,40 @@
|
|
3
3
|
module HealthCards
|
4
4
|
# Converts a JWS to formats needed by endpoints (e.g. $issue-health-card, download and qr code)
|
5
5
|
module Importer
|
6
|
-
extend Chunking
|
7
|
-
|
8
6
|
# Import JWS from file upload
|
9
7
|
# @param [String] JSON string containing file upload contents
|
10
|
-
# @return [Array<
|
8
|
+
# @return [Array<Hash>] An array of Hashes containing JWS payload and verification contents
|
11
9
|
def self.upload(jws_string)
|
12
10
|
vc = JSON.parse(jws_string)
|
13
11
|
vc_jws = vc['verifiableCredential']
|
14
12
|
vc_jws.map do |j|
|
15
|
-
|
16
|
-
HealthCard.decompress_payload(jws.payload)
|
13
|
+
verify_jws j
|
17
14
|
end
|
18
15
|
end
|
19
16
|
|
17
|
+
# Scan QR code
|
18
|
+
# @param [Array<String>] Array containing numeric QR chunks
|
19
|
+
# @return [Hash] Hash containing the JWS payload and verification contents
|
20
20
|
def self.scan(qr_contents)
|
21
|
-
|
22
|
-
|
21
|
+
qr_codes = QRCodes.new(qr_contents)
|
22
|
+
verify_jws qr_codes.to_jws
|
23
|
+
end
|
24
|
+
|
25
|
+
# Verify JWS signature
|
26
|
+
# @param [String] JWS string
|
27
|
+
# @return [Hash] Hash containing the JWS payload and verification contents
|
28
|
+
private_class_method def self.verify_jws(jws_string)
|
29
|
+
jws = JWS.from_jws jws_string
|
30
|
+
result = { payload: HealthCard.decompress_payload(jws.payload) }
|
31
|
+
begin
|
32
|
+
result[:verified] = Verifier.verify jws
|
33
|
+
result[:error_message] = 'Signature Invalid' if result[:verified] == false
|
34
|
+
rescue MissingPublicKeyError, UnresolvableKeySetError => e
|
35
|
+
result[:verified] = false
|
36
|
+
result[:error_message] = e.message
|
37
|
+
end
|
38
|
+
|
39
|
+
result
|
23
40
|
end
|
24
41
|
end
|
25
42
|
end
|
data/lib/health_cards/jws.rb
CHANGED
@@ -82,7 +82,7 @@ module HealthCards
|
|
82
82
|
def signature
|
83
83
|
return @signature if @signature
|
84
84
|
|
85
|
-
raise
|
85
|
+
raise MissingPrivateKeyError unless key
|
86
86
|
|
87
87
|
@signature ||= key.sign(jws_signing_input)
|
88
88
|
end
|
@@ -97,7 +97,7 @@ module HealthCards
|
|
97
97
|
#
|
98
98
|
# @return [Boolean]
|
99
99
|
def verify
|
100
|
-
raise
|
100
|
+
raise MissingPublicKeyError unless public_key
|
101
101
|
|
102
102
|
public_key.verify(jws_signing_input, signature)
|
103
103
|
end
|
data/lib/health_cards/key.rb
CHANGED
@@ -12,8 +12,8 @@ module HealthCards
|
|
12
12
|
# Checks if obj is the the correct key type or nil
|
13
13
|
# @param obj Object that should be of same type as caller or nil
|
14
14
|
# @param allow_nil Allow/Disallow key to be nil
|
15
|
-
def self.enforce_valid_key_type!(obj, allow_nil:
|
16
|
-
raise
|
15
|
+
def self.enforce_valid_key_type!(obj, allow_nil: false)
|
16
|
+
raise InvalidKeyError.new(self, obj) unless obj.is_a?(self) || (allow_nil && obj.nil?)
|
17
17
|
end
|
18
18
|
|
19
19
|
# Create a key from a JWK
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rqrcode'
|
4
|
+
|
5
|
+
module HealthCards
|
6
|
+
# Implements QR Code chunking in ruby
|
7
|
+
class QRCodes
|
8
|
+
attr_reader :chunks
|
9
|
+
|
10
|
+
# Creates a QRCodes from a JWS
|
11
|
+
# @param jws [String] the JWS string
|
12
|
+
# @return [HealthCards::QRCodes]
|
13
|
+
def self.from_jws(jws)
|
14
|
+
QRCodes.new(ChunkingUtils.jws_to_qr_chunks(jws.to_s))
|
15
|
+
end
|
16
|
+
|
17
|
+
# Creates a QRCodes from a set of encoded chunks
|
18
|
+
# @param chunks [Array<String>] An array of QR Code chunks as a string
|
19
|
+
def initialize(chunks)
|
20
|
+
@chunks = chunks.sort.map.with_index(1) { |ch, i| Chunk.new(ordinal: i, input: ch) }
|
21
|
+
end
|
22
|
+
|
23
|
+
# Find a single QR Code chunk from this collection based on its ordinal position
|
24
|
+
# @return [HealthCards::Chunk] A single QRCode chunk
|
25
|
+
def code_by_ordinal(num)
|
26
|
+
chunks.find { |ch| ch.ordinal == num }
|
27
|
+
end
|
28
|
+
|
29
|
+
# Combine all chunks and decodes it into a JWS object
|
30
|
+
# @return [HealthCards::JWS] JWS object that the chunks combine to create
|
31
|
+
def to_jws
|
32
|
+
jws_string = ChunkingUtils.qr_chunks_to_jws(chunks.map(&:data))
|
33
|
+
JWS.from_jws(jws_string)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -15,7 +15,10 @@ module HealthCards
|
|
15
15
|
key_set.add_keys(resolve_key(jws)) if resolve_keys && key_set.find_key(jws.kid).nil?
|
16
16
|
|
17
17
|
key = key_set.find_key(jws.kid)
|
18
|
-
|
18
|
+
unless key
|
19
|
+
raise MissingPublicKeyError,
|
20
|
+
'Verifier does not contain public key that is able to verify this signature'
|
21
|
+
end
|
19
22
|
|
20
23
|
jws.public_key = key
|
21
24
|
jws.verify
|
@@ -25,8 +28,12 @@ module HealthCards
|
|
25
28
|
# @param jws [HealthCards::JWS, String] The JWS for which to resolve keys
|
26
29
|
# @return [HealthCards::KeySet]
|
27
30
|
def resolve_key(jws)
|
28
|
-
|
31
|
+
jwks_uri = URI("#{HealthCard.from_jws(jws.to_s).issuer}/.well-known/jwks.json")
|
32
|
+
res = Net::HTTP.get(jwks_uri)
|
29
33
|
HealthCards::KeySet.from_jwks(res)
|
34
|
+
# Handle response if key is malformed or unreachable
|
35
|
+
rescue StandardError => e
|
36
|
+
raise HealthCards::UnresolvableKeySetError, "Unable resolve a valid key from uri #{jwks_uri}: #{e.message}"
|
30
37
|
end
|
31
38
|
end
|
32
39
|
end
|
data/lib/health_cards/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,21 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: health_cards
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Reece Adamson
|
8
|
+
- Samuel Sayer
|
9
|
+
- Stephen MacVicar
|
10
|
+
- Neelima Karipineni
|
11
|
+
- Daniel Lee
|
12
|
+
- Mick O'Hanlon
|
13
|
+
- Priyank Madria
|
14
|
+
- Shaumik Ashraf
|
8
15
|
autorequire:
|
9
16
|
bindir: exe
|
10
17
|
cert_chain: []
|
11
|
-
date: 2021-
|
18
|
+
date: 2021-07-09 00:00:00.000000000 Z
|
12
19
|
dependencies:
|
13
20
|
- !ruby/object:Gem::Dependency
|
14
21
|
name: fhir_models
|
@@ -16,16 +23,16 @@ dependencies:
|
|
16
23
|
requirements:
|
17
24
|
- - ">="
|
18
25
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
26
|
+
version: 4.0.0
|
20
27
|
type: :runtime
|
21
28
|
prerelease: false
|
22
29
|
version_requirements: !ruby/object:Gem::Requirement
|
23
30
|
requirements:
|
24
31
|
- - ">="
|
25
32
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
33
|
+
version: 4.0.0
|
27
34
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
35
|
+
name: rqrcode
|
29
36
|
requirement: !ruby/object:Gem::Requirement
|
30
37
|
requirements:
|
31
38
|
- - ">="
|
@@ -38,7 +45,12 @@ dependencies:
|
|
38
45
|
- - ">="
|
39
46
|
- !ruby/object:Gem::Version
|
40
47
|
version: '0'
|
41
|
-
description:
|
48
|
+
description: "Health Cards implements SMART Health Cards, a secure and decentralized
|
49
|
+
framework that allows \npeople to prove their vaccination status or medical test
|
50
|
+
results. It is built on top of FHIR R4 \nhealth data exchange standards and W3C
|
51
|
+
Verifiable Credentials. It allows conversion of\nclinical data into JWS which may
|
52
|
+
then be embedded into QR codes, exported to smart-health-card files, or\nreturned
|
53
|
+
by a $health-card-issue FHIR operation.\n"
|
42
54
|
email:
|
43
55
|
- radamson@mitre.org
|
44
56
|
executables: []
|
@@ -47,10 +59,13 @@ extra_rdoc_files: []
|
|
47
59
|
files:
|
48
60
|
- LICENSE.txt
|
49
61
|
- lib/health_cards.rb
|
50
|
-
- lib/health_cards/
|
62
|
+
- lib/health_cards/attribute_filters.rb
|
63
|
+
- lib/health_cards/card_types.rb
|
64
|
+
- lib/health_cards/chunk.rb
|
65
|
+
- lib/health_cards/chunking_utils.rb
|
51
66
|
- lib/health_cards/covid_health_card.rb
|
52
67
|
- lib/health_cards/encoding.rb
|
53
|
-
- lib/health_cards/
|
68
|
+
- lib/health_cards/errors.rb
|
54
69
|
- lib/health_cards/exporter.rb
|
55
70
|
- lib/health_cards/health_card.rb
|
56
71
|
- lib/health_cards/importer.rb
|
@@ -60,12 +75,13 @@ files:
|
|
60
75
|
- lib/health_cards/key_set.rb
|
61
76
|
- lib/health_cards/private_key.rb
|
62
77
|
- lib/health_cards/public_key.rb
|
78
|
+
- lib/health_cards/qr_codes.rb
|
63
79
|
- lib/health_cards/verification.rb
|
64
80
|
- lib/health_cards/verifier.rb
|
65
81
|
- lib/health_cards/version.rb
|
66
82
|
homepage: https://github.com/dvci/health-cards
|
67
83
|
licenses:
|
68
|
-
- Apache
|
84
|
+
- Apache-2.0
|
69
85
|
metadata:
|
70
86
|
homepage_uri: https://github.com/dvci/health-cards
|
71
87
|
source_code_uri: https://github.com/dvci/health_cards
|
@@ -85,8 +101,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
101
|
- !ruby/object:Gem::Version
|
86
102
|
version: '0'
|
87
103
|
requirements: []
|
88
|
-
rubygems_version: 3.
|
104
|
+
rubygems_version: 3.1.6
|
89
105
|
signing_key:
|
90
106
|
specification_version: 4
|
91
|
-
summary: Create SMART Health Cards
|
107
|
+
summary: Create verifiable clinical data using SMART Health Cards.
|
92
108
|
test_files: []
|