openid4vp 0.1.0

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.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +278 -0
  5. data/lib/openid4vp/authorization_request/validator.rb +177 -0
  6. data/lib/openid4vp/authorization_request.rb +302 -0
  7. data/lib/openid4vp/authorization_response.rb +151 -0
  8. data/lib/openid4vp/client_id.rb +90 -0
  9. data/lib/openid4vp/credential.rb +51 -0
  10. data/lib/openid4vp/dc_api.rb +116 -0
  11. data/lib/openid4vp/dcql/claims_path_pointer.rb +109 -0
  12. data/lib/openid4vp/dcql/claims_query.rb +76 -0
  13. data/lib/openid4vp/dcql/credential_query.rb +178 -0
  14. data/lib/openid4vp/dcql/credential_set_query.rb +49 -0
  15. data/lib/openid4vp/dcql/evaluator.rb +228 -0
  16. data/lib/openid4vp/dcql/matcher.rb +79 -0
  17. data/lib/openid4vp/dcql/meta.rb +99 -0
  18. data/lib/openid4vp/dcql/query.rb +93 -0
  19. data/lib/openid4vp/dcql/selection.rb +45 -0
  20. data/lib/openid4vp/dcql/trusted_authorities_query.rb +48 -0
  21. data/lib/openid4vp/dcql.rb +10 -0
  22. data/lib/openid4vp/encoded_response.rb +10 -0
  23. data/lib/openid4vp/error_response.rb +82 -0
  24. data/lib/openid4vp/errors.rb +73 -0
  25. data/lib/openid4vp/formats/base.rb +74 -0
  26. data/lib/openid4vp/formats/context.rb +56 -0
  27. data/lib/openid4vp/formats/jwt_vc_json.rb +274 -0
  28. data/lib/openid4vp/formats/ldp_vc.rb +160 -0
  29. data/lib/openid4vp/formats/mso_mdoc.rb +204 -0
  30. data/lib/openid4vp/formats/registry.rb +53 -0
  31. data/lib/openid4vp/formats/sd_jwt_vc.rb +132 -0
  32. data/lib/openid4vp/formats/verified_presentation.rb +45 -0
  33. data/lib/openid4vp/formats.rb +12 -0
  34. data/lib/openid4vp/jose/algorithms.rb +46 -0
  35. data/lib/openid4vp/jose/jwe.rb +184 -0
  36. data/lib/openid4vp/jose/jwk.rb +253 -0
  37. data/lib/openid4vp/jose/jwk_set.rb +57 -0
  38. data/lib/openid4vp/jose/jws.rb +225 -0
  39. data/lib/openid4vp/jose.rb +8 -0
  40. data/lib/openid4vp/mdoc/builder.rb +132 -0
  41. data/lib/openid4vp/mdoc/cbor_values.rb +32 -0
  42. data/lib/openid4vp/mdoc/cose_key.rb +71 -0
  43. data/lib/openid4vp/mdoc/device_authentication.rb +32 -0
  44. data/lib/openid4vp/mdoc/device_response.rb +77 -0
  45. data/lib/openid4vp/mdoc/device_signed.rb +111 -0
  46. data/lib/openid4vp/mdoc/document.rb +44 -0
  47. data/lib/openid4vp/mdoc/handover.rb +51 -0
  48. data/lib/openid4vp/mdoc/issuer_signed.rb +157 -0
  49. data/lib/openid4vp/mdoc/mso.rb +133 -0
  50. data/lib/openid4vp/mdoc/session_transcript.rb +49 -0
  51. data/lib/openid4vp/mdoc/sign1_decoder.rb +60 -0
  52. data/lib/openid4vp/mdoc/verifier.rb +169 -0
  53. data/lib/openid4vp/mdoc.rb +9 -0
  54. data/lib/openid4vp/metadata/verifier_metadata.rb +109 -0
  55. data/lib/openid4vp/metadata/vp_formats.rb +112 -0
  56. data/lib/openid4vp/metadata/wallet_metadata.rb +139 -0
  57. data/lib/openid4vp/metadata.rb +10 -0
  58. data/lib/openid4vp/request_object.rb +152 -0
  59. data/lib/openid4vp/request_verification/base.rb +63 -0
  60. data/lib/openid4vp/request_verification/config.rb +65 -0
  61. data/lib/openid4vp/request_verification/decentralized_identifier.rb +56 -0
  62. data/lib/openid4vp/request_verification/openid_federation.rb +52 -0
  63. data/lib/openid4vp/request_verification/pre_registered.rb +54 -0
  64. data/lib/openid4vp/request_verification/redirect_uri.rb +29 -0
  65. data/lib/openid4vp/request_verification/resolver.rb +76 -0
  66. data/lib/openid4vp/request_verification/verified_request.rb +12 -0
  67. data/lib/openid4vp/request_verification/verifier_attestation.rb +54 -0
  68. data/lib/openid4vp/request_verification/x509_hash.rb +40 -0
  69. data/lib/openid4vp/request_verification/x509_san_dns.rb +60 -0
  70. data/lib/openid4vp/request_verification.rb +10 -0
  71. data/lib/openid4vp/response_encryption.rb +51 -0
  72. data/lib/openid4vp/response_endpoint_result.rb +33 -0
  73. data/lib/openid4vp/response_mode.rb +44 -0
  74. data/lib/openid4vp/response_type.rb +7 -0
  75. data/lib/openid4vp/sd_jwt/digest.rb +30 -0
  76. data/lib/openid4vp/sd_jwt/disclosure.rb +104 -0
  77. data/lib/openid4vp/sd_jwt/issuer.rb +132 -0
  78. data/lib/openid4vp/sd_jwt/kb_jwt.rb +82 -0
  79. data/lib/openid4vp/sd_jwt/parser.rb +48 -0
  80. data/lib/openid4vp/sd_jwt/presentation.rb +333 -0
  81. data/lib/openid4vp/sd_jwt/reconstruct.rb +132 -0
  82. data/lib/openid4vp/sd_jwt.rb +10 -0
  83. data/lib/openid4vp/transaction_data/registry.rb +42 -0
  84. data/lib/openid4vp/transaction_data/sd_jwt_hashes.rb +124 -0
  85. data/lib/openid4vp/transaction_data.rb +138 -0
  86. data/lib/openid4vp/util/base64url.rb +44 -0
  87. data/lib/openid4vp/util/form.rb +24 -0
  88. data/lib/openid4vp/util/random.rb +29 -0
  89. data/lib/openid4vp/util/uri.rb +48 -0
  90. data/lib/openid4vp/util.rb +23 -0
  91. data/lib/openid4vp/verifier/config.rb +216 -0
  92. data/lib/openid4vp/verifier/created_request.rb +25 -0
  93. data/lib/openid4vp/verifier/http_result.rb +34 -0
  94. data/lib/openid4vp/verifier/memory_session_store.rb +102 -0
  95. data/lib/openid4vp/verifier/request_builder.rb +216 -0
  96. data/lib/openid4vp/verifier/request_signer.rb +59 -0
  97. data/lib/openid4vp/verifier/request_uri_endpoint.rb +151 -0
  98. data/lib/openid4vp/verifier/response_endpoint.rb +197 -0
  99. data/lib/openid4vp/verifier/response_validator.rb +258 -0
  100. data/lib/openid4vp/verifier/session.rb +109 -0
  101. data/lib/openid4vp/verifier/session_store.rb +48 -0
  102. data/lib/openid4vp/verifier/verified_response.rb +53 -0
  103. data/lib/openid4vp/verifier.rb +155 -0
  104. data/lib/openid4vp/verifier_attestation.rb +130 -0
  105. data/lib/openid4vp/verifier_info.rb +102 -0
  106. data/lib/openid4vp/version.rb +5 -0
  107. data/lib/openid4vp/vp_token.rb +81 -0
  108. data/lib/openid4vp/wallet/config.rb +49 -0
  109. data/lib/openid4vp/wallet/http_client.rb +73 -0
  110. data/lib/openid4vp/wallet/presenter.rb +90 -0
  111. data/lib/openid4vp/wallet/processed_request.rb +14 -0
  112. data/lib/openid4vp/wallet/request_processor.rb +237 -0
  113. data/lib/openid4vp/wallet/response_builder.rb +44 -0
  114. data/lib/openid4vp/wallet/submission.rb +68 -0
  115. data/lib/openid4vp/wallet.rb +142 -0
  116. data/lib/openid4vp/x509/chain_validator.rb +42 -0
  117. data/lib/openid4vp/x509.rb +103 -0
  118. data/lib/openid4vp.rb +18 -0
  119. metadata +233 -0
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+ require_relative "../errors"
5
+ require_relative "../credential"
6
+ require_relative "meta"
7
+ require_relative "matcher"
8
+ require_relative "selection"
9
+
10
+ module OpenID4VP
11
+ module DCQL
12
+ # OpenID4VP §6.4: evaluates a DCQL Query against a Wallet's available
13
+ # Credentials, selecting matching Credentials and claims (§6.4.1,
14
+ # §6.4.2). Also verifies, from the Verifier side, that a decoded
15
+ # response actually satisfies a Query (`.check_response!`).
16
+ class Evaluator
17
+ def initialize(query, credentials, hooks: {})
18
+ @query = query
19
+ @credentials = credentials
20
+ @hooks = hooks
21
+ end
22
+
23
+ def evaluate
24
+ matches_by_id = @query.credentials.to_h do |credential_query|
25
+ [credential_query.id, matches_for(credential_query)]
26
+ end
27
+
28
+ @query.credential_sets ? select_with_sets(matches_by_id) : select_without_sets(matches_by_id)
29
+ end
30
+
31
+ class << self
32
+ # presentations: {credential_query_id => [presentation]}, each
33
+ # presentation responding to #format and #claims. Raises
34
+ # OpenID4VP::QueryNotSatisfied naming the id/reason, or returns true.
35
+ # Named `!` (raises) rather than `?` per the DCQL evaluator interface.
36
+ # rubocop:disable-next Naming/PredicateMethod
37
+ def check_response!(query, presentations)
38
+ check_ids_known!(query, presentations)
39
+ check_multiple_and_claims!(query, presentations)
40
+ check_credential_sets!(query, presentations)
41
+ true
42
+ end
43
+
44
+ private
45
+
46
+ def check_ids_known!(query, presentations)
47
+ presentations.each_key do |id|
48
+ next if query.ids.include?(id)
49
+
50
+ raise OpenID4VP::QueryNotSatisfied, "response includes unknown credential id #{id.inspect}"
51
+ end
52
+ end
53
+
54
+ def check_multiple_and_claims!(query, presentations)
55
+ query.credentials.each do |credential_query|
56
+ creds = presentations[credential_query.id]
57
+ next if creds.nil? || creds.empty?
58
+
59
+ if !credential_query.multiple && creds.size != 1
60
+ raise OpenID4VP::QueryNotSatisfied,
61
+ "credential id #{credential_query.id.inspect} expects exactly one presentation, got #{creds.size}"
62
+ end
63
+
64
+ creds.each { |presentation| check_claims!(credential_query, presentation) }
65
+ end
66
+ end
67
+
68
+ def check_claims!(credential_query, presentation)
69
+ credential = OpenID4VP::Credential.new(format: presentation.format, claims: presentation.claims)
70
+ return if credential_query.claims.nil?
71
+
72
+ if credential_query.claim_sets.nil?
73
+ check_all_claims_present!(credential_query, credential)
74
+ else
75
+ check_claim_sets_satisfied!(credential_query, credential)
76
+ end
77
+ end
78
+
79
+ def check_all_claims_present!(credential_query, credential)
80
+ missing = credential_query.claims.reject { |claims_query| Matcher.claim_present?(claims_query, credential) }
81
+ return if missing.empty?
82
+
83
+ raise OpenID4VP::QueryNotSatisfied,
84
+ "credential id #{credential_query.id.inspect} is missing claim(s) #{missing.map(&:id).inspect}"
85
+ end
86
+
87
+ def check_claim_sets_satisfied!(credential_query, credential)
88
+ satisfied = credential_query.claim_sets.any? do |ids|
89
+ ids.all? { |id| Matcher.claim_present?(credential_query.claim(id), credential) }
90
+ end
91
+ return if satisfied
92
+
93
+ raise OpenID4VP::QueryNotSatisfied,
94
+ "credential id #{credential_query.id.inspect} does not satisfy any claim_sets option"
95
+ end
96
+
97
+ def check_credential_sets!(query, presentations)
98
+ satisfied_ids = presentations.reject { |_id, creds| creds.nil? || creds.empty? }.keys
99
+
100
+ if query.credential_sets.nil?
101
+ missing = query.ids - satisfied_ids
102
+ return if missing.empty?
103
+
104
+ raise OpenID4VP::QueryNotSatisfied, "response is missing required credential(s) #{missing.inspect}"
105
+ end
106
+
107
+ query.credential_sets.each do |credential_set_query|
108
+ next unless credential_set_query.required
109
+ next if credential_set_query.options.any? { |ids| ids.all? { |id| satisfied_ids.include?(id) } }
110
+
111
+ raise OpenID4VP::QueryNotSatisfied,
112
+ "response does not satisfy required credential_set options #{credential_set_query.options.inspect}"
113
+ end
114
+
115
+ check_unreferenced_ids!(query, satisfied_ids)
116
+ end
117
+
118
+ # Ids not referenced by any credential_sets option are always
119
+ # required, mirroring Evaluator#select_unreferenced_ids.
120
+ def check_unreferenced_ids!(query, satisfied_ids)
121
+ referenced = query.credential_sets.flat_map { |cs| cs.options.flatten(1) }.uniq
122
+ missing = query.ids - referenced - satisfied_ids
123
+ return if missing.empty?
124
+
125
+ raise OpenID4VP::QueryNotSatisfied,
126
+ "response is missing required credential(s) #{missing.inspect}"
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def matches_for(credential_query)
133
+ candidates = @credentials.select { |credential| candidate?(credential_query, credential) }
134
+ matches = candidates.filter_map { |credential| build_match(credential_query, credential) }
135
+ credential_query.multiple ? matches : matches.first(1)
136
+ end
137
+
138
+ def candidate?(credential_query, credential)
139
+ return false unless credential.format == credential_query.format
140
+ return false unless Meta.matches?(credential_query.format, credential_query.meta, credential)
141
+ return false unless trusted_authorities_ok?(credential_query, credential)
142
+ return true if credential_query.require_cryptographic_holder_binding == false
143
+
144
+ credential.holder_bindable?
145
+ end
146
+
147
+ def trusted_authorities_ok?(credential_query, credential)
148
+ return true unless credential_query.trusted_authorities
149
+
150
+ credential_query.trusted_authorities.any? { |ta| Matcher.authority_matches?(ta, credential, hooks: @hooks) }
151
+ end
152
+
153
+ def build_match(credential_query, credential)
154
+ if credential_query.claims.nil?
155
+ Match.new(credential: credential, claims: [], credential_query: credential_query)
156
+ elsif credential_query.claim_sets.nil?
157
+ build_match_all_claims(credential_query, credential)
158
+ else
159
+ build_match_claim_sets(credential_query, credential)
160
+ end
161
+ end
162
+
163
+ def build_match_all_claims(credential_query, credential)
164
+ all_present = credential_query.claims.all? { |claims_query| Matcher.claim_present?(claims_query, credential) }
165
+ return nil unless all_present
166
+
167
+ Match.new(credential: credential, claims: credential_query.claims, credential_query: credential_query)
168
+ end
169
+
170
+ def build_match_claim_sets(credential_query, credential)
171
+ option = credential_query.claim_sets.find do |ids|
172
+ ids.all? { |id| Matcher.claim_present?(credential_query.claim(id), credential) }
173
+ end
174
+ return nil unless option
175
+
176
+ selected_claims = option.map { |id| credential_query.claim(id) }
177
+ Match.new(credential: credential, claims: selected_claims, credential_query: credential_query)
178
+ end
179
+
180
+ def select_without_sets(matches_by_id)
181
+ chosen = {}
182
+ missing = []
183
+ matches_by_id.each do |id, matches|
184
+ matches.empty? ? missing << id : chosen[id] = matches
185
+ end
186
+
187
+ Selection.new(matches: chosen, missing: missing)
188
+ end
189
+
190
+ def select_with_sets(matches_by_id)
191
+ chosen = {}
192
+ missing = []
193
+ satisfied_sets = []
194
+ unsatisfied_required_sets = []
195
+
196
+ @query.credential_sets.each do |credential_set_query|
197
+ option = credential_set_query.options.find do |ids|
198
+ ids.all? { |id| matches_by_id[id] && !matches_by_id[id].empty? }
199
+ end
200
+ if option
201
+ satisfied_sets << credential_set_query
202
+ option.each { |id| chosen[id] = matches_by_id[id] }
203
+ elsif credential_set_query.required
204
+ unsatisfied_required_sets << credential_set_query
205
+ missing.concat(credential_set_query.options.first)
206
+ end
207
+ end
208
+
209
+ select_unreferenced_ids(matches_by_id, chosen, missing)
210
+
211
+ Selection.new(
212
+ matches: chosen, missing: missing.uniq,
213
+ satisfied_sets: satisfied_sets, unsatisfied_required_sets: unsatisfied_required_sets
214
+ )
215
+ end
216
+
217
+ # Ids not referenced by any credential_sets option are always
218
+ # required, mirroring the no-credential_sets behavior for them.
219
+ def select_unreferenced_ids(matches_by_id, chosen, missing)
220
+ referenced = @query.credential_sets.flat_map { |cs| cs.options.flatten(1) }.uniq
221
+ (@query.ids - referenced).each do |id|
222
+ matches = matches_by_id[id]
223
+ matches && !matches.empty? ? chosen[id] = matches : missing << id
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+ require_relative "../errors"
5
+ require_relative "claims_path_pointer"
6
+ require_relative "../x509"
7
+ require_relative "../util/base64url"
8
+
9
+ module OpenID4VP
10
+ module DCQL
11
+ # OpenID4VP §6.3-6.4 claim presence/value matching and §6.1.1 trusted
12
+ # authorities matching used by `DCQL::Evaluator`.
13
+ module Matcher
14
+ module_function
15
+
16
+ # §6.3: "the Wallet SHOULD return the claim only if the type and value
17
+ # of the claim both match exactly for at least one of the elements".
18
+ # Plain Ruby `==` is not enough (1 == 1.0), so types must match too.
19
+ def values_match?(values, actual)
20
+ return false if values.nil? || actual.nil?
21
+
22
+ values.any? { |value| value.instance_of?(actual.class) && value == actual }
23
+ end
24
+
25
+ # Returns the selected elements (an Array) for `claims_query` against
26
+ # `credential`, or nil when the claim is absent, its path cannot be
27
+ # resolved, or its `values` restriction does not match. Named per the
28
+ # DCQL evaluator interface, not a boolean predicate.
29
+ # rubocop:disable-next Naming/PredicateMethod, Style/ReturnNilInPredicateMethodDefinition
30
+ def claim_present?(claims_query, credential)
31
+ selected = select_claim(claims_query, credential)
32
+ return nil if selected.nil?
33
+ return nil if claims_query.values && selected.none? { |element| values_match?(claims_query.values, element) }
34
+
35
+ selected
36
+ end
37
+
38
+ # §6.1.1: a Credential matches a single Trusted Authorities Query when
39
+ # it matches one of its `values` under its `type`.
40
+ def authority_matches?(ta_query, credential, hooks: {})
41
+ case ta_query.type
42
+ when "aki" then aki_match?(ta_query, credential)
43
+ else
44
+ hook = hooks[ta_query.type]
45
+ hook ? !!hook.call(ta_query, credential) : false
46
+ end
47
+ end
48
+
49
+ def select_claim(claims_query, credential)
50
+ path = claims_query.path
51
+ if credential.format == "mso_mdoc"
52
+ mdoc_claim(path, credential)
53
+ else
54
+ path.select(credential.claims)
55
+ end
56
+ rescue ClaimsPathPointer::Error
57
+ nil
58
+ end
59
+ private_class_method :select_claim
60
+
61
+ def mdoc_claim(path, credential)
62
+ namespace, element = path.mdoc_components
63
+ value = credential.claims.dig(namespace, element)
64
+ value.nil? ? nil : [value]
65
+ end
66
+ private_class_method :mdoc_claim
67
+
68
+ def aki_match?(ta_query, credential)
69
+ credential.x5chain.any? do |cert|
70
+ aki = OpenID4VP::X509.authority_key_identifier(cert)
71
+ next false unless aki
72
+
73
+ ta_query.values.include?(OpenID4VP::Util::Base64url.encode(aki))
74
+ end
75
+ end
76
+ private_class_method :aki_match?
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+ require_relative "../errors"
5
+
6
+ module OpenID4VP
7
+ module DCQL
8
+ # OpenID4VP Appendix B: format-specific `meta` matching (used by
9
+ # `DCQL::Evaluator`) and validation for DCQL Credential Queries.
10
+ module Meta
11
+ module_function
12
+
13
+ def matches?(format, meta, credential)
14
+ return true if meta.nil? || meta.empty?
15
+
16
+ case format
17
+ when "dc+sd-jwt" then vct_values_match?(meta, credential)
18
+ when "mso_mdoc" then meta["doctype_value"] == credential.metadata["doctype"]
19
+ when "jwt_vc_json", "ldp_vc" then type_values_match?(meta, credential)
20
+ else generic_match?(meta, credential)
21
+ end
22
+ end
23
+
24
+ # Appendix B: validates the REQUIRED shape of format-specific meta
25
+ # parameters. Unknown formats: meta must be a Hash, nothing else
26
+ # checked.
27
+ def validate!(format, meta)
28
+ raise OpenID4VP::InvalidRequest, "meta must be an object" unless meta.is_a?(::Hash)
29
+
30
+ case format
31
+ when "dc+sd-jwt" then validate_vct_values!(meta)
32
+ when "mso_mdoc" then validate_doctype_value!(meta)
33
+ when "jwt_vc_json", "ldp_vc" then validate_type_values!(meta)
34
+ end
35
+ end
36
+
37
+ def vct_values_match?(meta, credential)
38
+ values = meta["vct_values"]
39
+ return true unless values
40
+
41
+ vct = credential.metadata["vct"] || credential.claims["vct"]
42
+ values.include?(vct)
43
+ end
44
+ private_class_method :vct_values_match?
45
+
46
+ def type_values_match?(meta, credential)
47
+ values = meta["type_values"]
48
+ return true unless values
49
+
50
+ types = credential_types(credential)
51
+ return false if types.nil?
52
+
53
+ values.any? { |option| (option - types).empty? }
54
+ end
55
+ private_class_method :type_values_match?
56
+
57
+ def credential_types(credential)
58
+ types = credential.metadata["type"] || credential.claims.dig("vc", "type") || credential.claims["type"]
59
+ return nil if types.nil?
60
+
61
+ types.is_a?(::String) ? [types] : types
62
+ end
63
+ private_class_method :credential_types
64
+
65
+ def generic_match?(meta, credential)
66
+ meta.all? do |key, value|
67
+ actual = credential.metadata[key]
68
+ actual == value || (actual.is_a?(::Array) && actual.include?(value))
69
+ end
70
+ end
71
+ private_class_method :generic_match?
72
+
73
+ def validate_vct_values!(meta)
74
+ values = meta["vct_values"]
75
+ valid = values.is_a?(::Array) && !values.empty? && values.all?(::String)
76
+ raise OpenID4VP::InvalidRequest, "meta.vct_values must be a non-empty array of strings" unless valid
77
+ end
78
+ private_class_method :validate_vct_values!
79
+
80
+ def validate_doctype_value!(meta)
81
+ return if meta["doctype_value"].is_a?(::String) && !meta["doctype_value"].empty?
82
+
83
+ raise OpenID4VP::InvalidRequest, "meta.doctype_value must be a non-empty string"
84
+ end
85
+ private_class_method :validate_doctype_value!
86
+
87
+ def validate_type_values!(meta)
88
+ values = meta["type_values"]
89
+ valid = values.is_a?(::Array) && !values.empty? && values.all? do |inner|
90
+ inner.is_a?(::Array) && !inner.empty? && inner.all?(::String)
91
+ end
92
+ return if valid
93
+
94
+ raise OpenID4VP::InvalidRequest, "meta.type_values must be a non-empty array of non-empty string arrays"
95
+ end
96
+ private_class_method :validate_type_values!
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+ require_relative "../errors"
5
+ require_relative "credential_query"
6
+ require_relative "credential_set_query"
7
+
8
+ module OpenID4VP
9
+ module DCQL
10
+ # OpenID4VP §6: the top-level DCQL query object, e.g. the value of the
11
+ # dcql_query Authorization Request parameter.
12
+ class Query
13
+ KNOWN_KEYS = %w[credentials credential_sets].freeze
14
+
15
+ attr_reader :credentials, :credential_sets, :extra
16
+
17
+ def initialize(credentials:, credential_sets: nil, extra: {})
18
+ @credentials = build_credentials(credentials)
19
+ @credential_sets = build_credential_sets(credential_sets, @credentials)
20
+ @extra = extra.dup.freeze
21
+ end
22
+
23
+ def self.from_h(hash)
24
+ raise OpenID4VP::InvalidRequest, "dcql query must be an object" unless hash.is_a?(::Hash)
25
+
26
+ new(
27
+ credentials: hash["credentials"],
28
+ credential_sets: hash["credential_sets"],
29
+ extra: hash.except(*KNOWN_KEYS)
30
+ )
31
+ end
32
+
33
+ def self.from_json(str)
34
+ from_h(::JSON.parse(str))
35
+ end
36
+
37
+ def credential(id)
38
+ credentials.find { |credential_query| credential_query.id == id }
39
+ end
40
+
41
+ def ids
42
+ credentials.map(&:id)
43
+ end
44
+
45
+ def to_h
46
+ h = { "credentials" => credentials.map(&:to_h) }
47
+ h["credential_sets"] = credential_sets.map(&:to_h) if credential_sets
48
+ h.merge(extra)
49
+ end
50
+
51
+ private
52
+
53
+ def build_credentials(credentials)
54
+ unless credentials.is_a?(::Array) && !credentials.empty?
55
+ raise OpenID4VP::InvalidRequest, "credentials must be a non-empty array"
56
+ end
57
+
58
+ built = credentials.map { |entry| entry.is_a?(CredentialQuery) ? entry : CredentialQuery.from_h(entry) }
59
+
60
+ ids = built.map(&:id)
61
+ raise OpenID4VP::InvalidRequest, "credentials ids must be unique" if ids.uniq.size != ids.size
62
+
63
+ built.freeze
64
+ end
65
+
66
+ def build_credential_sets(credential_sets, credentials)
67
+ return nil if credential_sets.nil?
68
+
69
+ unless credential_sets.is_a?(::Array) && !credential_sets.empty?
70
+ raise OpenID4VP::InvalidRequest, "credential_sets must be a non-empty array"
71
+ end
72
+
73
+ credential_ids = credentials.map(&:id)
74
+ built = credential_sets.map do |entry|
75
+ entry.is_a?(CredentialSetQuery) ? entry : CredentialSetQuery.from_h(entry)
76
+ end
77
+ built.each { |credential_set_query| validate_credential_set_references(credential_set_query, credential_ids) }
78
+
79
+ built.freeze
80
+ end
81
+
82
+ def validate_credential_set_references(credential_set_query, credential_ids)
83
+ credential_set_query.options.each do |option|
84
+ option.each do |id|
85
+ next if credential_ids.include?(id)
86
+
87
+ raise OpenID4VP::InvalidRequest, "credential_sets references unknown credential id #{id.inspect}"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+
5
+ module OpenID4VP
6
+ module DCQL
7
+ # A single Credential chosen for a `CredentialQuery`, together with the
8
+ # `ClaimsQuery` entries (from `claims` or the winning `claim_sets`
9
+ # option) that were selected for it. Carries no claim values: callers
10
+ # resolve those from `credential` via `Matcher.claim_present?`.
11
+ Match = Struct.new(:credential, :claims, :credential_query, keyword_init: true)
12
+
13
+ # OpenID4VP §6.4.2: the result of evaluating a DCQL Query against a set
14
+ # of available Credentials.
15
+ class Selection
16
+ # {credential_query_id => [Match]}, only for ids that are part of the
17
+ # chosen result (with credential_sets, ids only referenced by
18
+ # unsatisfiable optional sets are excluded).
19
+ attr_reader :matches
20
+
21
+ # [credential_query_id], the required ids/options that could not be
22
+ # satisfied.
23
+ attr_reader :missing
24
+
25
+ # [CredentialSetQuery] the credential_sets entries (required or
26
+ # optional) for which a satisfying option was found.
27
+ attr_reader :satisfied_sets
28
+
29
+ # [CredentialSetQuery] the required credential_sets entries for which
30
+ # no option could be satisfied.
31
+ attr_reader :unsatisfied_required_sets
32
+
33
+ def initialize(matches:, missing:, satisfied_sets: [], unsatisfied_required_sets: [])
34
+ @matches = matches
35
+ @missing = missing
36
+ @satisfied_sets = satisfied_sets
37
+ @unsatisfied_required_sets = unsatisfied_required_sets
38
+ end
39
+
40
+ def satisfiable?
41
+ missing.empty? && unsatisfied_required_sets.empty?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+ require_relative "../errors"
5
+
6
+ module OpenID4VP
7
+ module DCQL
8
+ # OpenID4VP §6.1.1: identifies an authority or trust framework that
9
+ # certifies Issuers a Credential must match.
10
+ class TrustedAuthoritiesQuery
11
+ # Type Identifiers defined by the specification (§6.1.1.1-3). Other
12
+ # string values are accepted and preserved for future extensions.
13
+ TYPES = %w[aki etsi_tl openid_federation].freeze
14
+
15
+ KNOWN_KEYS = %w[type values].freeze
16
+
17
+ attr_reader :type, :values, :extra
18
+
19
+ def initialize(type:, values:, extra: {})
20
+ unless type.is_a?(::String) && !type.empty?
21
+ raise OpenID4VP::InvalidRequest, "trusted_authorities[].type must be a non-empty string"
22
+ end
23
+
24
+ unless values.is_a?(::Array) && !values.empty? && values.all?(::String)
25
+ raise OpenID4VP::InvalidRequest, "trusted_authorities[].values must be a non-empty array of strings"
26
+ end
27
+
28
+ @type = type
29
+ @values = values.dup.freeze
30
+ @extra = extra.dup.freeze
31
+ end
32
+
33
+ def self.from_h(hash)
34
+ raise OpenID4VP::InvalidRequest, "trusted_authorities[] entry must be an object" unless hash.is_a?(::Hash)
35
+
36
+ new(
37
+ type: hash["type"],
38
+ values: hash["values"],
39
+ extra: hash.except(*KNOWN_KEYS)
40
+ )
41
+ end
42
+
43
+ def to_h
44
+ { "type" => type, "values" => values.dup }.merge(extra)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ # Digital Credentials Query Language (OpenID4VP §6-7): the JSON-encoded
5
+ # query language a Verifier uses to request Credentials and claims from a
6
+ # Wallet, plus the Claims Path Pointer used to address claims within a
7
+ # Credential.
8
+ module DCQL
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ # The shape returned by AuthorizationResponse#encode and
5
+ # ErrorResponse#encode: which members are set depends on the Response
6
+ # Mode encoded for (`url` for fragment/query, `form_body`/`form_params`
7
+ # for direct_post[.jwt], `data` for dc_api[.jwt], `jwe` in addition to
8
+ # `form_params`/`data` for the encrypted `.jwt` modes).
9
+ EncodedResponse = Struct.new(:mode, :url, :form_body, :form_params, :data, :jwe, keyword_init: true)
10
+ end