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,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sd_jwt"
4
+ require_relative "parser"
5
+ require_relative "digest"
6
+ require_relative "reconstruct"
7
+ require_relative "kb_jwt"
8
+ require_relative "../errors"
9
+ require_relative "../jose/jws"
10
+ require_relative "../jose/jwk"
11
+ require_relative "../jose/algorithms"
12
+ require_relative "../x509"
13
+ require_relative "../transaction_data/sd_jwt_hashes"
14
+
15
+ module OpenID4VP
16
+ module SdJwt
17
+ # Verifies (verifier side) and builds (wallet side) SD-JWT VC
18
+ # presentations: `dc+sd-jwt` / `vc+sd-jwt` typed Issuer-signed JWTs, their
19
+ # selective disclosures, and the Key Binding JWT that binds a
20
+ # presentation to a specific Verifier request.
21
+ module Presentation
22
+ VALID_TYPES = %w[dc+sd-jwt vc+sd-jwt].freeze
23
+
24
+ Result = Struct.new(:payload, :claims, :kb_payload, :holder_jwk, :x5chain, :issuer, :vct, :alg, :kb_alg,
25
+ keyword_init: true)
26
+
27
+ module_function
28
+
29
+ # `issuer_key_resolver` is a callable `(iss, header) -> JWK | [JWK] |
30
+ # nil`. Raises PresentationInvalid for an unknown issuer, an
31
+ # unexpected `typ`, an unverifiable signature, an expired/not-yet-valid
32
+ # credential, or a disclosure reconstruction failure (see
33
+ # Reconstruct). `require_kb` controls only whether a Key Binding JWT
34
+ # is MANDATORY: a Key Binding JWT that IS present is always verified
35
+ # (raising HolderBindingInvalid if `cnf.jwk` is missing or
36
+ # verification fails, see KbJwt.verify), regardless of `require_kb`;
37
+ # when one is absent, `require_kb` decides whether that is itself a
38
+ # HolderBindingInvalid or simply yields a nil `kb_payload`. When
39
+ # `transaction_data` is given and non-empty, verifies it against the
40
+ # Key Binding JWT (see OpenID4VP::TransactionData::SdJwtHashes.verify!).
41
+ def verify(str, issuer_key_resolver:, expected_nonce: nil, expected_aud: nil, require_kb: true, now: Time.now,
42
+ leeway: 60, algorithms: %w[ES256 ES384 ES512], kb_algorithms: nil, transaction_data: nil)
43
+ parsed = coerce_parsed(str)
44
+ header, payload, sd_alg = verify_issuer_jwt(parsed, issuer_key_resolver, algorithms, now, leeway)
45
+
46
+ claims, = Reconstruct.claims(payload, parsed.disclosures, sd_alg)
47
+ cnf_jwk = extract_cnf_jwk(payload)
48
+
49
+ kb_payload, kb_alg = verify_kb(
50
+ parsed: parsed, cnf_jwk: cnf_jwk, expected_aud: expected_aud, expected_nonce: expected_nonce,
51
+ require_kb: require_kb, now: now, leeway: leeway, algorithms: kb_algorithms || algorithms, sd_alg: sd_alg
52
+ )
53
+ verify_transaction_data!(kb_payload, transaction_data)
54
+
55
+ Result.new(payload: payload, claims: claims, kb_payload: kb_payload, holder_jwk: cnf_jwk,
56
+ x5chain: x5chain_from(header), issuer: payload["iss"], vct: payload["vct"],
57
+ alg: header["alg"], kb_alg: kb_alg)
58
+ end
59
+
60
+ # Peeks, resolves the issuer key, verifies the signature and the
61
+ # exp/nbf time bounds. Returns `[header, payload, sd_alg]`.
62
+ def verify_issuer_jwt(parsed, issuer_key_resolver, algorithms, now, leeway)
63
+ peeked = OpenID4VP::JOSE::JWS.peek(parsed.issuer_jwt)
64
+ header = peeked.header
65
+ check_typ!(header)
66
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: payload is not a JSON object" unless peeked.payload.is_a?(::Hash)
67
+
68
+ key = resolve_issuer_key(issuer_key_resolver, peeked.payload["iss"], header)
69
+ verified = verify_issuer_signature(parsed.issuer_jwt, header, key, algorithms)
70
+ payload = verified.payload
71
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: payload is not a JSON object" unless payload.is_a?(::Hash)
72
+
73
+ check_time_bounds!(payload, now, leeway)
74
+
75
+ [header, payload, payload["_sd_alg"] || "sha-256"]
76
+ end
77
+ private_class_method :verify_issuer_jwt
78
+
79
+ # Returns `"<issuer-jwt>~<disclosure 1>~...~"` (no Key Binding JWT)
80
+ # containing only the disclosures needed to reveal `paths` (ancestors
81
+ # included). Raises PresentationInvalid when a path cannot be
82
+ # resolved.
83
+ def select(str_or_parsed, paths:)
84
+ parsed = coerce_parsed(str_or_parsed)
85
+ payload = OpenID4VP::JOSE::JWS.peek(parsed.issuer_jwt).payload
86
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: payload is not a JSON object" unless payload.is_a?(::Hash)
87
+
88
+ sd_alg = payload["_sd_alg"] || "sha-256"
89
+ Reconstruct.reject_duplicate_disclosures!(parsed.disclosures)
90
+ by_digest = parsed.disclosures.to_h { |d| [d.digest(sd_alg), d] }
91
+
92
+ needed = {}
93
+ paths.each { |path| walk_path!(payload, path_components(path), by_digest, needed) }
94
+
95
+ selected = parsed.disclosures.select { |d| needed.key?(d.digest(sd_alg)) }
96
+ "#{parsed.issuer_jwt}~#{selected.map { |d| "#{d.raw}~" }.join}"
97
+ end
98
+
99
+ # `select` plus a Key Binding JWT signed by `holder_key`, whose
100
+ # `sd_hash` is computed over the selected (disclosure-minimized)
101
+ # presentation. Adds `SdJwtHashes.kb_claims(transaction_data)` when
102
+ # `transaction_data` is given and non-empty.
103
+ def present(str, paths:, holder_key:, aud:, nonce:, transaction_data: nil, kb_alg: nil, iat: nil)
104
+ selected = select(str, paths: paths)
105
+ payload = OpenID4VP::JOSE::JWS.peek(Parser.parse(selected).issuer_jwt).payload
106
+ sd_alg = (payload.is_a?(::Hash) && payload["_sd_alg"]) || "sha-256"
107
+ sd_hash = Digest.hash(sd_alg, selected)
108
+
109
+ extra =
110
+ if transaction_data && !transaction_data.empty?
111
+ OpenID4VP::TransactionData::SdJwtHashes.kb_claims(transaction_data)
112
+ else
113
+ {}
114
+ end
115
+
116
+ kb = KbJwt.build(aud: aud, nonce: nonce, sd_hash: sd_hash, key: holder_key, alg: kb_alg, iat: iat,
117
+ extra: extra)
118
+ "#{selected}#{kb}"
119
+ end
120
+
121
+ def coerce_parsed(str_or_parsed)
122
+ str_or_parsed.is_a?(Parser::Parsed) ? str_or_parsed : Parser.parse(str_or_parsed)
123
+ end
124
+ private_class_method :coerce_parsed
125
+
126
+ def path_components(path)
127
+ path.respond_to?(:components) ? path.components : path
128
+ end
129
+ private_class_method :path_components
130
+
131
+ def check_typ!(header)
132
+ return if VALID_TYPES.include?(header["typ"])
133
+
134
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: unexpected typ #{header['typ'].inspect}"
135
+ end
136
+ private_class_method :check_typ!
137
+
138
+ def resolve_issuer_key(resolver, iss, header)
139
+ resolved = resolver.call(iss, header)
140
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: unknown issuer #{iss.inspect}" if resolved.nil?
141
+
142
+ resolved
143
+ end
144
+ private_class_method :resolve_issuer_key
145
+
146
+ def verify_issuer_signature(compact, header, key_or_keys, algorithms)
147
+ keys = key_or_keys.is_a?(::Array) ? key_or_keys : [key_or_keys]
148
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: unknown issuer" if keys.empty?
149
+
150
+ try_keys(compact, order_by_kid(keys, header["kid"]), algorithms)
151
+ end
152
+ private_class_method :verify_issuer_signature
153
+
154
+ def order_by_kid(keys, kid)
155
+ return keys if kid.nil?
156
+
157
+ matching, rest = keys.partition { |k| k.kid == kid }
158
+ matching + rest
159
+ end
160
+ private_class_method :order_by_kid
161
+
162
+ def try_keys(compact, keys, algorithms)
163
+ last_error = nil
164
+ keys.each do |key|
165
+ return OpenID4VP::JOSE::JWS.verify(compact, key: key, algorithms: algorithms)
166
+ rescue OpenID4VP::VerificationError, OpenID4VP::InvalidRequest => e
167
+ last_error = e
168
+ end
169
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: issuer signature verification failed: #{last_error&.message}"
170
+ end
171
+ private_class_method :try_keys
172
+
173
+ def check_time_bounds!(payload, now, leeway)
174
+ now_i = now.to_i
175
+ exp = payload["exp"]
176
+ if exp.is_a?(::Integer) && now_i > exp + leeway
177
+ raise OpenID4VP::PresentationInvalid,
178
+ "sd-jwt: credential expired"
179
+ end
180
+
181
+ nbf = payload["nbf"]
182
+ return unless nbf.is_a?(::Integer) && now_i < nbf - leeway
183
+
184
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: credential not yet valid"
185
+ end
186
+ private_class_method :check_time_bounds!
187
+
188
+ def extract_cnf_jwk(payload)
189
+ jwk_hash = payload.is_a?(::Hash) ? payload.dig("cnf", "jwk") : nil
190
+ return nil unless jwk_hash.is_a?(::Hash)
191
+
192
+ OpenID4VP::JOSE::JWK.new(jwk_hash)
193
+ rescue OpenID4VP::InvalidRequest
194
+ nil
195
+ end
196
+ private_class_method :extract_cnf_jwk
197
+
198
+ # `require_kb` gates whether a Key Binding JWT is MANDATORY, not
199
+ # whether a present one gets checked: a KB-JWT that is present is
200
+ # always verified (and requires `cnf_jwk`), regardless of
201
+ # `require_kb`; only its absence is conditional on `require_kb`.
202
+ def verify_kb(parsed:, cnf_jwk:, expected_aud:, expected_nonce:, require_kb:, now:, leeway:, algorithms:,
203
+ sd_alg:)
204
+ if parsed.kb_jwt.nil?
205
+ raise OpenID4VP::HolderBindingInvalid, "sd-jwt: key binding required but missing" if require_kb
206
+
207
+ return [nil, nil]
208
+ end
209
+
210
+ raise OpenID4VP::HolderBindingInvalid, "sd-jwt: cnf.jwk required for key binding" if cnf_jwk.nil?
211
+
212
+ kb_header = OpenID4VP::JOSE::JWS.peek(parsed.kb_jwt).header
213
+ kb_payload = KbJwt.verify(
214
+ parsed.kb_jwt, key: cnf_jwk, expected_aud: expected_aud, expected_nonce: expected_nonce,
215
+ expected_sd_hash: parsed.sd_hash(sd_alg), now: now, leeway: leeway, algorithms: algorithms
216
+ )
217
+ [kb_payload, kb_header["alg"]]
218
+ end
219
+ private_class_method :verify_kb
220
+
221
+ def verify_transaction_data!(kb_payload, transaction_data)
222
+ return if transaction_data.nil? || transaction_data.empty?
223
+
224
+ if kb_payload.nil?
225
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: transaction_data given but no key binding present"
226
+ end
227
+
228
+ OpenID4VP::TransactionData::SdJwtHashes.verify!(kb_payload, transaction_data)
229
+ end
230
+ private_class_method :verify_transaction_data!
231
+
232
+ def x5chain_from(header)
233
+ x5c = header["x5c"]
234
+ return nil unless x5c.is_a?(::Array)
235
+
236
+ OpenID4VP::X509.certs_from_x5c(x5c)
237
+ end
238
+ private_class_method :x5chain_from
239
+
240
+ def walk_path!(payload, components, by_digest, needed)
241
+ selected = [payload]
242
+
243
+ components.each do |component|
244
+ selected = case component
245
+ when ::String then walk_key(selected, component, by_digest, needed)
246
+ when nil then walk_all(selected, by_digest, needed)
247
+ when ::Integer then walk_index(selected, component, by_digest, needed)
248
+ else
249
+ raise OpenID4VP::PresentationInvalid,
250
+ "sd-jwt: unsupported claims path component #{component.inspect}"
251
+ end
252
+
253
+ if selected.empty?
254
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: cannot resolve claims path #{components.inspect}"
255
+ end
256
+ end
257
+ end
258
+ private_class_method :walk_path!
259
+
260
+ def walk_key(selected, key, by_digest, needed)
261
+ selected.each_with_object([]) do |node, result|
262
+ unless node.is_a?(::Hash)
263
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: cannot select key #{key.inspect} on a non-object"
264
+ end
265
+
266
+ if node.key?(key)
267
+ result << node[key]
268
+ next
269
+ end
270
+
271
+ digest, disclosure = find_object_disclosure(node, key, by_digest)
272
+ next unless disclosure
273
+
274
+ needed[digest] = disclosure
275
+ result << disclosure.value
276
+ end
277
+ end
278
+ private_class_method :walk_key
279
+
280
+ def find_object_disclosure(node, key, by_digest)
281
+ sd = node["_sd"]
282
+ return [nil, nil] unless sd.is_a?(::Array)
283
+
284
+ sd.each do |digest|
285
+ disclosure = by_digest[digest]
286
+ return [digest, disclosure] if disclosure && !disclosure.array_element? && disclosure.name == key
287
+ end
288
+
289
+ [nil, nil]
290
+ end
291
+ private_class_method :find_object_disclosure
292
+
293
+ def walk_index(selected, index, by_digest, needed)
294
+ selected.each_with_object([]) do |node, result|
295
+ unless node.is_a?(::Array)
296
+ raise OpenID4VP::PresentationInvalid,
297
+ "sd-jwt: cannot select an index on a non-array"
298
+ end
299
+ next if index >= node.size
300
+
301
+ resolve_array_element(node[index], by_digest, needed) { |value| result << value }
302
+ end
303
+ end
304
+ private_class_method :walk_index
305
+
306
+ def walk_all(selected, by_digest, needed)
307
+ selected.each_with_object([]) do |node, result|
308
+ unless node.is_a?(::Array)
309
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: cannot select all elements of a non-array"
310
+ end
311
+
312
+ node.each { |element| resolve_array_element(element, by_digest, needed) { |value| result << value } }
313
+ end
314
+ end
315
+ private_class_method :walk_all
316
+
317
+ def resolve_array_element(element, by_digest, needed)
318
+ unless element.is_a?(::Hash) && element.size == 1 && element.key?("...")
319
+ yield element
320
+ return
321
+ end
322
+
323
+ digest = element["..."]
324
+ disclosure = by_digest[digest]
325
+ return unless disclosure # unknown digest: a decoy
326
+
327
+ needed[digest] = disclosure
328
+ yield disclosure.value
329
+ end
330
+ private_class_method :resolve_array_element
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sd_jwt"
4
+ require_relative "../errors"
5
+
6
+ module OpenID4VP
7
+ module SdJwt
8
+ # Reconstructs the full claims of an SD-JWT from its (verified) payload
9
+ # and disclosures (draft-ietf-oauth-selective-disclosure-jwt-22 §5.3):
10
+ # object `_sd` digests and array `{"..." => digest}` items are replaced
11
+ # by the matching disclosure's name/value (recursively); digests that
12
+ # match no disclosure are decoys and are dropped; `_sd`/`_sd_alg` are
13
+ # removed.
14
+ module Reconstruct
15
+ module_function
16
+
17
+ # Returns `[claims, used]`, `used` being the Array of Disclosure
18
+ # objects that were actually referenced. Raises PresentationInvalid if
19
+ # the same disclosure is listed more than once, if a digest is
20
+ # referenced more than once, or if any of `disclosures` is never
21
+ # referenced.
22
+ def claims(payload, disclosures, alg)
23
+ reject_duplicate_disclosures!(disclosures)
24
+ by_digest = disclosures.to_h { |d| [d.digest(alg), d] }
25
+ used = {}
26
+
27
+ result = process(payload, by_digest, used, alg)
28
+
29
+ unused = disclosures.reject { |d| used.key?(d.digest(alg)) }
30
+ unless unused.empty?
31
+ raise OpenID4VP::PresentationInvalid,
32
+ "sd-jwt: disclosure(s) not referenced by any digest: #{unused.map(&:raw).join(', ')}"
33
+ end
34
+
35
+ [result, used.values]
36
+ end
37
+
38
+ # Raises PresentationInvalid when the same disclosure (identical raw
39
+ # string) is listed more than once in `disclosures` -- shared by
40
+ # `.claims` and `Presentation.select`.
41
+ def reject_duplicate_disclosures!(disclosures)
42
+ duplicates = disclosures.map(&:raw).tally.select { |_, count| count > 1 }.keys
43
+ return if duplicates.empty?
44
+
45
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: disclosure listed more than once: #{duplicates.join(', ')}"
46
+ end
47
+
48
+ def process(node, by_digest, used, alg)
49
+ case node
50
+ when ::Hash then process_hash(node, by_digest, used, alg)
51
+ when ::Array then process_array(node, by_digest, used, alg)
52
+ else node
53
+ end
54
+ end
55
+ private_class_method :process
56
+
57
+ def process_hash(hash, by_digest, used, alg)
58
+ result = {}
59
+ hash.each do |key, value|
60
+ next if %w[_sd _sd_alg].include?(key)
61
+
62
+ result[key] = process(value, by_digest, used, alg)
63
+ end
64
+
65
+ merge_sd!(result, hash["_sd"], by_digest, used, alg)
66
+ result
67
+ end
68
+ private_class_method :process_hash
69
+
70
+ def merge_sd!(result, sd_digests, by_digest, used, alg)
71
+ return if sd_digests.nil?
72
+
73
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: _sd must be an array" unless sd_digests.is_a?(::Array)
74
+
75
+ sd_digests.each do |digest|
76
+ disclosure = by_digest[digest]
77
+ next unless disclosure # unknown digest: a decoy
78
+
79
+ mark_used!(used, digest, disclosure)
80
+ if disclosure.array_element?
81
+ raise OpenID4VP::PresentationInvalid,
82
+ "sd-jwt: array-element disclosure referenced from an object's _sd: #{disclosure.raw}"
83
+ end
84
+
85
+ if result.key?(disclosure.name)
86
+ raise OpenID4VP::PresentationInvalid,
87
+ "sd-jwt: claim name collision: #{disclosure.name.inspect} is already present"
88
+ end
89
+
90
+ result[disclosure.name] = process(disclosure.value, by_digest, used, alg)
91
+ end
92
+ end
93
+ private_class_method :merge_sd!
94
+
95
+ def process_array(array, by_digest, used, alg)
96
+ array.each_with_object([]) do |element, result|
97
+ digest = array_element_digest(element)
98
+ if digest.nil?
99
+ result << process(element, by_digest, used, alg)
100
+ next
101
+ end
102
+
103
+ disclosure = by_digest[digest]
104
+ next unless disclosure # unknown digest: a decoy, dropped
105
+
106
+ mark_used!(used, digest, disclosure)
107
+ unless disclosure.array_element?
108
+ raise OpenID4VP::PresentationInvalid,
109
+ "sd-jwt: object-property disclosure referenced from an array: #{disclosure.raw}"
110
+ end
111
+
112
+ result << process(disclosure.value, by_digest, used, alg)
113
+ end
114
+ end
115
+ private_class_method :process_array
116
+
117
+ def array_element_digest(element)
118
+ return nil unless element.is_a?(::Hash) && element.size == 1 && element.key?("...")
119
+
120
+ element["..."]
121
+ end
122
+ private_class_method :array_element_digest
123
+
124
+ def mark_used!(used, digest, disclosure)
125
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: digest used more than once: #{digest}" if used.key?(digest)
126
+
127
+ used[digest] = disclosure
128
+ end
129
+ private_class_method :mark_used!
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ # IETF SD-JWT VC (OpenID4VP Appendix B.3): parsing, digest computation,
5
+ # claims reconstruction, selective disclosure, issuance and Key Binding
6
+ # JWT handling for the `dc+sd-jwt` Credential Format, per
7
+ # draft-ietf-oauth-selective-disclosure-jwt-22.
8
+ module SdJwt
9
+ end
10
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../transaction_data"
4
+
5
+ module OpenID4VP
6
+ module TransactionData
7
+ # A wallet- or verifier-side registry of supported transaction_data
8
+ # types (OpenID4VP §8.4). Each type may be registered with an optional
9
+ # validator that checks the type-specific fields of the decoded entry.
10
+ class Registry
11
+ def initialize
12
+ @validators = {}
13
+ end
14
+
15
+ # Registers `type` as supported. `validator` (or `block`) is an
16
+ # optional callable invoked with the entry's decoded `data` Hash; it
17
+ # should raise InvalidTransactionData for bad type-specific fields.
18
+ # Registering with neither means "type supported, no extra checks".
19
+ def register(type, validator = nil, &block)
20
+ @validators[type] = validator || block
21
+ self
22
+ end
23
+
24
+ def supports?(type)
25
+ @validators.key?(type)
26
+ end
27
+
28
+ # Raises InvalidTransactionData when `entry.type` is not registered,
29
+ # or when its registered validator rejects `entry.data`. Returns
30
+ # `entry` on success.
31
+ def validate!(entry)
32
+ unless supports?(entry.type)
33
+ raise OpenID4VP::InvalidTransactionData, "unsupported transaction data type: #{entry.type}"
34
+ end
35
+
36
+ @validators[entry.type]&.call(entry.data)
37
+
38
+ entry
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../transaction_data"
4
+ require_relative "../util"
5
+ require_relative "../util/base64url"
6
+
7
+ module OpenID4VP
8
+ module TransactionData
9
+ # OpenID4VP §B.3.3.1: the `transaction_data_hashes` / `transaction_data_hashes_alg`
10
+ # profile of transaction data in SD-JWT VC Key Binding JWTs.
11
+ module SdJwtHashes
12
+ # IANA "Named Information Hash Algorithm" identifiers this gem
13
+ # supports, mapped to their OpenSSL::Digest name.
14
+ ALGS = {
15
+ "sha-256" => "SHA256",
16
+ "sha-384" => "SHA384",
17
+ "sha-512" => "SHA512"
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ # base64url(hash(entry.raw)) for each entry, in order, using `alg`
23
+ # (default sha-256; base64url decoding of `raw` is NOT performed
24
+ # before hashing, per §B.3.3.1).
25
+ def compute(entries, alg: "sha-256")
26
+ digest_name = digest_name_for(alg)
27
+
28
+ entries.map { |entry| OpenID4VP::Util::Base64url.encode(::OpenSSL::Digest.digest(digest_name, entry.raw)) }
29
+ end
30
+
31
+ # Builds the Key Binding JWT top-level claims for `entries`:
32
+ # `transaction_data_hashes` (via #compute) and, only when at least one
33
+ # entry declared `transaction_data_hashes_alg`, `transaction_data_hashes_alg`
34
+ # itself. `alg`, when given, is used as-is; otherwise the first
35
+ # algorithm (in ALGS order) supported by this gem and permitted by
36
+ # every declaring entry is chosen, defaulting to sha-256.
37
+ def kb_claims(entries, alg: nil)
38
+ chosen = alg || pick_alg(entries)
39
+ claims = { "transaction_data_hashes" => compute(entries, alg: chosen) }
40
+ claims["transaction_data_hashes_alg"] = chosen if entries.any?(&:transaction_data_hashes_alg)
41
+ claims
42
+ end
43
+
44
+ # Verifies a Key Binding JWT payload's transaction data hashes against
45
+ # `entries` (same length and order required). Raises
46
+ # PresentationInvalid on any mismatch, missing hashes, or a
47
+ # disallowed/missing `transaction_data_hashes_alg`. Returns true on
48
+ # success (the task interface mandates a boolean, not the payload).
49
+ # rubocop:disable-next Naming/PredicateMethod
50
+ def verify!(kb_payload, entries)
51
+ hashes = kb_payload["transaction_data_hashes"]
52
+ unless hashes.is_a?(::Array) && hashes.length == entries.length
53
+ raise OpenID4VP::PresentationInvalid,
54
+ "transaction_data_hashes missing or does not match the requested transaction_data entries"
55
+ end
56
+
57
+ alg = resolve_verify_alg(kb_payload, entries)
58
+ expected = compute(entries, alg: alg)
59
+
60
+ expected.each_with_index do |expected_hash, index|
61
+ actual = hashes[index]
62
+ next if actual.is_a?(::String) && OpenID4VP::Util.secure_compare(expected_hash, actual)
63
+
64
+ raise OpenID4VP::PresentationInvalid, "transaction_data_hashes[#{index}] does not match"
65
+ end
66
+
67
+ true
68
+ end
69
+
70
+ def digest_name_for(alg)
71
+ ALGS.fetch(alg) { raise OpenID4VP::InvalidTransactionData, "unsupported hash algorithm: #{alg.inspect}" }
72
+ end
73
+ private_class_method :digest_name_for
74
+
75
+ def pick_alg(entries)
76
+ declaring = entries.select(&:transaction_data_hashes_alg)
77
+ return "sha-256" if declaring.empty?
78
+
79
+ common = ALGS.each_key.find do |candidate|
80
+ declaring.all? { |entry| entry.transaction_data_hashes_alg.include?(candidate) }
81
+ end
82
+ common || "sha-256"
83
+ end
84
+ private_class_method :pick_alg
85
+
86
+ # OpenID4VP §B.3.3.1: "If transaction_data_hashes_alg was not
87
+ # specified in the request, the hash function MUST be sha-256." /
88
+ # "If transaction_data_hashes_alg was specified in the request, the
89
+ # hash function MUST be one of its values." Also rejects any alg this
90
+ # gem does not support (i.e. not in ALGS) before it ever reaches
91
+ # #compute.
92
+ def resolve_verify_alg(kb_payload, entries)
93
+ declaring = entries.select(&:transaction_data_hashes_alg)
94
+ alg = kb_payload["transaction_data_hashes_alg"]
95
+
96
+ alg = declaring.empty? ? resolve_unrequested_alg(alg) : resolve_requested_alg(alg, declaring)
97
+
98
+ unless ALGS.key?(alg)
99
+ raise OpenID4VP::PresentationInvalid, "unsupported transaction_data_hashes_alg: #{alg.inspect}"
100
+ end
101
+
102
+ alg
103
+ end
104
+ private_class_method :resolve_verify_alg
105
+
106
+ def resolve_unrequested_alg(alg)
107
+ return "sha-256" if alg.nil? || alg == "sha-256"
108
+
109
+ raise OpenID4VP::PresentationInvalid, "transaction_data_hashes_alg not requested"
110
+ end
111
+ private_class_method :resolve_unrequested_alg
112
+
113
+ def resolve_requested_alg(alg, declaring)
114
+ if alg.nil? || !declaring.all? { |entry| entry.transaction_data_hashes_alg.include?(alg) }
115
+ raise OpenID4VP::PresentationInvalid,
116
+ "transaction_data_hashes_alg #{alg.inspect} is not permitted by the requested transaction_data"
117
+ end
118
+
119
+ alg
120
+ end
121
+ private_class_method :resolve_requested_alg
122
+ end
123
+ end
124
+ end