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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d662c64af196cc33ed4b288a641442317fe49881eef85bfadc2385c0bee7e63f
4
- data.tar.gz: 599a753241ae95a168461d1a7f61b2a2b2147da13f1456479758873657fb18f5
3
+ metadata.gz: 9f2d8f33a8210a14b1c5a15e8ae953b38b88e1d35c05d98cfad97e55943d8879
4
+ data.tar.gz: 1f486a208212534c7ae06721dbad8d11b28ca492795a6409c7a8ab0cd1dcda07
5
5
  SHA512:
6
- metadata.gz: c360bb8b3b5a8fdad530e3cba2f6d3630999d635669d85e5ac57854f746bbf8250559073d258ca2aa95f0f443208aded8c90b68a15f75f58a00370963fa5c943
7
- data.tar.gz: e57d739e3567413f845a685caf55305d7704120fee29adf31ab09cbb0a85cbc2b6c0713ad4e85db73c65c1238d323ab04ab5e079adef06f2918a2114fb06aa96
6
+ metadata.gz: 673adaee5ef237a31b2ce80a7c9a86442b494fcea875ebcb48648d6a42057b13a0afc46ece40a8d648c8b75e7ee25ccdc35a4daeba972850aaacf3d28ee1a014
7
+ data.tar.gz: c62aae707ac8ffe1b29d994d86a9e581fd634f6349f0345dab0fdad5674a12fd5289ea89b17b99143d97e6d4dd43e722ec1cedc41112aca80588acee2d56bf35
data/.yardopts ADDED
@@ -0,0 +1,9 @@
1
+ --markup markdown
2
+ --readme README.md
3
+ --files CHANGELOG.md,SECURITY.md
4
+ --output-dir doc
5
+ --no-private
6
+ --protected
7
+ lib/**/*.rb
8
+ -
9
+ LICENSE
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.4.0...HEAD
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
@@ -10,6 +10,7 @@ group :development, :test do
10
10
  gem "rspec", "~> 3.13"
11
11
  gem "rubocop", "~> 1.75"
12
12
  gem "rubocop-rspec", "~> 3.0"
13
+ gem "yard", "~> 0.9"
13
14
  end
14
15
 
15
16
  group :test do
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
- spec.add_dependency "pqc_asn1", "~> 0.1"
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
- ed_sig = signing_key.ed25519_signing_key.sign(data)
38
- ml_sig = signing_key.ml_dsa_key.sign(data)
39
-
40
- # Concatenate: Ed25519 (64 bytes) || ML-DSA (variable)
41
- ed_sig + ml_sig
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 = begin
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
- ed_valid && ml_valid
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
- private
71
-
72
- attr_reader :ml_dsa_algorithm
73
-
74
- def resolve_signing_key(key)
75
- case key
76
- when JWT::PQ::HybridKey
77
- raise_sign_error!("Both Ed25519 and ML-DSA private keys required") unless key.private?
78
- key
79
- else
80
- raise_sign_error!(
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