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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jose"
4
+
5
+ require_relative "../jose"
6
+ require_relative "../errors"
7
+ require_relative "../util"
8
+ require_relative "../util/base64url"
9
+ require_relative "algorithms"
10
+ require_relative "jwk"
11
+
12
+ module OpenID4VP
13
+ module JOSE
14
+ # JSON Web Encryption (RFC 7516), compact serialization, restricted to
15
+ # the ECDH-ES key management family (RFC 7518 SS4.6) used for OpenID4VP
16
+ # SS8.3 encrypted Authorization Responses. A thin adapter over
17
+ # `JOSE::JWE` (the `jose` gem), which does the actual ECDH key
18
+ # agreement, Concat KDF, key wrapping and content encryption.
19
+ module JWE
20
+ # A decrypted JWE. `payload` is the parsed JSON Hash when `plaintext`
21
+ # parses as a JSON object, otherwise nil. `key` is the JWK used.
22
+ Decrypted = Struct.new(:header, :plaintext, :payload, :key, keyword_init: true)
23
+
24
+ # `jose`'s ECDH-ES key agreement for OKP (X25519/X448) recipients goes
25
+ # through JOSE::JWA::Curve25519/448, whose only implementation that
26
+ # ships without an extra runtime gem dependency (rbnacl/x25519) is the
27
+ # pure-Ruby fallback -- WITHOUT `rbnacl` or `x25519` installed, that
28
+ # fallback is NOT constant-time (a timing side-channel risk for X25519/
29
+ # X448 key agreement specifically). EC (P-256/384/521) recipients --
30
+ # the default and most common case -- go through OpenSSL and are
31
+ # unaffected. See README "Cryptography".
32
+ ::JOSE.crypto_fallback = true
33
+
34
+ class << self
35
+ # Encrypts `payload` (a Hash, JSON-encoded; or a String, used as raw
36
+ # plaintext bytes) to `recipient`'s public key, returning the
37
+ # compact serialization.
38
+ def encrypt(payload, recipient:, alg: nil, enc: "A128GCM", header: {}, apu: nil, apv: nil)
39
+ alg ||= recipient.alg || "ECDH-ES"
40
+ validate_algorithms!(alg, enc)
41
+
42
+ ephemeral = ephemeral_key_for(recipient)
43
+ protected_header = build_header(alg, enc, header, recipient, ephemeral, apu, apv)
44
+
45
+ ::JOSE::JWE.block_encrypt(
46
+ [jose_jwk(recipient.to_public), jose_jwk(ephemeral)], plaintext_bytes(payload), protected_header
47
+ ).compact
48
+ rescue ArgumentError, OpenSSL::OpenSSLError => e
49
+ raise OpenID4VP::InvalidRequest, "jwe: #{e.message}"
50
+ end
51
+
52
+ # Decrypts `compact` using the first of `keys` that both matches the
53
+ # header (by `kid`, or by `epk`'s `crv` when no `kid` is present) and
54
+ # successfully decrypts. Raises OpenID4VP::VerificationError when no
55
+ # key succeeds, and OpenID4VP::InvalidRequest on malformed input or
56
+ # an unsupported/unknown alg or enc.
57
+ def decrypt(compact, keys:)
58
+ protected_b64, = split_compact(compact)
59
+ header = decode_header(protected_b64)
60
+ validate_algorithms!(header["alg"], header["enc"])
61
+ epk = build_epk(header)
62
+
63
+ try_candidates(candidate_keys(keys, header, epk)) do |key|
64
+ plaintext, = ::JOSE::JWE.block_decrypt([jose_jwk(epk), jose_jwk(key)], compact)
65
+ Decrypted.new(header: header, plaintext: plaintext, payload: parse_payload(plaintext), key: key)
66
+ end
67
+ end
68
+
69
+ # Parses `compact` into its protected header WITHOUT decrypting.
70
+ # Raises OpenID4VP::InvalidRequest on malformed input.
71
+ def peek(compact)
72
+ protected_b64, = split_compact(compact)
73
+ decode_header(protected_b64)
74
+ end
75
+
76
+ private
77
+
78
+ # -- shared ---------------------------------------------------------
79
+
80
+ def jose_jwk(our_jwk)
81
+ ::JOSE::JWK.from_map(our_jwk.to_h)
82
+ rescue ArgumentError => e
83
+ raise OpenID4VP::InvalidRequest, "jwe: #{e.message}"
84
+ end
85
+
86
+ # -- encrypt helpers ---------------------------------------------
87
+
88
+ def validate_algorithms!(alg, enc)
89
+ unless alg.is_a?(String) && Algorithms::KEY_MANAGEMENT.include?(alg)
90
+ raise OpenID4VP::InvalidRequest, "jwe: unsupported alg #{alg.inspect}"
91
+ end
92
+ unless enc.is_a?(String) && Algorithms::CONTENT_ENCRYPTION.include?(enc)
93
+ raise OpenID4VP::InvalidRequest, "jwe: unsupported enc #{enc.inspect}"
94
+ end
95
+
96
+ [alg, enc]
97
+ end
98
+
99
+ def ephemeral_key_for(recipient)
100
+ case recipient.kty
101
+ when "EC" then JWK.generate(:ec, crv: recipient.crv)
102
+ when "OKP" then JWK.generate(:okp, crv: recipient.crv)
103
+ else raise OpenID4VP::InvalidRequest, "jwe: unsupported recipient kty #{recipient.kty.inspect}"
104
+ end
105
+ end
106
+
107
+ def build_header(alg, enc, header, recipient, ephemeral, apu, apv)
108
+ protected_header = { "alg" => alg, "enc" => enc }.merge(header)
109
+ protected_header["kid"] = recipient.kid if recipient.kid
110
+ protected_header["epk"] = ephemeral.to_public.to_h
111
+ protected_header["apu"] = Util::Base64url.encode(apu) if apu
112
+ protected_header["apv"] = Util::Base64url.encode(apv) if apv
113
+ protected_header
114
+ end
115
+
116
+ def plaintext_bytes(payload)
117
+ payload.is_a?(String) ? payload.b : ::JSON.generate(payload).b
118
+ end
119
+
120
+ # -- decrypt helpers ----------------------------------------------
121
+
122
+ def split_compact(compact)
123
+ parts = compact.to_s.split(".", -1)
124
+ raise OpenID4VP::InvalidRequest, "jwe: malformed compact serialization" unless parts.size == 5
125
+
126
+ parts
127
+ end
128
+
129
+ def decode_header(protected_b64)
130
+ header = Util::Base64url.decode_json(protected_b64)
131
+ raise OpenID4VP::InvalidRequest, "jwe: header is not a JSON object" unless header.is_a?(Hash)
132
+
133
+ header
134
+ end
135
+
136
+ # Strips any private member (e.g. "d") from the header's epk before
137
+ # it is used for ECDH key agreement: epk is attacker-controlled
138
+ # (RFC 7518 SS4.6.1.1 -- it's carried in the unauthenticated JWE
139
+ # header), and its only legitimate role is as the sender's
140
+ # EPHEMERAL PUBLIC key. Passing a private member through -- even
141
+ # though our own private key material is what's actually used for
142
+ # agreement -- is needless: it should never be present, let alone
143
+ # echoed or relied upon.
144
+ def build_epk(header)
145
+ epk = header["epk"]
146
+ raise OpenID4VP::InvalidRequest, "jwe: missing epk" unless epk.is_a?(Hash)
147
+
148
+ JWK.new(epk).to_public
149
+ end
150
+
151
+ # Restricts candidates to those whose `kty`/`crv` are actually
152
+ # compatible with `epk`'s -- a `kid` match alone does not guarantee
153
+ # that (a misconfigured or attacker-substituted `epk` can carry any
154
+ # curve) -- so an incompatible key never reaches key agreement,
155
+ # whose failure on mismatched domain parameters `try_candidates`
156
+ # also rescues, as defense in depth.
157
+ def candidate_keys(keys, header, epk)
158
+ matched = header["kid"] ? keys.select { |k| k.kid == header["kid"] } : keys
159
+ raise OpenID4VP::VerificationError, "no key for kid" if header["kid"] && matched.empty?
160
+
161
+ matched.select { |k| k.kty == epk.kty && k.crv == epk.crv }
162
+ end
163
+
164
+ def try_candidates(candidates)
165
+ raise OpenID4VP::VerificationError, "no matching key" if candidates.empty?
166
+
167
+ candidates.each do |key|
168
+ return yield(key)
169
+ rescue OpenID4VP::VerificationError, OpenSSL::OpenSSLError, ArgumentError
170
+ next
171
+ end
172
+ raise OpenID4VP::VerificationError, "decryption failed"
173
+ end
174
+
175
+ def parse_payload(bytes)
176
+ parsed = ::JSON.parse(bytes)
177
+ parsed.is_a?(Hash) ? parsed : nil
178
+ rescue ::JSON::ParserError, EncodingError
179
+ nil
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jose"
4
+
5
+ require_relative "../jose"
6
+ require_relative "../errors"
7
+ require_relative "../util/base64url"
8
+
9
+ module OpenID4VP
10
+ module JOSE
11
+ # A single JSON Web Key (RFC 7517): a thin adapter over `JOSE::JWK` (the
12
+ # `jose` gem), which does the actual key material parsing/serialization
13
+ # (including ASN.1 construction) for EC and RSA. Supports EC, OKP
14
+ # (Ed25519/X25519/X448) and RSA keys. Instances are immutable.
15
+ #
16
+ # OKP keys are converted to/from OpenSSL directly via OpenSSL's own raw
17
+ # key API (`JOSE::JWK#to_key` returns a raw byte String, not an
18
+ # OpenSSL::PKey, for OKP kty's, so it is not useful here).
19
+ class JWK
20
+ SUPPORTED_KTYS = %w[EC OKP RSA].freeze
21
+
22
+ EC_CURVE_TO_OPENSSL = {
23
+ "P-256" => "prime256v1",
24
+ "P-384" => "secp384r1",
25
+ "P-521" => "secp521r1"
26
+ }.freeze
27
+
28
+ EC_COORD_SIZES = { "P-256" => 32, "P-384" => 48, "P-521" => 66 }.freeze
29
+
30
+ OKP_CURVES = %w[Ed25519 X25519 X448].freeze
31
+ OKP_OID_TO_CRV = { "ED25519" => "Ed25519", "X25519" => "X25519", "X448" => "X448" }.freeze
32
+ OKP_KEY_LENGTH = { "Ed25519" => 32, "X25519" => 32, "X448" => 56 }.freeze
33
+
34
+ PRIVATE_ONLY_MEMBERS = {
35
+ "EC" => %w[d],
36
+ "OKP" => %w[d],
37
+ "RSA" => %w[d p q dp dq qi]
38
+ }.freeze
39
+
40
+ %w[kty crv kid alg use].each do |member|
41
+ define_method(member) { @members[member] }
42
+ end
43
+
44
+ class << self
45
+ # kty is :ec, :okp or :rsa. params (kid:, use:, alg:, ...) are merged
46
+ # into the resulting JWK's members.
47
+ def generate(kty, **params)
48
+ case kty
49
+ when :ec then generate_ec(params)
50
+ when :okp then generate_okp(params)
51
+ when :rsa then from_openssl(OpenSSL::PKey::RSA.new(params.delete(:bits) || 2048), **params)
52
+ else raise OpenID4VP::InvalidRequest, "jwk: unsupported kty for generate #{kty.inspect}"
53
+ end
54
+ end
55
+
56
+ # Any EC members hash reaching .new (directly, or built here) must
57
+ # have `d` left-padded to the curve's fixed coordinate length -- see
58
+ # #pad_ec_d below for why; a future constructor that bypasses it
59
+ # would reintroduce the P-521 truncation bug it fixes.
60
+ def from_openssl(pkey, **params)
61
+ members =
62
+ case pkey
63
+ when OpenSSL::PKey::EC then pad_ec_d(::JOSE::JWK.from_key(pkey).to_map.to_h)
64
+ when OpenSSL::PKey::RSA then ::JOSE::JWK.from_key(pkey).to_map.to_h
65
+ when OpenSSL::PKey::PKey then members_from_okp(pkey)
66
+ else raise OpenID4VP::InvalidRequest, "jwk: unsupported OpenSSL key type #{pkey.class}"
67
+ end
68
+ params.each { |k, v| members[k.to_s] = v unless v.nil? }
69
+ new(members)
70
+ rescue ArgumentError => e
71
+ raise OpenID4VP::InvalidRequest, "jwk: #{e.message}"
72
+ end
73
+
74
+ private
75
+
76
+ # JOSE::JWK::KTY_EC#to_map encodes `d` from the raw OpenSSL::BN,
77
+ # which strips leading zero bytes -- pad it back to the curve's
78
+ # fixed coordinate size (RFC 7518 SS6.2.1) so it round-trips through
79
+ # #decode_fixed_length! below.
80
+ def pad_ec_d(members)
81
+ size = EC_COORD_SIZES[members["crv"]]
82
+ return members unless size && members["d"]
83
+
84
+ bytes = Util::Base64url.decode(members["d"])
85
+ members["d"] = Util::Base64url.encode(bytes.rjust(size, "\x00".b))
86
+ members
87
+ end
88
+
89
+ def generate_ec(params)
90
+ crv = params.delete(:crv)
91
+ openssl_name = EC_CURVE_TO_OPENSSL.fetch(crv) do
92
+ raise OpenID4VP::InvalidRequest, "jwk: unsupported EC curve #{crv.inspect}"
93
+ end
94
+ from_openssl(OpenSSL::PKey::EC.generate(openssl_name), **params)
95
+ end
96
+
97
+ def generate_okp(params)
98
+ crv = params.delete(:crv)
99
+ raise OpenID4VP::InvalidRequest, "jwk: unsupported OKP curve #{crv.inspect}" unless OKP_CURVES.include?(crv)
100
+
101
+ from_openssl(OpenSSL::PKey.generate_key(crv), **params)
102
+ end
103
+
104
+ def members_from_okp(pkey)
105
+ crv = OKP_OID_TO_CRV.fetch(pkey.oid) do
106
+ raise OpenID4VP::InvalidRequest, "jwk: unsupported OKP key #{pkey.oid}"
107
+ end
108
+ members = { "kty" => "OKP", "crv" => crv, "x" => Util::Base64url.encode(pkey.raw_public_key) }
109
+ begin
110
+ members["d"] = Util::Base64url.encode(pkey.raw_private_key)
111
+ rescue OpenSSL::PKey::PKeyError
112
+ # public-only key
113
+ end
114
+ members
115
+ end
116
+ end
117
+
118
+ # Note for callers building an EC members Hash directly (rather than
119
+ # via .from_openssl/.generate): `d` must be left-padded with zero
120
+ # bytes to the curve's fixed coordinate length (see #pad_ec_d) --
121
+ # JOSE::JWK::KTY_EC#to_map does not pad it, so a `d` sourced from
122
+ # there needs the same fixup .from_openssl already applies.
123
+ def initialize(members)
124
+ @members = members.each_with_object({}) { |(k, v), h| h[k.to_s] = v }.freeze
125
+ raise OpenID4VP::InvalidRequest, "jwk: missing kty" if @members["kty"].nil?
126
+ unless SUPPORTED_KTYS.include?(@members["kty"])
127
+ raise OpenID4VP::InvalidRequest, "jwk: unsupported kty #{@members['kty'].inspect}"
128
+ end
129
+
130
+ freeze
131
+ end
132
+
133
+ def private?
134
+ @members.key?("d")
135
+ end
136
+
137
+ def public?
138
+ !private?
139
+ end
140
+
141
+ def to_public
142
+ return self if public?
143
+
144
+ self.class.new(@members.except(*PRIVATE_ONLY_MEMBERS.fetch(kty, [])))
145
+ end
146
+
147
+ def to_h
148
+ @members.dup
149
+ end
150
+
151
+ def to_json(*)
152
+ ::JSON.generate(to_h, *)
153
+ end
154
+
155
+ # RFC 7638 JWK thumbprint: raw 32-byte SHA-256 digest, computed over
156
+ # JOSE::JWK's canonical (sorted-key) member selection for `kty`.
157
+ def thumbprint
158
+ Util.sha256(::JOSE.encode(jose_jwk.to_thumbprint_map))
159
+ rescue ArgumentError, OpenSSL::OpenSSLError => e
160
+ raise OpenID4VP::InvalidRequest, "jwk: cannot compute thumbprint for kty #{kty}: #{e.message}"
161
+ end
162
+
163
+ def thumbprint_b64
164
+ Util::Base64url.encode(thumbprint)
165
+ end
166
+
167
+ def to_openssl
168
+ case kty
169
+ when "EC" then openssl_ec
170
+ when "OKP" then openssl_okp
171
+ when "RSA" then openssl_via_jose("RSA")
172
+ else raise OpenID4VP::InvalidRequest, "jwk: cannot convert kty #{kty} to an OpenSSL key"
173
+ end
174
+ end
175
+
176
+ # Equality is by public key material (the RFC 7638 thumbprint members),
177
+ # not full to_public.to_h: a private key equals its own public form,
178
+ # and a key equals itself after a round trip through OpenSSL even
179
+ # though such a round trip cannot preserve metadata like kid/use/alg.
180
+ def ==(other)
181
+ other.is_a?(JWK) && kty == other.kty && thumbprint == other.thumbprint
182
+ rescue OpenID4VP::InvalidRequest
183
+ false
184
+ end
185
+ alias eql? ==
186
+
187
+ def hash
188
+ [kty, thumbprint].hash
189
+ rescue OpenID4VP::InvalidRequest
190
+ kty.hash
191
+ end
192
+
193
+ private
194
+
195
+ # JOSE::JWK.from_map itself constructs OpenSSL key material (e.g. an
196
+ # EC::Point for the x/y coordinates), so a structurally well-formed
197
+ # but invalid member (an off-curve point of valid length, say) can
198
+ # raise a raw OpenSSL::OpenSSLError here, not just ArgumentError.
199
+ def jose_jwk
200
+ ::JOSE::JWK.from_map(@members)
201
+ rescue ArgumentError, OpenSSL::OpenSSLError => e
202
+ raise OpenID4VP::InvalidRequest, "jwk: #{e.message}"
203
+ end
204
+
205
+ def openssl_via_jose(label)
206
+ jose_jwk.to_key
207
+ rescue ArgumentError, OpenSSL::OpenSSLError => e
208
+ raise OpenID4VP::InvalidRequest, "jwk: invalid #{label} key material: #{e.message}"
209
+ end
210
+
211
+ def fetch!(key)
212
+ @members.fetch(key) { raise OpenID4VP::InvalidRequest, "jwk: missing #{key}" }
213
+ end
214
+
215
+ # Raises OpenID4VP::InvalidRequest unless the decoded member is exactly
216
+ # `expected` bytes long, so malformed key material never reaches
217
+ # OpenSSL's raw key constructors as a raw ArgumentError/OpenSSLError.
218
+ def decode_fixed_length!(member, expected)
219
+ bytes = Util::Base64url.decode(fetch!(member))
220
+ return bytes if bytes.bytesize == expected
221
+
222
+ raise OpenID4VP::InvalidRequest,
223
+ "jwk: #{member} must be #{expected} bytes for #{crv}, got #{bytes.bytesize}"
224
+ end
225
+
226
+ # EC key material is length-checked here (rather than left to jose/
227
+ # OpenSSL) because OpenSSL 3 can construct an EVP_PKEY from an
228
+ # inconsistent SEC1 private key (wrong-length or out-of-range `d`)
229
+ # without raising until the key is later used, which would leak a
230
+ # not-yet-invalid-looking key out of this method.
231
+ def openssl_ec
232
+ size = EC_COORD_SIZES.fetch(crv) { raise OpenID4VP::InvalidRequest, "jwk: unsupported EC curve #{crv.inspect}" }
233
+ decode_fixed_length!("x", size)
234
+ decode_fixed_length!("y", size)
235
+ decode_fixed_length!("d", size) if @members.key?("d")
236
+ openssl_via_jose("EC")
237
+ end
238
+
239
+ def openssl_okp
240
+ raise OpenID4VP::InvalidRequest, "jwk: unsupported OKP curve #{crv.inspect}" unless OKP_CURVES.include?(crv)
241
+
242
+ size = OKP_KEY_LENGTH.fetch(crv)
243
+ if @members.key?("d")
244
+ OpenSSL::PKey.new_raw_private_key(crv, decode_fixed_length!("d", size))
245
+ else
246
+ OpenSSL::PKey.new_raw_public_key(crv, decode_fixed_length!("x", size))
247
+ end
248
+ rescue OpenSSL::OpenSSLError, ArgumentError => e
249
+ raise OpenID4VP::InvalidRequest, "jwk: invalid OKP key material: #{e.message}"
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../jose"
4
+ require_relative "../errors"
5
+ require_relative "jwk"
6
+
7
+ module OpenID4VP
8
+ module JOSE
9
+ # A JSON Web Key Set (RFC 7517 §5): an ordered collection of JWKs.
10
+ class JWKSet
11
+ attr_reader :keys
12
+
13
+ def self.from_h(hash)
14
+ keys = (hash["keys"] || hash[:keys] || []).map { |member| JWK.new(member) }
15
+ new(keys: keys)
16
+ end
17
+
18
+ def initialize(keys:)
19
+ @keys = keys.dup.freeze
20
+ end
21
+
22
+ def find(kid)
23
+ @keys.find { |k| k.kid == kid }
24
+ end
25
+
26
+ def to_h
27
+ { "keys" => @keys.map(&:to_h) }
28
+ end
29
+
30
+ def to_json(*)
31
+ ::JSON.generate(to_h, *)
32
+ end
33
+
34
+ # Picks a key usable for content encryption key agreement/wrapping:
35
+ # keys without an `alg` are ignored, a key restricted to
36
+ # `supported_algs` (when given) is preferred, and a key with
37
+ # `use: "enc"` is preferred over the first remaining match.
38
+ def select_encryption_key(supported_algs: nil)
39
+ candidates = @keys.select do |key|
40
+ next false unless key.alg
41
+ next false if supported_algs && !supported_algs.include?(key.alg)
42
+
43
+ true
44
+ end
45
+ candidates.find { |key| key.use == "enc" } || candidates.first
46
+ end
47
+
48
+ # OID4VP §5.1: every JWK in a client_metadata.jwks JWK Set must carry a
49
+ # `kid` so it can be referenced unambiguously.
50
+ def require_kids!
51
+ return if @keys.all?(&:kid)
52
+
53
+ raise OpenID4VP::InvalidRequest, "jwks: every key must have a kid"
54
+ end
55
+ end
56
+ end
57
+ end