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