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.
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module JWT
7
+ module PQ
8
+ class JWKSet
9
+ # HTTP-backed JWKS loader with TTL cache and ETag revalidation.
10
+ #
11
+ # Fetches a remote JWKS document (e.g. `/.well-known/jwks.json`),
12
+ # parses it via {JWT::PQ::JWKSet.import}, and caches the result
13
+ # per-URL. Subsequent calls inside the TTL window return the cached
14
+ # set without touching the network. Once the TTL expires, an
15
+ # `If-None-Match` conditional GET is issued using the stored
16
+ # `ETag`; a `304 Not Modified` response refreshes the cache
17
+ # timestamp without re-parsing.
18
+ #
19
+ # Prefer the {JWT::PQ::JWKSet.fetch} shortcut over instantiating
20
+ # this class directly — it uses a process-global loader so the
21
+ # cache is shared across callers.
22
+ #
23
+ # Defense-in-depth defaults:
24
+ #
25
+ # - HTTPS only (pass `allow_http: true` to override for development).
26
+ # - Redirects are rejected; update the URL to the canonical location.
27
+ # - Response body is capped at 1 MB (`max_body_bytes`); ML-DSA public
28
+ # keys are ~1.3–2.6 KB each, so 1 MB already allows several hundred
29
+ # rotation candidates.
30
+ # - Read/open timeouts default to 5 seconds.
31
+ # - Failures raise {JWKSFetchError}; parse errors on the fetched body
32
+ # surface as {KeyError} from {JWKSet.import}.
33
+ #
34
+ # @example Fetch and verify a token
35
+ # jwks = JWT::PQ::JWKSet.fetch("https://issuer.example/.well-known/jwks.json")
36
+ # _payload, header = JWT.decode(token, nil, false)
37
+ # key = jwks[header["kid"]] or raise "unknown kid"
38
+ # payload, = JWT.decode(token, key, true, algorithms: [header["alg"]])
39
+ class Loader
40
+ DEFAULT_CACHE_TTL = 300
41
+ DEFAULT_TIMEOUT = 5
42
+ DEFAULT_OPEN_TIMEOUT = 5
43
+ DEFAULT_MAX_BODY_BYTES = 1_048_576
44
+
45
+ CacheEntry = Struct.new(:jwks, :etag, :fetched_at)
46
+ private_constant :CacheEntry
47
+
48
+ # @return [Loader] a process-global loader whose cache is shared
49
+ # across all {JWKSet.fetch} callers.
50
+ def self.default
51
+ @default ||= new
52
+ end
53
+
54
+ # Reset the process-global loader — mainly for tests.
55
+ # @api private
56
+ def self.reset_default!
57
+ @default = nil
58
+ end
59
+
60
+ def initialize
61
+ @cache = {}
62
+ @mutex = Mutex.new
63
+ end
64
+
65
+ # Fetch the JWKS at `url`, honouring the cache if the entry is
66
+ # still fresh.
67
+ #
68
+ # **URL provenance.** The URL is used verbatim: `Loader#fetch`
69
+ # does not resolve DNS, inspect the target IP, or block private,
70
+ # link-local, or cloud-metadata addresses. Callers are responsible
71
+ # for ensuring the URL comes from a trusted source (e.g. a pinned
72
+ # issuer configuration, not untrusted user input) to avoid SSRF.
73
+ #
74
+ # @param url [String] the absolute URL of the JWKS document.
75
+ # @param cache_ttl [Integer] seconds the cached set is considered
76
+ # fresh. Default: 300.
77
+ # @param timeout [Integer] read timeout in seconds. Default: 5.
78
+ # @param open_timeout [Integer] connect timeout in seconds. Default: 5.
79
+ # @param max_body_bytes [Integer] cap on response body size.
80
+ # Default: 1 MB.
81
+ # @param allow_http [Boolean] allow plain `http://` URLs. Default:
82
+ # false (strongly recommended for production).
83
+ # @return [JWKSet] the parsed set of verification keys.
84
+ # @raise [JWKSFetchError] on network error, timeout, non-2xx
85
+ # response, oversized body, redirect, or non-HTTPS URL.
86
+ # @raise [KeyError] if the fetched body is not a valid JWKS.
87
+ def fetch(url, # rubocop:disable Metrics/ParameterLists
88
+ cache_ttl: DEFAULT_CACHE_TTL,
89
+ timeout: DEFAULT_TIMEOUT,
90
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
91
+ max_body_bytes: DEFAULT_MAX_BODY_BYTES,
92
+ allow_http: false)
93
+ uri = validate_uri!(url, allow_http: allow_http)
94
+
95
+ fresh = fresh_entry(url, cache_ttl)
96
+ return fresh.jwks if fresh
97
+
98
+ existing = @mutex.synchronize { @cache[url] }
99
+ result = do_http_get(uri, existing&.etag, timeout, open_timeout, max_body_bytes)
100
+
101
+ if result[:not_modified] && existing
102
+ @mutex.synchronize { existing.fetched_at = now }
103
+ existing.jwks
104
+ else
105
+ jwks = JWKSet.import(result[:body])
106
+ @mutex.synchronize do
107
+ @cache[url] = CacheEntry.new(jwks, result[:etag], now)
108
+ end
109
+ jwks
110
+ end
111
+ end
112
+
113
+ # Drop all cached entries.
114
+ # @return [void]
115
+ def clear
116
+ @mutex.synchronize { @cache.clear }
117
+ end
118
+
119
+ # @api private
120
+ def cached?(url)
121
+ @mutex.synchronize { @cache.key?(url) }
122
+ end
123
+
124
+ private
125
+
126
+ def validate_uri!(url, allow_http:)
127
+ uri = URI.parse(url)
128
+ raise JWKSFetchError, "Invalid URL: #{url.inspect} (expected http/https)" unless uri.is_a?(URI::HTTP)
129
+
130
+ if uri.scheme == "http" && !allow_http
131
+ raise JWKSFetchError,
132
+ "Refusing non-HTTPS URL #{url.inspect} (pass allow_http: true to override)"
133
+ end
134
+ uri
135
+ rescue URI::InvalidURIError => e
136
+ raise JWKSFetchError, "Invalid URL: #{e.message}"
137
+ end
138
+
139
+ def fresh_entry(url, cache_ttl)
140
+ @mutex.synchronize do
141
+ entry = @cache[url]
142
+ return nil unless entry
143
+ return entry if (now - entry.fetched_at) < cache_ttl
144
+
145
+ nil
146
+ end
147
+ end
148
+
149
+ def do_http_get(uri, etag, timeout, open_timeout, max_body_bytes)
150
+ result = nil
151
+ perform_request(uri, etag, timeout, open_timeout) do |response|
152
+ result = handle_response(response, max_body_bytes)
153
+ end
154
+ result
155
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
156
+ raise JWKSFetchError, "Timeout fetching JWKS from #{uri}: #{e.message}"
157
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
158
+ Errno::ENETUNREACH, OpenSSL::SSL::SSLError => e
159
+ raise JWKSFetchError, "Network error fetching JWKS from #{uri}: #{e.message}"
160
+ end
161
+
162
+ # :nocov: — real HTTP path; unit tests stub this method.
163
+ def perform_request(uri, etag, timeout, open_timeout, &)
164
+ http = Net::HTTP.new(uri.host, uri.port)
165
+ http.use_ssl = (uri.scheme == "https")
166
+ http.read_timeout = timeout
167
+ http.open_timeout = open_timeout
168
+
169
+ req = Net::HTTP::Get.new(uri.request_uri)
170
+ req["Accept"] = "application/jwk-set+json, application/json"
171
+ req["If-None-Match"] = etag if etag
172
+
173
+ http.request(req, &)
174
+ end
175
+ # :nocov:
176
+
177
+ def handle_response(response, max_body_bytes)
178
+ case response
179
+ when Net::HTTPNotModified
180
+ { not_modified: true }
181
+ when Net::HTTPSuccess
182
+ body = read_body_with_cap!(response, max_body_bytes)
183
+ { not_modified: false, body: body, etag: response["ETag"] }
184
+ when Net::HTTPRedirection
185
+ raise JWKSFetchError,
186
+ "Refusing to follow redirect to #{response["Location"].inspect}"
187
+ else
188
+ raise JWKSFetchError, "HTTP #{response.code} #{response.message}"
189
+ end
190
+ end
191
+
192
+ # Stream the response body into memory while enforcing the cap
193
+ # on every chunk. A server that omits `Content-Length` cannot
194
+ # force unbounded allocation: the accumulator is checked before
195
+ # and after each append, and the connection is abandoned the
196
+ # moment the cap is exceeded.
197
+ def read_body_with_cap!(response, max_body_bytes)
198
+ declared = response["Content-Length"]&.to_i
199
+ if declared && declared > max_body_bytes
200
+ raise JWKSFetchError,
201
+ "JWKS body too large: Content-Length #{declared} > #{max_body_bytes}"
202
+ end
203
+
204
+ buffer = String.new(capacity: declared || 0)
205
+ response.read_body do |chunk|
206
+ if buffer.bytesize + chunk.bytesize > max_body_bytes
207
+ raise JWKSFetchError,
208
+ "JWKS body too large: exceeded #{max_body_bytes} bytes during streaming read"
209
+ end
210
+ buffer << chunk
211
+ end
212
+ buffer
213
+ end
214
+
215
+ def now
216
+ Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
data/lib/jwt/pq/key.rb CHANGED
@@ -4,35 +4,79 @@ require "pqc_asn1"
4
4
 
5
5
  module JWT
6
6
  module PQ
7
- # Represents an ML-DSA keypair (public + optional private key).
8
- # Used as the signing/verification key for JWT operations.
7
+ # An ML-DSA keypair (public key + optional private key) used for JWT
8
+ # signing and verification.
9
+ #
10
+ # Prefer the class-level constructors over {.new}:
11
+ #
12
+ # - {.generate} — create a fresh keypair
13
+ # - {.from_pem} — import from a combined SPKI or PKCS#8 PEM
14
+ # - {.from_pem_pair} — import from separate public/private PEMs
15
+ # - {.from_public_key} — wrap raw public key bytes (verification only)
16
+ #
17
+ # @example Generate and sign
18
+ # key = JWT::PQ::Key.generate(:ml_dsa_65)
19
+ # token = JWT.encode({ sub: "u-1" }, key, "ML-DSA-65")
20
+ #
21
+ # @example Verification-only key
22
+ # verifier = JWT::PQ::Key.from_public_key(:ml_dsa_65, pub_bytes)
23
+ # JWT.decode(token, verifier, true, algorithms: ["ML-DSA-65"])
9
24
  class Key # rubocop:disable Metrics/ClassLength
25
+ # Symbol → canonical algorithm name.
10
26
  ALGORITHM_ALIASES = {
11
27
  ml_dsa_44: "ML-DSA-44",
12
28
  ml_dsa_65: "ML-DSA-65",
13
29
  ml_dsa_87: "ML-DSA-87"
14
30
  }.freeze
15
31
 
32
+ # Algorithm name → ASN.1 OID.
16
33
  ALGORITHM_OIDS = {
17
34
  "ML-DSA-44" => PqcAsn1::OID::ML_DSA_44,
18
35
  "ML-DSA-65" => PqcAsn1::OID::ML_DSA_65,
19
36
  "ML-DSA-87" => PqcAsn1::OID::ML_DSA_87
20
37
  }.freeze
21
38
 
39
+ # ASN.1 OID → algorithm name.
22
40
  OID_TO_ALGORITHM = ALGORITHM_OIDS.invert.freeze
23
41
 
24
- attr_reader :algorithm, :public_key, :private_key
25
-
42
+ # @return [String] canonical algorithm name (`"ML-DSA-44"`, `"ML-DSA-65"`, or `"ML-DSA-87"`).
43
+ attr_reader :algorithm
44
+
45
+ # @return [String] raw public key bytes.
46
+ attr_reader :public_key
47
+
48
+ # @return [String, nil] raw private (secret) key bytes, or nil for
49
+ # verification-only keys.
50
+ attr_reader :private_key
51
+
52
+ # Low-level constructor. Prefer {.generate}, {.from_pem}, {.from_pem_pair},
53
+ # or {.from_public_key} in application code.
54
+ #
55
+ # @param algorithm [Symbol, String] one of `:ml_dsa_44`, `:ml_dsa_65`,
56
+ # `:ml_dsa_87` (or the canonical string form).
57
+ # @param public_key [String] raw public key bytes of the correct size
58
+ # for the algorithm.
59
+ # @param private_key [String, nil] raw private key bytes, or nil for a
60
+ # verification-only key.
61
+ # @raise [UnsupportedAlgorithmError] if `algorithm` is not recognized.
62
+ # @raise [KeyError] if a key's byte size does not match the algorithm.
26
63
  def initialize(algorithm:, public_key:, private_key: nil)
27
64
  @algorithm = resolve_algorithm(algorithm)
28
65
  @ml_dsa = MlDsa.new(@algorithm)
29
66
  @public_key = public_key
30
67
  @private_key = private_key
68
+ @op_mutex = Mutex.new
31
69
 
32
70
  validate!
71
+ init_ffi_buffers!
33
72
  end
34
73
 
35
74
  # Generate a new keypair for the given algorithm.
75
+ #
76
+ # @param algorithm [Symbol, String] one of `:ml_dsa_44`, `:ml_dsa_65`,
77
+ # `:ml_dsa_87` (or the canonical string form).
78
+ # @return [Key] a new keypair with both public and private components.
79
+ # @raise [UnsupportedAlgorithmError] if `algorithm` is not recognized.
36
80
  def self.generate(algorithm)
37
81
  alg_name = resolve_algorithm(algorithm)
38
82
  ml_dsa = MlDsa.new(alg_name)
@@ -41,46 +85,109 @@ module JWT
41
85
  new(algorithm: alg_name, public_key: pk, private_key: sk)
42
86
  end
43
87
 
44
- # Create a Key from raw public key bytes (verification only).
88
+ # Wrap raw public key bytes for verification-only use.
89
+ #
90
+ # @param algorithm [Symbol, String] the algorithm the public key belongs to.
91
+ # @param public_key_bytes [String] raw public key bytes.
92
+ # @return [Key] a verification-only key ({#private?} returns false).
45
93
  def self.from_public_key(algorithm, public_key_bytes)
46
94
  new(algorithm: algorithm, public_key: public_key_bytes)
47
95
  end
48
96
 
49
97
  # Sign data using the private key.
98
+ #
99
+ # Thread-safe: serialized on a per-instance mutex shared with
100
+ # {#verify} and {#destroy!}, so a concurrent `destroy!` cannot race
101
+ # with an in-flight sign. The mutex cost (~hundreds of ns) is
102
+ # negligible relative to ML-DSA signing (~130–200 µs).
103
+ #
104
+ # @param data [String] message bytes to sign.
105
+ # @return [String] raw signature bytes.
106
+ # @raise [KeyError] if this key has no private component.
107
+ # @raise [SignatureError] if liboqs reports a signing failure.
50
108
  def sign(data)
51
- raise KeyError, "Private key not available — cannot sign" unless @private_key
109
+ @op_mutex.synchronize do
110
+ raise KeyError, "Private key not available — cannot sign" unless @sk_buffer
52
111
 
53
- @ml_dsa.sign_with_sk_buffer(data, sk_buffer)
112
+ @ml_dsa.sign_with_sk_buffer(data, @sk_buffer)
113
+ end
54
114
  end
55
115
 
56
- # Verify a signature using the public key.
116
+ # Verify a signature against data using the public key.
117
+ #
118
+ # Thread-safe: serialized on a per-instance mutex shared with
119
+ # {#sign} and {#destroy!}.
120
+ #
121
+ # @param data [String] message bytes that were signed.
122
+ # @param signature [String] raw signature bytes produced by {#sign}.
123
+ # @return [Boolean] true if the signature is valid, false otherwise.
57
124
  def verify(data, signature)
58
- @ml_dsa.verify_with_pk_buffer(data, signature, pk_buffer)
125
+ @op_mutex.synchronize do
126
+ @ml_dsa.verify_with_pk_buffer(data, signature, @pk_buffer)
127
+ end
59
128
  end
60
129
 
61
- # Whether this key can be used for signing.
130
+ # @return [Boolean] true when this key has a private component and can sign.
62
131
  def private?
63
132
  !@private_key.nil?
64
133
  end
65
134
 
66
135
  # Zero and discard private key material from Ruby memory.
67
- # After calling this, the key can only be used for verification.
136
+ #
137
+ # After calling this, {#private?} becomes false and the key can only
138
+ # be used for verification. Idempotent — safe to call multiple times,
139
+ # and on verification-only keys.
140
+ #
141
+ # Thread-safe: serialized on a per-instance mutex shared with
142
+ # {#sign} and {#verify}, so `destroy!` waits for any in-flight
143
+ # signing or verification to complete before zeroing the buffer.
144
+ #
145
+ # @return [true]
68
146
  def destroy!
69
- if @private_key
70
- @private_key.replace("\0" * @private_key.bytesize)
71
- @private_key = nil
147
+ @op_mutex.synchronize do
148
+ if @private_key
149
+ @private_key.replace("\0" * @private_key.bytesize)
150
+ @private_key = nil
151
+ end
152
+ @sk_buffer&.clear
153
+ @sk_buffer = nil
72
154
  end
73
- @sk_buffer&.clear
74
- @sk_buffer = nil
75
155
  true
76
156
  end
77
157
 
158
+ # @return [String] short diagnostic string — never contains key material.
78
159
  def inspect
79
160
  "#<#{self.class} algorithm=#{@algorithm} private=#{private?}>"
80
161
  end
81
162
  alias to_s inspect
82
163
 
83
- # Import a Key from a PEM string (SPKI or PKCS#8).
164
+ # RFC 7638 JWK Thumbprint for this key, memoized.
165
+ #
166
+ # The thumbprint depends only on the canonical JSON of
167
+ # `{alg, kty, pub}` — all immutable for the lifetime of a Key —
168
+ # so it is computed lazily on first access and cached. Useful for
169
+ # callers (e.g. {JWT::PQ::JWKSet}) that index many keys by `kid`
170
+ # without wanting to allocate a {JWK} wrapper each time.
171
+ #
172
+ # Safe to call concurrently on a shared key: the inputs are
173
+ # immutable post-construction, so a concurrent first access at
174
+ # worst recomputes the same deterministic string; the `||=`
175
+ # assignment is a single atomic reference write on MRI.
176
+ #
177
+ # @return [String] base64url-encoded SHA-256 thumbprint.
178
+ def jwk_thumbprint
179
+ @jwk_thumbprint ||= JWT::PQ::JWK.compute_thumbprint(@algorithm, @public_key)
180
+ end
181
+
182
+ # Import a Key from a PEM string.
183
+ #
184
+ # Accepts both SPKI (public-only) and PKCS#8 (private + embedded public)
185
+ # PEM documents. For a PKCS#8 PEM that does not carry the public key,
186
+ # use {.from_pem_pair} with a separate public PEM instead.
187
+ #
188
+ # @param pem_string [String] a PEM-encoded key document.
189
+ # @return [Key] a public-only or full keypair, depending on the PEM format.
190
+ # @raise [KeyError] for unknown OIDs or PKCS#8 PEMs missing the public key.
84
191
  def self.from_pem(pem_string)
85
192
  info = PqcAsn1::DER.parse_pem(pem_string)
86
193
  alg_name = resolve_oid!(info.oid)
@@ -88,13 +195,24 @@ module JWT
88
195
  case info.format
89
196
  when :spki then new(algorithm: alg_name, public_key: info.key)
90
197
  when :pkcs8 then build_from_pkcs8(info, alg_name)
198
+ # :nocov: — defensive guard; PqcAsn1::DER.parse_pem only returns :spki or :pkcs8
91
199
  else raise KeyError, "Unsupported PEM format: #{info.format}"
200
+ # :nocov:
92
201
  end
93
202
  ensure
94
203
  info&.key&.wipe! if info&.format == :pkcs8
95
204
  end
96
205
 
97
206
  # Import a Key from separate public and private PEM strings.
207
+ #
208
+ # Use this when your private PEM is PKCS#8 without an embedded public
209
+ # key, or when public and private material come from different sources.
210
+ #
211
+ # @param public_pem [String] SPKI-encoded public key PEM.
212
+ # @param private_pem [String] PKCS#8-encoded private key PEM.
213
+ # @return [Key] a full keypair.
214
+ # @raise [KeyError] if the OIDs are unknown or the public and private
215
+ # PEMs specify different algorithms.
98
216
  def self.from_pem_pair(public_pem:, private_pem:)
99
217
  pub_info = PqcAsn1::DER.parse_pem(public_pem)
100
218
  priv_info = PqcAsn1::DER.parse_pem(private_pem)
@@ -112,26 +230,43 @@ module JWT
112
230
  priv_info&.key&.wipe!
113
231
  end
114
232
 
115
- # Export the public key as PEM (SPKI format).
233
+ # Export the public key as an SPKI PEM string.
234
+ #
235
+ # @return [String] a `-----BEGIN PUBLIC KEY-----` PEM document.
116
236
  def to_pem
117
237
  oid = ALGORITHM_OIDS[@algorithm]
118
238
  der = PqcAsn1::DER.build_spki(oid, @public_key)
119
239
  PqcAsn1::PEM.encode(der, "PUBLIC KEY")
120
240
  end
121
241
 
122
- # Export the private key as PEM (PKCS#8 format).
242
+ # Export the private key as a PKCS#8 PEM string.
243
+ #
244
+ # The PEM carries both the private key and the public key (so the pair
245
+ # can later be re-imported with {.from_pem} alone).
246
+ #
247
+ # Thread-safe: the read of `@private_key` and the DER build are
248
+ # serialized against {#destroy!} on the per-instance mutex, so a
249
+ # concurrent `destroy!` cannot zero the bytes mid-encode.
250
+ #
251
+ # @return [String] a `-----BEGIN PRIVATE KEY-----` PEM document.
252
+ # @raise [KeyError] if this key has no private component.
123
253
  def private_to_pem
124
- raise KeyError, "Private key not available" unless @private_key
254
+ @op_mutex.synchronize do
255
+ raise KeyError, "Private key not available" unless @private_key
125
256
 
126
- oid = ALGORITHM_OIDS[@algorithm]
127
- secure_der = PqcAsn1::DER.build_pkcs8(oid, @private_key, public_key: @public_key)
128
- secure_der.to_pem
257
+ oid = ALGORITHM_OIDS[@algorithm]
258
+ secure_der = PqcAsn1::DER.build_pkcs8(oid, @private_key, public_key: @public_key)
259
+ secure_der.to_pem
260
+ end
129
261
  end
130
262
 
263
+ # @api private
131
264
  def self.resolve_algorithm(algorithm)
132
265
  ALGORITHM_ALIASES.fetch(algorithm.to_sym) { algorithm.to_s }
133
266
  end
134
267
 
268
+ # @api private
269
+ #
135
270
  # Extract bytes from a PqcAsn1::SecureBuffer using the safe block API.
136
271
  # The yielded String shares the SecureBuffer's C-level memory, so
137
272
  # String.new / dup / b all get zeroed when the block exits.
@@ -140,10 +275,12 @@ module JWT
140
275
  secure_buffer.use { |bytes| bytes.bytes.pack("C*") }
141
276
  end
142
277
 
278
+ # @api private
143
279
  def self.resolve_oid!(oid)
144
280
  OID_TO_ALGORITHM[oid] || raise(KeyError, "Unknown OID in PEM: #{oid.dotted}")
145
281
  end
146
282
 
283
+ # @api private
147
284
  def self.build_from_pkcs8(info, alg_name)
148
285
  raise KeyError, "PKCS#8 PEM for #{alg_name} missing public key. Use from_pem_pair." unless info.public_key
149
286
 
@@ -151,16 +288,41 @@ module JWT
151
288
  new(algorithm: alg_name, public_key: info.public_key, private_key: sk_bytes)
152
289
  end
153
290
 
291
+ # @api private
292
+ #
293
+ # Build a GC finalizer that zeros the given FFI buffer before it is
294
+ # freed. Defined at the class level so the returned proc closes over
295
+ # the buffer only — capturing `self` would keep the Key alive forever
296
+ # and the finalizer would never run. Calling `.clear` on an already
297
+ # zeroed buffer (after an explicit {#destroy!}) is a safe no-op.
298
+ private_class_method def self.finalizer_for(sk_buffer)
299
+ proc { sk_buffer.clear }
300
+ end
301
+
154
302
  private_class_method :resolve_algorithm, :extract_secure_bytes, :resolve_oid!, :build_from_pkcs8
155
303
 
156
304
  private
157
305
 
158
- def sk_buffer
159
- @sk_buffer ||= FFI::MemoryPointer.new(:uint8, @private_key.bytesize).put_bytes(0, @private_key)
160
- end
306
+ # Allocate and populate the FFI buffers for the public key and (when
307
+ # present) the private key. Done eagerly at construction time to
308
+ # avoid a thread-safety race on lazy initialization: `@var ||= ...`
309
+ # is not atomic under MRI, so two threads concurrently calling
310
+ # {#sign} on a fresh key could each allocate separate
311
+ # FFI::MemoryPointers holding a copy of the secret key; the loser's
312
+ # copy then lingers in memory until GC, unzeroed. Eager init makes
313
+ # each Key hold exactly one copy of each key buffer for its lifetime.
314
+ #
315
+ # A GC finalizer zeros `@sk_buffer` before FFI releases its memory,
316
+ # so a forgotten {#destroy!} cannot leak secret key bytes to the
317
+ # allocator. {#destroy!} remains the preferred explicit path; the
318
+ # finalizer is a last-resort safety net. The finalizer closes over
319
+ # the buffer only — not `self` — so it does not pin the Key from GC.
320
+ def init_ffi_buffers!
321
+ @pk_buffer = FFI::MemoryPointer.new(:uint8, @public_key.bytesize).put_bytes(0, @public_key)
322
+ return unless @private_key
161
323
 
162
- def pk_buffer
163
- @pk_buffer ||= FFI::MemoryPointer.new(:uint8, @public_key.bytesize).put_bytes(0, @public_key)
324
+ @sk_buffer = FFI::MemoryPointer.new(:uint8, @private_key.bytesize).put_bytes(0, @private_key)
325
+ ObjectSpace.define_finalizer(self, self.class.send(:finalizer_for, @sk_buffer))
164
326
  end
165
327
 
166
328
  def resolve_algorithm(algorithm)
data/lib/jwt/pq/liboqs.rb CHANGED
@@ -4,6 +4,8 @@ require "ffi"
4
4
 
5
5
  module JWT
6
6
  module PQ
7
+ # @api private
8
+ #
7
9
  # FFI bindings for liboqs signature operations.
8
10
  #
9
11
  # Library search order:
@@ -40,12 +42,14 @@ module JWT
40
42
  begin
41
43
  ffi_lib lib_path
42
44
  rescue LoadError => e
45
+ # :nocov: — reached only when liboqs is missing at module load
43
46
  raise JWT::PQ::LiboqsError,
44
47
  "liboqs not found. The vendored library may not have been compiled " \
45
48
  "during gem install. Ensure cmake and a C compiler are installed, " \
46
49
  "then reinstall: gem install jwt-pq. Alternatively, install liboqs " \
47
50
  "manually and set OQS_LIB to the full path. " \
48
51
  "Original error: #{e.message}"
52
+ # :nocov:
49
53
  end
50
54
 
51
55
  # OQS_SIG *OQS_SIG_new(const char *method_name)
data/lib/jwt/pq/ml_dsa.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  module JWT
4
4
  module PQ
5
+ # @api private
6
+ #
5
7
  # Ruby wrapper around liboqs ML-DSA operations.
6
8
  # Handles memory allocation, FFI calls, and cleanup.
7
9
  class MlDsa
@@ -15,7 +17,7 @@ module JWT
15
17
  @sign_handles_mutex = Mutex.new
16
18
 
17
19
  def self.sign_handle(algorithm)
18
- @sign_handles[algorithm] || @sign_handles_mutex.synchronize do
20
+ @sign_handles_mutex.synchronize do
19
21
  @sign_handles[algorithm] ||= begin
20
22
  h = LibOQS.OQS_SIG_new(algorithm)
21
23
  raise LiboqsError, "Failed to initialize #{algorithm}" if h.null?
@@ -29,7 +31,7 @@ module JWT
29
31
  @verify_handles_mutex = Mutex.new
30
32
 
31
33
  def self.verify_handle(algorithm)
32
- @verify_handles[algorithm] || @verify_handles_mutex.synchronize do
34
+ @verify_handles_mutex.synchronize do
33
35
  @verify_handles[algorithm] ||= begin
34
36
  h = LibOQS.OQS_SIG_new(algorithm)
35
37
  raise LiboqsError, "Failed to initialize #{algorithm}" if h.null?
@@ -39,6 +41,25 @@ module JWT
39
41
  end
40
42
  end
41
43
 
44
+ # Drop the process-wide cache of `OQS_SIG` handles.
45
+ #
46
+ # The first call to {.sign_handle} or {.verify_handle} in a process
47
+ # (or after this reset) lazily allocates a fresh handle. The
48
+ # inherited handles in the child are not explicitly freed — doing
49
+ # so can confuse `malloc` bookkeeping across forked processes. The
50
+ # handles leak until process exit (≤3 algorithms × <1 KB each),
51
+ # which is negligible.
52
+ #
53
+ # Prefer the public {JWT::PQ.reset_handles!} wrapper from
54
+ # application code.
55
+ #
56
+ # @api private
57
+ # @return [void]
58
+ def self.reset_handles!
59
+ @sign_handles_mutex.synchronize { @sign_handles.clear }
60
+ @verify_handles_mutex.synchronize { @verify_handles.clear }
61
+ end
62
+
42
63
  attr_reader :algorithm
43
64
 
44
65
  def initialize(algorithm)
@@ -2,6 +2,7 @@
2
2
 
3
3
  module JWT
4
4
  module PQ
5
- VERSION = "0.3.0"
5
+ # Current gem version.
6
+ VERSION = "0.5.0"
6
7
  end
7
8
  end