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