openid4vp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +278 -0
- data/lib/openid4vp/authorization_request/validator.rb +177 -0
- data/lib/openid4vp/authorization_request.rb +302 -0
- data/lib/openid4vp/authorization_response.rb +151 -0
- data/lib/openid4vp/client_id.rb +90 -0
- data/lib/openid4vp/credential.rb +51 -0
- data/lib/openid4vp/dc_api.rb +116 -0
- data/lib/openid4vp/dcql/claims_path_pointer.rb +109 -0
- data/lib/openid4vp/dcql/claims_query.rb +76 -0
- data/lib/openid4vp/dcql/credential_query.rb +178 -0
- data/lib/openid4vp/dcql/credential_set_query.rb +49 -0
- data/lib/openid4vp/dcql/evaluator.rb +228 -0
- data/lib/openid4vp/dcql/matcher.rb +79 -0
- data/lib/openid4vp/dcql/meta.rb +99 -0
- data/lib/openid4vp/dcql/query.rb +93 -0
- data/lib/openid4vp/dcql/selection.rb +45 -0
- data/lib/openid4vp/dcql/trusted_authorities_query.rb +48 -0
- data/lib/openid4vp/dcql.rb +10 -0
- data/lib/openid4vp/encoded_response.rb +10 -0
- data/lib/openid4vp/error_response.rb +82 -0
- data/lib/openid4vp/errors.rb +73 -0
- data/lib/openid4vp/formats/base.rb +74 -0
- data/lib/openid4vp/formats/context.rb +56 -0
- data/lib/openid4vp/formats/jwt_vc_json.rb +274 -0
- data/lib/openid4vp/formats/ldp_vc.rb +160 -0
- data/lib/openid4vp/formats/mso_mdoc.rb +204 -0
- data/lib/openid4vp/formats/registry.rb +53 -0
- data/lib/openid4vp/formats/sd_jwt_vc.rb +132 -0
- data/lib/openid4vp/formats/verified_presentation.rb +45 -0
- data/lib/openid4vp/formats.rb +12 -0
- data/lib/openid4vp/jose/algorithms.rb +46 -0
- data/lib/openid4vp/jose/jwe.rb +184 -0
- data/lib/openid4vp/jose/jwk.rb +253 -0
- data/lib/openid4vp/jose/jwk_set.rb +57 -0
- data/lib/openid4vp/jose/jws.rb +225 -0
- data/lib/openid4vp/jose.rb +8 -0
- data/lib/openid4vp/mdoc/builder.rb +132 -0
- data/lib/openid4vp/mdoc/cbor_values.rb +32 -0
- data/lib/openid4vp/mdoc/cose_key.rb +71 -0
- data/lib/openid4vp/mdoc/device_authentication.rb +32 -0
- data/lib/openid4vp/mdoc/device_response.rb +77 -0
- data/lib/openid4vp/mdoc/device_signed.rb +111 -0
- data/lib/openid4vp/mdoc/document.rb +44 -0
- data/lib/openid4vp/mdoc/handover.rb +51 -0
- data/lib/openid4vp/mdoc/issuer_signed.rb +157 -0
- data/lib/openid4vp/mdoc/mso.rb +133 -0
- data/lib/openid4vp/mdoc/session_transcript.rb +49 -0
- data/lib/openid4vp/mdoc/sign1_decoder.rb +60 -0
- data/lib/openid4vp/mdoc/verifier.rb +169 -0
- data/lib/openid4vp/mdoc.rb +9 -0
- data/lib/openid4vp/metadata/verifier_metadata.rb +109 -0
- data/lib/openid4vp/metadata/vp_formats.rb +112 -0
- data/lib/openid4vp/metadata/wallet_metadata.rb +139 -0
- data/lib/openid4vp/metadata.rb +10 -0
- data/lib/openid4vp/request_object.rb +152 -0
- data/lib/openid4vp/request_verification/base.rb +63 -0
- data/lib/openid4vp/request_verification/config.rb +65 -0
- data/lib/openid4vp/request_verification/decentralized_identifier.rb +56 -0
- data/lib/openid4vp/request_verification/openid_federation.rb +52 -0
- data/lib/openid4vp/request_verification/pre_registered.rb +54 -0
- data/lib/openid4vp/request_verification/redirect_uri.rb +29 -0
- data/lib/openid4vp/request_verification/resolver.rb +76 -0
- data/lib/openid4vp/request_verification/verified_request.rb +12 -0
- data/lib/openid4vp/request_verification/verifier_attestation.rb +54 -0
- data/lib/openid4vp/request_verification/x509_hash.rb +40 -0
- data/lib/openid4vp/request_verification/x509_san_dns.rb +60 -0
- data/lib/openid4vp/request_verification.rb +10 -0
- data/lib/openid4vp/response_encryption.rb +51 -0
- data/lib/openid4vp/response_endpoint_result.rb +33 -0
- data/lib/openid4vp/response_mode.rb +44 -0
- data/lib/openid4vp/response_type.rb +7 -0
- data/lib/openid4vp/sd_jwt/digest.rb +30 -0
- data/lib/openid4vp/sd_jwt/disclosure.rb +104 -0
- data/lib/openid4vp/sd_jwt/issuer.rb +132 -0
- data/lib/openid4vp/sd_jwt/kb_jwt.rb +82 -0
- data/lib/openid4vp/sd_jwt/parser.rb +48 -0
- data/lib/openid4vp/sd_jwt/presentation.rb +333 -0
- data/lib/openid4vp/sd_jwt/reconstruct.rb +132 -0
- data/lib/openid4vp/sd_jwt.rb +10 -0
- data/lib/openid4vp/transaction_data/registry.rb +42 -0
- data/lib/openid4vp/transaction_data/sd_jwt_hashes.rb +124 -0
- data/lib/openid4vp/transaction_data.rb +138 -0
- data/lib/openid4vp/util/base64url.rb +44 -0
- data/lib/openid4vp/util/form.rb +24 -0
- data/lib/openid4vp/util/random.rb +29 -0
- data/lib/openid4vp/util/uri.rb +48 -0
- data/lib/openid4vp/util.rb +23 -0
- data/lib/openid4vp/verifier/config.rb +216 -0
- data/lib/openid4vp/verifier/created_request.rb +25 -0
- data/lib/openid4vp/verifier/http_result.rb +34 -0
- data/lib/openid4vp/verifier/memory_session_store.rb +102 -0
- data/lib/openid4vp/verifier/request_builder.rb +216 -0
- data/lib/openid4vp/verifier/request_signer.rb +59 -0
- data/lib/openid4vp/verifier/request_uri_endpoint.rb +151 -0
- data/lib/openid4vp/verifier/response_endpoint.rb +197 -0
- data/lib/openid4vp/verifier/response_validator.rb +258 -0
- data/lib/openid4vp/verifier/session.rb +109 -0
- data/lib/openid4vp/verifier/session_store.rb +48 -0
- data/lib/openid4vp/verifier/verified_response.rb +53 -0
- data/lib/openid4vp/verifier.rb +155 -0
- data/lib/openid4vp/verifier_attestation.rb +130 -0
- data/lib/openid4vp/verifier_info.rb +102 -0
- data/lib/openid4vp/version.rb +5 -0
- data/lib/openid4vp/vp_token.rb +81 -0
- data/lib/openid4vp/wallet/config.rb +49 -0
- data/lib/openid4vp/wallet/http_client.rb +73 -0
- data/lib/openid4vp/wallet/presenter.rb +90 -0
- data/lib/openid4vp/wallet/processed_request.rb +14 -0
- data/lib/openid4vp/wallet/request_processor.rb +237 -0
- data/lib/openid4vp/wallet/response_builder.rb +44 -0
- data/lib/openid4vp/wallet/submission.rb +68 -0
- data/lib/openid4vp/wallet.rb +142 -0
- data/lib/openid4vp/x509/chain_validator.rb +42 -0
- data/lib/openid4vp/x509.rb +103 -0
- data/lib/openid4vp.rb +18 -0
- metadata +233 -0
|
@@ -0,0 +1,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
|