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