openid4vp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +278 -0
  5. data/lib/openid4vp/authorization_request/validator.rb +177 -0
  6. data/lib/openid4vp/authorization_request.rb +302 -0
  7. data/lib/openid4vp/authorization_response.rb +151 -0
  8. data/lib/openid4vp/client_id.rb +90 -0
  9. data/lib/openid4vp/credential.rb +51 -0
  10. data/lib/openid4vp/dc_api.rb +116 -0
  11. data/lib/openid4vp/dcql/claims_path_pointer.rb +109 -0
  12. data/lib/openid4vp/dcql/claims_query.rb +76 -0
  13. data/lib/openid4vp/dcql/credential_query.rb +178 -0
  14. data/lib/openid4vp/dcql/credential_set_query.rb +49 -0
  15. data/lib/openid4vp/dcql/evaluator.rb +228 -0
  16. data/lib/openid4vp/dcql/matcher.rb +79 -0
  17. data/lib/openid4vp/dcql/meta.rb +99 -0
  18. data/lib/openid4vp/dcql/query.rb +93 -0
  19. data/lib/openid4vp/dcql/selection.rb +45 -0
  20. data/lib/openid4vp/dcql/trusted_authorities_query.rb +48 -0
  21. data/lib/openid4vp/dcql.rb +10 -0
  22. data/lib/openid4vp/encoded_response.rb +10 -0
  23. data/lib/openid4vp/error_response.rb +82 -0
  24. data/lib/openid4vp/errors.rb +73 -0
  25. data/lib/openid4vp/formats/base.rb +74 -0
  26. data/lib/openid4vp/formats/context.rb +56 -0
  27. data/lib/openid4vp/formats/jwt_vc_json.rb +274 -0
  28. data/lib/openid4vp/formats/ldp_vc.rb +160 -0
  29. data/lib/openid4vp/formats/mso_mdoc.rb +204 -0
  30. data/lib/openid4vp/formats/registry.rb +53 -0
  31. data/lib/openid4vp/formats/sd_jwt_vc.rb +132 -0
  32. data/lib/openid4vp/formats/verified_presentation.rb +45 -0
  33. data/lib/openid4vp/formats.rb +12 -0
  34. data/lib/openid4vp/jose/algorithms.rb +46 -0
  35. data/lib/openid4vp/jose/jwe.rb +184 -0
  36. data/lib/openid4vp/jose/jwk.rb +253 -0
  37. data/lib/openid4vp/jose/jwk_set.rb +57 -0
  38. data/lib/openid4vp/jose/jws.rb +225 -0
  39. data/lib/openid4vp/jose.rb +8 -0
  40. data/lib/openid4vp/mdoc/builder.rb +132 -0
  41. data/lib/openid4vp/mdoc/cbor_values.rb +32 -0
  42. data/lib/openid4vp/mdoc/cose_key.rb +71 -0
  43. data/lib/openid4vp/mdoc/device_authentication.rb +32 -0
  44. data/lib/openid4vp/mdoc/device_response.rb +77 -0
  45. data/lib/openid4vp/mdoc/device_signed.rb +111 -0
  46. data/lib/openid4vp/mdoc/document.rb +44 -0
  47. data/lib/openid4vp/mdoc/handover.rb +51 -0
  48. data/lib/openid4vp/mdoc/issuer_signed.rb +157 -0
  49. data/lib/openid4vp/mdoc/mso.rb +133 -0
  50. data/lib/openid4vp/mdoc/session_transcript.rb +49 -0
  51. data/lib/openid4vp/mdoc/sign1_decoder.rb +60 -0
  52. data/lib/openid4vp/mdoc/verifier.rb +169 -0
  53. data/lib/openid4vp/mdoc.rb +9 -0
  54. data/lib/openid4vp/metadata/verifier_metadata.rb +109 -0
  55. data/lib/openid4vp/metadata/vp_formats.rb +112 -0
  56. data/lib/openid4vp/metadata/wallet_metadata.rb +139 -0
  57. data/lib/openid4vp/metadata.rb +10 -0
  58. data/lib/openid4vp/request_object.rb +152 -0
  59. data/lib/openid4vp/request_verification/base.rb +63 -0
  60. data/lib/openid4vp/request_verification/config.rb +65 -0
  61. data/lib/openid4vp/request_verification/decentralized_identifier.rb +56 -0
  62. data/lib/openid4vp/request_verification/openid_federation.rb +52 -0
  63. data/lib/openid4vp/request_verification/pre_registered.rb +54 -0
  64. data/lib/openid4vp/request_verification/redirect_uri.rb +29 -0
  65. data/lib/openid4vp/request_verification/resolver.rb +76 -0
  66. data/lib/openid4vp/request_verification/verified_request.rb +12 -0
  67. data/lib/openid4vp/request_verification/verifier_attestation.rb +54 -0
  68. data/lib/openid4vp/request_verification/x509_hash.rb +40 -0
  69. data/lib/openid4vp/request_verification/x509_san_dns.rb +60 -0
  70. data/lib/openid4vp/request_verification.rb +10 -0
  71. data/lib/openid4vp/response_encryption.rb +51 -0
  72. data/lib/openid4vp/response_endpoint_result.rb +33 -0
  73. data/lib/openid4vp/response_mode.rb +44 -0
  74. data/lib/openid4vp/response_type.rb +7 -0
  75. data/lib/openid4vp/sd_jwt/digest.rb +30 -0
  76. data/lib/openid4vp/sd_jwt/disclosure.rb +104 -0
  77. data/lib/openid4vp/sd_jwt/issuer.rb +132 -0
  78. data/lib/openid4vp/sd_jwt/kb_jwt.rb +82 -0
  79. data/lib/openid4vp/sd_jwt/parser.rb +48 -0
  80. data/lib/openid4vp/sd_jwt/presentation.rb +333 -0
  81. data/lib/openid4vp/sd_jwt/reconstruct.rb +132 -0
  82. data/lib/openid4vp/sd_jwt.rb +10 -0
  83. data/lib/openid4vp/transaction_data/registry.rb +42 -0
  84. data/lib/openid4vp/transaction_data/sd_jwt_hashes.rb +124 -0
  85. data/lib/openid4vp/transaction_data.rb +138 -0
  86. data/lib/openid4vp/util/base64url.rb +44 -0
  87. data/lib/openid4vp/util/form.rb +24 -0
  88. data/lib/openid4vp/util/random.rb +29 -0
  89. data/lib/openid4vp/util/uri.rb +48 -0
  90. data/lib/openid4vp/util.rb +23 -0
  91. data/lib/openid4vp/verifier/config.rb +216 -0
  92. data/lib/openid4vp/verifier/created_request.rb +25 -0
  93. data/lib/openid4vp/verifier/http_result.rb +34 -0
  94. data/lib/openid4vp/verifier/memory_session_store.rb +102 -0
  95. data/lib/openid4vp/verifier/request_builder.rb +216 -0
  96. data/lib/openid4vp/verifier/request_signer.rb +59 -0
  97. data/lib/openid4vp/verifier/request_uri_endpoint.rb +151 -0
  98. data/lib/openid4vp/verifier/response_endpoint.rb +197 -0
  99. data/lib/openid4vp/verifier/response_validator.rb +258 -0
  100. data/lib/openid4vp/verifier/session.rb +109 -0
  101. data/lib/openid4vp/verifier/session_store.rb +48 -0
  102. data/lib/openid4vp/verifier/verified_response.rb +53 -0
  103. data/lib/openid4vp/verifier.rb +155 -0
  104. data/lib/openid4vp/verifier_attestation.rb +130 -0
  105. data/lib/openid4vp/verifier_info.rb +102 -0
  106. data/lib/openid4vp/version.rb +5 -0
  107. data/lib/openid4vp/vp_token.rb +81 -0
  108. data/lib/openid4vp/wallet/config.rb +49 -0
  109. data/lib/openid4vp/wallet/http_client.rb +73 -0
  110. data/lib/openid4vp/wallet/presenter.rb +90 -0
  111. data/lib/openid4vp/wallet/processed_request.rb +14 -0
  112. data/lib/openid4vp/wallet/request_processor.rb +237 -0
  113. data/lib/openid4vp/wallet/response_builder.rb +44 -0
  114. data/lib/openid4vp/wallet/submission.rb +68 -0
  115. data/lib/openid4vp/wallet.rb +142 -0
  116. data/lib/openid4vp/x509/chain_validator.rb +42 -0
  117. data/lib/openid4vp/x509.rb +103 -0
  118. data/lib/openid4vp.rb +18 -0
  119. metadata +233 -0
@@ -0,0 +1,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ VERSION = "0.1.0"
5
+ 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