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,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "util/base64url"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
# OpenID4VP §5.1/§8.4: the transaction_data Authorization Request
|
|
8
|
+
# parameter, a non-empty array of base64url-encoded JSON objects binding
|
|
9
|
+
# user authorization to specific transaction details.
|
|
10
|
+
module TransactionData
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Parses each element of the transaction_data request parameter.
|
|
14
|
+
# Raises InvalidTransactionData unless `array` is a non-empty Array of
|
|
15
|
+
# Strings that each decode to a valid entry.
|
|
16
|
+
def parse_all(array)
|
|
17
|
+
unless array.is_a?(::Array) && !array.empty?
|
|
18
|
+
raise OpenID4VP::InvalidTransactionData, "transaction_data must be a non-empty array"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
array.map do |item|
|
|
22
|
+
raise OpenID4VP::InvalidTransactionData, "transaction_data entries must be strings" unless item.is_a?(::String)
|
|
23
|
+
|
|
24
|
+
Entry.parse(item)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# OpenID4VP §5.1: every transaction_data credential_ids value MUST
|
|
29
|
+
# reference an id in the DCQL Credential Query. Raises
|
|
30
|
+
# InvalidTransactionData otherwise.
|
|
31
|
+
def validate_against_query!(entries, dcql_query)
|
|
32
|
+
ids = dcql_query.ids
|
|
33
|
+
|
|
34
|
+
entries.each do |entry|
|
|
35
|
+
entry.credential_ids.each do |credential_id|
|
|
36
|
+
next if ids.include?(credential_id)
|
|
37
|
+
|
|
38
|
+
raise OpenID4VP::InvalidTransactionData,
|
|
39
|
+
"transaction_data credential_ids references unknown credential id #{credential_id.inspect}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
entries
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A single parsed/built transaction_data entry: the base64url string as
|
|
47
|
+
# received or produced (`raw`, over which hashes are computed
|
|
48
|
+
# verbatim), the decoded JSON object (`data`), and its `type` and
|
|
49
|
+
# `credential_ids` parameters (§5.1) plus the optional
|
|
50
|
+
# `transaction_data_hashes_alg` parameter (§B.3.3.1).
|
|
51
|
+
class Entry
|
|
52
|
+
attr_reader :raw, :data, :type, :credential_ids, :transaction_data_hashes_alg
|
|
53
|
+
|
|
54
|
+
def initialize(raw:, data:, type:, credential_ids:, transaction_data_hashes_alg: nil)
|
|
55
|
+
@raw = raw
|
|
56
|
+
@data = data
|
|
57
|
+
@type = type
|
|
58
|
+
@credential_ids = credential_ids
|
|
59
|
+
@transaction_data_hashes_alg = transaction_data_hashes_alg
|
|
60
|
+
freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Decodes and validates a single base64url-encoded transaction_data
|
|
64
|
+
# string. Raises InvalidTransactionData on bad base64/JSON, a
|
|
65
|
+
# non-object payload, or missing/invalid `type`, `credential_ids`, or
|
|
66
|
+
# `transaction_data_hashes_alg`.
|
|
67
|
+
def self.parse(raw)
|
|
68
|
+
raise OpenID4VP::InvalidTransactionData, "transaction_data entry must be a string" unless raw.is_a?(::String)
|
|
69
|
+
|
|
70
|
+
data =
|
|
71
|
+
begin
|
|
72
|
+
OpenID4VP::Util::Base64url.decode_json(raw)
|
|
73
|
+
rescue OpenID4VP::InvalidRequest
|
|
74
|
+
raise OpenID4VP::InvalidTransactionData, "transaction_data entry is not valid base64url-encoded JSON"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unless data.is_a?(::Hash)
|
|
78
|
+
raise OpenID4VP::InvalidTransactionData, "transaction_data entry must decode to a JSON object"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
type = validate_type(data)
|
|
82
|
+
credential_ids = validate_credential_ids(data)
|
|
83
|
+
alg = validate_hashes_alg(data)
|
|
84
|
+
|
|
85
|
+
new(raw: raw, data: data, type: type, credential_ids: credential_ids, transaction_data_hashes_alg: alg)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Builds a new entry from its parameters, re-encoding `raw` from
|
|
89
|
+
# `data` (`{"type"=>type, "credential_ids"=>credential_ids}` merged
|
|
90
|
+
# with the stringified `fields`).
|
|
91
|
+
def self.build(type:, credential_ids:, **fields)
|
|
92
|
+
data = { "type" => type, "credential_ids" => credential_ids }.merge(fields.transform_keys(&:to_s))
|
|
93
|
+
raw = OpenID4VP::Util::Base64url.encode_json(data)
|
|
94
|
+
|
|
95
|
+
new(
|
|
96
|
+
raw: raw,
|
|
97
|
+
data: data,
|
|
98
|
+
type: type,
|
|
99
|
+
credential_ids: credential_ids,
|
|
100
|
+
transaction_data_hashes_alg: data["transaction_data_hashes_alg"]
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.validate_type(data)
|
|
105
|
+
type = data["type"]
|
|
106
|
+
return type if type.is_a?(::String)
|
|
107
|
+
|
|
108
|
+
raise OpenID4VP::InvalidTransactionData, "transaction_data entry is missing a valid type"
|
|
109
|
+
end
|
|
110
|
+
private_class_method :validate_type
|
|
111
|
+
|
|
112
|
+
def self.validate_credential_ids(data)
|
|
113
|
+
credential_ids = data["credential_ids"]
|
|
114
|
+
unless credential_ids.is_a?(::Array) && !credential_ids.empty? &&
|
|
115
|
+
credential_ids.all?(::String)
|
|
116
|
+
raise OpenID4VP::InvalidTransactionData,
|
|
117
|
+
"transaction_data entry credential_ids must be a non-empty array of strings"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
credential_ids
|
|
121
|
+
end
|
|
122
|
+
private_class_method :validate_credential_ids
|
|
123
|
+
|
|
124
|
+
def self.validate_hashes_alg(data)
|
|
125
|
+
return nil unless data.key?("transaction_data_hashes_alg")
|
|
126
|
+
|
|
127
|
+
alg = data["transaction_data_hashes_alg"]
|
|
128
|
+
unless alg.is_a?(::Array) && !alg.empty? && alg.all?(::String)
|
|
129
|
+
raise OpenID4VP::InvalidTransactionData,
|
|
130
|
+
"transaction_data entry transaction_data_hashes_alg must be a non-empty array of strings"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
alg
|
|
134
|
+
end
|
|
135
|
+
private_class_method :validate_hashes_alg
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../util"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
module Util
|
|
8
|
+
# Unpadded base64url (RFC 4648 §5), as used throughout OpenID4VP/JOSE/COSE.
|
|
9
|
+
module Base64url
|
|
10
|
+
VALID_CHARS = /\A[A-Za-z0-9\-_]*\z/
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def encode(bytes)
|
|
15
|
+
[bytes].pack("m0").tr("+/", "-_").delete("=")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def decode(str)
|
|
19
|
+
raise OpenID4VP::InvalidRequest, "invalid base64url" unless str.is_a?(String) && VALID_CHARS.match?(str)
|
|
20
|
+
|
|
21
|
+
padded = str.tr("-_", "+/")
|
|
22
|
+
padded += "=" * ((4 - (padded.length % 4)) % 4)
|
|
23
|
+
begin
|
|
24
|
+
padded.unpack1("m0")
|
|
25
|
+
rescue ArgumentError
|
|
26
|
+
raise OpenID4VP::InvalidRequest, "invalid base64url"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def encode_json(obj)
|
|
31
|
+
encode(::JSON.generate(obj))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def decode_json(str)
|
|
35
|
+
parsed = ::JSON.parse(decode(str))
|
|
36
|
+
raise OpenID4VP::InvalidRequest, "invalid json" unless parsed.is_a?(Hash) || parsed.is_a?(Array)
|
|
37
|
+
|
|
38
|
+
parsed
|
|
39
|
+
rescue ::JSON::ParserError
|
|
40
|
+
raise OpenID4VP::InvalidRequest, "invalid json"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../util"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
module Util
|
|
8
|
+
# application/x-www-form-urlencoded encode/decode for request/response
|
|
9
|
+
# bodies. Hash and Array values are JSON-encoded before being form-encoded.
|
|
10
|
+
module Form
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def encode(hash)
|
|
14
|
+
::URI.encode_www_form(
|
|
15
|
+
hash.transform_values { |v| v.is_a?(Hash) || v.is_a?(Array) ? ::JSON.generate(v) : v.to_s }
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def decode(body)
|
|
20
|
+
::URI.decode_www_form(body).to_h
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../util"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
module Util
|
|
8
|
+
# Cryptographically secure random value generators, base64url-encoded.
|
|
9
|
+
module Random
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def nonce(bytes = 32)
|
|
13
|
+
Base64url.encode(SecureRandom.random_bytes(bytes))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def state
|
|
17
|
+
nonce
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def response_code
|
|
21
|
+
nonce
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def id
|
|
25
|
+
nonce
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../util"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
module Util
|
|
8
|
+
# URI helpers for building and parsing authorization request/response
|
|
9
|
+
# URLs, including custom-scheme (e.g. `openid4vp://`, `openid4vp:`) URLs
|
|
10
|
+
# that the stdlib ::URI parser does not always handle uniformly.
|
|
11
|
+
module URI
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def with_query(base, params)
|
|
15
|
+
return base if params.nil? || params.empty?
|
|
16
|
+
|
|
17
|
+
separator = base.include?("?") ? "&" : "?"
|
|
18
|
+
"#{base}#{separator}#{::URI.encode_www_form(params)}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def with_fragment(base, params)
|
|
22
|
+
return base if params.nil? || params.empty?
|
|
23
|
+
|
|
24
|
+
"#{base}##{::URI.encode_www_form(params)}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def params_from(url)
|
|
28
|
+
query = parsed_query(url) || manual_query(url)
|
|
29
|
+
return {} if query.nil? || query.empty?
|
|
30
|
+
|
|
31
|
+
::URI.decode_www_form(query).to_h
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parsed_query(url)
|
|
35
|
+
::URI.parse(url).query
|
|
36
|
+
rescue ::URI::InvalidURIError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def manual_query(url)
|
|
41
|
+
idx = url.index("?")
|
|
42
|
+
return nil unless idx
|
|
43
|
+
|
|
44
|
+
url[(idx + 1)..]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
# Low-level helpers shared across the gem. See also the nested
|
|
5
|
+
# `OpenID4VP::Util::Base64url`, `Random`, `Form` and `URI` modules.
|
|
6
|
+
module Util
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Constant-time string comparison, safe for comparing secrets.
|
|
10
|
+
def secure_compare(lhs, rhs)
|
|
11
|
+
OpenSSL.secure_compare(lhs, rhs)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# OID4VP §5.2: nonce/state values are restricted to this charset.
|
|
15
|
+
def url_safe?(str)
|
|
16
|
+
str.is_a?(String) && str.match?(/\A[A-Za-z0-9\-._~]+\z/)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def sha256(bytes)
|
|
20
|
+
OpenSSL::Digest::SHA256.digest(bytes)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../client_id"
|
|
5
|
+
require_relative "../x509"
|
|
6
|
+
require_relative "../response_mode"
|
|
7
|
+
require_relative "../jose/jws"
|
|
8
|
+
require_relative "../jose/jwk"
|
|
9
|
+
require_relative "../jose/algorithms"
|
|
10
|
+
require_relative "../formats/registry"
|
|
11
|
+
require_relative "../transaction_data/registry"
|
|
12
|
+
require_relative "memory_session_store"
|
|
13
|
+
|
|
14
|
+
module OpenID4VP
|
|
15
|
+
class Verifier
|
|
16
|
+
# Normalizes and validates the keyword arguments of `Verifier.new`
|
|
17
|
+
# exactly once, at construction time (design doc §4.20), so later
|
|
18
|
+
# per-request code (`RequestBuilder`, `RequestSigner`) can trust it
|
|
19
|
+
# without re-checking. Immutable once built.
|
|
20
|
+
class Config
|
|
21
|
+
DEFAULT_AUTHORIZATION_ENDPOINT = "openid4vp://"
|
|
22
|
+
DEFAULT_SESSION_TTL = 600
|
|
23
|
+
DEFAULT_LEEWAY = 60
|
|
24
|
+
DEFAULT_ENC_VALUES = %w[A128GCM].freeze
|
|
25
|
+
DEFAULT_ENCRYPTION_ALG = "ECDH-ES"
|
|
26
|
+
DEFAULT_ENCRYPTION_CRV = "P-256"
|
|
27
|
+
|
|
28
|
+
# trust: see `Formats::Context`'s doc comment for its per-format hook
|
|
29
|
+
# keys; also consulted directly (not via `Context#trust_hook`) by
|
|
30
|
+
# `Verifier::ResponseValidator` for `trust[:authority_hooks]` --
|
|
31
|
+
# OpenID4VP §6.1.1 trusted_authorities matching for authority types
|
|
32
|
+
# `DCQL::Matcher.authority_matches?` has no built-in check for.
|
|
33
|
+
attr_reader :client_id, :response_uri, :redirect_uri, :request_uri_base, :redirect_uri_base,
|
|
34
|
+
:authorization_endpoint, :signing, :attestation_jwt, :encryption, :vp_formats_supported,
|
|
35
|
+
:client_metadata, :session_store, :trust, :formats, :transaction_data_types, :scope_resolver,
|
|
36
|
+
:session_ttl, :clock, :verify_on_receipt, :leeway
|
|
37
|
+
|
|
38
|
+
def initialize(client_id:, response_uri: nil, redirect_uri: nil, request_uri_base: nil,
|
|
39
|
+
redirect_uri_base: nil, authorization_endpoint: DEFAULT_AUTHORIZATION_ENDPOINT, signing: nil,
|
|
40
|
+
attestation_jwt: nil, encryption: nil, vp_formats_supported: nil, client_metadata: {},
|
|
41
|
+
session_store: nil, trust: {},
|
|
42
|
+
formats: OpenID4VP::Formats::Registry.default,
|
|
43
|
+
transaction_data_types: OpenID4VP::TransactionData::Registry.new, scope_resolver: nil,
|
|
44
|
+
session_ttl: DEFAULT_SESSION_TTL, clock: -> { Time.now }, verify_on_receipt: false,
|
|
45
|
+
leeway: DEFAULT_LEEWAY)
|
|
46
|
+
@client_id = client_id.is_a?(OpenID4VP::ClientId) ? client_id : OpenID4VP::ClientId.parse(client_id)
|
|
47
|
+
@response_uri = response_uri
|
|
48
|
+
@redirect_uri = redirect_uri
|
|
49
|
+
@request_uri_base = request_uri_base
|
|
50
|
+
@redirect_uri_base = redirect_uri_base
|
|
51
|
+
@authorization_endpoint = authorization_endpoint
|
|
52
|
+
@signing = normalize_signing(signing)
|
|
53
|
+
@attestation_jwt = attestation_jwt
|
|
54
|
+
@encryption = normalize_encryption(encryption)
|
|
55
|
+
@vp_formats_supported = vp_formats_supported
|
|
56
|
+
@client_metadata = client_metadata || {}
|
|
57
|
+
@clock = clock
|
|
58
|
+
@session_store = session_store || OpenID4VP::Verifier::MemorySessionStore.new(clock: clock)
|
|
59
|
+
@trust = trust
|
|
60
|
+
@formats = formats
|
|
61
|
+
@transaction_data_types = transaction_data_types
|
|
62
|
+
@scope_resolver = scope_resolver
|
|
63
|
+
@session_ttl = session_ttl
|
|
64
|
+
@verify_on_receipt = verify_on_receipt
|
|
65
|
+
@leeway = leeway
|
|
66
|
+
|
|
67
|
+
validate_client_id_prefix!
|
|
68
|
+
freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def now
|
|
72
|
+
clock.call
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def signing_key
|
|
76
|
+
signing && signing[:key]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Encryption additions for a `create_request` call: the
|
|
80
|
+
# client_metadata members to add for a response sent under
|
|
81
|
+
# `response_mode`, and the per-session ephemeral private JWK to store
|
|
82
|
+
# on the session (nil for static keys, or when no encryption is
|
|
83
|
+
# configured / the mode is not encrypted).
|
|
84
|
+
def encryption_metadata(response_mode, request_id)
|
|
85
|
+
return [{}, nil] unless OpenID4VP::ResponseMode.encrypted?(response_mode) && encryption
|
|
86
|
+
|
|
87
|
+
if encryption[:mode] == :ephemeral
|
|
88
|
+
private_jwk = OpenID4VP::JOSE::JWK.generate(
|
|
89
|
+
:ec, crv: encryption[:crv], kid: request_id, use: "enc", alg: encryption[:alg]
|
|
90
|
+
)
|
|
91
|
+
[jwks_metadata([private_jwk.to_public]), private_jwk]
|
|
92
|
+
else
|
|
93
|
+
[jwks_metadata(encryption[:keys].map(&:to_public)), nil]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def jwks_metadata(public_keys)
|
|
100
|
+
metadata = { "jwks" => { "keys" => public_keys.map(&:to_h) } }
|
|
101
|
+
enc_values = encryption[:enc_values]
|
|
102
|
+
metadata["encrypted_response_enc_values_supported"] = enc_values unless enc_values == DEFAULT_ENC_VALUES
|
|
103
|
+
metadata
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_signing(signing)
|
|
107
|
+
return nil if signing.nil?
|
|
108
|
+
|
|
109
|
+
{ key: signing.fetch(:key), alg: signing[:alg], x5c: signing[:x5c] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A Hash with `:keys` is static encryption; any other Hash (including
|
|
113
|
+
# `{}`) is ephemeral -- `:ephemeral` is never actually read, so
|
|
114
|
+
# `encryption: { ephemeral: true }` (README quick start) is accepted
|
|
115
|
+
# purely as documentation sugar for the reader, not as a flag this
|
|
116
|
+
# method branches on.
|
|
117
|
+
def normalize_encryption(encryption)
|
|
118
|
+
return nil if encryption.nil?
|
|
119
|
+
|
|
120
|
+
encryption[:keys] ? normalize_static_encryption(encryption) : normalize_ephemeral_encryption(encryption)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def normalize_static_encryption(encryption)
|
|
124
|
+
keys = encryption[:keys]
|
|
125
|
+
keys.each do |jwk|
|
|
126
|
+
next if jwk.kid && jwk.use && jwk.alg
|
|
127
|
+
|
|
128
|
+
raise OpenID4VP::ConfigurationError, "encryption keys must each have a kid, use and alg"
|
|
129
|
+
end
|
|
130
|
+
{ mode: :static, keys: keys, enc_values: encryption[:enc_values] || DEFAULT_ENC_VALUES }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def normalize_ephemeral_encryption(encryption)
|
|
134
|
+
{
|
|
135
|
+
mode: :ephemeral,
|
|
136
|
+
crv: encryption[:crv] || DEFAULT_ENCRYPTION_CRV,
|
|
137
|
+
alg: encryption[:alg] || DEFAULT_ENCRYPTION_ALG,
|
|
138
|
+
enc_values: encryption[:enc_values] || DEFAULT_ENC_VALUES
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_client_id_prefix!
|
|
143
|
+
case client_id.prefix
|
|
144
|
+
when "x509_san_dns" then validate_x509!(:san_dns)
|
|
145
|
+
when "x509_hash" then validate_x509!(:hash)
|
|
146
|
+
when "verifier_attestation" then validate_verifier_attestation!
|
|
147
|
+
when "decentralized_identifier" then validate_decentralized_identifier!
|
|
148
|
+
when "redirect_uri" then validate_redirect_uri!
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_x509!(kind)
|
|
153
|
+
unless signing && signing[:key] && signing[:x5c] && !Array(signing[:x5c]).empty?
|
|
154
|
+
raise OpenID4VP::ConfigurationError, "#{client_id.prefix} requires a signing key and x5c"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
leaf = OpenID4VP::X509.parse(Array(signing[:x5c]).first)
|
|
158
|
+
kind == :san_dns ? validate_x509_san_dns!(leaf) : validate_x509_hash!(leaf)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_x509_san_dns!(leaf)
|
|
162
|
+
return if OpenID4VP::X509.dns_sans(leaf).include?(client_id.original)
|
|
163
|
+
|
|
164
|
+
raise OpenID4VP::ConfigurationError,
|
|
165
|
+
"x509_san_dns: leaf certificate SAN does not include #{client_id.original.inspect}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def validate_x509_hash!(leaf)
|
|
169
|
+
return if OpenID4VP::X509.leaf_hash_b64(leaf) == client_id.original
|
|
170
|
+
|
|
171
|
+
raise OpenID4VP::ConfigurationError, "x509_hash: leaf certificate hash does not match client_id"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def validate_verifier_attestation!
|
|
175
|
+
raise OpenID4VP::ConfigurationError, "verifier_attestation requires attestation_jwt" if attestation_jwt.nil?
|
|
176
|
+
raise OpenID4VP::ConfigurationError, "verifier_attestation requires a signing key" unless signing_key
|
|
177
|
+
|
|
178
|
+
payload = OpenID4VP::JOSE::JWS.peek(attestation_jwt).payload || {}
|
|
179
|
+
validate_attestation_sub!(payload)
|
|
180
|
+
validate_attestation_cnf!(payload)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_attestation_sub!(payload)
|
|
184
|
+
return if payload["sub"] == client_id.original
|
|
185
|
+
|
|
186
|
+
raise OpenID4VP::ConfigurationError, "verifier_attestation: attestation_jwt sub does not match client_id"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def validate_attestation_cnf!(payload)
|
|
190
|
+
cnf_jwk_hash = payload.dig("cnf", "jwk")
|
|
191
|
+
unless cnf_jwk_hash.is_a?(::Hash)
|
|
192
|
+
raise OpenID4VP::ConfigurationError, "verifier_attestation: attestation_jwt missing cnf.jwk"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
cnf_jwk = OpenID4VP::JOSE::JWK.new(cnf_jwk_hash)
|
|
196
|
+
return if cnf_jwk.thumbprint == signing_key.thumbprint
|
|
197
|
+
|
|
198
|
+
raise OpenID4VP::ConfigurationError, "verifier_attestation: attestation_jwt cnf.jwk does not match signing key"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def validate_decentralized_identifier!
|
|
202
|
+
prefix = "#{client_id.original}#"
|
|
203
|
+
return if signing_key&.kid&.start_with?(prefix)
|
|
204
|
+
|
|
205
|
+
raise OpenID4VP::ConfigurationError,
|
|
206
|
+
"decentralized_identifier requires a signing key kid starting with #{prefix.inspect}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def validate_redirect_uri!
|
|
210
|
+
return if signing.nil?
|
|
211
|
+
|
|
212
|
+
raise OpenID4VP::ConfigurationError, "redirect_uri client_id must not have a signing key"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
class Verifier
|
|
5
|
+
# The result of `Verifier#create_request` (design doc §4.20): the
|
|
6
|
+
# stored `session`, and exactly one of `url` (by-value or by-reference
|
|
7
|
+
# Authorization Request URL) or `dc_api_request` (the DC API
|
|
8
|
+
# `{protocol, data}` object), plus the signed Request Object compact
|
|
9
|
+
# JWS when one was produced eagerly.
|
|
10
|
+
CreatedRequest = Struct.new(:session, :url, :request_object, :dc_api_request, :authorization_request,
|
|
11
|
+
keyword_init: true) do
|
|
12
|
+
def session_id
|
|
13
|
+
session.id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def request_id
|
|
17
|
+
session.request_id
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def nonce
|
|
21
|
+
session.nonce
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
class Verifier
|
|
5
|
+
# A framework-agnostic HTTP response from a Verifier endpoint (design doc
|
|
6
|
+
# §4.20): the status code, header Hash (lowercase keys) and body String
|
|
7
|
+
# a thin controller adapter can hand straight to its web framework.
|
|
8
|
+
HttpResult = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
class HttpResult
|
|
11
|
+
NO_STORE = { "cache-control" => "no-store" }.freeze
|
|
12
|
+
JSON_HEADERS = { "content-type" => "application/json", "cache-control" => "no-store" }.freeze
|
|
13
|
+
JWT_HEADERS = { "content-type" => "application/oauth-authz-req+jwt", "cache-control" => "no-store" }.freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# OpenID4VP §8.2: a JSON response (200 with a ResponseEndpointResult,
|
|
17
|
+
# or any error/status code with a `{error, error_description}` body).
|
|
18
|
+
def json(status, hash)
|
|
19
|
+
new(status: status, headers: JSON_HEADERS, body: ::JSON.generate(hash))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# OpenID4VP §5.10.1: a Request URI endpoint response carrying the
|
|
23
|
+
# signed (optionally encrypted) Request Object.
|
|
24
|
+
def jwt(jws)
|
|
25
|
+
new(status: 200, headers: JWT_HEADERS, body: jws)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def error(status, code, description)
|
|
29
|
+
json(status, { "error" => code, "error_description" => description })
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session_store"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
class Verifier
|
|
8
|
+
# In-process `SessionStore` (design doc §4.20): Mutex-protected Hash
|
|
9
|
+
# storage with secondary indexes by `request_id` and `response_code`.
|
|
10
|
+
# Expired sessions are dropped lazily on lookup (and in bulk via
|
|
11
|
+
# `#cleanup`); not shared across processes, so production deployments
|
|
12
|
+
# will typically supply their own `SessionStore`.
|
|
13
|
+
class MemorySessionStore < SessionStore
|
|
14
|
+
# `clock`: a callable returning the current Time, consulted by
|
|
15
|
+
# `#lookup` instead of `Time.now` directly -- lets a `Verifier`
|
|
16
|
+
# configured with its own `clock:` (design doc §4.20) keep this
|
|
17
|
+
# store's notion of "now" consistent with the rest of its request
|
|
18
|
+
# handling, and lets tests control expiry deterministically.
|
|
19
|
+
def initialize(clock: -> { Time.now })
|
|
20
|
+
super()
|
|
21
|
+
@clock = clock
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@sessions = {}
|
|
24
|
+
@by_request_id = {}
|
|
25
|
+
@by_response_code = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create(session)
|
|
29
|
+
@mutex.synchronize { index(session) }
|
|
30
|
+
session
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def find(id)
|
|
34
|
+
@mutex.synchronize { lookup(id) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def find_by_request_id(request_id)
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
id = @by_request_id[request_id]
|
|
40
|
+
id && lookup(id)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_by_response_code(response_code)
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
id = @by_response_code[response_code]
|
|
47
|
+
id && lookup(id)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def update(session)
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
raise OpenID4VP::SessionNotFound, session.id unless @sessions.key?(session.id)
|
|
54
|
+
|
|
55
|
+
index(session)
|
|
56
|
+
end
|
|
57
|
+
session
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def delete(id)
|
|
61
|
+
@mutex.synchronize { unindex(id) }
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Drops every session expired as of `now`; returns how many were removed.
|
|
66
|
+
def cleanup(now)
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
expired_ids = @sessions.each_value.select { |s| s.expired?(now) }.map(&:id)
|
|
69
|
+
expired_ids.each { |id| unindex(id) }
|
|
70
|
+
expired_ids.size
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def index(session)
|
|
77
|
+
@sessions[session.id] = session
|
|
78
|
+
@by_request_id[session.request_id] = session.id if session.request_id
|
|
79
|
+
@by_response_code[session.response_code] = session.id if session.response_code
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Not synchronized itself: only called from within an already-held
|
|
83
|
+
# @mutex.synchronize block.
|
|
84
|
+
def lookup(id)
|
|
85
|
+
session = @sessions[id]
|
|
86
|
+
return nil if session.nil?
|
|
87
|
+
return session unless session.expired?(@clock.call)
|
|
88
|
+
|
|
89
|
+
unindex(id)
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unindex(id)
|
|
94
|
+
session = @sessions.delete(id)
|
|
95
|
+
return unless session
|
|
96
|
+
|
|
97
|
+
@by_request_id.delete(session.request_id)
|
|
98
|
+
@by_response_code.delete(session.response_code) if session.response_code
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|