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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f299c1c4dec501f53d77e64007a9805316974ea5e649b1918f413e52ffc0dbf4
4
- data.tar.gz: 118a46c61178a67461d0ee3fbeedab15ea4b0f93433ece7add27aceb1f398e0c
3
+ metadata.gz: c6e94c49814ad651b6377277252fef1276ca99fa3b0704171f31571a986da62e
4
+ data.tar.gz: 9a3a9b0e599a196d70d498089b23efb2479cece350f3bd1cb430ffbe4f22a32b
5
5
  SHA512:
6
- metadata.gz: d9765d939d22cd01ff11b5c20a29d413af83124970ecb06cf89abc0b7cff393080ae12a908e22de411b991aa05ecaaddeb2023a9192aaab741379ecffd9a5c4a
7
- data.tar.gz: 1102f68ec1d832766ecd37c15fd790ff3ab0e7d8505468dcf210d3b97d516900d5876a2aa7e5de963bad9667b4c48d654b5fcec733f3b3a141dd4f84b9d19097
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/chunking'
13
- require 'health_cards/exceptions'
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 Chunking
5
+ module ChunkingUtils
6
6
  extend self
7
7
  MAX_SINGLE_JWS_SIZE = 1195
8
8
  MAX_CHUNK_SIZE = 1191
9
9
 
10
- def split_bundle(jws)
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 generate_qr_chunks(jws)
22
- jws_chunks = split_bundle jws.to_s
23
- jws_chunks.map { |c| convert_jws_to_numeric(c) }
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 assemble_jws(qr_chunks)
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 MissingPrivateKey < StandardError
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 MissingPublicKey < StandardError
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 InvalidPayloadException < ArgumentError
20
- def initialize(msg = 'Bundle must be a FHIR::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 InvalidCredentialException < ArgumentError
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 InvalidBundleReferenceException < ArgumentError
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
- extend Chunking
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
- # Export JWS for file download
9
- # @param [Array<JWS, String>] An array of JWS objects to be exported
10
- # @return [String] JSON string containing file download contents
11
- def self.download(jws)
12
- { verifiableCredential: jws.map(&:to_s) }.to_json
13
- end
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
- # Export JWS for $issue-health-card endpoint
16
- # @param [Array<WJS, String] An array of JWS objects to be exported
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
- VC_TYPE = [
10
- 'https://healthwallet.cards#health-card'
11
- ].freeze
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::InvalidCredentialException unless bundle_hash
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
- # Additional type claims this HealthCard class supports
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
- # Check if this class supports the given type claim(s)
96
- # @param type [Array, String] A type as defined by the SMART Health Cards framework
97
- # @return [Boolean] Whether or not the type param is included in the types supported by the HealthCard (sub)class
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 InvalidPayloadException unless bundle.is_a?(FHIR::Bundle)
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
- JSON.minify(to_hash.to_json)
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
- stripped_bundle = @bundle.to_hash
150
- if stripped_bundle.key?('entry') && !stripped_bundle['entry'].empty?
151
- entries = stripped_bundle['entry']
152
- entries, @url_map = redefine_uris(entries)
153
- update_elements(entries)
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
- stripped_bundle
162
+
163
+ new_bundle
156
164
  end
157
165
 
158
166
  private
159
167
 
160
- def redefine_uris(entries)
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
- entries.each do |entry|
164
- old_url = entry['fullUrl']
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['fullUrl'] = new_url
179
+ entry.fullUrl = new_url
168
180
  resource_count += 1
169
181
  end
170
- [entries, url_map]
182
+ url_map
171
183
  end
172
184
 
173
- def update_elements(entries)
174
- entries.each do |entry|
175
- resource = entry['resource']
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
- resource.delete('id')
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
- def handle_allowable(resource)
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
- allow = allowable + ['resourceType']
194
- resource.select! { |att| allow.include?(att) }
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
- def process_url(url)
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
- def update_nested_element(hash, attribute_name, value) # rubocop:disable Metrics/CyclomaticComplexity
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<JWS>] An array of JWS objects
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
- jws = JWS.from_jws(j)
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
- contents = JSON.parse(qr_contents)
22
- get_payload_from_qr contents
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
@@ -82,7 +82,7 @@ module HealthCards
82
82
  def signature
83
83
  return @signature if @signature
84
84
 
85
- raise MissingPrivateKey unless key
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 MissingPublicKey unless public_key
100
+ raise MissingPublicKeyError unless public_key
101
101
 
102
102
  public_key.verify(jws_signing_input, signature)
103
103
  end
@@ -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: false)
16
- raise InvalidKeyException.new(self, obj) unless obj.is_a?(self) || (allow_nil && obj.nil?)
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
- raise MissingPublicKey, 'Verifier does not contain public key that is able to verify this signature' unless key
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
- res = Net::HTTP.get(URI("#{HealthCard.from_jws(jws.to_s).issuer}/.well-known/jwks.json"))
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HealthCards
4
- VERSION = '0.0.1'
4
+ VERSION = '0.0.2'
5
5
  end
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.1
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-05-14 00:00:00.000000000 Z
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: '0'
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: '0'
33
+ version: 4.0.0
27
34
  - !ruby/object:Gem::Dependency
28
- name: json-minify
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: Create SMART Health Cards
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/chunking.rb
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/exceptions.rb
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 2.0
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.2.15
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: []