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,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../vp_token"
|
|
5
|
+
require_relative "../response_mode"
|
|
6
|
+
require_relative "../util"
|
|
7
|
+
require_relative "../formats/context"
|
|
8
|
+
require_relative "../dcql/evaluator"
|
|
9
|
+
require_relative "../dcql/meta"
|
|
10
|
+
require_relative "../dcql/matcher"
|
|
11
|
+
require_relative "../transaction_data/sd_jwt_hashes"
|
|
12
|
+
require_relative "verified_response"
|
|
13
|
+
|
|
14
|
+
module OpenID4VP
|
|
15
|
+
class Verifier
|
|
16
|
+
# OpenID4VP §8.6/§14.1 (also §13.3 steps 8-10): verifies a Wallet's
|
|
17
|
+
# Authorization Response against its Verifier::Session -- parsing the
|
|
18
|
+
# vp_token, verifying every Presentation with its Credential Format
|
|
19
|
+
# handler (`Formats::Registry#for`), and checking the surviving
|
|
20
|
+
# Presentations satisfy the session's effective DCQL query
|
|
21
|
+
# (`DCQL::Evaluator.check_response!`). A Presentation that fails its own
|
|
22
|
+
# verification is DISCARDED rather than failing the whole response (a
|
|
23
|
+
# Credential id left with no surviving Presentation is then reported as
|
|
24
|
+
# missing by the DCQL check); an unexpected credential id, a malformed
|
|
25
|
+
# vp_token, or a mismatched `state` fails the whole response.
|
|
26
|
+
class ResponseValidator
|
|
27
|
+
def initialize(verifier)
|
|
28
|
+
@verifier = verifier
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# `payload` is a decoded Authorization Response payload Hash (as
|
|
32
|
+
# produced by `AuthorizationResponse#to_payload`, i.e.
|
|
33
|
+
# `session.response_params`): a "vp_token" member (String or Hash) and,
|
|
34
|
+
# for non-DC-API response modes, an optional "state". Returns a
|
|
35
|
+
# `VerifiedResponse`. Raises `InvalidResponse` (or a subclass) on
|
|
36
|
+
# failure; never mutates `session`.
|
|
37
|
+
def validate(session, payload)
|
|
38
|
+
vp_token = OpenID4VP::VpToken.parse(payload["vp_token"])
|
|
39
|
+
verify_state!(session, payload)
|
|
40
|
+
query = session.request.effective_dcql_query
|
|
41
|
+
check_known_ids!(query, vp_token)
|
|
42
|
+
|
|
43
|
+
surviving, discarded = verify_all(session, query, vp_token)
|
|
44
|
+
check_query_satisfied!(query, surviving, discarded)
|
|
45
|
+
check_multi_credential_transaction_data!(session, surviving)
|
|
46
|
+
|
|
47
|
+
build_response(session, surviving)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Validates `session`'s current response (`session.response_params`)
|
|
51
|
+
# and records the outcome on it: on success, `session.result` (a
|
|
52
|
+
# JSON-safe Hash) and status `:verified`; on failure, `session.error`
|
|
53
|
+
# and status `:failed` -- then re-raises. Used by both
|
|
54
|
+
# `ResponseEndpoint#verify_on_receipt!` and `Verifier#fetch_response`.
|
|
55
|
+
def verify(session)
|
|
56
|
+
verified = validate(session, session.response_params)
|
|
57
|
+
session.result = verified.to_h
|
|
58
|
+
session.transition!(:verified)
|
|
59
|
+
@verifier.session_store.update(session)
|
|
60
|
+
verified
|
|
61
|
+
rescue OpenID4VP::InvalidResponse => e
|
|
62
|
+
session.error = { "code" => e.code, "description" => e.description }
|
|
63
|
+
session.transition!(:failed)
|
|
64
|
+
@verifier.session_store.update(session)
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# OpenID4VP §14.3.2: for non-DC-API response modes, a `state` present
|
|
71
|
+
# in the response payload must match the session it was decoded for
|
|
72
|
+
# (an encrypted response's `state` is not itself used to locate the
|
|
73
|
+
# session, so it can only be checked here).
|
|
74
|
+
def verify_state!(session, payload)
|
|
75
|
+
return if OpenID4VP::ResponseMode.dc_api?(session.response_mode)
|
|
76
|
+
|
|
77
|
+
state = payload["state"]
|
|
78
|
+
return if state.nil? || state == session.state
|
|
79
|
+
|
|
80
|
+
raise OpenID4VP::InvalidResponse, "response state does not match session"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def check_known_ids!(query, vp_token)
|
|
84
|
+
vp_token.ids.each do |id|
|
|
85
|
+
next if query.ids.include?(id)
|
|
86
|
+
|
|
87
|
+
raise OpenID4VP::InvalidResponse, "unexpected credential id #{id.inspect}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def verify_all(session, query, vp_token)
|
|
92
|
+
surviving = {}
|
|
93
|
+
discarded = []
|
|
94
|
+
vp_token.each do |id, presentations|
|
|
95
|
+
credential_query = query.credential(id)
|
|
96
|
+
verified_list = verify_presentations(session, credential_query, presentations, discarded)
|
|
97
|
+
surviving[id] = verified_list unless verified_list.empty?
|
|
98
|
+
end
|
|
99
|
+
[surviving, discarded]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def verify_presentations(session, credential_query, presentations, discarded)
|
|
103
|
+
context = build_context(session, credential_query)
|
|
104
|
+
presentations.filter_map do |presentation|
|
|
105
|
+
verify_one(credential_query, presentation, context)
|
|
106
|
+
rescue OpenID4VP::PresentationInvalid, OpenID4VP::HolderBindingInvalid, OpenID4VP::VerificationError => e
|
|
107
|
+
discarded << e
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def verify_one(credential_query, presentation, context)
|
|
113
|
+
handler = @verifier.formats.for(credential_query.format)
|
|
114
|
+
verified = handler.verify_presentation(presentation, context)
|
|
115
|
+
handler.check_vp_formats!(verified, context.vp_formats)
|
|
116
|
+
check_holder_binding!(credential_query, verified)
|
|
117
|
+
check_meta!(credential_query, verified)
|
|
118
|
+
check_trusted_authorities!(credential_query, verified)
|
|
119
|
+
verified
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def check_holder_binding!(credential_query, verified)
|
|
123
|
+
return unless credential_query.require_cryptographic_holder_binding
|
|
124
|
+
return if verified.holder_bound
|
|
125
|
+
|
|
126
|
+
raise OpenID4VP::HolderBindingInvalid,
|
|
127
|
+
"credential id #{credential_query.id.inspect}: holder binding required but presentation is not bound"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def check_meta!(credential_query, verified)
|
|
131
|
+
credential = verified.to_credential
|
|
132
|
+
return if OpenID4VP::DCQL::Meta.matches?(credential_query.format, credential_query.meta, credential)
|
|
133
|
+
|
|
134
|
+
raise OpenID4VP::PresentationInvalid, "credential does not match meta"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# OpenID4VP §6.1.1: `config.trust[:authority_hooks]` is a Hash of
|
|
138
|
+
# `{trusted_authorities "type" String => callable(ta_query, credential)
|
|
139
|
+
# -> boolean}` for types `DCQL::Matcher.authority_matches?` does not
|
|
140
|
+
# already know how to check itself (only `"aki"` is built in).
|
|
141
|
+
def check_trusted_authorities!(credential_query, verified)
|
|
142
|
+
return unless credential_query.trusted_authorities
|
|
143
|
+
|
|
144
|
+
credential = verified.to_credential
|
|
145
|
+
hooks = @verifier.config.trust[:authority_hooks] || {}
|
|
146
|
+
matched = credential_query.trusted_authorities.any? do |ta|
|
|
147
|
+
OpenID4VP::DCQL::Matcher.authority_matches?(ta, credential, hooks: hooks)
|
|
148
|
+
end
|
|
149
|
+
return if matched
|
|
150
|
+
|
|
151
|
+
raise OpenID4VP::PresentationInvalid, "credential does not match any trusted_authorities"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Appends the first discarded Presentation's error message to a raised
|
|
155
|
+
# QueryNotSatisfied so callers see the root cause, rather than just
|
|
156
|
+
# "credential id is missing".
|
|
157
|
+
def check_query_satisfied!(query, surviving, discarded)
|
|
158
|
+
OpenID4VP::DCQL::Evaluator.check_response!(query, surviving)
|
|
159
|
+
rescue OpenID4VP::QueryNotSatisfied => e
|
|
160
|
+
raise e if discarded.empty?
|
|
161
|
+
|
|
162
|
+
raise OpenID4VP::QueryNotSatisfied,
|
|
163
|
+
"#{e.message} (a presentation was discarded: #{discarded.first.message})"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_response(session, surviving)
|
|
167
|
+
claims = surviving.transform_values { |list| list.map(&:claims) }
|
|
168
|
+
OpenID4VP::Verifier::VerifiedResponse.new(
|
|
169
|
+
presentations: surviving, claims: claims, session_id: session.id, nonce: session.nonce
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def build_context(session, credential_query)
|
|
174
|
+
OpenID4VP::Formats::Context.new(
|
|
175
|
+
nonce: session.nonce,
|
|
176
|
+
audience: session.request.audience(origin: session.origin),
|
|
177
|
+
client_id: session.request.client_id,
|
|
178
|
+
response_uri: OpenID4VP::ResponseMode.dc_api?(session.response_mode) ? nil : session.request.return_uri,
|
|
179
|
+
origin: session.origin,
|
|
180
|
+
encryption_jwk: encryption_jwk(session),
|
|
181
|
+
transaction_data: transaction_data_for(session, credential_query.id),
|
|
182
|
+
now: @verifier.now,
|
|
183
|
+
leeway: @verifier.config.leeway,
|
|
184
|
+
trust: @verifier.config.trust,
|
|
185
|
+
vp_formats: @verifier.config.vp_formats_supported,
|
|
186
|
+
require_holder_binding: credential_query.require_cryptographic_holder_binding,
|
|
187
|
+
dc_api: OpenID4VP::ResponseMode.dc_api?(session.response_mode)
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The PUBLIC form of the JWK that actually decrypted this response:
|
|
192
|
+
# the session's own ephemeral key when one was generated for it,
|
|
193
|
+
# otherwise the configured static key whose `kid` matches the JWE
|
|
194
|
+
# header's (nil when the response was not encrypted).
|
|
195
|
+
def encryption_jwk(session)
|
|
196
|
+
return nil unless session.response_encryption
|
|
197
|
+
|
|
198
|
+
return session.encryption_jwk.to_public if session.encryption_jwk
|
|
199
|
+
|
|
200
|
+
kid = session.response_encryption["kid"]
|
|
201
|
+
static_keys = @verifier.config.encryption && @verifier.config.encryption[:keys]
|
|
202
|
+
static_keys&.find { |jwk| jwk.kid == kid }&.to_public
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# OpenID4VP §5.1: "the Wallet MUST use only one of the referenced
|
|
206
|
+
# Credentials" to satisfy an entry naming more than one credential id,
|
|
207
|
+
# so which of them actually carries it is not known in advance. Only
|
|
208
|
+
# single-id entries are enforced here, per presentation, at
|
|
209
|
+
# verification time (via `Formats::Context#transaction_data`); entries
|
|
210
|
+
# naming several ids are excluded and checked afterwards, across the
|
|
211
|
+
# surviving presentations of all their ids, by
|
|
212
|
+
# `check_multi_credential_transaction_data!`.
|
|
213
|
+
def transaction_data_for(session, credential_id)
|
|
214
|
+
entries = session.request.transaction_data
|
|
215
|
+
return nil if entries.nil?
|
|
216
|
+
|
|
217
|
+
entries.select { |entry| entry.credential_ids == [credential_id] }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# OpenID4VP §5.1/§8.5: for every transaction_data entry naming more
|
|
221
|
+
# than one credential id, at least one surviving presentation among
|
|
222
|
+
# those ids must carry the entry's hash in its Key Binding JWT's
|
|
223
|
+
# `transaction_data_hashes` (dc+sd-jwt only -- mso_mdoc's transaction
|
|
224
|
+
# data binding is format-specific and not checked here). Raises
|
|
225
|
+
# PresentationInvalid otherwise.
|
|
226
|
+
def check_multi_credential_transaction_data!(session, surviving)
|
|
227
|
+
entries = session.request.transaction_data
|
|
228
|
+
return if entries.nil?
|
|
229
|
+
|
|
230
|
+
entries.select { |entry| entry.credential_ids.size > 1 }.each do |entry|
|
|
231
|
+
next if multi_credential_entry_satisfied?(entry, surviving)
|
|
232
|
+
|
|
233
|
+
raise OpenID4VP::PresentationInvalid,
|
|
234
|
+
"transaction_data entry is not authorized by any presented credential: " \
|
|
235
|
+
"#{entry.credential_ids.inspect}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def multi_credential_entry_satisfied?(entry, surviving)
|
|
240
|
+
entry.credential_ids.any? do |id|
|
|
241
|
+
(surviving[id] || []).any? { |verified| transaction_data_hash_present?(verified, entry) }
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def transaction_data_hash_present?(verified, entry)
|
|
246
|
+
kb_payload = verified.details && verified.details[:kb_payload]
|
|
247
|
+
hashes = kb_payload && kb_payload["transaction_data_hashes"]
|
|
248
|
+
return false unless hashes.is_a?(::Array)
|
|
249
|
+
|
|
250
|
+
alg = kb_payload["transaction_data_hashes_alg"] || "sha-256"
|
|
251
|
+
expected = OpenID4VP::TransactionData::SdJwtHashes.compute([entry], alg: alg).first
|
|
252
|
+
hashes.any? { |actual| actual.is_a?(::String) && OpenID4VP::Util.secure_compare(expected, actual) }
|
|
253
|
+
rescue OpenID4VP::InvalidTransactionData
|
|
254
|
+
false
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../authorization_request"
|
|
4
|
+
require_relative "../jose/jwk"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module OpenID4VP
|
|
8
|
+
class Verifier
|
|
9
|
+
# A Verifier-side transaction (design doc §4.20, spec §13.3). Plain
|
|
10
|
+
# mutable value object so a `SessionStore` can freely update it in
|
|
11
|
+
# place; `#to_h`/`.from_h` round-trip it through a JSON-safe Hash for
|
|
12
|
+
# stores that serialize (e.g. a database or cache), storing `request`
|
|
13
|
+
# as `request.to_h` and `encryption_jwk` as its Hash form.
|
|
14
|
+
class Session
|
|
15
|
+
# created -> request_retrieved -> response_received -> (verified |
|
|
16
|
+
# failed), plus the created -> response_received shortcut used by
|
|
17
|
+
# response modes where the Wallet returns the response by value
|
|
18
|
+
# (fragment/query/DC API) without ever fetching a request_uri.
|
|
19
|
+
TRANSITIONS = {
|
|
20
|
+
created: %i[request_retrieved response_received],
|
|
21
|
+
request_retrieved: %i[response_received],
|
|
22
|
+
response_received: %i[verified failed]
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
attr_accessor :id, :request_id, :nonce, :state, :response_mode, :request, :request_object, :wallet_nonce,
|
|
26
|
+
:wallet_metadata, :status, :response_params, :response_code, :encryption_jwk, :error, :result,
|
|
27
|
+
:origin, :created_at, :expires_at, :response_encryption
|
|
28
|
+
|
|
29
|
+
def initialize(id:, request_id:, nonce:, response_mode:, request:, created_at:, expires_at:, state: nil,
|
|
30
|
+
request_object: nil, wallet_nonce: nil, wallet_metadata: nil, status: :created,
|
|
31
|
+
response_params: nil, response_code: nil, encryption_jwk: nil, error: nil, result: nil,
|
|
32
|
+
origin: nil, response_encryption: nil)
|
|
33
|
+
@id = id
|
|
34
|
+
@request_id = request_id
|
|
35
|
+
@nonce = nonce
|
|
36
|
+
@state = state
|
|
37
|
+
@response_mode = response_mode
|
|
38
|
+
@request = request
|
|
39
|
+
@request_object = request_object
|
|
40
|
+
@wallet_nonce = wallet_nonce
|
|
41
|
+
@wallet_metadata = wallet_metadata
|
|
42
|
+
@status = status
|
|
43
|
+
@response_params = response_params
|
|
44
|
+
@response_code = response_code
|
|
45
|
+
@encryption_jwk = encryption_jwk
|
|
46
|
+
@error = error
|
|
47
|
+
@result = result
|
|
48
|
+
@origin = origin
|
|
49
|
+
@created_at = created_at
|
|
50
|
+
@expires_at = expires_at
|
|
51
|
+
@response_encryption = response_encryption
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def expired?(now)
|
|
55
|
+
!expires_at.nil? && now >= expires_at
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Moves to `to`, raising ConfigurationError when that transition is
|
|
59
|
+
# not allowed from the current `status`.
|
|
60
|
+
def transition!(to)
|
|
61
|
+
allowed = TRANSITIONS[status] || []
|
|
62
|
+
unless allowed.include?(to)
|
|
63
|
+
raise OpenID4VP::ConfigurationError, "cannot transition session from #{status.inspect} to #{to.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
self.status = to
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# WARNING: includes `encryption_jwk` in its PRIVATE form (with `d`) --
|
|
70
|
+
# a session's ephemeral response-decryption key -- so any
|
|
71
|
+
# `SessionStore` that persists this Hash (a database, a cache, ...) is
|
|
72
|
+
# persisting private key material and must protect it accordingly
|
|
73
|
+
# (encryption at rest, access control, etc).
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
"id" => id, "request_id" => request_id, "nonce" => nonce, "state" => state,
|
|
77
|
+
"response_mode" => response_mode, "request" => request&.to_h, "request_object" => request_object,
|
|
78
|
+
"wallet_nonce" => wallet_nonce, "wallet_metadata" => wallet_metadata, "status" => status.to_s,
|
|
79
|
+
"response_params" => response_params, "response_code" => response_code,
|
|
80
|
+
"encryption_jwk" => encryption_jwk&.to_h, "error" => error, "result" => result, "origin" => origin,
|
|
81
|
+
"created_at" => created_at&.to_f, "expires_at" => expires_at&.to_f,
|
|
82
|
+
"response_encryption" => response_encryption
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.from_h(hash)
|
|
87
|
+
h = hash.transform_keys(&:to_s)
|
|
88
|
+
new(**from_h_kwargs(h))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.from_h_kwargs(raw)
|
|
92
|
+
{
|
|
93
|
+
id: raw["id"], request_id: raw["request_id"], nonce: raw["nonce"], state: raw["state"],
|
|
94
|
+
response_mode: raw["response_mode"],
|
|
95
|
+
request: raw["request"] && OpenID4VP::AuthorizationRequest.new(raw["request"]),
|
|
96
|
+
request_object: raw["request_object"], wallet_nonce: raw["wallet_nonce"],
|
|
97
|
+
wallet_metadata: raw["wallet_metadata"], status: (raw["status"] || "created").to_sym,
|
|
98
|
+
response_params: raw["response_params"], response_code: raw["response_code"],
|
|
99
|
+
encryption_jwk: raw["encryption_jwk"] && OpenID4VP::JOSE::JWK.new(raw["encryption_jwk"]),
|
|
100
|
+
error: raw["error"], result: raw["result"], origin: raw["origin"],
|
|
101
|
+
created_at: raw["created_at"] && Time.at(raw["created_at"]),
|
|
102
|
+
expires_at: raw["expires_at"] && Time.at(raw["expires_at"]),
|
|
103
|
+
response_encryption: raw["response_encryption"]
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
private_class_method :from_h_kwargs
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
class Verifier
|
|
5
|
+
# Abstract Verifier session store interface (design doc §4.20). A
|
|
6
|
+
# concrete implementation persists `Verifier::Session` instances (or
|
|
7
|
+
# anything responding to the same shape) keyed by their `id`, with
|
|
8
|
+
# secondary lookups by `request_id` and `response_code`.
|
|
9
|
+
#
|
|
10
|
+
# WARNING: a Session's `encryption_jwk` (see `Session#to_h`) is a
|
|
11
|
+
# PRIVATE key -- the session's ephemeral response-decryption key -- so
|
|
12
|
+
# any implementation that persists Sessions outside this process (a
|
|
13
|
+
# database, a cache, ...) is persisting private key material and must
|
|
14
|
+
# protect it accordingly.
|
|
15
|
+
#
|
|
16
|
+
# See `OpenID4VP::Verifier::MemorySessionStore` for the in-process
|
|
17
|
+
# reference implementation.
|
|
18
|
+
class SessionStore
|
|
19
|
+
def create(_session)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find(_id)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find_by_request_id(_request_id)
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def find_by_response_code(_response_code)
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def update(_session)
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delete(_id)
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def cleanup(_now)
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formats/verified_presentation"
|
|
4
|
+
|
|
5
|
+
module OpenID4VP
|
|
6
|
+
class Verifier
|
|
7
|
+
# The result of `ResponseValidator#validate` / `Verifier#fetch_response`
|
|
8
|
+
# (OpenID4VP §8.6, §14.1): the verified Presentations -- and, redundantly,
|
|
9
|
+
# just their claims -- for each Credential Query id the response
|
|
10
|
+
# satisfied. `#to_h`/`.from_h` round-trip it through a JSON-safe Hash
|
|
11
|
+
# (e.g. `Session#result`); a Presentation restored via `.from_h` carries
|
|
12
|
+
# `raw: nil` (the original wire-format Presentation is not preserved).
|
|
13
|
+
VerifiedResponse = Struct.new(:presentations, :claims, :session_id, :nonce, keyword_init: true) do
|
|
14
|
+
def to_h
|
|
15
|
+
{
|
|
16
|
+
"session_id" => session_id,
|
|
17
|
+
"nonce" => nonce,
|
|
18
|
+
"presentations" => presentations.transform_values { |list| list.map { |vp| presentation_to_h(vp) } }
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.from_h(hash)
|
|
23
|
+
h = hash.transform_keys(&:to_s)
|
|
24
|
+
presentations = (h["presentations"] || {}).transform_values do |list|
|
|
25
|
+
list.map { |presentation_hash| presentation_from_h(presentation_hash) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
new(
|
|
29
|
+
presentations: presentations, claims: presentations.transform_values { |list| list.map(&:claims) },
|
|
30
|
+
session_id: h["session_id"], nonce: h["nonce"]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.presentation_from_h(hash)
|
|
35
|
+
OpenID4VP::Formats::VerifiedPresentation.new(
|
|
36
|
+
format: hash["format"], claims: hash["claims"], holder_bound: hash["holder_bound"],
|
|
37
|
+
issuer: hash["issuer"], raw: nil, metadata: hash["metadata"] || {}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
private_class_method :presentation_from_h
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def presentation_to_h(verified_presentation)
|
|
45
|
+
{
|
|
46
|
+
"format" => verified_presentation.format, "claims" => verified_presentation.claims,
|
|
47
|
+
"holder_bound" => verified_presentation.holder_bound, "issuer" => verified_presentation.issuer,
|
|
48
|
+
"metadata" => verified_presentation.metadata
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "verifier/config"
|
|
5
|
+
require_relative "verifier/session"
|
|
6
|
+
require_relative "verifier/session_store"
|
|
7
|
+
require_relative "verifier/memory_session_store"
|
|
8
|
+
require_relative "verifier/request_signer"
|
|
9
|
+
require_relative "verifier/request_builder"
|
|
10
|
+
require_relative "verifier/created_request"
|
|
11
|
+
require_relative "verifier/http_result"
|
|
12
|
+
require_relative "verifier/request_uri_endpoint"
|
|
13
|
+
require_relative "verifier/response_endpoint"
|
|
14
|
+
require_relative "verifier/response_validator"
|
|
15
|
+
require_relative "verifier/verified_response"
|
|
16
|
+
require_relative "util"
|
|
17
|
+
|
|
18
|
+
module OpenID4VP
|
|
19
|
+
# OpenID4VP Verifier orchestration (design doc §4.20, spec §13.3): builds
|
|
20
|
+
# and signs Authorization Requests, receives and validates the Wallet's
|
|
21
|
+
# Authorization Response, and manages the Verifier-side session for a
|
|
22
|
+
# transaction.
|
|
23
|
+
class Verifier
|
|
24
|
+
# `session.error["code"]` -> the Error subclass `fetch_response` raises
|
|
25
|
+
# for a `:failed` session, when one is registered here; unregistered
|
|
26
|
+
# codes (including `nil`, from a ResponseValidator failure) raise
|
|
27
|
+
# `InvalidResponse`.
|
|
28
|
+
FAILURE_CLASSES = {
|
|
29
|
+
"access_denied" => OpenID4VP::AccessDenied,
|
|
30
|
+
"invalid_request" => OpenID4VP::InvalidRequest
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def initialize(**)
|
|
34
|
+
@config = Config.new(**)
|
|
35
|
+
@request_signer = RequestSigner.new(@config)
|
|
36
|
+
@request_builder = RequestBuilder.new(self)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :config, :request_signer
|
|
40
|
+
|
|
41
|
+
def session_store
|
|
42
|
+
config.session_store
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def formats
|
|
46
|
+
config.formats
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def clock
|
|
50
|
+
config.clock
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def now
|
|
54
|
+
config.now
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def client_id
|
|
58
|
+
config.client_id
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# OpenID4VP §5, §13.3 steps 2-4: builds an Authorization Request (or DC
|
|
62
|
+
# API request), creates its session and returns a CreatedRequest.
|
|
63
|
+
def create_request(**)
|
|
64
|
+
@request_builder.build(**)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Internal accessor used by `handle_request_uri`, `handle_response`,
|
|
68
|
+
# `fetch_response` and `result` to look up a session directly.
|
|
69
|
+
def session(session_id)
|
|
70
|
+
session_store.find(session_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def status(session_id)
|
|
74
|
+
found = session(session_id)
|
|
75
|
+
raise OpenID4VP::SessionNotFound, "unknown session #{session_id.inspect}" if found.nil?
|
|
76
|
+
|
|
77
|
+
found.status
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# OpenID4VP §5.10-5.10.2: the Request URI endpoint (memoized instance).
|
|
81
|
+
def request_uri_endpoint
|
|
82
|
+
@request_uri_endpoint ||= RequestUriEndpoint.new(self)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# OpenID4VP §8.2/§8.3.1: the Response endpoint (memoized instance).
|
|
86
|
+
def response_endpoint
|
|
87
|
+
@response_endpoint ||= ResponseEndpoint.new(self)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# OpenID4VP §8.6/§14.1: the Response validator (memoized instance).
|
|
91
|
+
def response_validator
|
|
92
|
+
@response_validator ||= ResponseValidator.new(self)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Serves a Request URI GET/POST for `request_id` (OpenID4VP §5.10-5.10.2).
|
|
96
|
+
def handle_request_uri(request_id, method:, params: {}, headers: {})
|
|
97
|
+
request_uri_endpoint.call(request_id, method: method, params: params, headers: headers)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Receives an Authorization Response/Error Response (OpenID4VP §8.2,
|
|
101
|
+
# §8.3.1). `session_id` is required for DC API sessions, which carry no
|
|
102
|
+
# request/state parameter of their own to locate the session by.
|
|
103
|
+
def handle_response(params, headers: {}, session_id: nil)
|
|
104
|
+
response_endpoint.call(params, headers: headers, session_id: session_id)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# OpenID4VP §8.6/§14.1: fetches (validating on first call) the
|
|
108
|
+
# `VerifiedResponse` for `session_id`. `response_code` is REQUIRED and
|
|
109
|
+
# checked (constant-time) whenever the session has one (OpenID4VP §8.5:
|
|
110
|
+
# the redirect_uri-carried `response_code` used by the RP's frontend to
|
|
111
|
+
# retrieve the response from its backend). Returns nil while the
|
|
112
|
+
# response is still pending; raises the session's stored error (mapped
|
|
113
|
+
# through `FAILURE_CLASSES`) once it has failed; validates (storing the
|
|
114
|
+
# result or the error) the first time it is called on a session that has
|
|
115
|
+
# merely received a response.
|
|
116
|
+
def fetch_response(session_id, response_code: nil)
|
|
117
|
+
found = session(session_id)
|
|
118
|
+
raise OpenID4VP::SessionNotFound, "unknown session #{session_id.inspect}" if found.nil?
|
|
119
|
+
|
|
120
|
+
check_response_code!(found, response_code)
|
|
121
|
+
|
|
122
|
+
case found.status
|
|
123
|
+
when :verified then OpenID4VP::Verifier::VerifiedResponse.from_h(found.result)
|
|
124
|
+
when :failed then raise_stored_error!(found)
|
|
125
|
+
when :response_received then response_validator.verify(found)
|
|
126
|
+
when :created, :request_retrieved then nil
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Like `fetch_response`, but never validates or raises: returns the
|
|
131
|
+
# cached `VerifiedResponse` for an already-`:verified` session, or nil
|
|
132
|
+
# otherwise (pending, failed, or unknown). For internal/trusted use,
|
|
133
|
+
# where a `response_code` has already been (or need not be) checked.
|
|
134
|
+
def result(session_id)
|
|
135
|
+
found = session(session_id)
|
|
136
|
+
return nil unless found && found.status == :verified
|
|
137
|
+
|
|
138
|
+
OpenID4VP::Verifier::VerifiedResponse.from_h(found.result)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def check_response_code!(session, response_code)
|
|
144
|
+
return unless session.response_code
|
|
145
|
+
return if response_code && OpenID4VP::Util.secure_compare(response_code, session.response_code)
|
|
146
|
+
|
|
147
|
+
raise OpenID4VP::InvalidResponse, "invalid response_code"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def raise_stored_error!(session)
|
|
151
|
+
error = session.error || {}
|
|
152
|
+
raise(FAILURE_CLASSES[error["code"]] || OpenID4VP::InvalidResponse, error["description"])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|