health_cards 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|