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 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: []