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,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../client_id"
5
+ require_relative "../request_object"
6
+ require_relative "../authorization_request"
7
+ require_relative "../dc_api"
8
+ require_relative "../response_mode"
9
+ require_relative "../response_encryption"
10
+ require_relative "../metadata/verifier_metadata"
11
+ require_relative "../request_verification/resolver"
12
+ require_relative "../request_verification/verified_request"
13
+ require_relative "../util/uri"
14
+ require_relative "../util/form"
15
+ require_relative "../util/random"
16
+ require_relative "processed_request"
17
+
18
+ module OpenID4VP
19
+ class Wallet
20
+ # Wallet-side Authorization Request processing (design doc §4.21;
21
+ # OpenID4VP §5, §5.9-§5.10, Appendix A.2): parses `request`/`request_uri`,
22
+ # dispatches Client Identifier Prefix verification, runs
23
+ # `AuthorizationRequest#validate!`, and checks the Wallet-specific
24
+ # constraints (Client Identifier Prefix support, response_mode support,
25
+ # transaction_data types, vp_formats intersection, response encryption
26
+ # usability) before returning a `ProcessedRequest`.
27
+ class RequestProcessor
28
+ ACCEPT_HEADER = "application/oauth-authz-req+jwt"
29
+
30
+ def initialize(wallet)
31
+ @wallet = wallet
32
+ end
33
+
34
+ # OpenID4VP §5/§5.9-§5.10: processes an Authorization Request given as
35
+ # a URL String or an already-decoded params Hash.
36
+ def process(url_or_params)
37
+ params = normalize_params(url_or_params)
38
+ client_id = resolve_client_id(params)
39
+ parsed, wallet_nonce = resolve_request_source(params)
40
+ request = parsed.nil? ? OpenID4VP::AuthorizationRequest.from_params(params) : nil
41
+
42
+ verified = resolver.verify(client_id: client_id, request: request, parsed: parsed, wallet_nonce: wallet_nonce)
43
+ finalize(verified, wallet_nonce: wallet_nonce)
44
+ end
45
+
46
+ # Appendix A.2: processes a `{protocol, data}` DC API request.
47
+ def process_dc_api_request(protocol, data, origin:)
48
+ parsed_dc = OpenID4VP::DcApi.parse_request(protocol, data, origin: origin)
49
+ verified = parsed_dc.signed ? verify_dc_api_signed(parsed_dc) : verify_dc_api_unsigned(parsed_dc)
50
+ finalize(verified, dc_api: true, origin: origin)
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :wallet
56
+
57
+ def resolver
58
+ OpenID4VP::RequestVerification::Resolver.new(wallet.verification_config)
59
+ end
60
+
61
+ def normalize_params(url_or_params)
62
+ url_or_params.is_a?(::String) ? OpenID4VP::Util::URI.params_from(url_or_params) : url_or_params
63
+ end
64
+
65
+ def resolve_client_id(params)
66
+ raw = params["client_id"]
67
+ raise OpenID4VP::InvalidRequest, "client_id is required" if raw.nil?
68
+
69
+ raw.is_a?(OpenID4VP::ClientId) ? raw : OpenID4VP::ClientId.parse(raw)
70
+ end
71
+
72
+ # request vs request_uri (OpenID4VP §5): mutually exclusive. Returns
73
+ # `[parsed, wallet_nonce]`: `parsed` is nil for an unsigned request
74
+ # (built by the caller from `params` instead), `wallet_nonce` is only
75
+ # set for the request_uri POST method (§5.10).
76
+ def resolve_request_source(params)
77
+ has_request = !params["request"].nil?
78
+ has_request_uri = !params["request_uri"].nil?
79
+ if has_request && has_request_uri
80
+ raise OpenID4VP::InvalidRequest, "request and request_uri must not both be present"
81
+ end
82
+
83
+ return [OpenID4VP::RequestObject.parse(params["request"]), nil] if has_request
84
+ return fetch_by_reference(params) if has_request_uri
85
+
86
+ [nil, nil]
87
+ end
88
+
89
+ def fetch_by_reference(params)
90
+ method = resolve_request_uri_method(params["request_uri_method"])
91
+ method == "get" ? fetch_get(params["request_uri"]) : fetch_post(params["request_uri"])
92
+ end
93
+
94
+ def resolve_request_uri_method(raw)
95
+ return "get" if raw.nil?
96
+ return raw if %w[get post].include?(raw)
97
+
98
+ raise OpenID4VP::InvalidRequestUriMethod, "invalid request_uri_method: #{raw.inspect}"
99
+ end
100
+
101
+ def fetch_get(request_uri)
102
+ response = wallet.http.get(request_uri, headers: { "accept" => ACCEPT_HEADER })
103
+ check_http_status!(response)
104
+ [OpenID4VP::RequestObject.parse(response.body), nil]
105
+ end
106
+
107
+ # OpenID4VP §5.10: a POST carries a fresh wallet_nonce and the
108
+ # Wallet's metadata, and MUST get back a Request Object echoing that
109
+ # wallet_nonce (checked by Resolver#verify via `wallet_nonce:`).
110
+ def fetch_post(request_uri)
111
+ wallet_nonce = OpenID4VP::Util::Random.nonce
112
+ body = OpenID4VP::Util::Form.encode("wallet_metadata" => wallet.metadata.to_h, "wallet_nonce" => wallet_nonce)
113
+ headers = { "content-type" => "application/x-www-form-urlencoded", "accept" => ACCEPT_HEADER }
114
+ response = wallet.http.post(request_uri, body: body, headers: headers)
115
+ check_http_status!(response)
116
+ [OpenID4VP::RequestObject.parse(response.body), wallet_nonce]
117
+ end
118
+
119
+ def check_http_status!(response)
120
+ return if (200..299).cover?(response.status)
121
+
122
+ raise OpenID4VP::InvalidRequest, "request_uri fetch failed (#{response.status})"
123
+ end
124
+
125
+ # Appendix A.3.2.2: a multisigned DC API request carries one signature
126
+ # per Client Identifier (Trust Framework), each verifiable
127
+ # independently -- a Wallet supporting only some of the listed Client
128
+ # Identifier Prefixes verifies (only) the first one it supports,
129
+ # rather than requiring every signature (for prefixes it may not even
130
+ # have the means to verify) to succeed. A signed (non-multisigned)
131
+ # request has exactly one candidate, so this also covers §A.3.2.1.
132
+ def verify_dc_api_signed(parsed_dc)
133
+ supported = parsed_dc.requests.find { |parsed| wallet.metadata.supports_prefix?(parsed.request.client_id) }
134
+ raise OpenID4VP::InvalidRequest, "no supported client_id prefix in signed request" if supported.nil?
135
+
136
+ resolver.verify(
137
+ client_id: supported.request.client_id, request: supported.request, parsed: supported, dc_api: true
138
+ )
139
+ end
140
+
141
+ # Appendix A.2/A.3.1: an unsigned DC API request carries no client_id
142
+ # (any present value is ignored) and is not verified.
143
+ def verify_dc_api_unsigned(parsed_dc)
144
+ request = parsed_dc.request
145
+ OpenID4VP::RequestVerification::VerifiedRequest.new(
146
+ request: request, client_id: nil, verifier_metadata: request.client_metadata,
147
+ signed: false, signing_key: nil, parsed: nil, attestation: nil
148
+ )
149
+ end
150
+
151
+ def finalize(verified, wallet_nonce: nil, dc_api: false, origin: nil)
152
+ request = verified.request
153
+ request.validate!(scope_resolver: wallet.scope_resolver, signed: verified.signed, dc_api: dc_api)
154
+
155
+ check_prefix!(verified.client_id) if verified.client_id
156
+ check_response_mode!(request.response_mode)
157
+ validate_transaction_data!(request.transaction_data)
158
+
159
+ vp_formats = compute_vp_formats(verified.verifier_metadata)
160
+ check_formats!(request.effective_dcql_query, vp_formats)
161
+ encryption = build_encryption(request.response_mode, verified.verifier_metadata)
162
+
163
+ build_processed_request(verified, request, wallet_nonce, dc_api, origin, vp_formats, encryption)
164
+ end
165
+
166
+ def build_processed_request(verified, request, wallet_nonce, dc_api, origin, vp_formats, encryption)
167
+ audience = dc_api ? OpenID4VP::DcApi.audience(origin) : request.audience
168
+
169
+ OpenID4VP::Wallet::ProcessedRequest.new(
170
+ request: request, client_id: verified.client_id, verified: verified,
171
+ verifier_metadata: verified.verifier_metadata, audience: audience, response_mode: request.response_mode,
172
+ return_uri: request.return_uri, dc_api: dc_api, origin: origin, vp_formats: vp_formats,
173
+ dcql_query: request.effective_dcql_query, transaction_data: request.transaction_data || [],
174
+ encryption: encryption, wallet_nonce: wallet_nonce
175
+ )
176
+ end
177
+
178
+ # OpenID4VP §8.5: "The Wallet does not support the Client Identifier
179
+ # Prefix passed in the Authorization Request."
180
+ def check_prefix!(client_id)
181
+ return if wallet.metadata.supports_prefix?(client_id)
182
+
183
+ raise OpenID4VP::InvalidRequest, "unsupported client_id prefix"
184
+ end
185
+
186
+ def check_response_mode!(response_mode)
187
+ supported = wallet.metadata.response_modes_supported
188
+ return if supported.nil? || supported.include?(response_mode)
189
+
190
+ raise OpenID4VP::InvalidRequest, "unsupported response_mode"
191
+ end
192
+
193
+ # OpenID4VP §8.4/§8.5: unsupported transaction_data types raise
194
+ # invalid_transaction_data.
195
+ def validate_transaction_data!(entries)
196
+ (entries || []).each { |entry| wallet.transaction_data_types.validate!(entry) }
197
+ end
198
+
199
+ # OpenID4VP §10.1: the vp_formats in effect for this request -- the
200
+ # Wallet's own vp_formats_supported, narrowed to what the Verifier
201
+ # also supports when it declared vp_formats_supported.
202
+ def compute_vp_formats(verifier_metadata)
203
+ wallet_formats = wallet.metadata.vp_formats_supported
204
+ verifier_formats = verifier_metadata&.vp_formats_supported
205
+ verifier_formats ? wallet_formats.intersect(verifier_formats) : wallet_formats
206
+ end
207
+
208
+ # OpenID4VP §8.5: vp_formats_not_supported when the query requests a
209
+ # credential format outside the (possibly narrowed) vp_formats.
210
+ def check_formats!(dcql_query, vp_formats)
211
+ return if dcql_query.nil?
212
+
213
+ dcql_query.credentials.each do |credential_query|
214
+ next if vp_formats.formats.include?(credential_query.format)
215
+
216
+ raise OpenID4VP::VpFormatsNotSupported, "unsupported credential format: #{credential_query.format}"
217
+ end
218
+ end
219
+
220
+ # OpenID4VP §8.3: for an encrypted response_mode, resolves the
221
+ # Verifier's encryption key/alg/enc now so an unusable configuration
222
+ # is reported eagerly rather than at response-build time.
223
+ def build_encryption(response_mode, verifier_metadata)
224
+ return nil unless OpenID4VP::ResponseMode.encrypted?(response_mode)
225
+
226
+ encryption = OpenID4VP::ResponseEncryption.new(
227
+ verifier_metadata: verifier_metadata || OpenID4VP::Metadata::VerifierMetadata.new({}),
228
+ wallet_supported_algs: wallet.metadata.authorization_encryption_alg_values_supported,
229
+ wallet_supported_encs: wallet.metadata.authorization_encryption_enc_values_supported
230
+ )
231
+ encryption.key
232
+ encryption.enc
233
+ encryption
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../authorization_response"
5
+ require_relative "presenter"
6
+
7
+ module OpenID4VP
8
+ class Wallet
9
+ # OpenID4VP §8: builds the encoded Authorization Response for a
10
+ # (`ProcessedRequest`, `DCQL::Selection`) pair -- presenting every
11
+ # matched Credential (`Presenter`) and encoding the result for the
12
+ # request's Response Mode (`AuthorizationResponse#encode`).
13
+ class ResponseBuilder
14
+ # response: the `AuthorizationResponse`. encoded: its
15
+ # `AuthorizationResponse::Encoded`. vp_token: `response.vp_token` (a
16
+ # `VpToken`), exposed directly for convenience.
17
+ BuiltResponse = Struct.new(:response, :encoded, :vp_token, keyword_init: true)
18
+
19
+ def initialize(wallet)
20
+ @wallet = wallet
21
+ end
22
+
23
+ # Raises OpenID4VP::AccessDenied when `selection` does not satisfy the
24
+ # request's DCQL query (OpenID4VP §8.5: "The Wallet does not have a
25
+ # Credential that matches the requested parameters.").
26
+ def build(processed, selection)
27
+ raise OpenID4VP::AccessDenied, "no matching credentials" unless selection.satisfiable?
28
+
29
+ response = build_response(processed, selection)
30
+ encoded = response.encode(processed.response_mode, redirect_uri: processed.return_uri,
31
+ encryption: processed.encryption)
32
+ BuiltResponse.new(response: response, encoded: encoded, vp_token: response.vp_token)
33
+ end
34
+
35
+ private
36
+
37
+ def build_response(processed, selection)
38
+ vp_token = Presenter.new(@wallet).present(processed, selection)
39
+ state = processed.dc_api ? nil : processed.request.state
40
+ OpenID4VP::AuthorizationResponse.new(vp_token: vp_token, state: state)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../response_mode"
5
+
6
+ module OpenID4VP
7
+ class Wallet
8
+ # The result of `Wallet#submit`: `kind` says how the built response was
9
+ # delivered -- `:posted` (direct_post[.jwt]; the Verifier's HTTP
10
+ # response is consulted for `redirect_uri`/`status`), `:redirect` (a
11
+ # fragment/query URL for the caller's own HTTP layer to redirect the
12
+ # user agent to), or `:dc_api` (the `data` object for the caller's own
13
+ # Digital Credentials API bridge to return to the browser).
14
+ Submission = Struct.new(:kind, :redirect_uri, :data, :status, keyword_init: true)
15
+
16
+ # OpenID4VP §8.2/§8.3.1 (wallet side): delivers a
17
+ # `ResponseBuilder::BuiltResponse`'s encoded Authorization Response per
18
+ # the request's Response Mode, returning a `Submission`.
19
+ class Submitter
20
+ FORM_HEADERS = { "content-type" => "application/x-www-form-urlencoded" }.freeze
21
+
22
+ def initialize(wallet)
23
+ @wallet = wallet
24
+ end
25
+
26
+ def submit(processed, built)
27
+ return post(processed, built.encoded) if OpenID4VP::ResponseMode.direct_post?(processed.response_mode)
28
+ return Submission.new(kind: :dc_api, data: built.encoded.data) if
29
+ OpenID4VP::ResponseMode.dc_api?(processed.response_mode)
30
+
31
+ Submission.new(kind: :redirect, redirect_uri: built.encoded.url)
32
+ end
33
+
34
+ private
35
+
36
+ # OpenID4VP §8.2: POSTs the response, then reads the Verifier's JSON
37
+ # body for an OPTIONAL `redirect_uri` the caller's frontend should
38
+ # navigate to next (an empty body is valid and yields a nil
39
+ # redirect_uri).
40
+ def post(processed, encoded)
41
+ response = @wallet.http.post(processed.return_uri, body: encoded.form_body, headers: FORM_HEADERS)
42
+ check_status!(response)
43
+ Submission.new(kind: :posted, redirect_uri: redirect_uri_from(response.body), status: response.status)
44
+ end
45
+
46
+ def check_status!(response)
47
+ return if (200..299).cover?(response.status)
48
+
49
+ raise OpenID4VP::InvalidResponse, "response rejected (#{response.status})"
50
+ end
51
+
52
+ def redirect_uri_from(body)
53
+ return nil if body.nil? || body.strip.empty?
54
+
55
+ parsed = parse_json!(body)
56
+ raise OpenID4VP::InvalidResponse, "response body is not a JSON object" unless parsed.is_a?(::Hash)
57
+
58
+ parsed["redirect_uri"]
59
+ end
60
+
61
+ def parse_json!(body)
62
+ ::JSON.parse(body)
63
+ rescue ::JSON::ParserError
64
+ raise OpenID4VP::InvalidResponse, "response body is not valid JSON"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wallet/config"
4
+ require_relative "wallet/request_processor"
5
+ require_relative "wallet/response_builder"
6
+ require_relative "wallet/submission"
7
+ require_relative "dcql/evaluator"
8
+ require_relative "error_response"
9
+ require_relative "response_mode"
10
+
11
+ module OpenID4VP
12
+ # OpenID4VP Wallet orchestration (design doc §4.21): processes
13
+ # Authorization Requests (by value, by reference, or over the W3C Digital
14
+ # Credentials API), verifying the Client Identifier Prefix (§5.9) and
15
+ # validating the request (§5, §5.10); evaluates a DCQL query against the
16
+ # Wallet's Credentials (`#evaluate`), presents and encodes the
17
+ # Authorization Response (`#build_response`), and submits it per the
18
+ # Response Mode (`#submit`) -- or does all of that in one call
19
+ # (`#respond`).
20
+ class Wallet
21
+ # The documented public name for `ResponseBuilder::BuiltResponse`
22
+ # (`#build_response`'s return type).
23
+ BuiltResponse = ResponseBuilder::BuiltResponse
24
+
25
+ def initialize(**)
26
+ @config = Config.new(**)
27
+ @request_processor = RequestProcessor.new(self)
28
+ end
29
+
30
+ attr_reader :config, :request_processor
31
+
32
+ def http
33
+ config.http
34
+ end
35
+
36
+ def metadata
37
+ config.metadata
38
+ end
39
+
40
+ def formats
41
+ config.formats
42
+ end
43
+
44
+ def verification_config
45
+ config.verification
46
+ end
47
+
48
+ def transaction_data_types
49
+ config.transaction_data_types
50
+ end
51
+
52
+ def scope_resolver
53
+ config.scope_resolver
54
+ end
55
+
56
+ def clock
57
+ config.clock
58
+ end
59
+
60
+ def now
61
+ config.now
62
+ end
63
+
64
+ def holder_keys
65
+ config.holder_keys
66
+ end
67
+
68
+ # OpenID4VP §5, §5.9-§5.10: parses and verifies `url_or_params` (a
69
+ # request URL/String, or an already-decoded params Hash), returning a
70
+ # `ProcessedRequest`.
71
+ def process_request(url_or_params)
72
+ request_processor.process(url_or_params)
73
+ end
74
+
75
+ # OpenID4VP Appendix A: parses and verifies a `{protocol, data}` DC API
76
+ # request, returning a `ProcessedRequest`.
77
+ def process_dc_api_request(protocol, data, origin:)
78
+ request_processor.process_dc_api_request(protocol, data, origin: origin)
79
+ end
80
+
81
+ # OpenID4VP §6.4: evaluates `processed.dcql_query` against `credentials`
82
+ # (an Array of `OpenID4VP::Credential`), returning a `DCQL::Selection`.
83
+ def evaluate(processed, credentials)
84
+ OpenID4VP::DCQL::Evaluator.new(processed.dcql_query, credentials, hooks: config.authority_hooks).evaluate
85
+ end
86
+
87
+ # OpenID4VP §8: builds and encodes the Authorization Response for
88
+ # `selection` (a `DCQL::Selection`, from `#evaluate`). Raises
89
+ # OpenID4VP::AccessDenied when `selection` is not `#satisfiable?`.
90
+ def build_response(processed, selection)
91
+ ResponseBuilder.new(self).build(processed, selection)
92
+ end
93
+
94
+ # OpenID4VP §8.5: builds and encodes an Authorization Error Response
95
+ # for `exception`. `processed`, when given, supplies the response_mode/
96
+ # return_uri/state; otherwise pass them via `response_mode:`/
97
+ # `return_uri:` directly (e.g. when `exception` was raised by
98
+ # `#process_request` itself, before a `ProcessedRequest` exists).
99
+ def build_error(processed, exception, response_mode: nil, return_uri: nil)
100
+ mode = processed ? processed.response_mode : response_mode
101
+ uri = processed ? processed.return_uri : return_uri
102
+ state = processed&.request&.state
103
+
104
+ OpenID4VP::ErrorResponse.from_exception(exception, state: state).encode(mode, redirect_uri: uri)
105
+ end
106
+
107
+ # OpenID4VP §8.2/§8.3.1: delivers `built` (a
108
+ # `ResponseBuilder::BuiltResponse`, from `#build_response`) per
109
+ # `processed.response_mode`, returning a `Submission`.
110
+ def submit(processed, built)
111
+ Submitter.new(self).submit(processed, built)
112
+ end
113
+
114
+ # Convenience: processes `url_or_params`, evaluates it against
115
+ # `credentials`, builds and submits the Authorization Response, and
116
+ # returns the resulting `Submission`. When an `OpenID4VP::Error` is
117
+ # raised after the request was successfully processed AND its
118
+ # response_mode is direct_post[.jwt], the corresponding Authorization
119
+ # Error Response is POSTed to `processed.return_uri` (its own outcome is
120
+ # not inspected) before the original exception is re-raised.
121
+ def respond(url_or_params, credentials)
122
+ processed = process_request(url_or_params)
123
+ selection = evaluate(processed, credentials)
124
+ built = build_response(processed, selection)
125
+ submit(processed, built)
126
+ rescue OpenID4VP::Error => e
127
+ post_error(processed, e) if processed
128
+ raise
129
+ end
130
+
131
+ private
132
+
133
+ def post_error(processed, exception)
134
+ return unless OpenID4VP::ResponseMode.direct_post?(processed.response_mode)
135
+
136
+ encoded = build_error(processed, exception)
137
+ http.post(processed.return_uri, body: encoded.form_body, headers: Submitter::FORM_HEADERS)
138
+ rescue OpenID4VP::Error
139
+ nil
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../x509"
5
+
6
+ module OpenID4VP
7
+ module X509
8
+ # Validates an X.509 certificate chain (as presented e.g. via a JOSE/COSE
9
+ # `x5c` header) against a fixed set of trust anchors, using
10
+ # OpenSSL::X509::Store.
11
+ class ChainValidator
12
+ def initialize(trust_anchors:, time: nil)
13
+ @trust_anchors = trust_anchors.map { |c| OpenID4VP::X509.parse(c) }
14
+ @time = time
15
+ end
16
+
17
+ # chain: leaf certificate first, followed by zero or more untrusted
18
+ # intermediates (each an OpenSSL::X509::Certificate, DER or PEM
19
+ # string). Returns the parsed leaf certificate on success.
20
+ def validate!(chain)
21
+ certs = Array(chain).map { |c| OpenID4VP::X509.parse(c) }
22
+ leaf, *intermediates = certs
23
+ raise OpenID4VP::VerificationError, "empty certificate chain" if leaf.nil?
24
+
25
+ store = build_store
26
+ store_context = OpenSSL::X509::StoreContext.new(store, leaf, intermediates)
27
+ return leaf if store_context.verify
28
+
29
+ raise OpenID4VP::VerificationError, "certificate chain validation failed: #{store_context.error_string}"
30
+ end
31
+
32
+ private
33
+
34
+ def build_store
35
+ store = OpenSSL::X509::Store.new
36
+ store.time = @time if @time
37
+ @trust_anchors.each { |anchor| store.add_cert(anchor) }
38
+ store
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "util"
5
+ require_relative "util/base64url"
6
+
7
+ module OpenID4VP
8
+ # Framework-agnostic X.509 helpers: parsing, subjectAltName dNSName
9
+ # extraction, key identifier extensions and x5c (RFC 7515 §4.1.6)
10
+ # conversions. See also `OpenID4VP::X509::ChainValidator`.
11
+ module X509
12
+ module_function
13
+
14
+ # Accepts an OpenSSL::X509::Certificate, a DER string or a PEM string.
15
+ def parse(input)
16
+ case input
17
+ when OpenSSL::X509::Certificate
18
+ input
19
+ when String
20
+ begin
21
+ OpenSSL::X509::Certificate.new(input)
22
+ rescue OpenSSL::X509::CertificateError
23
+ raise OpenID4VP::InvalidRequest, "invalid X.509 certificate"
24
+ end
25
+ else
26
+ raise OpenID4VP::InvalidRequest, "invalid X.509 certificate: #{input.class}"
27
+ end
28
+ end
29
+
30
+ # subjectAltName dNSName entries, in order.
31
+ def dns_sans(cert)
32
+ ext = extension(parse(cert), "subjectAltName")
33
+ return [] unless ext
34
+
35
+ ext.value.split(",").filter_map do |entry|
36
+ entry = entry.strip
37
+ entry.delete_prefix("DNS:") if entry.start_with?("DNS:")
38
+ end
39
+ end
40
+
41
+ # base64url(SHA-256(DER)) of the certificate, as used for the
42
+ # OID4VP `x509_hash` client identifier prefix.
43
+ def leaf_hash_b64(cert)
44
+ Util::Base64url.encode(Util.sha256(parse(cert).to_der))
45
+ end
46
+
47
+ # Raw keyIdentifier bytes from the authorityKeyIdentifier extension, or
48
+ # nil if the certificate has none / it carries no keyIdentifier.
49
+ def authority_key_identifier(cert)
50
+ ext = extension(parse(cert), "authorityKeyIdentifier")
51
+ return nil unless ext
52
+
53
+ # AuthorityKeyIdentifier ::= SEQUENCE { keyIdentifier [0] IMPLICIT
54
+ # OCTET STRING OPTIONAL, ... }. extnValue is the OCTET STRING wrapping
55
+ # that SEQUENCE's DER encoding.
56
+ seq = OpenSSL::ASN1.decode(extension_value_octets(ext))
57
+ key_id = seq.value.find { |v| v.tag.zero? && v.tag_class == :CONTEXT_SPECIFIC }
58
+ key_id&.value
59
+ end
60
+
61
+ # Raw keyIdentifier bytes from the subjectKeyIdentifier extension, or nil.
62
+ def subject_key_identifier(cert)
63
+ ext = extension(parse(cert), "subjectKeyIdentifier")
64
+ return nil unless ext
65
+
66
+ # SubjectKeyIdentifier ::= OCTET STRING, so extnValue wraps a single
67
+ # nested OCTET STRING whose value is the raw key identifier.
68
+ OpenSSL::ASN1.decode(extension_value_octets(ext)).value
69
+ end
70
+
71
+ # RFC 7515 x5c: standard (padded) base64 of each certificate's DER,
72
+ # leaf first.
73
+ def x5c_from(certs)
74
+ certs.map { |c| [parse(c).to_der].pack("m0") }
75
+ end
76
+
77
+ def certs_from_x5c(array)
78
+ array.map do |b64|
79
+ der =
80
+ begin
81
+ b64.unpack1("m0")
82
+ rescue ArgumentError
83
+ raise OpenID4VP::InvalidRequest, "invalid x5c entry"
84
+ end
85
+ parse(der)
86
+ end
87
+ end
88
+
89
+ # The extnValue OCTET STRING's raw contents (the DER encoding of the
90
+ # extension's actual value), decoded from the extension's own DER form
91
+ # since OpenSSL::X509::Extension does not expose it directly.
92
+ def extension_value_octets(ext)
93
+ asn1_extension = OpenSSL::ASN1.decode(ext.to_der)
94
+ asn1_extension.value.last.value
95
+ end
96
+ private_class_method :extension_value_octets
97
+
98
+ def extension(cert, oid)
99
+ cert.extensions.find { |e| e.oid == oid }
100
+ end
101
+ private_class_method :extension
102
+ end
103
+ end
data/lib/openid4vp.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require "uri"
7
+ require "cbor"
8
+
9
+ require_relative "openid4vp/version"
10
+ require_relative "openid4vp/errors"
11
+
12
+ # Every file under lib/openid4vp declares its own load-time dependencies
13
+ # (superclasses, mixins, constants used in class bodies) with require_relative,
14
+ # so the load order of this glob does not matter.
15
+ Dir[File.join(__dir__, "openid4vp", "**", "*.rb")].each { |file| require file }
16
+
17
+ module OpenID4VP
18
+ end