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,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