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.
- checksums.yaml +4 -4
- data/.yardopts +9 -0
- data/CHANGELOG.md +61 -1
- data/Gemfile +1 -0
- data/README.md +107 -1
- data/SECURITY.md +56 -0
- data/SPEC.md +72 -0
- data/jwt-pq.gemspec +5 -2
- data/lib/jwt/pq/algorithms/hybrid_eddsa.rb +46 -45
- data/lib/jwt/pq/algorithms/ml_dsa.rb +10 -12
- data/lib/jwt/pq/errors.rb +12 -0
- data/lib/jwt/pq/hybrid_key.rb +109 -16
- data/lib/jwt/pq/jwk.rb +79 -10
- data/lib/jwt/pq/jwk_set.rb +196 -0
- data/lib/jwt/pq/jwks_loader.rb +221 -0
- data/lib/jwt/pq/key.rb +190 -28
- data/lib/jwt/pq/liboqs.rb +4 -0
- data/lib/jwt/pq/ml_dsa.rb +23 -2
- data/lib/jwt/pq/version.rb +2 -1
- data/lib/jwt/pq.rb +42 -1
- metadata +8 -7
- data/bench/fixtures/ml_dsa_65_sk.pem +0 -128
- data/bench/sign_throughput.rb +0 -26
- data/bench/verify_throughput.rb +0 -29
- data/bin/smoke.rb +0 -40
|
@@ -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
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
109
|
+
@op_mutex.synchronize do
|
|
110
|
+
raise KeyError, "Private key not available — cannot sign" unless @sk_buffer
|
|
52
111
|
|
|
53
|
-
|
|
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
|
-
@
|
|
125
|
+
@op_mutex.synchronize do
|
|
126
|
+
@ml_dsa.verify_with_pk_buffer(data, signature, @pk_buffer)
|
|
127
|
+
end
|
|
59
128
|
end
|
|
60
129
|
|
|
61
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
254
|
+
@op_mutex.synchronize do
|
|
255
|
+
raise KeyError, "Private key not available" unless @private_key
|
|
125
256
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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)
|