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