jwt-pq 0.5.1 → 0.6.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/CHANGELOG.md +13 -1
- data/README.md +23 -0
- data/SECURITY.md +2 -2
- data/SPEC.md +2 -2
- data/jwt-pq.gemspec +1 -1
- data/lib/jwt/pq/jwk.rb +21 -0
- data/lib/jwt/pq/jwk_set.rb +34 -6
- data/lib/jwt/pq/jwks_loader.rb +6 -2
- data/lib/jwt/pq/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0c45ba386fb98df768124e32529f0e0d80f574ddf0a0132e57e87a3ce94ea7a
|
|
4
|
+
data.tar.gz: c6071071b4814e40c707179d575447b7d730a1a2a789ba1cee5bdf71929abb47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 96c10bfd04d93b946f634257b95427a64e39cf23d8a10f330b1b4bdc3d29e212d0a575ddb7486df7ab47d88bd8af73d21ac4ec2620507db9c5289e2bc5a2588b
|
|
7
|
+
data.tar.gz: 88bc0b05ade5ab27f3dfa8b56d7f8bf3d8bb566145468e452c7a570db5db0e57ddabcc9ad452d86a48206633368675b83cf7a28d832f54a157cc80e75559464a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-04-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Ruby 4.0 officially supported (CI matrix now covers 3.2, 3.3, 3.4, and 4.0 on Linux and macOS) (#40)
|
|
15
|
+
- YARD reference site: `docs.yml` workflow publishes the generated docs to GitHub Pages on every tag push, served at `docs.jwt-pq.marcelopazzo.com` (#41)
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `JWT::PQ::JWKSet.import` (and `JWKSet.fetch`) now tolerates mixed JWKSes: members with unknown `kty` (e.g. RSA, EC, OKP) or unsupported `alg` within `kty: "AKP"` are silently skipped instead of aborting the whole set — enabling incremental PQ rollouts where a single `/.well-known/jwks.json` carries both classical and ML-DSA keys. Pass `strict: true` to restore the previous fail-fast behaviour. Recognized-but-malformed AKP members still raise `KeyError` (#39)
|
|
20
|
+
|
|
10
21
|
## [0.5.1] - 2026-04-22
|
|
11
22
|
|
|
12
23
|
### Changed
|
|
@@ -140,7 +151,8 @@ Throughput on Ruby 3.4.6, macOS x86_64, liboqs 0.15.0 (benchmark-ips, 2s warmup
|
|
|
140
151
|
- Optional dependency on jwt-eddsa / ed25519
|
|
141
152
|
- Error classes: `LiboqsError`, `KeyError`, `SignatureError`, `MissingDependencyError`
|
|
142
153
|
|
|
143
|
-
[Unreleased]: https://github.com/marcelopazzo/jwt-pq/compare/v0.
|
|
154
|
+
[Unreleased]: https://github.com/marcelopazzo/jwt-pq/compare/v0.6.0...HEAD
|
|
155
|
+
[0.6.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.5.1...v0.6.0
|
|
144
156
|
[0.5.1]: https://github.com/marcelopazzo/jwt-pq/compare/v0.5.0...v0.5.1
|
|
145
157
|
[0.5.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.4.0...v0.5.0
|
|
146
158
|
[0.4.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.3.0...v0.4.0
|
data/README.md
CHANGED
|
@@ -138,6 +138,29 @@ signing: `JWT.encode(payload, key, alg, { kid: key.jwk_thumbprint })`.
|
|
|
138
138
|
`Key#jwk_thumbprint` memoizes the digest, so it's cheap to call
|
|
139
139
|
repeatedly on the same key.
|
|
140
140
|
|
|
141
|
+
#### Mixed classical + PQ JWKSes
|
|
142
|
+
|
|
143
|
+
`JWKSet.import` (and `JWKSet.fetch`) tolerates JWKSes that mix this
|
|
144
|
+
gem's `ML-DSA-{44,65,87}` keys with classical RSA/EC/EdDSA entries or
|
|
145
|
+
future PQ algorithms — members with an unknown `kty` or an
|
|
146
|
+
unsupported `alg` within `kty: "AKP"` are silently dropped. This is
|
|
147
|
+
the realistic shape of an incremental PQ rollout, where an issuer
|
|
148
|
+
publishes both classical and ML-DSA keys on the same
|
|
149
|
+
`/.well-known/jwks.json`.
|
|
150
|
+
|
|
151
|
+
Members that **do** have `kty: "AKP"` with a supported `alg` but are
|
|
152
|
+
otherwise malformed (missing `pub`, invalid base64url, wrong key size,
|
|
153
|
+
etc.) still raise `JWT::PQ::KeyError` — that is a real bug in the
|
|
154
|
+
emitter, not an interop boundary.
|
|
155
|
+
|
|
156
|
+
Pass `strict: true` to restore fail-fast behaviour on any unknown
|
|
157
|
+
`kty`/`alg`:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
JWT::PQ::JWKSet.import(body, strict: true)
|
|
161
|
+
JWT::PQ::JWKSet.fetch(url, strict: true)
|
|
162
|
+
```
|
|
163
|
+
|
|
141
164
|
### Fetching a remote JWKS
|
|
142
165
|
|
|
143
166
|
For consuming a JWKS from an identity provider or sibling service,
|
data/SECURITY.md
CHANGED
data/SPEC.md
CHANGED
|
@@ -4,9 +4,9 @@ This document records which external specifications each jwt-pq release
|
|
|
4
4
|
targets, what its current divergences are (if any), and the compatibility
|
|
5
5
|
policy for drafts in flight.
|
|
6
6
|
|
|
7
|
-
## Tracked specifications — jwt-pq 0.
|
|
7
|
+
## Tracked specifications — jwt-pq 0.6.x
|
|
8
8
|
|
|
9
|
-
Last reviewed: **2026-04-
|
|
9
|
+
Last reviewed: **2026-04-22**.
|
|
10
10
|
|
|
11
11
|
| Spec | Role in jwt-pq | Status |
|
|
12
12
|
|-----------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------|-----------------------------|
|
data/jwt-pq.gemspec
CHANGED
|
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
|
|
|
22
22
|
"source_code_uri" => github_uri,
|
|
23
23
|
"changelog_uri" => "#{github_uri}/blob/main/CHANGELOG.md",
|
|
24
24
|
"bug_tracker_uri" => "#{github_uri}/issues",
|
|
25
|
-
"documentation_uri" => "https://
|
|
25
|
+
"documentation_uri" => "https://docs.jwt-pq.marcelopazzo.com",
|
|
26
26
|
"rubygems_mfa_required" => "true"
|
|
27
27
|
}
|
|
28
28
|
|
data/lib/jwt/pq/jwk.rb
CHANGED
|
@@ -98,6 +98,27 @@ module JWT
|
|
|
98
98
|
end
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
# Whether a JWK hash looks like something this gem can import:
|
|
102
|
+
# a JSON object with `kty == "AKP"` and a supported ML-DSA `alg`.
|
|
103
|
+
#
|
|
104
|
+
# Used by {JWT::PQ::JWKSet.import} to tolerate mixed JWKSes during
|
|
105
|
+
# incremental PQ rollouts — a `/.well-known/jwks.json` carrying a
|
|
106
|
+
# blend of RSA/EdDSA and ML-DSA members will not raise on the
|
|
107
|
+
# classical entries. It does **not** validate the remaining fields
|
|
108
|
+
# (`pub`, `priv`, base64url-ness, key sizes); a recognized-but-
|
|
109
|
+
# malformed member still raises from {.import}, since that signals
|
|
110
|
+
# a real bug in the emitter rather than an interop boundary.
|
|
111
|
+
#
|
|
112
|
+
# @api private
|
|
113
|
+
# @param jwk_hash [Object] candidate JWK.
|
|
114
|
+
# @return [Boolean] true iff the member is in this gem's scope.
|
|
115
|
+
def self.recognized?(jwk_hash)
|
|
116
|
+
return false unless jwk_hash.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
jwk = normalize_keys(jwk_hash)
|
|
119
|
+
jwk["kty"] == KTY && ALGORITHMS.include?(jwk["alg"])
|
|
120
|
+
end
|
|
121
|
+
|
|
101
122
|
# Compute the JWK Thumbprint (RFC 7638) used as `kid`.
|
|
102
123
|
#
|
|
103
124
|
# Delegates to {JWT::PQ::Key#jwk_thumbprint}, which memoizes the
|
data/lib/jwt/pq/jwk_set.rb
CHANGED
|
@@ -127,8 +127,20 @@ module JWT
|
|
|
127
127
|
|
|
128
128
|
# Import a JWKS from a Hash or JSON string.
|
|
129
129
|
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
130
|
+
# By default, members this gem cannot represent — unknown `kty`
|
|
131
|
+
# (e.g. `RSA`, `EC`, `OKP`) or unknown `alg` within `kty: "AKP"`
|
|
132
|
+
# (e.g. a future ML-DSA parameter set or a sibling PQ algorithm not
|
|
133
|
+
# yet implemented here) — are silently dropped. This keeps the set
|
|
134
|
+
# usable during an incremental PQ rollout, where a single
|
|
135
|
+
# `/.well-known/jwks.json` carries both classical and ML-DSA keys.
|
|
136
|
+
#
|
|
137
|
+
# Members that **are** in scope (`kty: "AKP"` with a supported
|
|
138
|
+
# `alg`) but malformed — missing `pub`, wrong field type, invalid
|
|
139
|
+
# base64url, wrong key size — still raise {KeyError}: that is a
|
|
140
|
+
# real bug in the emitter, not an interop boundary.
|
|
141
|
+
#
|
|
142
|
+
# Pass `strict: true` to restore the previous fail-fast behaviour,
|
|
143
|
+
# where any unknown `kty`/`alg` raises.
|
|
132
144
|
#
|
|
133
145
|
# ML-DSA public keys are ~1.3–2.6 KB each, so a JWKS with N keys is
|
|
134
146
|
# at least N × ~2 KB. When ingesting untrusted JWKS payloads (e.g.
|
|
@@ -138,10 +150,14 @@ module JWT
|
|
|
138
150
|
#
|
|
139
151
|
# @param source [Hash, String] a JWKS hash or JSON string with a
|
|
140
152
|
# `"keys"` array.
|
|
141
|
-
# @
|
|
153
|
+
# @param strict [Boolean] if true, unknown `kty`/`alg` members
|
|
154
|
+
# raise {KeyError} instead of being skipped. Default: false.
|
|
155
|
+
# @return [JWKSet] a new set with the parsed in-scope members.
|
|
142
156
|
# @raise [KeyError] if `source` is not a Hash/String, if the `keys`
|
|
143
|
-
# field is missing or not an Array,
|
|
144
|
-
|
|
157
|
+
# field is missing or not an Array, if an in-scope member is
|
|
158
|
+
# malformed, or (only when `strict: true`) if any member has an
|
|
159
|
+
# unknown `kty`/`alg`.
|
|
160
|
+
def self.import(source, strict: false)
|
|
145
161
|
hash = coerce_to_hash(source)
|
|
146
162
|
raise KeyError, "Expected Hash for JWKS body, got #{hash.class}" unless hash.is_a?(Hash)
|
|
147
163
|
|
|
@@ -149,10 +165,18 @@ module JWT
|
|
|
149
165
|
raise KeyError, "Missing 'keys' in JWKS" unless hash.key?("keys")
|
|
150
166
|
raise KeyError, "'keys' must be an Array" unless hash["keys"].is_a?(Array)
|
|
151
167
|
|
|
152
|
-
members = hash["keys"].
|
|
168
|
+
members = hash["keys"].filter_map { |jwk| import_member(jwk, strict: strict) }
|
|
153
169
|
new(members)
|
|
154
170
|
end
|
|
155
171
|
|
|
172
|
+
# @api private
|
|
173
|
+
def self.import_member(jwk, strict:)
|
|
174
|
+
return nil unless strict || JWT::PQ::JWK.recognized?(jwk)
|
|
175
|
+
|
|
176
|
+
JWT::PQ::JWK.import(jwk)
|
|
177
|
+
end
|
|
178
|
+
private_class_method :import_member
|
|
179
|
+
|
|
156
180
|
# @api private
|
|
157
181
|
def self.coerce_to_hash(source)
|
|
158
182
|
case source
|
|
@@ -184,6 +208,10 @@ module JWT
|
|
|
184
208
|
# key = jwks[header["kid"]] or raise "unknown kid"
|
|
185
209
|
# payload, = JWT.decode(token, key, true, algorithms: [header["alg"]])
|
|
186
210
|
#
|
|
211
|
+
# By default, members with unknown `kty`/`alg` in the fetched
|
|
212
|
+
# body are skipped (see {.import}); pass `strict: true` to make
|
|
213
|
+
# them raise.
|
|
214
|
+
#
|
|
187
215
|
# @param url [String] absolute JWKS URL.
|
|
188
216
|
# @return [JWKSet] the parsed set of verification keys.
|
|
189
217
|
# @raise [JWKSFetchError] on fetch failure (see {Loader#fetch}).
|
data/lib/jwt/pq/jwks_loader.rb
CHANGED
|
@@ -80,6 +80,9 @@ module JWT
|
|
|
80
80
|
# Default: 1 MB.
|
|
81
81
|
# @param allow_http [Boolean] allow plain `http://` URLs. Default:
|
|
82
82
|
# false (strongly recommended for production).
|
|
83
|
+
# @param strict [Boolean] forwarded to {JWKSet.import}: if true,
|
|
84
|
+
# members with unknown `kty`/`alg` raise instead of being
|
|
85
|
+
# skipped. Default: false.
|
|
83
86
|
# @return [JWKSet] the parsed set of verification keys.
|
|
84
87
|
# @raise [JWKSFetchError] on network error, timeout, non-2xx
|
|
85
88
|
# response, oversized body, redirect, or non-HTTPS URL.
|
|
@@ -89,7 +92,8 @@ module JWT
|
|
|
89
92
|
timeout: DEFAULT_TIMEOUT,
|
|
90
93
|
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
91
94
|
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
|
|
92
|
-
allow_http: false
|
|
95
|
+
allow_http: false,
|
|
96
|
+
strict: false)
|
|
93
97
|
uri = validate_uri!(url, allow_http: allow_http)
|
|
94
98
|
|
|
95
99
|
fresh = fresh_entry(url, cache_ttl)
|
|
@@ -102,7 +106,7 @@ module JWT
|
|
|
102
106
|
@mutex.synchronize { existing.fetched_at = now }
|
|
103
107
|
existing.jwks
|
|
104
108
|
else
|
|
105
|
-
jwks = JWKSet.import(result[:body])
|
|
109
|
+
jwks = JWKSet.import(result[:body], strict: strict)
|
|
106
110
|
@mutex.synchronize do
|
|
107
111
|
@cache[url] = CacheEntry.new(jwks, result[:etag], now)
|
|
108
112
|
end
|
data/lib/jwt/pq/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jwt-pq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marcelo Almeida
|
|
@@ -91,7 +91,7 @@ metadata:
|
|
|
91
91
|
source_code_uri: https://github.com/marcelopazzo/jwt-pq
|
|
92
92
|
changelog_uri: https://github.com/marcelopazzo/jwt-pq/blob/main/CHANGELOG.md
|
|
93
93
|
bug_tracker_uri: https://github.com/marcelopazzo/jwt-pq/issues
|
|
94
|
-
documentation_uri: https://
|
|
94
|
+
documentation_uri: https://docs.jwt-pq.marcelopazzo.com
|
|
95
95
|
rubygems_mfa_required: 'true'
|
|
96
96
|
post_install_message: |
|
|
97
97
|
jwt-pq compiles liboqs from source during installation.
|