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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +278 -0
- data/lib/openid4vp/authorization_request/validator.rb +177 -0
- data/lib/openid4vp/authorization_request.rb +302 -0
- data/lib/openid4vp/authorization_response.rb +151 -0
- data/lib/openid4vp/client_id.rb +90 -0
- data/lib/openid4vp/credential.rb +51 -0
- data/lib/openid4vp/dc_api.rb +116 -0
- data/lib/openid4vp/dcql/claims_path_pointer.rb +109 -0
- data/lib/openid4vp/dcql/claims_query.rb +76 -0
- data/lib/openid4vp/dcql/credential_query.rb +178 -0
- data/lib/openid4vp/dcql/credential_set_query.rb +49 -0
- data/lib/openid4vp/dcql/evaluator.rb +228 -0
- data/lib/openid4vp/dcql/matcher.rb +79 -0
- data/lib/openid4vp/dcql/meta.rb +99 -0
- data/lib/openid4vp/dcql/query.rb +93 -0
- data/lib/openid4vp/dcql/selection.rb +45 -0
- data/lib/openid4vp/dcql/trusted_authorities_query.rb +48 -0
- data/lib/openid4vp/dcql.rb +10 -0
- data/lib/openid4vp/encoded_response.rb +10 -0
- data/lib/openid4vp/error_response.rb +82 -0
- data/lib/openid4vp/errors.rb +73 -0
- data/lib/openid4vp/formats/base.rb +74 -0
- data/lib/openid4vp/formats/context.rb +56 -0
- data/lib/openid4vp/formats/jwt_vc_json.rb +274 -0
- data/lib/openid4vp/formats/ldp_vc.rb +160 -0
- data/lib/openid4vp/formats/mso_mdoc.rb +204 -0
- data/lib/openid4vp/formats/registry.rb +53 -0
- data/lib/openid4vp/formats/sd_jwt_vc.rb +132 -0
- data/lib/openid4vp/formats/verified_presentation.rb +45 -0
- data/lib/openid4vp/formats.rb +12 -0
- data/lib/openid4vp/jose/algorithms.rb +46 -0
- data/lib/openid4vp/jose/jwe.rb +184 -0
- data/lib/openid4vp/jose/jwk.rb +253 -0
- data/lib/openid4vp/jose/jwk_set.rb +57 -0
- data/lib/openid4vp/jose/jws.rb +225 -0
- data/lib/openid4vp/jose.rb +8 -0
- data/lib/openid4vp/mdoc/builder.rb +132 -0
- data/lib/openid4vp/mdoc/cbor_values.rb +32 -0
- data/lib/openid4vp/mdoc/cose_key.rb +71 -0
- data/lib/openid4vp/mdoc/device_authentication.rb +32 -0
- data/lib/openid4vp/mdoc/device_response.rb +77 -0
- data/lib/openid4vp/mdoc/device_signed.rb +111 -0
- data/lib/openid4vp/mdoc/document.rb +44 -0
- data/lib/openid4vp/mdoc/handover.rb +51 -0
- data/lib/openid4vp/mdoc/issuer_signed.rb +157 -0
- data/lib/openid4vp/mdoc/mso.rb +133 -0
- data/lib/openid4vp/mdoc/session_transcript.rb +49 -0
- data/lib/openid4vp/mdoc/sign1_decoder.rb +60 -0
- data/lib/openid4vp/mdoc/verifier.rb +169 -0
- data/lib/openid4vp/mdoc.rb +9 -0
- data/lib/openid4vp/metadata/verifier_metadata.rb +109 -0
- data/lib/openid4vp/metadata/vp_formats.rb +112 -0
- data/lib/openid4vp/metadata/wallet_metadata.rb +139 -0
- data/lib/openid4vp/metadata.rb +10 -0
- data/lib/openid4vp/request_object.rb +152 -0
- data/lib/openid4vp/request_verification/base.rb +63 -0
- data/lib/openid4vp/request_verification/config.rb +65 -0
- data/lib/openid4vp/request_verification/decentralized_identifier.rb +56 -0
- data/lib/openid4vp/request_verification/openid_federation.rb +52 -0
- data/lib/openid4vp/request_verification/pre_registered.rb +54 -0
- data/lib/openid4vp/request_verification/redirect_uri.rb +29 -0
- data/lib/openid4vp/request_verification/resolver.rb +76 -0
- data/lib/openid4vp/request_verification/verified_request.rb +12 -0
- data/lib/openid4vp/request_verification/verifier_attestation.rb +54 -0
- data/lib/openid4vp/request_verification/x509_hash.rb +40 -0
- data/lib/openid4vp/request_verification/x509_san_dns.rb +60 -0
- data/lib/openid4vp/request_verification.rb +10 -0
- data/lib/openid4vp/response_encryption.rb +51 -0
- data/lib/openid4vp/response_endpoint_result.rb +33 -0
- data/lib/openid4vp/response_mode.rb +44 -0
- data/lib/openid4vp/response_type.rb +7 -0
- data/lib/openid4vp/sd_jwt/digest.rb +30 -0
- data/lib/openid4vp/sd_jwt/disclosure.rb +104 -0
- data/lib/openid4vp/sd_jwt/issuer.rb +132 -0
- data/lib/openid4vp/sd_jwt/kb_jwt.rb +82 -0
- data/lib/openid4vp/sd_jwt/parser.rb +48 -0
- data/lib/openid4vp/sd_jwt/presentation.rb +333 -0
- data/lib/openid4vp/sd_jwt/reconstruct.rb +132 -0
- data/lib/openid4vp/sd_jwt.rb +10 -0
- data/lib/openid4vp/transaction_data/registry.rb +42 -0
- data/lib/openid4vp/transaction_data/sd_jwt_hashes.rb +124 -0
- data/lib/openid4vp/transaction_data.rb +138 -0
- data/lib/openid4vp/util/base64url.rb +44 -0
- data/lib/openid4vp/util/form.rb +24 -0
- data/lib/openid4vp/util/random.rb +29 -0
- data/lib/openid4vp/util/uri.rb +48 -0
- data/lib/openid4vp/util.rb +23 -0
- data/lib/openid4vp/verifier/config.rb +216 -0
- data/lib/openid4vp/verifier/created_request.rb +25 -0
- data/lib/openid4vp/verifier/http_result.rb +34 -0
- data/lib/openid4vp/verifier/memory_session_store.rb +102 -0
- data/lib/openid4vp/verifier/request_builder.rb +216 -0
- data/lib/openid4vp/verifier/request_signer.rb +59 -0
- data/lib/openid4vp/verifier/request_uri_endpoint.rb +151 -0
- data/lib/openid4vp/verifier/response_endpoint.rb +197 -0
- data/lib/openid4vp/verifier/response_validator.rb +258 -0
- data/lib/openid4vp/verifier/session.rb +109 -0
- data/lib/openid4vp/verifier/session_store.rb +48 -0
- data/lib/openid4vp/verifier/verified_response.rb +53 -0
- data/lib/openid4vp/verifier.rb +155 -0
- data/lib/openid4vp/verifier_attestation.rb +130 -0
- data/lib/openid4vp/verifier_info.rb +102 -0
- data/lib/openid4vp/version.rb +5 -0
- data/lib/openid4vp/vp_token.rb +81 -0
- data/lib/openid4vp/wallet/config.rb +49 -0
- data/lib/openid4vp/wallet/http_client.rb +73 -0
- data/lib/openid4vp/wallet/presenter.rb +90 -0
- data/lib/openid4vp/wallet/processed_request.rb +14 -0
- data/lib/openid4vp/wallet/request_processor.rb +237 -0
- data/lib/openid4vp/wallet/response_builder.rb +44 -0
- data/lib/openid4vp/wallet/submission.rb +68 -0
- data/lib/openid4vp/wallet.rb +142 -0
- data/lib/openid4vp/x509/chain_validator.rb +42 -0
- data/lib/openid4vp/x509.rb +103 -0
- data/lib/openid4vp.rb +18 -0
- 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
|