jwt-pq 0.3.0 → 0.5.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.
@@ -2,17 +2,52 @@
2
2
 
3
3
  module JWT
4
4
  module PQ
5
- # Composite key combining an Ed25519 keypair with an ML-DSA keypair
6
- # for hybrid EdDSA + ML-DSA JWT signatures.
5
+ # A composite key that pairs an Ed25519 keypair with an ML-DSA keypair
6
+ # for hybrid `EdDSA+ML-DSA-*` JWT signatures.
7
+ #
8
+ # Hybrid mode concatenates the two signatures (`ed25519 || ml_dsa`) so
9
+ # that a verifier only accepts the token if **both** signatures are
10
+ # valid. The classical half remains secure against today's attackers
11
+ # while the post-quantum half resists a future cryptographically
12
+ # relevant quantum computer.
13
+ #
14
+ # Requires the `ed25519` gem (or `jwt-eddsa`, which depends on it).
15
+ # Use {JWT::PQ.hybrid_available?} to probe availability.
16
+ #
17
+ # @example Generate a hybrid key and encode a JWT
18
+ # key = JWT::PQ::HybridKey.generate(:ml_dsa_65)
19
+ # token = JWT.encode({ sub: "u-1" }, key, "EdDSA+ML-DSA-65")
20
+ #
21
+ # @example Verification-only hybrid key
22
+ # verifier = JWT::PQ::HybridKey.new(
23
+ # ed25519: ed25519_verify_key,
24
+ # ml_dsa: JWT::PQ::Key.from_public_key(:ml_dsa_65, pub_bytes)
25
+ # )
7
26
  class HybridKey
8
- attr_reader :ed25519_signing_key, :ed25519_verify_key, :ml_dsa_key
27
+ # @return [Ed25519::SigningKey, nil] Ed25519 signing key, or nil for
28
+ # verification-only.
29
+ attr_reader :ed25519_signing_key
9
30
 
10
- # @param ed25519 [Ed25519::SigningKey, Ed25519::VerifyKey] Ed25519 key
11
- # @param ml_dsa [JWT::PQ::Key] ML-DSA key
31
+ # @return [Ed25519::VerifyKey] Ed25519 verification key.
32
+ attr_reader :ed25519_verify_key
33
+
34
+ # @return [JWT::PQ::Key] ML-DSA keypair (public-only or full).
35
+ attr_reader :ml_dsa_key
36
+
37
+ # Build a hybrid key from existing Ed25519 and ML-DSA components.
38
+ #
39
+ # Pass an `Ed25519::SigningKey` for a full signing key, or an
40
+ # `Ed25519::VerifyKey` for verification-only.
41
+ #
42
+ # @param ed25519 [Ed25519::SigningKey, Ed25519::VerifyKey] Ed25519 key.
43
+ # @param ml_dsa [JWT::PQ::Key] ML-DSA keypair.
44
+ # @raise [MissingDependencyError] if the `ed25519` gem is not installed.
45
+ # @raise [KeyError] if `ed25519` is not one of the accepted key types.
12
46
  def initialize(ed25519:, ml_dsa:)
13
47
  require_eddsa_dependency!
14
48
 
15
49
  @ml_dsa_key = ml_dsa
50
+ @op_mutex = Mutex.new
16
51
 
17
52
  case ed25519
18
53
  when Ed25519::SigningKey
@@ -26,7 +61,15 @@ module JWT
26
61
  end
27
62
  end
28
63
 
29
- # Generate a new hybrid keypair.
64
+ # Generate a fresh hybrid keypair.
65
+ #
66
+ # Creates both an Ed25519 `SigningKey` and an ML-DSA keypair of the
67
+ # requested parameter set.
68
+ #
69
+ # @param ml_dsa_algorithm [Symbol, String] one of `:ml_dsa_44`,
70
+ # `:ml_dsa_65`, `:ml_dsa_87`. Defaults to `:ml_dsa_65`.
71
+ # @return [HybridKey] a full hybrid keypair (signing + verification).
72
+ # @raise [MissingDependencyError] if the `ed25519` gem is not installed.
30
73
  def self.generate(ml_dsa_algorithm = :ml_dsa_65)
31
74
  require_eddsa_dependency!
32
75
 
@@ -36,38 +79,88 @@ module JWT
36
79
  new(ed25519: ed_key, ml_dsa: ml_key)
37
80
  end
38
81
 
39
- # Whether both keys have private components (can sign).
82
+ # @return [Boolean] true when both halves have private components and
83
+ # the key can be used for signing.
40
84
  def private?
41
85
  !@ed25519_signing_key.nil? && @ml_dsa_key.private?
42
86
  end
43
87
 
44
- # The ML-DSA algorithm name (e.g., "ML-DSA-65").
88
+ # @return [String] the ML-DSA algorithm name (e.g. `"ML-DSA-65"`).
45
89
  def algorithm
46
90
  @ml_dsa_key.algorithm
47
91
  end
48
92
 
49
- # The hybrid algorithm name (e.g., "EdDSA+ML-DSA-65").
93
+ # @return [String] the hybrid JWT algorithm name
94
+ # (e.g. `"EdDSA+ML-DSA-65"`).
50
95
  def hybrid_algorithm
51
96
  "EdDSA+#{@ml_dsa_key.algorithm}"
52
97
  end
53
98
 
54
- # Zero and discard private key material from both key components.
55
- # After calling this, the key can only be used for verification.
99
+ # Produce a hybrid signature (Ed25519 ML-DSA) over `data`.
100
+ #
101
+ # Thread-safe: both component signatures are taken under the hybrid
102
+ # key's own mutex, and {#destroy!} contends on the same mutex. That
103
+ # guarantees a concurrent `destroy!` cannot zero the Ed25519 seed
104
+ # while libsodium is mid-sign, and cannot produce a half-signed
105
+ # output (Ed25519 succeeds, ML-DSA fails because the buffer was
106
+ # just zeroed). Lock order is hybrid mutex → ML-DSA mutex; callers
107
+ # must not invoke any Key method while holding another lock that
108
+ # might be taken by `destroy!`.
109
+ #
110
+ # @param data [String] message bytes to sign.
111
+ # @return [String] concatenated signature — 64 bytes of Ed25519
112
+ # followed by the ML-DSA signature.
113
+ # @raise [KeyError] if either half is missing its private component.
114
+ def sign(data)
115
+ @op_mutex.synchronize do
116
+ raise KeyError, "Ed25519 private key not available — cannot sign" unless @ed25519_signing_key
117
+
118
+ ed_sig = @ed25519_signing_key.sign(data)
119
+ ml_sig = @ml_dsa_key.sign(data)
120
+ ed_sig + ml_sig
121
+ end
122
+ end
123
+
124
+ # Zero and discard private key material from both halves.
125
+ #
126
+ # After calling this, {#private?} becomes false and the key can only
127
+ # be used for verification. Idempotent — safe on verification-only keys.
128
+ #
129
+ # Thread-safe: serialized on the hybrid key's own mutex (which also
130
+ # guards {#sign}) and internally delegates to
131
+ # {JWT::PQ::Key#destroy!}, which uses its own mutex. A concurrent
132
+ # {#sign} will block until `destroy!` completes and then raise
133
+ # `KeyError`, never observing a half-destroyed state.
134
+ #
135
+ # Ed25519 wipe: `Ed25519::SigningKey` stores the private seed in two
136
+ # separate Strings — `@seed` (32 bytes) returned by `#to_bytes`, and
137
+ # `@keypair` (64 bytes = `seed || public_key`) returned by `#keypair`.
138
+ # Both are `attr_reader`-backed and hand out the internal String by
139
+ # reference, so we must zero both in place — wiping only `@seed`
140
+ # leaves the first 32 bytes of `@keypair` holding the seed until GC.
141
+ #
142
+ # @return [true]
56
143
  def destroy!
57
- @ml_dsa_key.destroy!
58
- if @ed25519_signing_key
59
- seed = @ed25519_signing_key.to_bytes
60
- seed.replace("\0" * seed.bytesize)
61
- @ed25519_signing_key = nil
144
+ @op_mutex.synchronize do
145
+ @ml_dsa_key.destroy!
146
+ if @ed25519_signing_key
147
+ seed = @ed25519_signing_key.to_bytes
148
+ seed.replace("\0" * seed.bytesize)
149
+ keypair = @ed25519_signing_key.keypair
150
+ keypair.replace("\0" * keypair.bytesize)
151
+ @ed25519_signing_key = nil
152
+ end
62
153
  end
63
154
  true
64
155
  end
65
156
 
157
+ # @return [String] short diagnostic string — never contains key material.
66
158
  def inspect
67
159
  "#<#{self.class} algorithm=#{hybrid_algorithm} private=#{private?}>"
68
160
  end
69
161
  alias to_s inspect
70
162
 
163
+ # @api private
71
164
  def self.require_eddsa_dependency!
72
165
  require "ed25519"
73
166
  rescue LoadError
data/lib/jwt/pq/jwk.rb CHANGED
@@ -1,23 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "base64"
4
+ require "json"
4
5
  require "openssl"
5
6
 
6
7
  module JWT
7
8
  module PQ
8
- # JWK (JSON Web Key) support for ML-DSA keys.
9
+ # JWK (JSON Web Key) import/export for ML-DSA keys.
9
10
  #
10
- # Follows the draft-ietf-cose-dilithium conventions:
11
- # kty: "AKP" (Algorithm Key Pair)
12
- # alg: "ML-DSA-44", "ML-DSA-65", or "ML-DSA-87"
13
- # pub: base64url-encoded public key
14
- # priv: base64url-encoded private key (optional)
11
+ # Follows the `draft-ietf-cose-dilithium` conventions for the `AKP`
12
+ # ("Algorithm Key Pair") key type:
13
+ #
14
+ # - `kty`: `"AKP"`
15
+ # - `alg`: `"ML-DSA-44"`, `"ML-DSA-65"`, or `"ML-DSA-87"`
16
+ # - `pub`: base64url-encoded public key (no padding)
17
+ # - `priv`: base64url-encoded private key (optional, no padding)
18
+ # - `kid`: RFC 7638 thumbprint over the required members
19
+ #
20
+ # @example Export and re-import
21
+ # jwk = JWT::PQ::JWK.new(key).export
22
+ # restored = JWT::PQ::JWK.import(jwk)
23
+ #
24
+ # @see https://datatracker.ietf.org/doc/draft-ietf-cose-dilithium/ draft-ietf-cose-dilithium
25
+ # @see https://www.rfc-editor.org/rfc/rfc7638 RFC 7638 (JWK Thumbprint)
15
26
  class JWK
27
+ # Algorithm names accepted in the `alg` field.
16
28
  ALGORITHMS = MlDsa::ALGORITHMS.keys.freeze
29
+
30
+ # Value of the `kty` field for all ML-DSA JWKs.
17
31
  KTY = "AKP"
18
32
 
33
+ # @return [JWT::PQ::Key] the wrapped key.
19
34
  attr_reader :key
20
35
 
36
+ # Wrap a {JWT::PQ::Key} for JWK operations.
37
+ #
38
+ # @param key [JWT::PQ::Key] the key to export or thumbprint.
39
+ # @raise [KeyError] if `key` is not a {JWT::PQ::Key}.
21
40
  def initialize(key)
22
41
  raise KeyError, "Expected a JWT::PQ::Key, got #{key.class}" unless key.is_a?(JWT::PQ::Key)
23
42
 
@@ -25,6 +44,14 @@ module JWT
25
44
  end
26
45
 
27
46
  # Export the key as a JWK hash.
47
+ #
48
+ # By default, only the public material is included. Pass
49
+ # `include_private: true` to emit the `priv` field as well (and only
50
+ # when the wrapped key actually has a private component).
51
+ #
52
+ # @param include_private [Boolean] include the `priv` field. Default: false.
53
+ # @return [Hash{Symbol=>String}] a JWK with `:kty`, `:alg`, `:pub`,
54
+ # `:kid`, and optionally `:priv`.
28
55
  def export(include_private: false)
29
56
  jwk = {
30
57
  kty: KTY,
@@ -39,16 +66,31 @@ module JWT
39
66
  end
40
67
 
41
68
  # Import a Key from a JWK hash.
69
+ #
70
+ # Accepts string or symbol keys. Validates `kty`, `alg`, and the
71
+ # presence/base64url-ness of `pub` (and `priv` if present).
72
+ #
73
+ # @param jwk_hash [Hash] a JWK object.
74
+ # @return [JWT::PQ::Key] a key reconstructed from the JWK — with a
75
+ # private component iff the JWK carried a `priv` field.
76
+ # @raise [KeyError] on missing/wrong `kty`, missing/unsupported `alg`,
77
+ # missing `pub`, wrong field types, or invalid base64url in
78
+ # `pub`/`priv`.
42
79
  def self.import(jwk_hash)
80
+ raise KeyError, "Expected a Hash for JWK, got #{jwk_hash.class}" unless jwk_hash.is_a?(Hash)
81
+
43
82
  jwk = normalize_keys(jwk_hash)
44
83
 
45
84
  validate_kty!(jwk)
46
85
  alg = validate_alg!(jwk)
47
86
  raise KeyError, "Missing 'pub' in JWK" unless jwk.key?("pub")
87
+ raise KeyError, "'pub' must be a String, got #{jwk["pub"].class}" unless jwk["pub"].is_a?(String)
48
88
 
49
89
  pub_bytes = decode_field(jwk, "pub")
50
90
 
51
91
  if jwk.key?("priv")
92
+ raise KeyError, "'priv' must be a String, got #{jwk["priv"].class}" unless jwk["priv"].is_a?(String)
93
+
52
94
  priv_bytes = decode_field(jwk, "priv")
53
95
  Key.new(algorithm: alg, public_key: pub_bytes, private_key: priv_bytes)
54
96
  else
@@ -56,14 +98,38 @@ module JWT
56
98
  end
57
99
  end
58
100
 
59
- # JWK Thumbprint (RFC 7638) for key identification.
60
- # Uses the required members: alg, kty, pub.
101
+ # Compute the JWK Thumbprint (RFC 7638) used as `kid`.
102
+ #
103
+ # Delegates to {JWT::PQ::Key#jwk_thumbprint}, which memoizes the
104
+ # result on the key — repeated calls on the same key avoid
105
+ # recomputing the canonical JSON + SHA-256 digest.
106
+ #
107
+ # @return [String] base64url-encoded SHA-256 thumbprint.
61
108
  def thumbprint
62
- canonical = "{\"alg\":\"#{@key.algorithm}\",\"kty\":\"#{KTY}\",\"pub\":\"#{base64url_encode(@key.public_key)}\"}"
109
+ @key.jwk_thumbprint
110
+ end
111
+
112
+ # Compute an RFC 7638 thumbprint from algorithm + public key bytes
113
+ # without allocating a {JWK} or {JWT::PQ::Key} wrapper.
114
+ #
115
+ # @api private
116
+ # @param algorithm [String] canonical algorithm name.
117
+ # @param public_key [String] raw public key bytes.
118
+ # @return [String] base64url-encoded SHA-256 thumbprint.
119
+ def self.compute_thumbprint(algorithm, public_key)
120
+ # RFC 7638 §3.2: canonical JSON over the required members in
121
+ # lexicographic order (alg, kty, pub), no whitespace. Using
122
+ # `JSON.generate` over an ordered Hash instead of string
123
+ # interpolation so a future algorithm or key-byte change that
124
+ # introduces a character needing JSON escape does not silently
125
+ # produce a divergent thumbprint.
126
+ pub_b64 = ::Base64.urlsafe_encode64(public_key, padding: false)
127
+ canonical = JSON.generate({ alg: algorithm, kty: KTY, pub: pub_b64 })
63
128
  digest = OpenSSL::Digest::SHA256.digest(canonical)
64
- base64url_encode(digest)
129
+ ::Base64.urlsafe_encode64(digest, padding: false)
65
130
  end
66
131
 
132
+ # @api private
67
133
  def self.validate_kty!(jwk)
68
134
  kty = jwk["kty"]
69
135
  raise KeyError, "Missing 'kty' in JWK" unless kty
@@ -71,6 +137,7 @@ module JWT
71
137
  end
72
138
  private_class_method :validate_kty!
73
139
 
140
+ # @api private
74
141
  def self.validate_alg!(jwk)
75
142
  alg = jwk["alg"]
76
143
  raise KeyError, "Missing 'alg' in JWK" unless alg
@@ -80,11 +147,13 @@ module JWT
80
147
  end
81
148
  private_class_method :validate_alg!
82
149
 
150
+ # @api private
83
151
  def self.normalize_keys(hash)
84
152
  hash.transform_keys(&:to_s)
85
153
  end
86
154
  private_class_method :normalize_keys
87
155
 
156
+ # @api private
88
157
  def self.decode_field(jwk, field)
89
158
  ::Base64.urlsafe_decode64(jwk[field])
90
159
  rescue ArgumentError => e
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module JWT
6
+ module PQ
7
+ # A set of JWKs (RFC 7517 §5) for publication and `kid`-based lookup.
8
+ #
9
+ # Typical producer flow — publish verification keys on
10
+ # `/.well-known/jwks.json`:
11
+ #
12
+ # @example Publish a JWKS
13
+ # jwks = JWT::PQ::JWKSet.new([key_current, key_next])
14
+ # File.write("jwks.json", jwks.to_json)
15
+ #
16
+ # Typical consumer flow — pick the right key for an incoming JWT by the
17
+ # `kid` header:
18
+ #
19
+ # @example Resolve a verification key by kid
20
+ # jwks = JWT::PQ::JWKSet.import(JSON.parse(fetch_jwks))
21
+ # _payload, header = JWT.decode(token, nil, false) # unverified peek
22
+ # key = jwks[header["kid"]] or raise "unknown kid"
23
+ # payload, = JWT.decode(token, key, true, algorithms: [header["alg"]])
24
+ #
25
+ # The set indexes members by their RFC 7638 JWK Thumbprint, which is the
26
+ # `kid` {JWK#export} emits. If you import a JWKS that uses custom (non-
27
+ # thumbprint) `kid` values, lookup by those custom values is not
28
+ # supported — rely on thumbprints when generating the set.
29
+ #
30
+ # @see https://www.rfc-editor.org/rfc/rfc7517#section-5 RFC 7517 §5
31
+ class JWKSet
32
+ include Enumerable
33
+
34
+ # Build a set from zero or more {JWT::PQ::Key}s.
35
+ #
36
+ # @param keys [Array<JWT::PQ::Key>, JWT::PQ::Key, nil] initial members.
37
+ # @raise [KeyError] if any element is not a {JWT::PQ::Key}.
38
+ def initialize(keys = [])
39
+ @keys = []
40
+ @kid_index = {}
41
+ Array(keys).each { |k| add(k) }
42
+ end
43
+
44
+ # Add a key to the set.
45
+ #
46
+ # Idempotent: if a key with the same RFC 7638 thumbprint is already
47
+ # in the set, the call is a no-op (Set semantics). The thumbprint
48
+ # is computed before any mutation, so a failure to derive the
49
+ # `kid` leaves the set unchanged.
50
+ #
51
+ # @param key [JWT::PQ::Key] the key to add.
52
+ # @return [JWKSet] self, for chaining.
53
+ # @raise [KeyError] if `key` is not a {JWT::PQ::Key}.
54
+ def add(key)
55
+ raise KeyError, "Expected a JWT::PQ::Key, got #{key.class}" unless key.is_a?(JWT::PQ::Key)
56
+
57
+ kid = key.jwk_thumbprint
58
+ return self if @kid_index.key?(kid)
59
+
60
+ @keys << key
61
+ @kid_index[kid] = key
62
+ self
63
+ end
64
+
65
+ # Iterate over the keys in insertion order.
66
+ #
67
+ # @yieldparam key [JWT::PQ::Key]
68
+ # @return [Enumerator] when called without a block.
69
+ def each(&)
70
+ @keys.each(&)
71
+ end
72
+
73
+ # @return [Integer] number of keys in the set.
74
+ def size
75
+ @keys.size
76
+ end
77
+ alias length size
78
+
79
+ # @return [Boolean] true if the set is empty.
80
+ def empty?
81
+ @keys.empty?
82
+ end
83
+
84
+ # Look up a key by its RFC 7638 thumbprint (the `kid` from {JWK#export}).
85
+ #
86
+ # @param kid [String] the thumbprint to match.
87
+ # @return [JWT::PQ::Key, nil] the matching key, or nil if not in the set.
88
+ def find(kid)
89
+ @kid_index[kid]
90
+ end
91
+ alias [] find
92
+
93
+ # @return [Array<JWT::PQ::Key>] a frozen snapshot of the keys in the set.
94
+ def keys
95
+ @keys.dup.freeze
96
+ end
97
+
98
+ # Export the set as a JWKS hash.
99
+ #
100
+ # @param include_private [Boolean] include the `priv` field on each
101
+ # member that has a private component. Default: false.
102
+ # @return [Hash{Symbol=>Array<Hash>}] a hash with a single `:keys`
103
+ # member, suitable for serialization with {#to_json}.
104
+ def export(include_private: false)
105
+ { keys: @keys.map { |k| JWK.new(k).export(include_private: include_private) } }
106
+ end
107
+
108
+ # Serialize the set as a JWKS JSON document.
109
+ #
110
+ # Always emits public-only keys — the `priv` field is never
111
+ # written out. This keeps the method safe for arbitrary nesting
112
+ # inside other JSON (e.g. `{ jwks: set }.to_json`), where Ruby's
113
+ # stdlib JSON passes a generator state as a positional argument.
114
+ # To publish private material (unusual), call
115
+ # `JSON.generate(set.export(include_private: true))` explicitly.
116
+ #
117
+ # @return [String] a JSON document ready for `/.well-known/jwks.json`.
118
+ def to_json(*)
119
+ export.to_json(*)
120
+ end
121
+
122
+ # @return [String] short diagnostic string — never contains key material.
123
+ def inspect
124
+ "#<#{self.class} size=#{size}>"
125
+ end
126
+ alias to_s inspect
127
+
128
+ # Import a JWKS from a Hash or JSON string.
129
+ #
130
+ # Each member is reconstructed via {JWT::PQ::JWK.import}; malformed
131
+ # members raise {KeyError}.
132
+ #
133
+ # ML-DSA public keys are ~1.3–2.6 KB each, so a JWKS with N keys is
134
+ # at least N × ~2 KB. When ingesting untrusted JWKS payloads (e.g.
135
+ # a remote `/.well-known/jwks.json`), bound the HTTP body size
136
+ # before calling `import` — this method does not cap the number of
137
+ # members.
138
+ #
139
+ # @param source [Hash, String] a JWKS hash or JSON string with a
140
+ # `"keys"` array.
141
+ # @return [JWKSet] a new set with all parsed members.
142
+ # @raise [KeyError] if `source` is not a Hash/String, if the `keys`
143
+ # field is missing or not an Array, or if any member fails to import.
144
+ def self.import(source)
145
+ hash = coerce_to_hash(source)
146
+ raise KeyError, "Expected Hash for JWKS body, got #{hash.class}" unless hash.is_a?(Hash)
147
+
148
+ hash = hash.transform_keys(&:to_s)
149
+ raise KeyError, "Missing 'keys' in JWKS" unless hash.key?("keys")
150
+ raise KeyError, "'keys' must be an Array" unless hash["keys"].is_a?(Array)
151
+
152
+ members = hash["keys"].map { |jwk| JWT::PQ::JWK.import(jwk) }
153
+ new(members)
154
+ end
155
+
156
+ # @api private
157
+ def self.coerce_to_hash(source)
158
+ case source
159
+ when String
160
+ begin
161
+ ::JSON.parse(source)
162
+ rescue ::JSON::ParserError => e
163
+ raise KeyError, "Invalid JSON for JWKS: #{e.message}"
164
+ end
165
+ when Hash then source
166
+ else raise KeyError, "Expected Hash or JSON String for JWKS, got #{source.class}"
167
+ end
168
+ end
169
+ private_class_method :coerce_to_hash
170
+
171
+ # Fetch a JWKS from a URL, honouring the process-global cache.
172
+ #
173
+ # Convenience wrapper around {Loader#fetch} using
174
+ # {Loader.default} — the cache is shared across all callers, so
175
+ # repeated hits on the same URL within `cache_ttl` seconds return
176
+ # the in-memory set without touching the network.
177
+ #
178
+ # See {Loader} for the full option reference (cache TTL, timeouts,
179
+ # body-size cap, HTTPS enforcement, ETag-based revalidation).
180
+ #
181
+ # @example Verify a token using a remote JWKS
182
+ # jwks = JWT::PQ::JWKSet.fetch("https://issuer.example/.well-known/jwks.json")
183
+ # _payload, header = JWT.decode(token, nil, false)
184
+ # key = jwks[header["kid"]] or raise "unknown kid"
185
+ # payload, = JWT.decode(token, key, true, algorithms: [header["alg"]])
186
+ #
187
+ # @param url [String] absolute JWKS URL.
188
+ # @return [JWKSet] the parsed set of verification keys.
189
+ # @raise [JWKSFetchError] on fetch failure (see {Loader#fetch}).
190
+ # @raise [KeyError] if the fetched body is not a valid JWKS.
191
+ def self.fetch(url, **)
192
+ Loader.default.fetch(url, **)
193
+ end
194
+ end
195
+ end
196
+ end