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,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../util/random"
5
+ require_relative "../util/uri"
6
+ require_relative "../response_mode"
7
+ require_relative "../authorization_request"
8
+ require_relative "../dc_api"
9
+ require_relative "session"
10
+ require_relative "created_request"
11
+
12
+ module OpenID4VP
13
+ class Verifier
14
+ # Builds and stores the Verifier::Session + Authorization Request (or
15
+ # DC API request) for `Verifier#create_request` (OpenID4VP §5, §13.3
16
+ # steps 2-4; design doc §4.20).
17
+ class RequestBuilder
18
+ DEFAULT_RESPONSE_MODE = "direct_post"
19
+
20
+ def initialize(verifier)
21
+ @verifier = verifier
22
+ end
23
+
24
+ # rubocop:disable-next Metrics/MethodLength
25
+ def build(dcql_query:, response_mode: DEFAULT_RESPONSE_MODE, by_reference: nil, request_uri_method: nil,
26
+ signed: nil, transaction_data: [], verifier_info: [], scope: nil, expected_origins: nil,
27
+ origin: nil, client_metadata: {}, state: nil, dc_api_protocol: nil)
28
+ config = @verifier.config
29
+ is_dc_api = OpenID4VP::ResponseMode.dc_api?(response_mode)
30
+
31
+ request_id = OpenID4VP::Util::Random.id
32
+ nonce = OpenID4VP::Util::Random.nonce
33
+ session_id = OpenID4VP::Util::Random.id
34
+ state_value = resolve_state!(is_dc_api, state, request_id)
35
+
36
+ signed = resolve_signed!(config, signed)
37
+ by_reference = resolve_by_reference!(config, by_reference, signed)
38
+ validate_expected_origins!(is_dc_api, signed, expected_origins)
39
+
40
+ metadata_additions, encryption_jwk = config.encryption_metadata(response_mode, request_id)
41
+ metadata = build_client_metadata(config, client_metadata, metadata_additions)
42
+
43
+ params = build_params(
44
+ config: config, nonce: nonce, state: state_value, response_mode: response_mode, dcql_query: dcql_query,
45
+ client_metadata: metadata, transaction_data: transaction_data, verifier_info: verifier_info,
46
+ scope: scope, expected_origins: expected_origins, is_dc_api: is_dc_api
47
+ )
48
+
49
+ request = OpenID4VP::AuthorizationRequest.new(params)
50
+ request.validate!(scope_resolver: config.scope_resolver, signed: signed, dc_api: is_dc_api)
51
+
52
+ outcome = build_outcome(
53
+ config, request, is_dc_api: is_dc_api, by_reference: by_reference, signed: signed,
54
+ request_id: request_id, request_uri_method: request_uri_method, client_metadata: metadata,
55
+ dc_api_protocol: dc_api_protocol
56
+ )
57
+
58
+ session = build_session(
59
+ id: session_id, request_id: request_id, nonce: nonce, state: state_value, response_mode: response_mode,
60
+ request: request, request_object: outcome[:request_object], encryption_jwk: encryption_jwk,
61
+ origin: origin, config: config
62
+ )
63
+ config.session_store.create(session)
64
+
65
+ OpenID4VP::Verifier::CreatedRequest.new(
66
+ session: session, url: outcome[:url], request_object: outcome[:request_object],
67
+ dc_api_request: outcome[:dc_api_request], authorization_request: request
68
+ )
69
+ end
70
+
71
+ private
72
+
73
+ def build_session(id:, request_id:, nonce:, state:, response_mode:, request:, request_object:,
74
+ encryption_jwk:, origin:, config:)
75
+ OpenID4VP::Verifier::Session.new(
76
+ id: id, request_id: request_id, nonce: nonce, state: state, response_mode: response_mode,
77
+ request: request, request_object: request_object, encryption_jwk: encryption_jwk, origin: origin,
78
+ created_at: config.now, expires_at: config.now + config.session_ttl
79
+ )
80
+ end
81
+
82
+ # `state` is always the request_id for non-DC-API flows: the session
83
+ # store only indexes sessions by request_id, so any other state value
84
+ # could never be resolved back to a session by the response endpoint
85
+ # (§13.3 step 6). The `state:` kwarg is kept for API compatibility but
86
+ # a caller-supplied value is rejected rather than silently ignored.
87
+ def resolve_state!(is_dc_api, state, request_id)
88
+ return nil if is_dc_api
89
+ raise OpenID4VP::ConfigurationError, "state is derived from the request id and cannot be overridden" if state
90
+
91
+ request_id
92
+ end
93
+
94
+ def resolve_signed!(config, signed)
95
+ signed = config.client_id.signed_request_required? if signed.nil?
96
+
97
+ if signed && !config.client_id.signed_request_allowed?
98
+ raise OpenID4VP::ConfigurationError, "#{config.client_id.prefix} client_id cannot sign requests"
99
+ end
100
+ if !signed && config.client_id.signed_request_required?
101
+ raise OpenID4VP::ConfigurationError, "#{config.client_id.prefix} requires a signed request"
102
+ end
103
+
104
+ signed
105
+ end
106
+
107
+ def resolve_by_reference!(config, by_reference, signed)
108
+ by_reference = !config.request_uri_base.nil? if by_reference.nil?
109
+
110
+ if by_reference && config.request_uri_base.nil?
111
+ raise OpenID4VP::ConfigurationError, "by_reference requires a request_uri_base"
112
+ end
113
+ raise OpenID4VP::ConfigurationError, "request_uri requires a signed request object" if by_reference && !signed
114
+
115
+ by_reference
116
+ end
117
+
118
+ def validate_expected_origins!(is_dc_api, signed, expected_origins)
119
+ return unless is_dc_api && signed && expected_origins.nil?
120
+
121
+ raise OpenID4VP::ConfigurationError, "expected_origins required for signed DC API requests"
122
+ end
123
+
124
+ def build_client_metadata(config, overrides, additions)
125
+ metadata = config.client_metadata.merge(overrides || {})
126
+ metadata["vp_formats_supported"] = config.vp_formats_supported if config.vp_formats_supported
127
+ metadata.merge(additions)
128
+ end
129
+
130
+ def build_params(config:, nonce:, state:, response_mode:, dcql_query:, client_metadata:, transaction_data:,
131
+ verifier_info:, scope:, expected_origins:, is_dc_api:)
132
+ params = {
133
+ "response_type" => "vp_token", "client_id" => config.client_id.to_s, "nonce" => nonce,
134
+ "response_mode" => response_mode
135
+ }
136
+ # OpenID4VP §5.9.2: a pre-registered client_id's metadata (formats,
137
+ # jwks, ...) is already registered out-of-band, so the request MUST
138
+ # NOT carry a client_metadata parameter -- RequestVerification::
139
+ # PreRegistered rejects one on the Wallet side (InvalidClient) even
140
+ # when it is the `{}` this builder would otherwise always send.
141
+ params["client_metadata"] = client_metadata unless config.client_id.pre_registered?
142
+ params["state"] = state if state
143
+ params["dcql_query"] = dcql_query if dcql_query
144
+ params["scope"] = scope if scope
145
+ params["transaction_data"] = transaction_data unless transaction_data.nil? || transaction_data.empty?
146
+ params["verifier_info"] = verifier_info unless verifier_info.nil? || verifier_info.empty?
147
+ params["expected_origins"] = expected_origins if expected_origins
148
+ add_return_uri!(params, config, response_mode, is_dc_api)
149
+ params
150
+ end
151
+
152
+ def add_return_uri!(params, config, response_mode, is_dc_api)
153
+ if OpenID4VP::ResponseMode.direct_post?(response_mode)
154
+ raise OpenID4VP::ConfigurationError, "response_uri is not configured" if config.response_uri.nil?
155
+
156
+ params["response_uri"] = config.response_uri
157
+ elsif !is_dc_api
158
+ if config.redirect_uri.nil?
159
+ raise OpenID4VP::ConfigurationError, "redirect_uri is required for response_mode #{response_mode}"
160
+ end
161
+
162
+ params["redirect_uri"] = config.redirect_uri
163
+ end
164
+ end
165
+
166
+ def build_outcome(config, request, is_dc_api:, by_reference:, signed:, request_id:, request_uri_method:,
167
+ client_metadata:, dc_api_protocol:)
168
+ if is_dc_api
169
+ build_dc_api(request, signed: signed, _dc_api_protocol: dc_api_protocol)
170
+ elsif by_reference
171
+ build_by_reference(config, request, request_id: request_id, request_uri_method: request_uri_method,
172
+ client_metadata: client_metadata)
173
+ else
174
+ build_by_value(config, request, signed: signed)
175
+ end
176
+ end
177
+
178
+ # `_dc_api_protocol` is accepted for forward-compatibility with
179
+ # multisigned DC API requests (not built by this task); unsigned and
180
+ # signed requests always use their respective DcApi::PROTOCOLS entry.
181
+ def build_dc_api(request, signed:, _dc_api_protocol: nil)
182
+ return { url: nil, request_object: nil, dc_api_request: OpenID4VP::DcApi.unsigned_request(request) } unless
183
+ signed
184
+
185
+ jws = nil
186
+ dc_api_request = OpenID4VP::DcApi.signed_request(request, signer: lambda { |r|
187
+ jws = @verifier.request_signer.sign(r)
188
+ })
189
+ { url: nil, request_object: jws, dc_api_request: dc_api_request }
190
+ end
191
+
192
+ def build_by_reference(config, request, request_id:, request_uri_method:, client_metadata:)
193
+ request_uri = "#{config.request_uri_base}/#{request_id}"
194
+ url_params = { "client_id" => config.client_id.to_s, "request_uri" => request_uri }
195
+ url_params["request_uri_method"] = request_uri_method if request_uri_method
196
+ url_params["client_metadata"] = ::JSON.generate(client_metadata) if request_uri_method == "post"
197
+ url = OpenID4VP::Util::URI.with_query(config.authorization_endpoint, url_params)
198
+
199
+ request_object = request_uri_method == "post" ? nil : @verifier.request_signer.sign(request)
200
+ { url: url, request_object: request_object, dc_api_request: nil }
201
+ end
202
+
203
+ def build_by_value(config, request, signed:)
204
+ if signed
205
+ jws = @verifier.request_signer.sign(request)
206
+ url = OpenID4VP::Util::URI.with_query(
207
+ config.authorization_endpoint, { "client_id" => config.client_id.to_s, "request" => jws }
208
+ )
209
+ { url: url, request_object: jws, dc_api_request: nil }
210
+ else
211
+ { url: request.to_url(config.authorization_endpoint), request_object: nil, dc_api_request: nil }
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../request_object"
5
+ require_relative "../jose/algorithms"
6
+
7
+ module OpenID4VP
8
+ class Verifier
9
+ # Signs Authorization Requests / Request Objects (OpenID4VP §5, §5.10)
10
+ # on behalf of a `Verifier`, picking the header material required by
11
+ # the Verifier's Client Identifier Prefix (design doc §4.20).
12
+ class RequestSigner
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ attr_reader :config
18
+
19
+ def signing_key
20
+ config.signing_key
21
+ end
22
+
23
+ def alg
24
+ return nil if config.signing.nil?
25
+
26
+ config.signing[:alg] || OpenID4VP::JOSE::Algorithms.for_key(signing_key)
27
+ end
28
+
29
+ # Signs `request` (an AuthorizationRequest) as a Request Object,
30
+ # returning the compact JWS. Raises ConfigurationError for the
31
+ # `redirect_uri` Client Identifier Prefix, which forbids signed
32
+ # Request Objects (OpenID4VP §5.9).
33
+ def sign(request, wallet_nonce: nil, aud: OpenID4VP::RequestObject::DEFAULT_AUD)
34
+ if config.client_id.prefix == "redirect_uri"
35
+ raise OpenID4VP::ConfigurationError, "redirect_uri requests cannot be signed"
36
+ end
37
+
38
+ OpenID4VP::RequestObject.sign(
39
+ request, key: signing_key, alg: alg, wallet_nonce: wallet_nonce, aud: aud, **header_options
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def header_options
46
+ case config.client_id.prefix
47
+ when "x509_san_dns", "x509_hash"
48
+ { x5c: config.signing[:x5c] }
49
+ when "decentralized_identifier"
50
+ { kid: signing_key.kid }
51
+ when "verifier_attestation"
52
+ { attestation_jwt: config.attestation_jwt, kid: signing_key.kid }
53
+ else
54
+ signing_key.kid ? { kid: signing_key.kid } : {}
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../metadata/wallet_metadata"
5
+ require_relative "../metadata/verifier_metadata"
6
+ require_relative "../metadata/vp_formats"
7
+ require_relative "../response_encryption"
8
+ require_relative "../response_mode"
9
+ require_relative "http_result"
10
+
11
+ module OpenID4VP
12
+ class Verifier
13
+ # OpenID4VP §5.10-5.10.2: the Request URI endpoint. Serves the
14
+ # Verifier's signed Request Object by value, either as-is (GET, or a
15
+ # cached POST-tailored object) or tailored to the Wallet's `wallet_nonce`
16
+ # and `wallet_metadata` (POST, per §5.10).
17
+ class RequestUriEndpoint
18
+ MAX_WALLET_NONCE_LENGTH = 512
19
+
20
+ def initialize(verifier)
21
+ @verifier = verifier
22
+ end
23
+
24
+ def call(request_id, method:, params: {}, headers: {}) # rubocop:disable Lint/UnusedMethodArgument
25
+ session = @verifier.session_store.find_by_request_id(request_id)
26
+ return HttpResult.error(404, "invalid_request", "unknown request") if session.nil?
27
+ return HttpResult.error(400, "invalid_request", "request already used") unless retrievable?(session)
28
+
29
+ dispatch(session, method, params)
30
+ end
31
+
32
+ private
33
+
34
+ def retrievable?(session)
35
+ %i[created request_retrieved].include?(session.status)
36
+ end
37
+
38
+ def dispatch(session, method, params)
39
+ case method.to_s.upcase
40
+ when "GET" then handle_get(session)
41
+ when "POST" then handle_post(session, params)
42
+ else HttpResult.new(status: 405, headers: HttpResult::NO_STORE, body: "")
43
+ end
44
+ end
45
+
46
+ def handle_get(session)
47
+ jws = session.request_object || @verifier.request_signer.sign(session.request)
48
+ session.request_object = jws
49
+ persist!(session)
50
+ HttpResult.jwt(jws)
51
+ end
52
+
53
+ def handle_post(session, params)
54
+ wallet_metadata = parse_wallet_metadata(params["wallet_metadata"])
55
+ wallet_nonce = parse_wallet_nonce(params["wallet_nonce"])
56
+ request = tailor(session, wallet_metadata)
57
+ validate_signing_alg!(wallet_metadata)
58
+
59
+ session.wallet_metadata = wallet_metadata&.to_h
60
+ session.wallet_nonce = wallet_nonce
61
+ session.request_object = @verifier.request_signer.sign(request, wallet_nonce: wallet_nonce)
62
+ persist!(session)
63
+ HttpResult.jwt(session.request_object)
64
+ rescue OpenID4VP::Error => e
65
+ HttpResult.error(400, e.code || "invalid_request", e.description)
66
+ end
67
+
68
+ def persist!(session)
69
+ session.transition!(:request_retrieved) if session.status == :created
70
+ @verifier.session_store.update(session)
71
+ end
72
+
73
+ def tailor(session, wallet_metadata)
74
+ request = session.request
75
+ return request if wallet_metadata.nil?
76
+
77
+ request = tailor_encryption(session, request, wallet_metadata)
78
+ tailor_formats(request, wallet_metadata)
79
+ end
80
+
81
+ def tailor_encryption(session, request, wallet_metadata)
82
+ return request unless OpenID4VP::ResponseMode.encrypted?(session.response_mode)
83
+
84
+ algs = wallet_metadata.authorization_encryption_alg_values_supported
85
+ encs = wallet_metadata.authorization_encryption_enc_values_supported
86
+ return request if algs.nil? && encs.nil?
87
+
88
+ verifier_metadata = request.client_metadata || OpenID4VP::Metadata::VerifierMetadata.new({})
89
+ enc = recompute_enc(verifier_metadata, algs, encs)
90
+ request.with(client_metadata: verifier_metadata.with(encrypted_response_enc_values_supported: [enc]).to_h)
91
+ end
92
+
93
+ def recompute_enc(verifier_metadata, algs, encs)
94
+ encryption = OpenID4VP::ResponseEncryption.new(
95
+ verifier_metadata: verifier_metadata, wallet_supported_algs: algs, wallet_supported_encs: encs
96
+ )
97
+ encryption.key
98
+ encryption.enc
99
+ rescue OpenID4VP::InvalidRequest
100
+ raise OpenID4VP::InvalidRequest, "no common encryption algorithm"
101
+ end
102
+
103
+ def tailor_formats(request, wallet_metadata)
104
+ config_formats = @verifier.config.vp_formats_supported
105
+ return request if config_formats.nil?
106
+
107
+ raw_wallet_formats = wallet_metadata.to_h["vp_formats_supported"]
108
+ return request if raw_wallet_formats.nil?
109
+
110
+ wallet_formats = OpenID4VP::Metadata::VpFormats.from_h(raw_wallet_formats)
111
+ intersection = OpenID4VP::Metadata::VpFormats.from_h(config_formats).intersect(wallet_formats)
112
+ raise OpenID4VP::VpFormatsNotSupported, "no common vp_formats_supported" if intersection.empty?
113
+
114
+ verifier_metadata = request.client_metadata || OpenID4VP::Metadata::VerifierMetadata.new({})
115
+ request.with(client_metadata: verifier_metadata.with(vp_formats_supported: intersection.to_h).to_h)
116
+ end
117
+
118
+ def validate_signing_alg!(wallet_metadata)
119
+ return if wallet_metadata.nil?
120
+
121
+ algs = wallet_metadata.request_object_signing_alg_values_supported
122
+ return if algs.nil? || algs.include?(@verifier.request_signer.alg)
123
+
124
+ raise OpenID4VP::InvalidRequest, "wallet does not support this Verifier's request object signing algorithm"
125
+ end
126
+
127
+ def parse_wallet_metadata(raw)
128
+ return nil if raw.nil?
129
+
130
+ hash = raw.is_a?(::String) ? parse_json_string(raw) : raw
131
+ OpenID4VP::Metadata::WalletMetadata.new(hash)
132
+ end
133
+
134
+ def parse_json_string(str)
135
+ ::JSON.parse(str)
136
+ rescue ::JSON::ParserError
137
+ raise OpenID4VP::InvalidRequest, "wallet_metadata is not valid JSON"
138
+ end
139
+
140
+ def parse_wallet_nonce(raw)
141
+ return nil if raw.nil?
142
+ unless raw.is_a?(::String) && raw.length <= MAX_WALLET_NONCE_LENGTH
143
+ raise OpenID4VP::InvalidRequest,
144
+ "wallet_nonce must be a string of at most #{MAX_WALLET_NONCE_LENGTH} characters"
145
+ end
146
+
147
+ raw
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../authorization_response"
5
+ require_relative "../error_response"
6
+ require_relative "../response_endpoint_result"
7
+ require_relative "../jose/jwe"
8
+ require_relative "../response_mode"
9
+ require_relative "../util/random"
10
+ require_relative "../util/uri"
11
+ require_relative "http_result"
12
+ require_relative "response_validator"
13
+
14
+ module OpenID4VP
15
+ class Verifier
16
+ # OpenID4VP §8.2/§8.3.1/§13.3: the Response endpoint, receiving the
17
+ # Wallet's Authorization Response (or Authorization Error Response) for
18
+ # a direct_post[.jwt] session, or (via the `session_id:` kwarg) a DC API
19
+ # session for which there is no request/state parameter to look it up by.
20
+ class ResponseEndpoint
21
+ def initialize(verifier)
22
+ @verifier = verifier
23
+ end
24
+
25
+ def call(params, headers: {}, session_id: nil) # rubocop:disable Lint/UnusedMethodArgument
26
+ session, error, decoded = locate_session(params, session_id)
27
+ return error if error
28
+ return HttpResult.error(400, "invalid_request", "response already received") unless awaiting_response?(session)
29
+
30
+ process(session, params, decoded)
31
+ rescue OpenID4VP::InvalidRequest, OpenID4VP::InvalidResponse => e
32
+ HttpResult.error(400, e.code || "invalid_request", e.description)
33
+ end
34
+
35
+ private
36
+
37
+ def awaiting_response?(session)
38
+ %i[created request_retrieved].include?(session.status)
39
+ end
40
+
41
+ # DC API sessions have no request/state parameter for a Wallet to send
42
+ # back over HTTP, so their caller passes `session_id` directly;
43
+ # otherwise the session is located from `params` (§13.3/§14.3.2). Each
44
+ # `locate_by_*` returns `[session, error, decoded]`: `decoded` is only
45
+ # ever non-nil when locating the session already required decrypting
46
+ # the response (the static-key path below), so `#process` can reuse it
47
+ # instead of decrypting twice.
48
+ def locate_session(params, session_id)
49
+ return locate_by_session_id(session_id) if session_id
50
+
51
+ locate_by_params(params)
52
+ end
53
+
54
+ def locate_by_session_id(session_id)
55
+ session = @verifier.session(session_id)
56
+ session ? [session, nil, nil] : [nil, HttpResult.error(404, "invalid_request", "unknown session"), nil]
57
+ end
58
+
59
+ def locate_by_params(params)
60
+ return locate_by_response(params["response"]) if params["response"]
61
+
62
+ locate_by_state(params["state"])
63
+ end
64
+
65
+ # §8.3: an encrypted direct_post.jwt/dc_api.jwt response is located by
66
+ # the `kid` of its JWE header. For ephemeral encryption that `kid` is
67
+ # the session's request_id (Task 25); for static encryption keys it
68
+ # instead identifies one of `config.encryption[:keys]`, so the
69
+ # response must be decrypted with those *before* a session can be
70
+ # found -- by the decrypted payload's `state` (§13.3).
71
+ def locate_by_response(compact_jwe)
72
+ header = OpenID4VP::JOSE::JWE.peek(compact_jwe)
73
+ kid = header["kid"]
74
+ ephemeral_session = kid && @verifier.session_store.find_by_request_id(kid)
75
+ return [ephemeral_session, nil, nil] if ephemeral_session
76
+
77
+ locate_by_static_response(compact_jwe, kid)
78
+ end
79
+
80
+ def locate_by_static_response(compact_jwe, kid)
81
+ keys = static_encryption_keys
82
+ unless static_key_candidate?(keys, kid)
83
+ return [nil, HttpResult.error(400, "invalid_request", "unknown key id"), nil]
84
+ end
85
+
86
+ decoded = OpenID4VP::AuthorizationResponse.decode(
87
+ OpenID4VP::ResponseMode::DIRECT_POST_JWT, { "response" => compact_jwe }, decryption_keys: keys
88
+ )
89
+ session, error, = locate_by_state(decoded.response.state)
90
+ error ? [nil, error, nil] : [session, nil, decoded]
91
+ end
92
+
93
+ def static_encryption_keys
94
+ encryption = @verifier.config.encryption
95
+ encryption && encryption[:keys]
96
+ end
97
+
98
+ def static_key_candidate?(keys, kid)
99
+ return false if keys.nil? || keys.empty?
100
+
101
+ kid.nil? || keys.any? { |key| key.kid == kid }
102
+ end
103
+
104
+ # Also used for the unencrypted error-response fallback of an
105
+ # otherwise-encrypted response_mode (§8.3.1: error responses are never
106
+ # encrypted).
107
+ def locate_by_state(state)
108
+ session = state && @verifier.session_store.find_by_request_id(state)
109
+ session ? [session, nil, nil] : [nil, HttpResult.error(400, "invalid_request", "unknown state"), nil]
110
+ end
111
+
112
+ def process(session, params, decoded)
113
+ decoded ||= OpenID4VP::AuthorizationResponse.decode(
114
+ session.response_mode, params, decryption_keys: decryption_keys(session)
115
+ )
116
+ verify_state!(session, decoded)
117
+ store!(session, decoded)
118
+ finish(session)
119
+ end
120
+
121
+ def decryption_keys(session)
122
+ return [session.encryption_jwk] if session.encryption_jwk
123
+
124
+ static_encryption_keys || []
125
+ end
126
+
127
+ # §14.3.2: an encrypted response's state (unlike an unencrypted one) was
128
+ # not itself used to locate the session, so it is checked here instead.
129
+ # DC API responses have no state to check (§13.3/RequestBuilder never
130
+ # sets one for them); every other encrypted mode MUST carry one that
131
+ # matches the session's, or the response is rejected (§8.2/§14.3.2).
132
+ def verify_state!(session, decoded)
133
+ return unless decoded.encrypted
134
+ return if OpenID4VP::ResponseMode.dc_api?(session.response_mode)
135
+ return if decoded.response.state == session.state
136
+
137
+ raise OpenID4VP::InvalidResponse, "state mismatch"
138
+ end
139
+
140
+ def store!(session, decoded)
141
+ response = decoded.response
142
+ if response.is_a?(OpenID4VP::ErrorResponse)
143
+ store_error!(session, response)
144
+ else
145
+ store_success!(session, decoded, response)
146
+ end
147
+ end
148
+
149
+ def store_error!(session, response)
150
+ session.error = { "code" => response.error, "description" => response.error_description }
151
+ session.transition!(:response_received)
152
+ session.transition!(:failed)
153
+ end
154
+
155
+ def store_success!(session, decoded, response)
156
+ session.response_params = response.to_payload
157
+ session.response_encryption = encryption_summary(decoded) if decoded.encrypted
158
+ session.transition!(:response_received)
159
+ verify_on_receipt!(session)
160
+ end
161
+
162
+ def encryption_summary(decoded)
163
+ header = decoded.encryption_header
164
+ { "kid" => header["kid"], "alg" => header["alg"], "enc" => header["enc"] }
165
+ end
166
+
167
+ # OpenID4VP §8.6/§14.1: a Verifier configured with
168
+ # `verify_on_receipt: true` validates the response immediately
169
+ # (storing its result/error and transitioning the session to
170
+ # `:verified`/`:failed`) rather than waiting for `fetch_response`. The
171
+ # outcome is only ever observable via `fetch_response`/`result`: a
172
+ # validation failure here is swallowed (the session is already left
173
+ # `:failed` with the error stored by `ResponseValidator#verify`) so the
174
+ # Wallet still gets the normal 200 `{redirect_uri}` response and learns
175
+ # nothing about why verification failed (OpenID4VP §8.2: the Response
176
+ # endpoint's HTTP response is not the channel for reporting that).
177
+ def verify_on_receipt!(session)
178
+ return unless @verifier.config.verify_on_receipt
179
+
180
+ @verifier.response_validator.verify(session)
181
+ rescue OpenID4VP::InvalidResponse
182
+ nil
183
+ end
184
+
185
+ def finish(session)
186
+ config = @verifier.config
187
+ session.response_code = OpenID4VP::Util::Random.response_code if config.redirect_uri_base
188
+ @verifier.session_store.update(session)
189
+
190
+ redirect_uri = config.redirect_uri_base && OpenID4VP::Util::URI.with_fragment(
191
+ config.redirect_uri_base, "response_code" => session.response_code
192
+ )
193
+ HttpResult.json(200, OpenID4VP::ResponseEndpointResult.new(redirect_uri: redirect_uri).to_h)
194
+ end
195
+ end
196
+ end
197
+ end