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,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "jose/jws"
|
|
4
|
+
require_relative "jose/jwk"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module OpenID4VP
|
|
8
|
+
# Verifier Attestation JWT (OpenID4VP 1.0 Section 12): a JWT issued to a
|
|
9
|
+
# Verifier by a party the Wallet trusts, binding the Verifier's client_id
|
|
10
|
+
# (`sub`) to a public key (`cnf.jwk`) the Verifier must prove possession of.
|
|
11
|
+
module VerifierAttestation
|
|
12
|
+
TYP = "verifier-attestation+jwt"
|
|
13
|
+
|
|
14
|
+
Verified = Struct.new(:payload, :cnf_jwk, :sub, :iss, :redirect_uris, :header, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Issues a Verifier Attestation JWT signed by `signing_key`.
|
|
19
|
+
def issue(iss:, sub:, cnf_jwk:, exp:, signing_key:, alg: nil, iat: nil, nbf: nil, redirect_uris: nil, extra: {})
|
|
20
|
+
header = { "typ" => TYP }
|
|
21
|
+
header["kid"] = signing_key.kid if signing_key.kid
|
|
22
|
+
|
|
23
|
+
payload = { "iss" => iss, "sub" => sub, "exp" => exp_to_i(exp), "cnf" => { "jwk" => cnf_jwk.to_public.to_h } }
|
|
24
|
+
payload["iat"] = iat unless iat.nil?
|
|
25
|
+
payload["nbf"] = nbf unless nbf.nil?
|
|
26
|
+
payload["redirect_uris"] = redirect_uris unless redirect_uris.nil?
|
|
27
|
+
payload.merge!(extra)
|
|
28
|
+
|
|
29
|
+
OpenID4VP::JOSE::JWS.sign(payload, key: signing_key, alg: alg, header: header)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Verifies a Verifier Attestation JWT against `trusted_issuers` (a Hash of
|
|
33
|
+
# `iss => JWK | [JWK]`, or a callable `(iss, header) -> JWK | [JWK] | nil`).
|
|
34
|
+
def verify(jwt, trusted_issuers:, now: Time.now, leeway: 60, algorithms: OpenID4VP::JOSE::Algorithms::SIGNING)
|
|
35
|
+
peeked = OpenID4VP::JOSE::JWS.peek(jwt)
|
|
36
|
+
unless peeked.header["typ"] == TYP
|
|
37
|
+
raise OpenID4VP::VerificationError, "verifier attestation: unexpected typ #{peeked.header['typ'].inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
verified = verify_signature(jwt, peeked, trusted_issuers, algorithms)
|
|
41
|
+
payload = verified.payload
|
|
42
|
+
check_exp!(payload, now, leeway)
|
|
43
|
+
check_nbf!(payload, now, leeway)
|
|
44
|
+
sub = check_sub!(payload)
|
|
45
|
+
cnf_jwk = check_cnf!(payload)
|
|
46
|
+
|
|
47
|
+
Verified.new(payload: payload, cnf_jwk: cnf_jwk, sub: sub, iss: payload["iss"],
|
|
48
|
+
redirect_uris: payload["redirect_uris"], header: verified.header)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def exp_to_i(exp)
|
|
52
|
+
exp.is_a?(Time) ? exp.to_i : exp
|
|
53
|
+
end
|
|
54
|
+
private_class_method :exp_to_i
|
|
55
|
+
|
|
56
|
+
def verify_signature(jwt, peeked, trusted_issuers, algorithms)
|
|
57
|
+
iss = peeked.payload.is_a?(Hash) ? peeked.payload["iss"] : nil
|
|
58
|
+
keys = Array(resolve_keys(trusted_issuers, iss, peeked.header))
|
|
59
|
+
raise OpenID4VP::VerificationError, "verifier attestation: untrusted attestation issuer" if keys.empty?
|
|
60
|
+
|
|
61
|
+
try_keys(jwt, keys_matching_kid(keys, peeked.header["kid"]), algorithms)
|
|
62
|
+
end
|
|
63
|
+
private_class_method :verify_signature
|
|
64
|
+
|
|
65
|
+
def resolve_keys(trusted_issuers, iss, header)
|
|
66
|
+
if trusted_issuers.respond_to?(:call)
|
|
67
|
+
trusted_issuers.call(iss, header)
|
|
68
|
+
elsif trusted_issuers.is_a?(Hash)
|
|
69
|
+
trusted_issuers[iss]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_class_method :resolve_keys
|
|
73
|
+
|
|
74
|
+
# Keys whose `kid` matches the header's are tried first (when both are
|
|
75
|
+
# present); the rest follow, so a kid mismatch still falls back to trying
|
|
76
|
+
# every trusted key for the issuer.
|
|
77
|
+
def keys_matching_kid(keys, kid)
|
|
78
|
+
return keys if kid.nil?
|
|
79
|
+
|
|
80
|
+
matching, rest = keys.partition { |k| k.kid == kid }
|
|
81
|
+
matching + rest
|
|
82
|
+
end
|
|
83
|
+
private_class_method :keys_matching_kid
|
|
84
|
+
|
|
85
|
+
def try_keys(jwt, keys, algorithms)
|
|
86
|
+
last_error = nil
|
|
87
|
+
keys.each do |key|
|
|
88
|
+
return OpenID4VP::JOSE::JWS.verify(jwt, key: key, algorithms: algorithms)
|
|
89
|
+
rescue OpenID4VP::VerificationError => e
|
|
90
|
+
last_error = e
|
|
91
|
+
end
|
|
92
|
+
raise last_error || OpenID4VP::VerificationError.new("verifier attestation: untrusted attestation issuer")
|
|
93
|
+
end
|
|
94
|
+
private_class_method :try_keys
|
|
95
|
+
|
|
96
|
+
def check_exp!(payload, now, leeway)
|
|
97
|
+
exp = payload["exp"]
|
|
98
|
+
raise OpenID4VP::VerificationError, "verifier attestation: missing exp" unless exp.is_a?(Integer)
|
|
99
|
+
raise OpenID4VP::VerificationError, "verifier attestation: exp has passed" unless exp > now.to_i - leeway
|
|
100
|
+
end
|
|
101
|
+
private_class_method :check_exp!
|
|
102
|
+
|
|
103
|
+
def check_nbf!(payload, now, leeway)
|
|
104
|
+
nbf = payload["nbf"]
|
|
105
|
+
return if nbf.nil?
|
|
106
|
+
raise OpenID4VP::VerificationError, "verifier attestation: nbf is not an Integer" unless nbf.is_a?(Integer)
|
|
107
|
+
|
|
108
|
+
raise OpenID4VP::VerificationError, "verifier attestation: nbf is in the future" unless nbf <= now.to_i + leeway
|
|
109
|
+
end
|
|
110
|
+
private_class_method :check_nbf!
|
|
111
|
+
|
|
112
|
+
def check_sub!(payload)
|
|
113
|
+
sub = payload["sub"]
|
|
114
|
+
raise OpenID4VP::VerificationError, "verifier attestation: missing sub" unless sub.is_a?(String) && !sub.empty?
|
|
115
|
+
|
|
116
|
+
sub
|
|
117
|
+
end
|
|
118
|
+
private_class_method :check_sub!
|
|
119
|
+
|
|
120
|
+
def check_cnf!(payload)
|
|
121
|
+
jwk_hash = payload.is_a?(Hash) ? payload.dig("cnf", "jwk") : nil
|
|
122
|
+
raise OpenID4VP::VerificationError, "verifier attestation: missing cnf.jwk" unless jwk_hash.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
OpenID4VP::JOSE::JWK.new(jwk_hash)
|
|
125
|
+
rescue OpenID4VP::InvalidRequest => e
|
|
126
|
+
raise OpenID4VP::VerificationError, "verifier attestation: malformed cnf.jwk: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
private_class_method :check_cnf!
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module OpenID4VP
|
|
6
|
+
# OpenID4VP §5.1/§5.11: the verifier_info Authorization Request
|
|
7
|
+
# parameter, a non-empty array of attestations about the Verifier.
|
|
8
|
+
module VerifierInfo
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Parses each element of the verifier_info request parameter. Raises
|
|
12
|
+
# InvalidRequest unless `array` is a non-empty Array of valid entry
|
|
13
|
+
# objects.
|
|
14
|
+
def parse_all(array)
|
|
15
|
+
unless array.is_a?(::Array) && !array.empty?
|
|
16
|
+
raise OpenID4VP::InvalidRequest, "verifier_info must be a non-empty array"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
array.map { |item| Entry.parse(item) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# OpenID4VP §5.11: when present, every verifier_info credential_ids
|
|
23
|
+
# value MUST reference an id in the DCQL Credential Query. Raises
|
|
24
|
+
# InvalidRequest otherwise.
|
|
25
|
+
def validate_against_query!(entries, dcql_query)
|
|
26
|
+
ids = dcql_query.ids
|
|
27
|
+
|
|
28
|
+
entries.each do |entry|
|
|
29
|
+
next unless entry.credential_ids
|
|
30
|
+
|
|
31
|
+
entry.credential_ids.each do |credential_id|
|
|
32
|
+
next if ids.include?(credential_id)
|
|
33
|
+
|
|
34
|
+
raise OpenID4VP::InvalidRequest,
|
|
35
|
+
"verifier_info credential_ids references unknown credential id #{credential_id.inspect}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
entries
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A single verifier_info attestation object: `format`, `data` (a String
|
|
43
|
+
# or a Hash), and the optional `credential_ids` it is relevant to (nil
|
|
44
|
+
# meaning relevant to all requested Credentials).
|
|
45
|
+
class Entry
|
|
46
|
+
attr_reader :format, :data, :credential_ids
|
|
47
|
+
|
|
48
|
+
def initialize(format:, data:, credential_ids: nil)
|
|
49
|
+
@format = format
|
|
50
|
+
@data = data
|
|
51
|
+
@credential_ids = credential_ids
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
h = { "format" => format, "data" => data }
|
|
57
|
+
h["credential_ids"] = credential_ids if credential_ids
|
|
58
|
+
h
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.parse(hash)
|
|
62
|
+
raise OpenID4VP::InvalidRequest, "verifier_info entry must be an object" unless hash.is_a?(::Hash)
|
|
63
|
+
|
|
64
|
+
new(
|
|
65
|
+
format: validate_format(hash),
|
|
66
|
+
data: validate_data(hash),
|
|
67
|
+
credential_ids: validate_credential_ids(hash)
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.validate_format(hash)
|
|
72
|
+
format = hash["format"]
|
|
73
|
+
return format if format.is_a?(::String) && !format.empty?
|
|
74
|
+
|
|
75
|
+
raise OpenID4VP::InvalidRequest, "verifier_info entry format must be a non-empty string"
|
|
76
|
+
end
|
|
77
|
+
private_class_method :validate_format
|
|
78
|
+
|
|
79
|
+
def self.validate_data(hash)
|
|
80
|
+
data = hash["data"]
|
|
81
|
+
return data if data.is_a?(::String) || data.is_a?(::Hash)
|
|
82
|
+
|
|
83
|
+
raise OpenID4VP::InvalidRequest, "verifier_info entry data must be a string or object"
|
|
84
|
+
end
|
|
85
|
+
private_class_method :validate_data
|
|
86
|
+
|
|
87
|
+
def self.validate_credential_ids(hash)
|
|
88
|
+
return nil unless hash.key?("credential_ids")
|
|
89
|
+
|
|
90
|
+
credential_ids = hash["credential_ids"]
|
|
91
|
+
unless credential_ids.is_a?(::Array) && !credential_ids.empty? &&
|
|
92
|
+
credential_ids.all?(::String)
|
|
93
|
+
raise OpenID4VP::InvalidRequest,
|
|
94
|
+
"verifier_info entry credential_ids must be a non-empty array of strings"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
credential_ids
|
|
98
|
+
end
|
|
99
|
+
private_class_method :validate_credential_ids
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module OpenID4VP
|
|
6
|
+
# OpenID4VP §8.1: the vp_token Authorization Response parameter — a JSON
|
|
7
|
+
# object mapping DCQL Credential Query ids to a non-empty array of
|
|
8
|
+
# Presentations (each a String or an object). Instances are immutable.
|
|
9
|
+
class VpToken
|
|
10
|
+
class << self
|
|
11
|
+
# `json_or_hash` is either a JSON String or an already-parsed Hash.
|
|
12
|
+
# Raises OpenID4VP::InvalidResponse unless the result is a Hash with
|
|
13
|
+
# String keys and non-empty Array values whose items are all String
|
|
14
|
+
# or Hash.
|
|
15
|
+
def parse(json_or_hash)
|
|
16
|
+
hash = json_or_hash.is_a?(::String) ? parse_json(json_or_hash) : json_or_hash
|
|
17
|
+
validate!(hash)
|
|
18
|
+
new(hash)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def parse_json(str)
|
|
24
|
+
::JSON.parse(str)
|
|
25
|
+
rescue ::JSON::ParserError
|
|
26
|
+
raise OpenID4VP::InvalidResponse, "vp_token is not valid JSON"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate!(hash)
|
|
30
|
+
raise OpenID4VP::InvalidResponse, "vp_token must be a JSON object" unless hash.is_a?(::Hash)
|
|
31
|
+
|
|
32
|
+
hash.each do |id, presentations|
|
|
33
|
+
raise OpenID4VP::InvalidResponse, "vp_token keys must be strings" unless id.is_a?(::String)
|
|
34
|
+
|
|
35
|
+
validate_presentations!(id, presentations)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_presentations!(id, presentations)
|
|
40
|
+
unless presentations.is_a?(::Array) && !presentations.empty?
|
|
41
|
+
raise OpenID4VP::InvalidResponse, "vp_token[#{id}] must be a non-empty array"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
return if presentations.all? { |p| p.is_a?(::String) || p.is_a?(::Hash) }
|
|
45
|
+
|
|
46
|
+
raise OpenID4VP::InvalidResponse, "vp_token[#{id}] items must be strings or objects"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(hash)
|
|
51
|
+
@hash = hash.each_with_object({}) { |(k, v), h| h[k] = v.dup.freeze }.freeze
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ids
|
|
56
|
+
@hash.keys
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def [](id)
|
|
60
|
+
@hash[id]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def presentations_for(id)
|
|
64
|
+
@hash[id]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def each(&)
|
|
68
|
+
@hash.each(&)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A shallow-mutable copy: the top-level Hash and each Presentation Array
|
|
72
|
+
# are duplicated, so callers cannot mutate this VpToken's state.
|
|
73
|
+
def to_h
|
|
74
|
+
@hash.transform_values(&:dup)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_json(*)
|
|
78
|
+
::JSON.generate(@hash, *)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../metadata/wallet_metadata"
|
|
4
|
+
require_relative "../request_verification/config"
|
|
5
|
+
require_relative "../formats/registry"
|
|
6
|
+
require_relative "../transaction_data/registry"
|
|
7
|
+
require_relative "http_client"
|
|
8
|
+
|
|
9
|
+
module OpenID4VP
|
|
10
|
+
class Wallet
|
|
11
|
+
# Normalizes and validates the keyword arguments of `Wallet.new` exactly
|
|
12
|
+
# once, at construction time, mirroring `Verifier::Config` (design doc
|
|
13
|
+
# §4.21). Immutable once built.
|
|
14
|
+
class Config
|
|
15
|
+
attr_reader :metadata, :http, :verification, :formats, :transaction_data_types, :scope_resolver, :clock,
|
|
16
|
+
:holder_keys, :authority_hooks
|
|
17
|
+
|
|
18
|
+
def initialize(metadata:, http: OpenID4VP::Wallet::HttpClient.new, verification: {},
|
|
19
|
+
formats: OpenID4VP::Formats::Registry.default,
|
|
20
|
+
transaction_data_types: OpenID4VP::TransactionData::Registry.new, scope_resolver: nil,
|
|
21
|
+
clock: -> { ::Time.now }, holder_keys: nil, authority_hooks: {})
|
|
22
|
+
@metadata = normalize_metadata(metadata)
|
|
23
|
+
@http = http
|
|
24
|
+
@verification = normalize_verification(verification)
|
|
25
|
+
@formats = formats
|
|
26
|
+
@transaction_data_types = transaction_data_types
|
|
27
|
+
@scope_resolver = scope_resolver
|
|
28
|
+
@clock = clock
|
|
29
|
+
@holder_keys = holder_keys
|
|
30
|
+
@authority_hooks = authority_hooks
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def now
|
|
35
|
+
clock.call
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def normalize_metadata(metadata)
|
|
41
|
+
metadata.is_a?(::Hash) ? OpenID4VP::Metadata::WalletMetadata.new(metadata) : metadata
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def normalize_verification(verification)
|
|
45
|
+
verification.is_a?(::Hash) ? OpenID4VP::RequestVerification::Config.new(**verification) : verification
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module OpenID4VP
|
|
8
|
+
class Wallet
|
|
9
|
+
# Raised by `HttpClient` for URL scheme violations and connection/HTTP
|
|
10
|
+
# failures while fetching a request_uri or posting a response.
|
|
11
|
+
class HttpError < OpenID4VP::Error; end
|
|
12
|
+
|
|
13
|
+
# Default Net::HTTP-based `Wallet` HTTP adapter. `#get`/`#post` return a
|
|
14
|
+
# `Response` Struct; connection failures raise `HttpError`. Requires
|
|
15
|
+
# `https` URLs unless constructed with `allow_http: true` (tests only).
|
|
16
|
+
class HttpClient
|
|
17
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def initialize(allow_http: false, open_timeout: 10, read_timeout: 10)
|
|
20
|
+
@allow_http = allow_http
|
|
21
|
+
@open_timeout = open_timeout
|
|
22
|
+
@read_timeout = read_timeout
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get(url, headers: {})
|
|
26
|
+
perform(:get, url, headers: headers)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def post(url, body:, headers: {})
|
|
30
|
+
perform(:post, url, headers: headers, body: body)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def perform(method, url, headers:, body: nil)
|
|
36
|
+
uri = parse_uri!(url)
|
|
37
|
+
request = build_request(method, uri, headers, body)
|
|
38
|
+
response = execute(uri, request)
|
|
39
|
+
Response.new(status: response.code.to_i, headers: header_hash(response), body: response.body.to_s)
|
|
40
|
+
rescue HttpError
|
|
41
|
+
raise
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
raise HttpError, "#{method.to_s.upcase} #{url} failed: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_uri!(url)
|
|
47
|
+
uri = ::URI.parse(url)
|
|
48
|
+
allowed = uri.scheme == "https" || (uri.scheme == "http" && @allow_http)
|
|
49
|
+
raise HttpError, "refusing to use non-https URL: #{url.inspect}" unless allowed
|
|
50
|
+
|
|
51
|
+
uri
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_request(method, uri, headers, body)
|
|
55
|
+
request = method == :get ? ::Net::HTTP::Get.new(uri) : ::Net::HTTP::Post.new(uri)
|
|
56
|
+
headers.each { |name, value| request[name.to_s] = value.to_s }
|
|
57
|
+
request.body = body if body
|
|
58
|
+
request
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def execute(uri, request)
|
|
62
|
+
::Net::HTTP.start(
|
|
63
|
+
uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @open_timeout,
|
|
64
|
+
read_timeout: @read_timeout
|
|
65
|
+
) { |http| http.request(request) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def header_hash(response)
|
|
69
|
+
response.each_header.with_object({}) { |(name, value), acc| acc[name] = value }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../formats/context"
|
|
5
|
+
require_relative "../response_mode"
|
|
6
|
+
|
|
7
|
+
module OpenID4VP
|
|
8
|
+
class Wallet
|
|
9
|
+
# OpenID4VP §8.1: builds the vp_token Hash (`{credential_query_id =>
|
|
10
|
+
# [presentation, ...]}`) for a wallet-side `DCQL::Selection`
|
|
11
|
+
# (`DCQL::Evaluator#evaluate`), delegating each Presentation to its
|
|
12
|
+
# Credential Format handler (`Formats::Registry#for`).
|
|
13
|
+
class Presenter
|
|
14
|
+
def initialize(wallet)
|
|
15
|
+
@wallet = wallet
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# `selection` is a `DCQL::Selection` (its `#matches` need not be
|
|
19
|
+
# `#satisfiable?` -- callers check that separately, e.g.
|
|
20
|
+
# `ResponseBuilder`). Returns a Hash suitable for `VpToken.parse`.
|
|
21
|
+
def present(processed, selection)
|
|
22
|
+
assignment = assign_transaction_data(processed.transaction_data, selection)
|
|
23
|
+
|
|
24
|
+
selection.matches.each_with_object({}) do |(id, matches), vp_token|
|
|
25
|
+
vp_token[id] = matches.map { |match| build_one(processed, match, assignment[id]) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_one(processed, match, transaction_data)
|
|
32
|
+
handler = @wallet.formats.for(match.credential_query.format)
|
|
33
|
+
context = build_context(processed, match, transaction_data)
|
|
34
|
+
handler.build_presentation(match.credential, match.claims, context)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_context(processed, match, transaction_data)
|
|
38
|
+
OpenID4VP::Formats::Context.new(
|
|
39
|
+
nonce: processed.request.nonce,
|
|
40
|
+
audience: processed.audience,
|
|
41
|
+
client_id: processed.client_id,
|
|
42
|
+
response_uri: return_uri_for(processed),
|
|
43
|
+
origin: processed.origin,
|
|
44
|
+
encryption_jwk: processed.encryption&.key,
|
|
45
|
+
transaction_data: transaction_data,
|
|
46
|
+
now: @wallet.now,
|
|
47
|
+
require_holder_binding: match.credential_query.require_cryptographic_holder_binding,
|
|
48
|
+
dc_api: processed.dc_api
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# OpenID4VP Appendix B.2.6/B.3.6: `response_uri` (bound into a
|
|
53
|
+
# holder-bound Presentation's audience/transcript, e.g. mso_mdoc's
|
|
54
|
+
# OpenID4VPHandover) is meaningful for every non-DC-API Response Mode --
|
|
55
|
+
# direct_post[.jwt] via `response_uri`, fragment/query (redirect) via
|
|
56
|
+
# `redirect_uri` -- `AuthorizationRequest#return_uri` already resolves
|
|
57
|
+
# either. Only DC API Presentations bind against `client_id`/`origin`
|
|
58
|
+
# instead of a response_uri at all.
|
|
59
|
+
def return_uri_for(processed)
|
|
60
|
+
processed.dc_api ? nil : processed.return_uri
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# OpenID4VP §5.1: each transaction_data entry is authorized by
|
|
64
|
+
# EXACTLY ONE credential -- the first id (in the entry's
|
|
65
|
+
# `credential_ids` order) that is actually part of `selection.matches`
|
|
66
|
+
# -- so it is passed only to that id's Formats::Context, never to any
|
|
67
|
+
# other credential_ids entry it also names. Returns
|
|
68
|
+
# `{credential_query_id => [entry, ...]}`, defaulting to `[]` for ids
|
|
69
|
+
# with no assigned entries. OpenID4VP §8.5: raises
|
|
70
|
+
# InvalidTransactionData when NONE of an entry's `credential_ids` is
|
|
71
|
+
# being presented ("the referenced Credential(s) are not available in
|
|
72
|
+
# the Wallet").
|
|
73
|
+
def assign_transaction_data(entries, selection)
|
|
74
|
+
assignment = selection.matches.each_key.to_h { |id| [id, []] }
|
|
75
|
+
|
|
76
|
+
(entries || []).each do |entry|
|
|
77
|
+
owner = entry.credential_ids.find { |id| assignment.key?(id) }
|
|
78
|
+
if owner.nil?
|
|
79
|
+
raise OpenID4VP::InvalidTransactionData,
|
|
80
|
+
"transaction_data references credential(s) not being presented: #{entry.credential_ids.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
assignment[owner] << entry
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
assignment
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
class Wallet
|
|
5
|
+
# The result of `Wallet#process_request`/`#process_dc_api_request`
|
|
6
|
+
# (design doc §4.21): the effective, verified AuthorizationRequest
|
|
7
|
+
# together with everything a caller needs to build a response
|
|
8
|
+
# (`Presenter`/`ResponseBuilder`/`#submit`, Task 29).
|
|
9
|
+
ProcessedRequest = Struct.new(
|
|
10
|
+
:request, :client_id, :verified, :verifier_metadata, :audience, :response_mode, :return_uri, :dc_api,
|
|
11
|
+
:origin, :vp_formats, :dcql_query, :transaction_data, :encryption, :wallet_nonce, keyword_init: true
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|