jwt-pq 0.4.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 +33 -1
- data/Gemfile +1 -0
- data/README.md +106 -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 +30 -39
- 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 -4
- data/bin/smoke.rb +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f2d8f33a8210a14b1c5a15e8ae953b38b88e1d35c05d98cfad97e55943d8879
|
|
4
|
+
data.tar.gz: 1f486a208212534c7ae06721dbad8d11b28ca492795a6409c7a8ab0cd1dcda07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 673adaee5ef237a31b2ce80a7c9a86442b494fcea875ebcb48648d6a42057b13a0afc46ece40a8d648c8b75e7ee25ccdc35a4daeba972850aaacf3d28ee1a014
|
|
7
|
+
data.tar.gz: c62aae707ac8ffe1b29d994d86a9e581fd634f6349f0345dab0fdad5674a12fd5289ea89b17b99143d97e6d4dd43e722ec1cedc41112aca80588acee2d56bf35
|
data/.yardopts
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-04-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `JWT::PQ::JWKSet` for RFC 7517 §5 JWK Sets — parse, serialize, lookup by `kid`, and enumerate keys (#22)
|
|
15
|
+
- Remote JWKS fetcher with TTL cache and ETag/If-None-Match revalidation for interop with identity providers (#24)
|
|
16
|
+
- YARD documentation across the public API surface (`@param`, `@return`, `@raise`, `@example`); `@api private` markers on internals (#21)
|
|
17
|
+
- `SPEC.md` tracking the IETF drafts jwt-pq targets (JOSE/COSE PQC) and the gem's compatibility policy (#28)
|
|
18
|
+
- Fuzz-style tests hardening `JWK` and `JWKSet` import against malformed input (#23)
|
|
19
|
+
- Ruby→Python JWK cross-interop CI job against an independent ML-DSA / FIPS 204 implementation (#19)
|
|
20
|
+
- Thumbprint test verifying `JWK#thumbprint` matches an independent RFC 7638 computation (#18)
|
|
21
|
+
- Weekly liboqs upstream release monitor workflow (#17)
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Thread safety**: `Key#sign` / `#verify` / `#destroy!` now use a per-instance mutex instead of a class-level one, restoring real parallelism across keys while keeping a single key safe under concurrent use (#25)
|
|
26
|
+
- **Thread safety**: `MlDsa` handle-cache reads are now always synchronized — the previous double-checked pattern relied on Ruby memory-model guarantees that are not portable across implementations (#31)
|
|
27
|
+
- `JWK#thumbprint` now uses `JSON.generate` for the canonical member dictionary instead of string interpolation, eliminating a class of subtle escaping bugs (#27)
|
|
28
|
+
- `EdDSA+ML-DSA` hybrid `verify` no longer short-circuits between the two component verifications — both are always evaluated so neither timing nor error-path behavior leaks which half failed (#26)
|
|
29
|
+
- `HybridKey#destroy!` now wipes the underlying Ed25519 `@keypair` in addition to the ML-DSA half; FFI secret-key buffers are auto-zeroed on GC as a defense-in-depth finalizer (#32)
|
|
30
|
+
- `JWKSet` remote fetcher enforces the body cap during streaming read (not just post-hoc) and documents the URL-provenance contract callers must honor (#33)
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `pqc_asn1` dependency tightened to `~> 0.1.0` (patch-only) so a future 0.2.0 with potential API breakage does not silently upgrade (#29)
|
|
35
|
+
- `bin/` directory no longer packaged into the published gem (smaller install footprint) (#29)
|
|
36
|
+
|
|
37
|
+
### Dependencies
|
|
38
|
+
|
|
39
|
+
- Bump `ruby/setup-ruby` from 1.301.0 to 1.302.0 (#30)
|
|
40
|
+
|
|
10
41
|
## [0.4.0] - 2026-04-19
|
|
11
42
|
|
|
12
43
|
### Added
|
|
@@ -103,7 +134,8 @@ Throughput on Ruby 3.4.6, macOS x86_64, liboqs 0.15.0 (benchmark-ips, 2s warmup
|
|
|
103
134
|
- Optional dependency on jwt-eddsa / ed25519
|
|
104
135
|
- Error classes: `LiboqsError`, `KeyError`, `SignatureError`, `MissingDependencyError`
|
|
105
136
|
|
|
106
|
-
[Unreleased]: https://github.com/marcelopazzo/jwt-pq/compare/v0.
|
|
137
|
+
[Unreleased]: https://github.com/marcelopazzo/jwt-pq/compare/v0.5.0...HEAD
|
|
138
|
+
[0.5.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.4.0...v0.5.0
|
|
107
139
|
[0.4.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.3.0...v0.4.0
|
|
108
140
|
[0.3.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.2.0...v0.3.0
|
|
109
141
|
[0.2.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.1.0...v0.2.0
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -115,6 +115,63 @@ jwk.export(include_private: true)
|
|
|
115
115
|
restored = JWT::PQ::JWK.import(jwk_hash)
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
### JWK Set (JWKS)
|
|
119
|
+
|
|
120
|
+
For publishing multiple verification keys (e.g. during key rotation) or
|
|
121
|
+
consuming a remote JWKS endpoint:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
# Producer — publish verification keys on /.well-known/jwks.json
|
|
125
|
+
jwks = JWT::PQ::JWKSet.new([key_current, key_next])
|
|
126
|
+
File.write("jwks.json", jwks.to_json)
|
|
127
|
+
|
|
128
|
+
# Consumer — resolve the verification key by kid
|
|
129
|
+
jwks = JWT::PQ::JWKSet.import(JSON.parse(fetch_jwks))
|
|
130
|
+
_payload, header = JWT.decode(token, nil, false) # unverified peek
|
|
131
|
+
key = jwks[header["kid"]] or raise "unknown kid"
|
|
132
|
+
payload, = JWT.decode(token, key, true, algorithms: [header["alg"]])
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Members are indexed by their RFC 7638 thumbprint (the same value
|
|
136
|
+
`JWK#export` emits as `kid`). Remember to set the `kid` header when
|
|
137
|
+
signing: `JWT.encode(payload, key, alg, { kid: key.jwk_thumbprint })`.
|
|
138
|
+
`Key#jwk_thumbprint` memoizes the digest, so it's cheap to call
|
|
139
|
+
repeatedly on the same key.
|
|
140
|
+
|
|
141
|
+
### Fetching a remote JWKS
|
|
142
|
+
|
|
143
|
+
For consuming a JWKS from an identity provider or sibling service,
|
|
144
|
+
`JWT::PQ::JWKSet.fetch` wraps `Net::HTTP` with a TTL cache and
|
|
145
|
+
`ETag`-based revalidation:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
jwks = JWT::PQ::JWKSet.fetch("https://issuer.example/.well-known/jwks.json")
|
|
149
|
+
|
|
150
|
+
_payload, header = JWT.decode(token, nil, false)
|
|
151
|
+
key = jwks[header["kid"]] or raise "unknown kid"
|
|
152
|
+
payload, = JWT.decode(token, key, true, algorithms: [header["alg"]])
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The cache is process-global and keyed by URL, so repeated calls inside
|
|
156
|
+
the TTL window (default: 300 s) return the cached set without touching
|
|
157
|
+
the network. Once the TTL expires, the next call issues a conditional
|
|
158
|
+
GET with `If-None-Match`; a `304 Not Modified` refreshes the cache
|
|
159
|
+
timestamp without re-parsing. Tune via kwargs:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
JWT::PQ::JWKSet.fetch(url,
|
|
163
|
+
cache_ttl: 600, # seconds; default 300
|
|
164
|
+
timeout: 3, # read timeout; default 5
|
|
165
|
+
open_timeout: 3, # connect timeout; default 5
|
|
166
|
+
max_body_bytes: 65_536, # response body cap; default 1 MB
|
|
167
|
+
allow_http: false) # reject plain http:// (default)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Defense-in-depth defaults: HTTPS only, redirects rejected, body capped
|
|
171
|
+
at 1 MB, 5 s timeouts. Network/HTTP failures raise
|
|
172
|
+
`JWT::PQ::JWKSFetchError`; malformed JWKS bodies raise
|
|
173
|
+
`JWT::PQ::KeyError`.
|
|
174
|
+
|
|
118
175
|
## Algorithms
|
|
119
176
|
|
|
120
177
|
| Algorithm | NIST Level | Public Key | Signature | JWT `alg` value |
|
|
@@ -129,7 +186,55 @@ restored = JWT::PQ::JWK.import(jwk_hash)
|
|
|
129
186
|
|
|
130
187
|
The hybrid algorithms (`EdDSA+ML-DSA-{44,65,87}`) provide defense-in-depth: if either algorithm is broken, the other still protects the token.
|
|
131
188
|
|
|
132
|
-
The `alg` header values follow a `ClassicAlg+PQAlg` convention. The IETF draft `draft-ietf-cose-dilithium` is still evolving — these values may change in future versions to align with the final standard.
|
|
189
|
+
The `alg` header values follow a `ClassicAlg+PQAlg` convention. The IETF draft [`draft-ietf-cose-dilithium`](https://datatracker.ietf.org/doc/draft-ietf-cose-dilithium/) is still evolving — these values may change in future versions to align with the final standard.
|
|
190
|
+
|
|
191
|
+
## Correctness
|
|
192
|
+
|
|
193
|
+
- **NIST ACVP known-answer tests.** `spec/jwt/pq/kat_spec.rb` runs the full sigVer KAT subset shipped with liboqs against `JWT::PQ::Key#verify` for ML-DSA-44/65/87, covering both positive and negative cases. These vectors are executed in CI on every push. (sigGen KATs are not used because FIPS 204 specifies hedged signing with internal randomness, which makes signature output non-deterministic.)
|
|
194
|
+
- **Cross-language interop.** The [`Cross-interop`](https://github.com/marcelopazzo/jwt-pq/actions/workflows/interop.yml) workflow signs with `jwt-pq` and verifies with [`dilithium-py`](https://pypi.org/project/dilithium-py/), and vice versa, for all three parameter sets. It runs on every push and weekly on `cron`.
|
|
195
|
+
|
|
196
|
+
## Performance
|
|
197
|
+
|
|
198
|
+
Measured with `bench/sign_throughput.rb` / `bench/verify_throughput.rb` (and the hybrid variants) using `benchmark-ips`. Hardware: Intel Core i9-9880H @ 2.30 GHz, macOS, Ruby 3.4.6, bundled liboqs 0.15.0, single-threaded.
|
|
199
|
+
|
|
200
|
+
| Algorithm | Sign | Verify |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| ML-DSA-44 | 8,026 ops/s (125 µs) | 11,074 ops/s (90 µs) |
|
|
203
|
+
| ML-DSA-65 | 5,972 ops/s (167 µs) | 9,339 ops/s (107 µs) |
|
|
204
|
+
| ML-DSA-87 | 4,911 ops/s (204 µs) | 6,471 ops/s (155 µs) |
|
|
205
|
+
| EdDSA+ML-DSA-65 | 4,695 ops/s (213 µs) | 3,924 ops/s (255 µs) |
|
|
206
|
+
|
|
207
|
+
Numbers are illustrative — rerun `bundle exec ruby bench/sign_throughput.rb` on your target hardware before capacity-planning. ML-DSA is ~1–2 orders of magnitude slower than Ed25519 (~70 k sigs/s on the same box); plan accordingly.
|
|
208
|
+
|
|
209
|
+
## Backends
|
|
210
|
+
|
|
211
|
+
ML-DSA operations are delegated to [liboqs](https://github.com/open-quantum-safe/liboqs), bundled and compiled during `gem install`. An alternative OpenSSL 3.5+ backend is tracked in [#14](https://github.com/marcelopazzo/jwt-pq/issues/14) and will be added once OpenSSL 3.5 ships widely in distros.
|
|
212
|
+
|
|
213
|
+
## Specification tracking
|
|
214
|
+
|
|
215
|
+
jwt-pq targets the current IETF specs for JOSE/COSE post-quantum signatures:
|
|
216
|
+
|
|
217
|
+
- [`draft-ietf-cose-dilithium`](https://datatracker.ietf.org/doc/draft-ietf-cose-dilithium/) — ML-DSA in JOSE/COSE, including the `AKP` key type *(draft)*
|
|
218
|
+
- [RFC 9864](https://datatracker.ietf.org/doc/rfc9864/) — Fully-Specified Algorithms for JOSE and COSE *(published October 2025)*
|
|
219
|
+
- [FIPS 204](https://csrc.nist.gov/pubs/fips/204/final) — ML-DSA itself *(final)*
|
|
220
|
+
|
|
221
|
+
See [SPEC.md](SPEC.md) for the full tracked-specs table, the current-draft revisions the shipped `alg`/`kty` values target, the hybrid-mode convention, known divergences, and the compatibility policy for draft transitions and the eventual RFC finalization.
|
|
222
|
+
|
|
223
|
+
## Thread safety
|
|
224
|
+
|
|
225
|
+
`JWT::PQ::Key` and `JWT::PQ::HybridKey` are safe to share across threads for `#sign`, `#verify`, `#destroy!`, and `Key#private_to_pem` (hybrid sign also covers the Ed25519 + ML-DSA compound atomically). Each instance carries its own mutex that serializes those operations against each other, so a concurrent `#destroy!` waits for any in-flight signing/verification/PEM export to finish before zeroing the private key material. Read-only exporters on immutable state (`#to_pem`, `#public_key`, `#algorithm`, `#jwk_thumbprint`) do not take the mutex.
|
|
226
|
+
|
|
227
|
+
The mutex cost is negligible against ML-DSA signing latency (~130–200 µs): single-threaded ML-DSA-65 throughput stays within run-to-run noise of the pre-mutex baseline (~7300 sigs/s on an i9-9880H). Under MRI the GVL already serializes signing within a single process; the mutex exists to guarantee atomicity against `destroy!`, not to extract parallelism.
|
|
228
|
+
|
|
229
|
+
**Fork caveat.** A Mutex held by a live thread at `fork(2)` time is inherited locked-with-no-owner in the child. If you fork workers (Puma, Unicorn, Resque, etc.), do so before any thread begins signing on a shared `Key`, or let each worker construct its own keys post-fork. Do not call `destroy!` on a parent-side key and then fork.
|
|
230
|
+
|
|
231
|
+
## Algorithm registration
|
|
232
|
+
|
|
233
|
+
`require "jwt/pq"` registers `ML-DSA-{44,65,87}` and `EdDSA+ML-DSA-{44,65,87}` with ruby-jwt via the **public** `JWT::JWA::SigningAlgorithm.register_algorithm` API — this is not a monkey-patch. The registration is idempotent and coexists with other custom algorithms (e.g. `jwt-eddsa`). Load order between `jwt` and `jwt/pq` does not matter.
|
|
234
|
+
|
|
235
|
+
## Security
|
|
236
|
+
|
|
237
|
+
See [SECURITY.md](SECURITY.md) for the supported-versions policy, vulnerability reporting process, and how upstream liboqs advisories are handled.
|
|
133
238
|
|
|
134
239
|
## Development
|
|
135
240
|
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
jwt-pq is pre-1.0. Only the latest minor release receives security fixes.
|
|
6
|
+
|
|
7
|
+
| Version | Supported |
|
|
8
|
+
|---------|-----------|
|
|
9
|
+
| 0.5.x | Yes |
|
|
10
|
+
| < 0.5 | No |
|
|
11
|
+
|
|
12
|
+
## Reporting a vulnerability
|
|
13
|
+
|
|
14
|
+
Please **do not** open a public GitHub issue for security problems.
|
|
15
|
+
|
|
16
|
+
Report privately via GitHub's [private vulnerability reporting](https://github.com/marcelopazzo/jwt-pq/security/advisories/new) or by email to `security@marcelopazzo.com`.
|
|
17
|
+
|
|
18
|
+
Include:
|
|
19
|
+
|
|
20
|
+
- Affected version(s)
|
|
21
|
+
- Reproduction steps or proof of concept
|
|
22
|
+
- Impact assessment (key recovery, signature forgery, denial of service, etc.)
|
|
23
|
+
|
|
24
|
+
You should receive an acknowledgement within **72 hours**. A fix or mitigation plan will follow within **14 days** for high-severity issues.
|
|
25
|
+
|
|
26
|
+
## Scope
|
|
27
|
+
|
|
28
|
+
In scope:
|
|
29
|
+
|
|
30
|
+
- Signature forgery, key recovery, or authentication bypass in `JWT::PQ::Key`, `JWT::PQ::HybridKey`, or the JWT algorithm adapters
|
|
31
|
+
- JWK/PEM parsers accepting malformed input in a way that leaks private key material or enables forgery
|
|
32
|
+
- Memory safety issues in the FFI layer (e.g. out-of-bounds reads through user-controlled inputs)
|
|
33
|
+
|
|
34
|
+
Out of scope (report upstream):
|
|
35
|
+
|
|
36
|
+
- Vulnerabilities in [liboqs](https://github.com/open-quantum-safe/liboqs) itself — report to the liboqs maintainers
|
|
37
|
+
- Vulnerabilities in [ruby-jwt](https://github.com/jwt/ruby-jwt), [ed25519](https://github.com/RubyCrypto/ed25519), or [pqc_asn1](https://github.com/msuliq/pqc_asn1) — report to those projects
|
|
38
|
+
|
|
39
|
+
## Upstream liboqs advisories
|
|
40
|
+
|
|
41
|
+
jwt-pq bundles a pinned version of liboqs (see `LIBOQS_VERSION` in `ext/jwt/pq/extconf.rb`, currently **0.15.0**, integrity-checked against a SHA-256 of the source tarball).
|
|
42
|
+
|
|
43
|
+
When [liboqs](https://github.com/open-quantum-safe/liboqs/security/advisories) publishes a security advisory affecting an algorithm we ship:
|
|
44
|
+
|
|
45
|
+
1. A patch release of jwt-pq will bump `LIBOQS_VERSION` to the fixed upstream version.
|
|
46
|
+
2. The release will be cut within **7 days** of the liboqs advisory for high-severity issues, **30 days** for medium/low.
|
|
47
|
+
3. The changelog entry and GitHub Release notes will link to the upstream advisory.
|
|
48
|
+
|
|
49
|
+
Users running with `--use-system-libraries` are responsible for upgrading the system liboqs themselves.
|
|
50
|
+
|
|
51
|
+
## Cryptographic notes
|
|
52
|
+
|
|
53
|
+
- ML-DSA operations (keygen, sign, verify) are delegated to liboqs in C. jwt-pq does not reimplement the algorithm.
|
|
54
|
+
- Private key material is wiped via `#destroy!` on `JWT::PQ::Key` / `JWT::PQ::HybridKey`, and during PEM import via `PqcAsn1::SecureBuffer`.
|
|
55
|
+
- jwt-pq does not perform manual constant-time comparisons on signatures or MACs — signature verification returns a boolean from liboqs, and there are no equality checks on key/signature bytes in the Ruby layer.
|
|
56
|
+
- Randomness for keygen and signing is sourced from liboqs' internal RNG (which uses the system CSPRNG).
|
data/SPEC.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Specification tracking
|
|
2
|
+
|
|
3
|
+
This document records which external specifications each jwt-pq release
|
|
4
|
+
targets, what its current divergences are (if any), and the compatibility
|
|
5
|
+
policy for drafts in flight.
|
|
6
|
+
|
|
7
|
+
## Tracked specifications — jwt-pq 0.5.x
|
|
8
|
+
|
|
9
|
+
Last reviewed: **2026-04-20**.
|
|
10
|
+
|
|
11
|
+
| Spec | Role in jwt-pq | Status |
|
|
12
|
+
|-----------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------|-----------------------------|
|
|
13
|
+
| [FIPS 204](https://csrc.nist.gov/pubs/fips/204/final) | ML-DSA itself — key generation, signing, verification, key sizes | Final (August 2024) |
|
|
14
|
+
| [RFC 9864](https://datatracker.ietf.org/doc/rfc9864/) | Fully-Specified Algorithms for JOSE and COSE — underlies the `AKP` key type concept | Final (October 2025) |
|
|
15
|
+
| [RFC 7515](https://www.rfc-editor.org/rfc/rfc7515) / [RFC 7517](https://www.rfc-editor.org/rfc/rfc7517) / [RFC 7518](https://www.rfc-editor.org/rfc/rfc7518) / [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) | JWS wire format, JWK structure, `alg` registration, JWK thumbprints | Final |
|
|
16
|
+
| [`draft-ietf-cose-dilithium`](https://datatracker.ietf.org/doc/draft-ietf-cose-dilithium/) | `AKP` (`"Algorithm Key Pair"`) JWK key type and ML-DSA JWS `alg` names (`ML-DSA-44/65/87`) | Internet-Draft, pre-RFC |
|
|
17
|
+
|
|
18
|
+
The hybrid mode (`EdDSA+ML-DSA-*`) is jwt-pq's own convention —
|
|
19
|
+
concatenated `Ed25519 || ML-DSA` signatures with a `pq_alg` header for
|
|
20
|
+
cross-impl disambiguation. There is no IETF draft that specifies this
|
|
21
|
+
exact shape for EdDSA+ML-DSA; it is implemented here as an interop-safe
|
|
22
|
+
stepping stone until the JOSE WG publishes one.
|
|
23
|
+
|
|
24
|
+
## Known divergences from the specs
|
|
25
|
+
|
|
26
|
+
None at the time of writing. jwt-pq implements the ML-DSA JOSE convention
|
|
27
|
+
as currently written in the draft. Any divergence introduced in the
|
|
28
|
+
future (e.g. to work around an ambiguous draft point) will be listed
|
|
29
|
+
here with a link to the commit that introduced it.
|
|
30
|
+
|
|
31
|
+
## Compatibility policy
|
|
32
|
+
|
|
33
|
+
Because `draft-ietf-cose-dilithium` is an evolving Internet-Draft and
|
|
34
|
+
not yet an RFC, the `alg`, `kty`, and `pub`/`priv` field semantics can
|
|
35
|
+
change between draft revisions. Our policy for adapting to them:
|
|
36
|
+
|
|
37
|
+
1. **Pre-1.0 (current).** A breaking change in the draft that requires
|
|
38
|
+
a wire-format change — renamed `kty`, renamed `alg`, field moved to
|
|
39
|
+
a different section — ships in a new **minor** version (`0.4 → 0.5`).
|
|
40
|
+
The CHANGELOG entry will call it out as breaking and name the draft
|
|
41
|
+
revision motivating the change.
|
|
42
|
+
|
|
43
|
+
2. **Post-1.0.** The same class of breaking change ships in a new
|
|
44
|
+
**major** version (`1.x → 2.x`). Minor versions may add support for
|
|
45
|
+
a new draft revision side-by-side with the old one if doing so does
|
|
46
|
+
not break wire-compatible deployments.
|
|
47
|
+
|
|
48
|
+
3. **Overlap window for in-flight drafts.** Where feasible we accept
|
|
49
|
+
both the previous and the current draft's field names on the import
|
|
50
|
+
side (`JWK.import`, `from_pem`) for one minor version following a
|
|
51
|
+
change, and emit only the current draft's form on the export side.
|
|
52
|
+
This gives operators a migration window without requiring a
|
|
53
|
+
flag-day rollout.
|
|
54
|
+
|
|
55
|
+
4. **Final RFC.** When `draft-ietf-cose-dilithium` is published as an
|
|
56
|
+
RFC, jwt-pq will bump to the RFC's final field names on the export
|
|
57
|
+
side in a minor (pre-1.0) or major (post-1.0) release, and document
|
|
58
|
+
the RFC reference here. The import side will continue accepting the
|
|
59
|
+
last draft revision for at least one further minor to ease
|
|
60
|
+
migration.
|
|
61
|
+
|
|
62
|
+
## Reporting divergences
|
|
63
|
+
|
|
64
|
+
If you find a place where jwt-pq's output does not match the spec,
|
|
65
|
+
please open an issue with:
|
|
66
|
+
|
|
67
|
+
- The jwt-pq version (`JWT::PQ::VERSION`)
|
|
68
|
+
- The spec section and line/field that disagrees
|
|
69
|
+
- A minimal example showing the difference (e.g. a JWK hash produced by
|
|
70
|
+
jwt-pq alongside what the spec requires)
|
|
71
|
+
|
|
72
|
+
Correctness issues are prioritized over feature work.
|
data/jwt-pq.gemspec
CHANGED
|
@@ -36,7 +36,7 @@ Gem::Specification.new do |spec|
|
|
|
36
36
|
|
|
37
37
|
spec.files = Dir.chdir(__dir__) do
|
|
38
38
|
`git ls-files -z`.split("\x0").reject do |f|
|
|
39
|
-
f.start_with?("spec/", "vendor/", ".github/", "bench/") ||
|
|
39
|
+
f.start_with?("spec/", "vendor/", ".github/", "bench/", "bin/") ||
|
|
40
40
|
f.match?(/\A(?:\.git|\.rspec|\.rubocop|jwt-pq-plan)/)
|
|
41
41
|
end
|
|
42
42
|
end
|
|
@@ -46,5 +46,8 @@ Gem::Specification.new do |spec|
|
|
|
46
46
|
|
|
47
47
|
spec.add_dependency "ffi", "~> 1.15"
|
|
48
48
|
spec.add_dependency "jwt", "~> 3.0"
|
|
49
|
-
|
|
49
|
+
# Pre-1.0 crypto dependency: tightened to `~> 0.1.0` (patch-only) so a
|
|
50
|
+
# 0.2.0 release with a potential API break does not silently upgrade.
|
|
51
|
+
# Bump manually after vetting each new minor.
|
|
52
|
+
spec.add_dependency "pqc_asn1", "~> 0.1.0"
|
|
50
53
|
end
|
|
@@ -5,11 +5,16 @@ require "jwt"
|
|
|
5
5
|
module JWT
|
|
6
6
|
module PQ
|
|
7
7
|
module Algorithms
|
|
8
|
+
# @api private
|
|
9
|
+
#
|
|
8
10
|
# JWT signing algorithm for hybrid EdDSA + ML-DSA signatures.
|
|
9
11
|
#
|
|
10
12
|
# The signature is a simple concatenation: ed25519_sig (64 bytes) || ml_dsa_sig.
|
|
11
13
|
# This allows PQ-aware verifiers to validate both, while the fixed 64-byte
|
|
12
14
|
# Ed25519 prefix makes it possible to split the signatures deterministically.
|
|
15
|
+
#
|
|
16
|
+
# Users interact with these algorithms via `JWT.encode`/`JWT.decode` by
|
|
17
|
+
# name (`"EdDSA+ML-DSA-*"`); they never instantiate this class directly.
|
|
13
18
|
class HybridEdDsa
|
|
14
19
|
include ::JWT::JWA::SigningAlgorithm
|
|
15
20
|
|
|
@@ -34,11 +39,11 @@ module JWT
|
|
|
34
39
|
end
|
|
35
40
|
raise_sign_error!("Both Ed25519 and ML-DSA private keys required") unless signing_key.private?
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
+
# Delegate to HybridKey#sign so the Ed25519 and ML-DSA halves
|
|
43
|
+
# are taken atomically under the hybrid key's mutex — a
|
|
44
|
+
# concurrent destroy! can no longer slip between the two
|
|
45
|
+
# component signatures.
|
|
46
|
+
signing_key.sign(data)
|
|
42
47
|
end
|
|
43
48
|
|
|
44
49
|
def verify(data:, signature:, verification_key:)
|
|
@@ -53,46 +58,32 @@ module JWT
|
|
|
53
58
|
ed_sig = signature.byteslice(0, ED25519_SIG_SIZE)
|
|
54
59
|
ml_sig = signature.byteslice(ED25519_SIG_SIZE..)
|
|
55
60
|
|
|
56
|
-
ed_valid =
|
|
57
|
-
verification_key.ed25519_verify_key.verify(ed_sig, data)
|
|
58
|
-
true
|
|
59
|
-
rescue Ed25519::VerifyError
|
|
60
|
-
false
|
|
61
|
-
end
|
|
62
|
-
|
|
61
|
+
ed_valid = safe_ed25519_verify(verification_key.ed25519_verify_key, ed_sig, data)
|
|
63
62
|
ml_valid = verification_key.ml_dsa_key.verify(data, ml_sig)
|
|
64
63
|
|
|
65
|
-
|
|
64
|
+
# Bitwise `&`, not `&&`: both checks are already computed above,
|
|
65
|
+
# and a bitwise AND over booleans has no short-circuit, so the
|
|
66
|
+
# final combinator does not branch on which half failed. This
|
|
67
|
+
# does not give a cryptographic constant-time guarantee (Ruby
|
|
68
|
+
# can't), but it removes the obvious observable path.
|
|
69
|
+
ed_valid & ml_valid
|
|
70
|
+
# :nocov: — defensive rescue; Key#verify returns bool, does not raise PQ::Error in practice
|
|
66
71
|
rescue JWT::PQ::Error
|
|
67
72
|
false
|
|
73
|
+
# :nocov:
|
|
68
74
|
end
|
|
69
75
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"Expected a JWT::PQ::HybridKey, got #{key.class}. " \
|
|
82
|
-
"Use JWT::PQ::HybridKey.generate to create a hybrid key."
|
|
83
|
-
)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def resolve_verification_key(key)
|
|
88
|
-
case key
|
|
89
|
-
when JWT::PQ::HybridKey
|
|
90
|
-
key
|
|
91
|
-
else
|
|
92
|
-
raise_verify_error!(
|
|
93
|
-
"Expected a JWT::PQ::HybridKey, got #{key.class}."
|
|
94
|
-
)
|
|
95
|
-
end
|
|
76
|
+
# Boolean-returning wrapper over `Ed25519::VerifyKey#verify`, which
|
|
77
|
+
# raises on failure. Gives the verify path a uniform shape for both
|
|
78
|
+
# halves (both produce a bool by assignment, rather than one via
|
|
79
|
+
# return value and one via rescue).
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
def safe_ed25519_verify(verify_key, signature, data)
|
|
83
|
+
verify_key.verify(signature, data)
|
|
84
|
+
true
|
|
85
|
+
rescue Ed25519::VerifyError
|
|
86
|
+
false
|
|
96
87
|
end
|
|
97
88
|
|
|
98
89
|
register_algorithm(new("EdDSA+ML-DSA-44"))
|
|
@@ -4,9 +4,17 @@ require "jwt"
|
|
|
4
4
|
|
|
5
5
|
module JWT
|
|
6
6
|
module PQ
|
|
7
|
+
# @api private
|
|
8
|
+
#
|
|
9
|
+
# ruby-jwt `SigningAlgorithm` implementations that register jwt-pq's
|
|
10
|
+
# algorithms on load. Not intended for direct use.
|
|
7
11
|
module Algorithms
|
|
12
|
+
# @api private
|
|
13
|
+
#
|
|
8
14
|
# JWT signing algorithm implementation for ML-DSA (FIPS 204).
|
|
9
15
|
# Registers ML-DSA-44, ML-DSA-65, and ML-DSA-87 with the ruby-jwt library.
|
|
16
|
+
# Users interact with these algorithms via `JWT.encode`/`JWT.decode` by
|
|
17
|
+
# name; they never instantiate this class directly.
|
|
10
18
|
class MlDsa
|
|
11
19
|
include ::JWT::JWA::SigningAlgorithm
|
|
12
20
|
|
|
@@ -27,8 +35,10 @@ module JWT
|
|
|
27
35
|
)
|
|
28
36
|
end
|
|
29
37
|
verification_key.verify(data, signature)
|
|
38
|
+
# :nocov: — defensive rescue; Key#verify returns bool, does not raise PQ::Error in practice
|
|
30
39
|
rescue JWT::PQ::Error
|
|
31
40
|
false
|
|
41
|
+
# :nocov:
|
|
32
42
|
end
|
|
33
43
|
|
|
34
44
|
private
|
|
@@ -46,18 +56,6 @@ module JWT
|
|
|
46
56
|
end
|
|
47
57
|
end
|
|
48
58
|
|
|
49
|
-
def resolve_verification_key(key)
|
|
50
|
-
case key
|
|
51
|
-
when JWT::PQ::Key
|
|
52
|
-
key
|
|
53
|
-
else
|
|
54
|
-
raise_verify_error!(
|
|
55
|
-
"Expected a JWT::PQ::Key, got #{key.class}. " \
|
|
56
|
-
"Use JWT::PQ::Key.generate(:#{alg_symbol}) to create a key."
|
|
57
|
-
)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
59
|
def alg_symbol
|
|
62
60
|
alg.downcase.tr("-", "_")
|
|
63
61
|
end
|
data/lib/jwt/pq/errors.rb
CHANGED
|
@@ -2,16 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module JWT
|
|
4
4
|
module PQ
|
|
5
|
+
# Base class for all jwt-pq errors.
|
|
5
6
|
class Error < StandardError; end
|
|
6
7
|
|
|
8
|
+
# Raised when liboqs reports a failure or is missing at load time.
|
|
7
9
|
class LiboqsError < Error; end
|
|
8
10
|
|
|
11
|
+
# Raised when a requested algorithm name is not supported.
|
|
9
12
|
class UnsupportedAlgorithmError < Error; end
|
|
10
13
|
|
|
14
|
+
# Raised for malformed keys, wrong key types, or invalid key material.
|
|
11
15
|
class KeyError < Error; end
|
|
12
16
|
|
|
17
|
+
# Raised when an optional runtime dependency (e.g. `ed25519` for hybrid
|
|
18
|
+
# mode) is needed but not installed.
|
|
13
19
|
class MissingDependencyError < Error; end
|
|
14
20
|
|
|
21
|
+
# Raised when a signing operation fails inside liboqs.
|
|
15
22
|
class SignatureError < Error; end
|
|
23
|
+
|
|
24
|
+
# Raised when a remote JWKS fetch fails — network error, timeout,
|
|
25
|
+
# non-2xx response, oversized body, or blocked URL (non-HTTPS, redirect).
|
|
26
|
+
# Parsing failures on the fetched body still surface as {KeyError}.
|
|
27
|
+
class JWKSFetchError < Error; end
|
|
16
28
|
end
|
|
17
29
|
end
|