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
data/lib/jwt/pq/hybrid_key.rb
CHANGED
|
@@ -2,17 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
module JWT
|
|
4
4
|
module PQ
|
|
5
|
-
#
|
|
6
|
-
# for hybrid EdDSA
|
|
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
|
-
|
|
27
|
+
# @return [Ed25519::SigningKey, nil] Ed25519 signing key, or nil for
|
|
28
|
+
# verification-only.
|
|
29
|
+
attr_reader :ed25519_signing_key
|
|
9
30
|
|
|
10
|
-
# @
|
|
11
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
55
|
-
#
|
|
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
|
-
@
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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)
|
|
9
|
+
# JWK (JSON Web Key) import/export for ML-DSA keys.
|
|
9
10
|
#
|
|
10
|
-
# Follows the draft-ietf-cose-dilithium conventions
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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)
|
|
60
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|