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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "request_object"
4
+ require_relative "authorization_request"
5
+ require_relative "client_id"
6
+ require_relative "errors"
7
+ require_relative "jose/jws"
8
+
9
+ module OpenID4VP
10
+ # OpenID4VP Appendix A: OpenID4VP over the W3C Digital Credentials API
11
+ # (DC API). The DC API passes the request as a `{protocol, data}` object
12
+ # instead of a URL; this module wraps an AuthorizationRequest into that
13
+ # shape (Appendix A.1/A.3) and parses it back on the Wallet side,
14
+ # enforcing the `expected_origins` check of Appendix A.2. It does NOT
15
+ # verify any signature -- that happens once the caller has resolved the
16
+ # signer's key material.
17
+ module DcApi
18
+ PROTOCOLS = {
19
+ unsigned: "openid4vp-v1-unsigned",
20
+ signed: "openid4vp-v1-signed",
21
+ multisigned: "openid4vp-v1-multisigned"
22
+ }.freeze
23
+
24
+ # `requests` is nil for unsigned (there is no Request Object), the
25
+ # single-element `[RequestObject::Parsed]` for signed, or one
26
+ # `RequestObject::Parsed` per Client Identifier for multisigned.
27
+ # `request` is the effective AuthorizationRequest (unsigned: built
28
+ # straight from `data`; signed/multisigned: the first Parsed's).
29
+ ParsedDcApiRequest = Struct.new(:protocol, :requests, :request, :signed, keyword_init: true)
30
+
31
+ module_function
32
+
33
+ # Appendix A.3.1: an unsigned request. client_id and expected_origins
34
+ # are signed-request-only and MUST be omitted (the Wallet ignores them
35
+ # if present).
36
+ def unsigned_request(request)
37
+ { "protocol" => PROTOCOLS[:unsigned], "data" => request.to_h.except("client_id", "expected_origins") }
38
+ end
39
+
40
+ # Appendix A.3.2.1 (JWS Compact Serialization). `signer` is a callable
41
+ # receiving `request` and returning a compact JWS, e.g. Task 25's
42
+ # `->(r) { request_signer.sign(r) }`.
43
+ def signed_request(request, signer:)
44
+ { "protocol" => PROTOCOLS[:signed], "data" => { "request" => signer.call(request) } }
45
+ end
46
+
47
+ # Appendix A.3.2.2 (JWS JSON Serialization). `signers` is an Array of
48
+ # `{key:, alg:, header:}`, one per Client Identifier; each `header` MUST
49
+ # carry "client_id" and MAY carry "verifier_info", "trust_chain", "x5c"
50
+ # or "jwt". The shared payload omits the header-only params.
51
+ def multisigned_request(request, signers:)
52
+ payload = request.to_h.except(*OpenID4VP::RequestObject::HEADER_ONLY_PARAMS)
53
+ jws = OpenID4VP::JOSE::JWS.sign_json(payload, signers: signers.map { |signer| multisigned_signer(signer) })
54
+ { "protocol" => PROTOCOLS[:multisigned], "data" => jws }
55
+ end
56
+
57
+ # Parses a `{protocol, data}` DC API request. Raises InvalidRequest for
58
+ # an unsupported protocol, malformed `data`, or (signed/multisigned)
59
+ # when `origin` is not among the request's `expected_origins`.
60
+ def parse_request(protocol, data, origin:)
61
+ case protocol
62
+ when PROTOCOLS[:unsigned] then parse_unsigned(data)
63
+ when PROTOCOLS[:signed] then parse_signed(data, origin)
64
+ when PROTOCOLS[:multisigned] then parse_multisigned(data, origin)
65
+ else raise OpenID4VP::InvalidRequest, "unsupported DC API protocol #{protocol.inspect}"
66
+ end
67
+ end
68
+
69
+ # Appendix A.4: the audience for a DC API response is the Origin,
70
+ # prefixed with "origin:".
71
+ def audience(origin)
72
+ OpenID4VP::ClientId.origin(origin).to_s
73
+ end
74
+
75
+ def multisigned_signer(signer)
76
+ header = { "typ" => OpenID4VP::RequestObject::TYP }.merge(signer[:header] || {})
77
+ { key: signer.fetch(:key), alg: signer[:alg], header: header }
78
+ end
79
+ private_class_method :multisigned_signer
80
+
81
+ def parse_unsigned(data)
82
+ raise OpenID4VP::InvalidRequest, "DC API request data must be an object" unless data.is_a?(::Hash)
83
+
84
+ request = OpenID4VP::AuthorizationRequest.new(data.except("client_id", "expected_origins"))
85
+ ParsedDcApiRequest.new(protocol: PROTOCOLS[:unsigned], requests: nil, request: request, signed: false)
86
+ end
87
+ private_class_method :parse_unsigned
88
+
89
+ def parse_signed(data, origin)
90
+ unless data.is_a?(::Hash) && data["request"].is_a?(::String)
91
+ raise OpenID4VP::InvalidRequest, "DC API signed request data must be an object with a request string"
92
+ end
93
+
94
+ parsed = OpenID4VP::RequestObject.parse(data["request"])
95
+ check_expected_origins!(parsed.request, origin)
96
+ ParsedDcApiRequest.new(protocol: PROTOCOLS[:signed], requests: [parsed], request: parsed.request, signed: true)
97
+ end
98
+ private_class_method :parse_signed
99
+
100
+ def parse_multisigned(data, origin)
101
+ requests = OpenID4VP::RequestObject.parse_json(data)
102
+ request = requests.first.request
103
+ check_expected_origins!(request, origin)
104
+ ParsedDcApiRequest.new(protocol: PROTOCOLS[:multisigned], requests: requests, request: request, signed: true)
105
+ end
106
+ private_class_method :parse_multisigned
107
+
108
+ def check_expected_origins!(request, origin)
109
+ origins = request.expected_origins
110
+ return if origins.is_a?(::Array) && !origins.empty? && origins.include?(origin)
111
+
112
+ raise OpenID4VP::InvalidRequest, "origin not expected"
113
+ end
114
+ private_class_method :check_expected_origins!
115
+ end
116
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+ require_relative "../errors"
5
+
6
+ module OpenID4VP
7
+ module DCQL
8
+ # OpenID4VP §7: a Claims Path Pointer is a non-empty array of strings,
9
+ # nulls and non-negative integers that addresses one or more claims
10
+ # within a Credential.
11
+ class ClaimsPathPointer
12
+ # Raised when processing a pointer against a Credential fails (§7.1.1,
13
+ # §7.2.1). Not a wire-level protocol error on its own (code is nil).
14
+ class Error < OpenID4VP::Error; end
15
+
16
+ attr_reader :components
17
+
18
+ def initialize(components)
19
+ unless components.is_a?(::Array) && !components.empty?
20
+ raise OpenID4VP::InvalidRequest, "claims path pointer must be a non-empty array"
21
+ end
22
+
23
+ components.each { |component| validate_component(component) }
24
+
25
+ @components = components.dup.freeze
26
+ end
27
+
28
+ # OpenID4VP §7.1.1: process the pointer against a JSON-based Credential
29
+ # (a Hash/Array/scalar tree, e.g. from JSON.parse). Returns the array
30
+ # of selected elements, or raises Error.
31
+ def select(json)
32
+ selected = [json]
33
+
34
+ components.each do |component|
35
+ selected = case component
36
+ when ::String then select_key(selected, component)
37
+ when nil then select_all(selected)
38
+ when ::Integer then select_index(selected, component)
39
+ end
40
+
41
+ raise Error, "claims path pointer selection is empty" if selected.empty?
42
+ end
43
+
44
+ selected
45
+ end
46
+
47
+ # OpenID4VP §7.2.1: extract the [namespace, element_identifier] pair
48
+ # for an ISO mdoc-based Credential.
49
+ def mdoc_components
50
+ unless components.size == 2 && components.all?(::String)
51
+ raise Error, "mdoc claims path pointer must contain exactly two string components"
52
+ end
53
+
54
+ components
55
+ end
56
+
57
+ def to_a
58
+ components.dup
59
+ end
60
+
61
+ def to_s
62
+ ::JSON.generate(components)
63
+ end
64
+
65
+ def ==(other)
66
+ other.is_a?(self.class) && components == other.components
67
+ end
68
+ alias eql? ==
69
+
70
+ def hash
71
+ components.hash
72
+ end
73
+
74
+ private
75
+
76
+ def validate_component(component)
77
+ return if component.nil? || component.is_a?(::String)
78
+ return if component.is_a?(::Integer) && component >= 0
79
+
80
+ raise OpenID4VP::InvalidRequest,
81
+ "claims path pointer component must be a string, null, or non-negative integer, got: #{component.inspect}"
82
+ end
83
+
84
+ def select_key(selected, key)
85
+ selected.each_with_object([]) do |element, result|
86
+ raise Error, "cannot select key #{key.inspect} on a non-object element" unless element.is_a?(::Hash)
87
+
88
+ result << element[key] if element.key?(key)
89
+ end
90
+ end
91
+
92
+ def select_all(selected)
93
+ selected.each do |element|
94
+ raise Error, "cannot select all elements of a non-array element" unless element.is_a?(::Array)
95
+ end
96
+
97
+ selected.flat_map { |element| element }
98
+ end
99
+
100
+ def select_index(selected, index)
101
+ selected.each_with_object([]) do |element, result|
102
+ raise Error, "cannot select index #{index} of a non-array element" unless element.is_a?(::Array)
103
+
104
+ result << element[index] if index < element.size
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+ require_relative "../errors"
5
+ require_relative "claims_path_pointer"
6
+
7
+ module OpenID4VP
8
+ module DCQL
9
+ # OpenID4VP §6.3: specifies a claim within a requested Credential.
10
+ class ClaimsQuery
11
+ ID_PATTERN = /\A[A-Za-z0-9_-]+\z/
12
+
13
+ KNOWN_KEYS = %w[id path values intent_to_retain].freeze
14
+
15
+ attr_reader :id, :path, :values, :intent_to_retain, :extra
16
+
17
+ def initialize(path:, id: nil, values: nil, intent_to_retain: nil, extra: {})
18
+ validate_id(id)
19
+ validate_values(values)
20
+ validate_intent_to_retain(intent_to_retain)
21
+
22
+ @id = id
23
+ @path = path.is_a?(ClaimsPathPointer) ? path : ClaimsPathPointer.new(path)
24
+ @values = values&.dup&.freeze
25
+ @intent_to_retain = intent_to_retain
26
+ @extra = extra.dup.freeze
27
+ end
28
+
29
+ def self.from_h(hash)
30
+ raise OpenID4VP::InvalidRequest, "claims[] entry must be an object" unless hash.is_a?(::Hash)
31
+
32
+ new(
33
+ id: hash["id"],
34
+ path: hash["path"],
35
+ values: hash["values"],
36
+ intent_to_retain: hash["intent_to_retain"],
37
+ extra: hash.except(*KNOWN_KEYS)
38
+ )
39
+ end
40
+
41
+ def to_h
42
+ h = { "path" => path.to_a }
43
+ h["id"] = id if id
44
+ h["values"] = values.dup if values
45
+ h["intent_to_retain"] = intent_to_retain unless intent_to_retain.nil?
46
+ h.merge(extra)
47
+ end
48
+
49
+ private
50
+
51
+ def validate_id(id)
52
+ return if id.nil?
53
+ return if id.is_a?(::String) && id.match?(ID_PATTERN)
54
+
55
+ raise OpenID4VP::InvalidRequest, "claims[].id must be a non-empty string matching #{ID_PATTERN.inspect}"
56
+ end
57
+
58
+ def validate_values(values)
59
+ return if values.nil?
60
+
61
+ valid = values.is_a?(::Array) && !values.empty? &&
62
+ values.all? { |value| value.is_a?(::String) || value.is_a?(::Integer) || [true, false].include?(value) }
63
+ return if valid
64
+
65
+ raise OpenID4VP::InvalidRequest,
66
+ "claims[].values must be a non-empty array of strings, integers or booleans"
67
+ end
68
+
69
+ def validate_intent_to_retain(intent_to_retain)
70
+ return if intent_to_retain.nil? || [true, false].include?(intent_to_retain)
71
+
72
+ raise OpenID4VP::InvalidRequest, "claims[].intent_to_retain must be a boolean"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+ require_relative "../errors"
5
+ require_relative "claims_query"
6
+ require_relative "trusted_authorities_query"
7
+
8
+ module OpenID4VP
9
+ module DCQL
10
+ # OpenID4VP §6.1: a request for a presentation of one or more matching
11
+ # Credentials.
12
+ class CredentialQuery
13
+ ID_PATTERN = /\A[A-Za-z0-9_-]+\z/
14
+
15
+ KNOWN_KEYS = %w[
16
+ id format multiple meta trusted_authorities
17
+ require_cryptographic_holder_binding claims claim_sets
18
+ ].freeze
19
+
20
+ attr_reader :id, :format, :multiple, :meta, :trusted_authorities,
21
+ :require_cryptographic_holder_binding, :claims, :claim_sets, :extra
22
+
23
+ def initialize(
24
+ id:, format:, meta:, multiple: false, trusted_authorities: nil,
25
+ require_cryptographic_holder_binding: true, claims: nil, claim_sets: nil, extra: {}
26
+ )
27
+ validate_id(id)
28
+ validate_format(format)
29
+ validate_meta(meta)
30
+ validate_boolean(multiple, "multiple")
31
+ validate_boolean(require_cryptographic_holder_binding, "require_cryptographic_holder_binding")
32
+
33
+ @id = id
34
+ @format = format
35
+ @meta = meta.dup.freeze
36
+ @multiple = multiple
37
+ @require_cryptographic_holder_binding = require_cryptographic_holder_binding
38
+ @trusted_authorities = build_trusted_authorities(trusted_authorities)
39
+ @claims = build_claims(claims)
40
+ @claim_sets = build_claim_sets(claim_sets, @claims)
41
+ @extra = extra.dup.freeze
42
+ end
43
+
44
+ def self.from_h(hash)
45
+ raise OpenID4VP::InvalidRequest, "credentials[] entry must be an object" unless hash.is_a?(::Hash)
46
+
47
+ new(
48
+ id: hash["id"],
49
+ format: hash["format"],
50
+ meta: hash["meta"],
51
+ multiple: hash.key?("multiple") ? hash["multiple"] : false,
52
+ trusted_authorities: hash["trusted_authorities"],
53
+ require_cryptographic_holder_binding:
54
+ hash.key?("require_cryptographic_holder_binding") ? hash["require_cryptographic_holder_binding"] : true,
55
+ claims: hash["claims"],
56
+ claim_sets: hash["claim_sets"],
57
+ extra: hash.except(*KNOWN_KEYS)
58
+ )
59
+ end
60
+
61
+ def claim(id)
62
+ claims&.find { |claims_query| claims_query.id == id }
63
+ end
64
+
65
+ def to_h
66
+ h = {
67
+ "id" => id,
68
+ "format" => format,
69
+ "multiple" => multiple,
70
+ "meta" => meta.dup,
71
+ "require_cryptographic_holder_binding" => require_cryptographic_holder_binding
72
+ }
73
+ h["trusted_authorities"] = trusted_authorities.map(&:to_h) if trusted_authorities
74
+ h["claims"] = claims.map(&:to_h) if claims
75
+ h["claim_sets"] = claim_sets.map(&:dup) if claim_sets
76
+ h.merge(extra)
77
+ end
78
+
79
+ private
80
+
81
+ def validate_id(id)
82
+ return if id.is_a?(::String) && id.match?(ID_PATTERN)
83
+
84
+ raise OpenID4VP::InvalidRequest, "credentials[].id must be a non-empty string matching #{ID_PATTERN.inspect}"
85
+ end
86
+
87
+ def validate_format(format)
88
+ return if format.is_a?(::String) && !format.empty?
89
+
90
+ raise OpenID4VP::InvalidRequest, "credentials[].format must be a non-empty string"
91
+ end
92
+
93
+ def validate_meta(meta)
94
+ return if meta.is_a?(::Hash)
95
+
96
+ raise OpenID4VP::InvalidRequest, "credentials[].meta must be an object"
97
+ end
98
+
99
+ def validate_boolean(value, field)
100
+ return if [true, false].include?(value)
101
+
102
+ raise OpenID4VP::InvalidRequest, "credentials[].#{field} must be a boolean"
103
+ end
104
+
105
+ def build_trusted_authorities(trusted_authorities)
106
+ return nil if trusted_authorities.nil?
107
+
108
+ unless trusted_authorities.is_a?(::Array) && !trusted_authorities.empty?
109
+ raise OpenID4VP::InvalidRequest, "credentials[].trusted_authorities must be a non-empty array"
110
+ end
111
+
112
+ trusted_authorities.map do |entry|
113
+ entry.is_a?(TrustedAuthoritiesQuery) ? entry : TrustedAuthoritiesQuery.from_h(entry)
114
+ end.freeze
115
+ end
116
+
117
+ def build_claims(claims)
118
+ return nil if claims.nil?
119
+
120
+ unless claims.is_a?(::Array) && !claims.empty?
121
+ raise OpenID4VP::InvalidRequest, "credentials[].claims must be a non-empty array"
122
+ end
123
+
124
+ built = claims.map { |claim| claim.is_a?(ClaimsQuery) ? claim : ClaimsQuery.from_h(claim) }
125
+ deduped = dedupe_by_path(built)
126
+
127
+ ids = deduped.filter_map(&:id)
128
+ raise OpenID4VP::InvalidRequest, "credentials[].claims ids must be unique" if ids.uniq.size != ids.size
129
+
130
+ deduped.freeze
131
+ end
132
+
133
+ # OID4VP §6.1: "Verifiers MUST NOT point to the same claim more than
134
+ # once... Wallets SHOULD ignore such duplicate claim queries." Keep the
135
+ # first occurrence for each distinct path, silently.
136
+ def dedupe_by_path(claims_queries)
137
+ seen_paths = []
138
+ claims_queries.each_with_object([]) do |claims_query, kept|
139
+ next if seen_paths.any? { |path| path == claims_query.path }
140
+
141
+ seen_paths << claims_query.path
142
+ kept << claims_query
143
+ end
144
+ end
145
+
146
+ def build_claim_sets(claim_sets, claims)
147
+ return nil if claim_sets.nil?
148
+
149
+ raise OpenID4VP::InvalidRequest, "credentials[].claim_sets requires claims to be present" if claims.nil?
150
+
151
+ unless claim_sets.is_a?(::Array) && !claim_sets.empty?
152
+ raise OpenID4VP::InvalidRequest, "credentials[].claim_sets must be a non-empty array"
153
+ end
154
+
155
+ unless claims.all?(&:id)
156
+ raise OpenID4VP::InvalidRequest, "credentials[].claims must all have an id when claim_sets is present"
157
+ end
158
+
159
+ claim_ids = claims.map(&:id)
160
+ claim_sets.each { |option| validate_claim_set_option(option, claim_ids) }
161
+
162
+ claim_sets.map(&:dup).freeze
163
+ end
164
+
165
+ def validate_claim_set_option(option, claim_ids)
166
+ unless option.is_a?(::Array) && !option.empty? && option.all?(::String)
167
+ raise OpenID4VP::InvalidRequest, "credentials[].claim_sets options must be non-empty arrays of strings"
168
+ end
169
+
170
+ option.each do |id|
171
+ next if claim_ids.include?(id)
172
+
173
+ raise OpenID4VP::InvalidRequest, "credentials[].claim_sets references unknown claim id #{id.inspect}"
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dcql"
4
+ require_relative "../errors"
5
+
6
+ module OpenID4VP
7
+ module DCQL
8
+ # OpenID4VP §6.2: a request for one or more Credentials to satisfy a
9
+ # particular use case, expressed as alternative sets of credential ids.
10
+ class CredentialSetQuery
11
+ KNOWN_KEYS = %w[options required].freeze
12
+
13
+ attr_reader :options, :required, :extra
14
+
15
+ def initialize(options:, required: true, extra: {})
16
+ valid_options = options.is_a?(::Array) && !options.empty? && options.all? do |option|
17
+ option.is_a?(::Array) && !option.empty? && option.all?(::String)
18
+ end
19
+ unless valid_options
20
+ raise OpenID4VP::InvalidRequest,
21
+ "credential_sets[].options must be a non-empty array of non-empty arrays of strings"
22
+ end
23
+
24
+ unless [true, false].include?(required)
25
+ raise OpenID4VP::InvalidRequest, "credential_sets[].required must be a boolean"
26
+ end
27
+
28
+ @options = options.map(&:dup).freeze
29
+ @required = required
30
+ @extra = extra.dup.freeze
31
+ end
32
+
33
+ def self.from_h(hash)
34
+ raise OpenID4VP::InvalidRequest, "credential_sets[] entry must be an object" unless hash.is_a?(::Hash)
35
+
36
+ required = hash.key?("required") ? hash["required"] : true
37
+ new(
38
+ options: hash["options"],
39
+ required: required,
40
+ extra: hash.except(*KNOWN_KEYS)
41
+ )
42
+ end
43
+
44
+ def to_h
45
+ { "options" => options.map(&:dup), "required" => required }.merge(extra)
46
+ end
47
+ end
48
+ end
49
+ end