jwt-pq 0.2.0 → 0.4.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: cc80be78087261982368d78df297e89720290aa2c1252ed7b3b1f44298dd4856
4
- data.tar.gz: f4c95127b8f0aab465029c1eedff1e0ef37c6e1342348f3286bc396e26ef6961
3
+ metadata.gz: d662c64af196cc33ed4b288a641442317fe49881eef85bfadc2385c0bee7e63f
4
+ data.tar.gz: 599a753241ae95a168461d1a7f61b2a2b2147da13f1456479758873657fb18f5
5
5
  SHA512:
6
- metadata.gz: 3498399528c54d624c93ca00f1c6a884cb6db6591dd861cc26409aa58502a8ecf1ae1bd6901c7fb5b8d1ebe44cd5462661757b299dc8f7127100b60d9153d5ed
7
- data.tar.gz: 22a25bf0c7f3562f226a88832dba44ee164964328dcf2c2841a0c6ea286a585bd7f85c5237d5356d6272fc1ff3ef1ca3cf63f5b4057e91ef8895a22a2d75ff3b
6
+ metadata.gz: c360bb8b3b5a8fdad530e3cba2f6d3630999d635669d85e5ac57854f746bbf8250559073d258ca2aa95f0f443208aded8c90b68a15f75f58a00370963fa5c943
7
+ data.tar.gz: e57d739e3567413f845a685caf55305d7704120fee29adf31ab09cbb0a85cbc2b6c0713ad4e85db73c65c1238d323ab04ab5e079adef06f2918a2114fb06aa96
data/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-04-19
11
+
12
+ ### Added
13
+
14
+ - Hybrid-sign throughput benchmark at `bench/hybrid_sign_throughput.rb`
15
+ - Hybrid-verify throughput benchmark at `bench/hybrid_verify_throughput.rb`
16
+ - Parameterized `bench/sign_throughput.rb` and `bench/verify_throughput.rb` via `ALG` env var — previously hardcoded to `ML-DSA-65`, now supports all three security levels
17
+ - PEM key fixtures for ML-DSA-44 and ML-DSA-87 under `bench/fixtures/`
18
+ - `bench/generate_fixtures.rb` to regenerate bench fixtures idempotently
19
+ - Cross-implementation interop CI against `dilithium-py` (independent pure-Python ML-DSA / FIPS 204 implementation) — runs on push, PR, and weekly
20
+
21
+ ### Changed
22
+
23
+ - **Hybrid EdDSA+ML-DSA-65 sign throughput: +12.1%** (5200 → 5831 sigs/s on Ruby 3.4.6 + liboqs 0.15.0). Inline type-check in `HybridEdDsa#sign` (+1.6%) plus cached frozen header hash and precomputed `ml_dsa_algorithm` at init (+10.4%) — `#header` is called once per `JWT.encode`, so eliminating the per-call Hash allocation and `String#sub` compounds noticeably.
24
+ - **Hybrid EdDSA+ML-DSA-65 verify throughput: +2.3%** (4812 → 4923 verifies/s). Inline type-check in `HybridEdDsa#verify`, mirroring the sign-side pattern.
25
+ - `bench/` directory no longer packaged into the published gem (smaller install footprint).
26
+
27
+ ### Benchmarks
28
+
29
+ Throughput on Ruby 3.4.6, macOS x86_64, liboqs 0.15.0 (benchmark-ips, 2s warmup + 5s measurement):
30
+
31
+ | Algorithm | Sign | Verify |
32
+ |------------|----------:|-----------:|
33
+ | ML-DSA-44 | 9678 ops/s | 12650 ops/s |
34
+ | ML-DSA-65 | 6236 ops/s | 8567 ops/s |
35
+ | ML-DSA-87 | 3591 ops/s | 6510 ops/s |
36
+
37
+ ## [0.3.0] - 2026-04-19
38
+
39
+ ### Added
40
+
41
+ - Sign-throughput benchmark at `bench/sign_throughput.rb` with a fixed PEM key fixture (`bench/fixtures/ml_dsa_65_sk.pem`), driven by `benchmark-ips`
42
+ - Verify-throughput benchmark at `bench/verify_throughput.rb`
43
+ - NIST ACVP sigVer KAT tests at `spec/jwt/pq/kat_spec.rb` — external interface, pure ML-DSA, empty context; covers ML-DSA-44, ML-DSA-65, and ML-DSA-87 with both passing and known-bad signatures as a canonical correctness gate
44
+ - `JWT::PQ::MlDsa#sign_with_sk_buffer` and `#verify_with_pk_buffer` — fast paths that accept pre-populated FFI buffers. The existing bytes-in `#sign` / `#verify` APIs are unchanged
45
+
46
+ ### Changed
47
+
48
+ - **ML-DSA signing throughput: +2.6%** (from 6676 to 6849 sigs/s on Ruby 3.4.6 + liboqs 0.15.0 for ML-DSA-65). Class-level cache of the `OQS_SIG` handle per algorithm avoids `OQS_SIG_new`/`OQS_SIG_free` per call; per-`Key` memoization of the secret-key FFI buffer avoids a 4032-byte allocation + copy per sign
49
+ - **ML-DSA verification throughput: +19.4%** (from 7995 to 9548 verifies/s on the same setup for ML-DSA-65). Class-level cache of the `OQS_SIG` handle for verify; per-`Key` memoization of the public-key FFI buffer; inlined type-check in the JWA verify entry point. `Key#verify` now reaches 93% of the raw `OQS_SIG_verify` ceiling; remaining overhead lives inside `ruby-jwt`
50
+ - `Key#destroy!` now also zeroes the cached secret-key FFI buffer (`@sk_buffer`) in addition to `@private_key`, preserving the secure-erase contract after the buffer memoization
51
+
52
+ ### Dependencies
53
+
54
+ - Add `benchmark-ips ~> 2.14` as a development/test dependency (powers the bench harnesses)
55
+ - Bump `ruby/setup-ruby` from 1.299.0 to 1.301.0 (#2)
56
+
10
57
  ## [0.2.0] - 2026-04-06
11
58
 
12
59
  ### Added
@@ -56,6 +103,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
56
103
  - Optional dependency on jwt-eddsa / ed25519
57
104
  - Error classes: `LiboqsError`, `KeyError`, `SignatureError`, `MissingDependencyError`
58
105
 
59
- [Unreleased]: https://github.com/marcelopazzo/jwt-pq/compare/v0.2.0...HEAD
106
+ [Unreleased]: https://github.com/marcelopazzo/jwt-pq/compare/v0.4.0...HEAD
107
+ [0.4.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.3.0...v0.4.0
108
+ [0.3.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.2.0...v0.3.0
60
109
  [0.2.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.1.0...v0.2.0
61
110
  [0.1.0]: https://github.com/marcelopazzo/jwt-pq/releases/tag/v0.1.0
data/Gemfile CHANGED
@@ -5,6 +5,7 @@ source "https://rubygems.org"
5
5
  gemspec
6
6
 
7
7
  group :development, :test do
8
+ gem "benchmark-ips", "~> 2.14"
8
9
  gem "rake"
9
10
  gem "rspec", "~> 3.13"
10
11
  gem "rubocop", "~> 1.75"
data/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/jwt-pq.svg)](https://rubygems.org/gems/jwt-pq)
4
4
  [![CI](https://github.com/marcelopazzo/jwt-pq/actions/workflows/ci.yml/badge.svg)](https://github.com/marcelopazzo/jwt-pq/actions/workflows/ci.yml)
5
+ [![Cross-interop](https://github.com/marcelopazzo/jwt-pq/actions/workflows/interop.yml/badge.svg)](https://github.com/marcelopazzo/jwt-pq/actions/workflows/interop.yml)
5
6
  [![codecov](https://codecov.io/gh/marcelopazzo/jwt-pq/graph/badge.svg)](https://codecov.io/gh/marcelopazzo/jwt-pq)
6
7
 
7
8
  Post-quantum JWT signatures for Ruby. Adds **ML-DSA** (FIPS 204) support to the [ruby-jwt](https://github.com/jwt/ruby-jwt) ecosystem, with an optional **hybrid EdDSA + ML-DSA** mode.
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/") ||
39
+ f.start_with?("spec/", "vendor/", ".github/", "bench/") ||
40
40
  f.match?(/\A(?:\.git|\.rspec|\.rubocop|jwt-pq-plan)/)
41
41
  end
42
42
  end
@@ -17,24 +17,36 @@ module JWT
17
17
 
18
18
  def initialize(alg)
19
19
  @alg = alg
20
+ @ml_dsa_algorithm = alg.sub("EdDSA+", "")
21
+ @header = { "alg" => alg, "pq_alg" => @ml_dsa_algorithm }.freeze
20
22
  end
21
23
 
22
24
  def header(*)
23
- { "alg" => alg, "pq_alg" => ml_dsa_algorithm }
25
+ @header
24
26
  end
25
27
 
26
28
  def sign(data:, signing_key:)
27
- key = resolve_signing_key(signing_key)
29
+ unless signing_key.is_a?(JWT::PQ::HybridKey)
30
+ raise_sign_error!(
31
+ "Expected a JWT::PQ::HybridKey, got #{signing_key.class}. " \
32
+ "Use JWT::PQ::HybridKey.generate to create a hybrid key."
33
+ )
34
+ end
35
+ raise_sign_error!("Both Ed25519 and ML-DSA private keys required") unless signing_key.private?
28
36
 
29
- ed_sig = key.ed25519_signing_key.sign(data)
30
- ml_sig = key.ml_dsa_key.sign(data)
37
+ ed_sig = signing_key.ed25519_signing_key.sign(data)
38
+ ml_sig = signing_key.ml_dsa_key.sign(data)
31
39
 
32
40
  # Concatenate: Ed25519 (64 bytes) || ML-DSA (variable)
33
41
  ed_sig + ml_sig
34
42
  end
35
43
 
36
44
  def verify(data:, signature:, verification_key:)
37
- key = resolve_verification_key(verification_key)
45
+ unless verification_key.is_a?(JWT::PQ::HybridKey)
46
+ raise_verify_error!(
47
+ "Expected a JWT::PQ::HybridKey, got #{verification_key.class}."
48
+ )
49
+ end
38
50
 
39
51
  return false if signature.bytesize <= ED25519_SIG_SIZE
40
52
 
@@ -42,13 +54,13 @@ module JWT
42
54
  ml_sig = signature.byteslice(ED25519_SIG_SIZE..)
43
55
 
44
56
  ed_valid = begin
45
- key.ed25519_verify_key.verify(ed_sig, data)
57
+ verification_key.ed25519_verify_key.verify(ed_sig, data)
46
58
  true
47
59
  rescue Ed25519::VerifyError
48
60
  false
49
61
  end
50
62
 
51
- ml_valid = key.ml_dsa_key.verify(data, ml_sig)
63
+ ml_valid = verification_key.ml_dsa_key.verify(data, ml_sig)
52
64
 
53
65
  ed_valid && ml_valid
54
66
  rescue JWT::PQ::Error
@@ -57,9 +69,7 @@ module JWT
57
69
 
58
70
  private
59
71
 
60
- def ml_dsa_algorithm
61
- alg.sub("EdDSA+", "")
62
- end
72
+ attr_reader :ml_dsa_algorithm
63
73
 
64
74
  def resolve_signing_key(key)
65
75
  case key
@@ -20,8 +20,13 @@ module JWT
20
20
  end
21
21
 
22
22
  def verify(data:, signature:, verification_key:)
23
- key = resolve_verification_key(verification_key)
24
- key.verify(data, signature)
23
+ unless verification_key.is_a?(JWT::PQ::Key)
24
+ raise_verify_error!(
25
+ "Expected a JWT::PQ::Key, got #{verification_key.class}. " \
26
+ "Use JWT::PQ::Key.generate(:#{alg_symbol}) to create a key."
27
+ )
28
+ end
29
+ verification_key.verify(data, signature)
25
30
  rescue JWT::PQ::Error
26
31
  false
27
32
  end
data/lib/jwt/pq/key.rb CHANGED
@@ -6,7 +6,7 @@ module JWT
6
6
  module PQ
7
7
  # Represents an ML-DSA keypair (public + optional private key).
8
8
  # Used as the signing/verification key for JWT operations.
9
- class Key
9
+ class Key # rubocop:disable Metrics/ClassLength
10
10
  ALGORITHM_ALIASES = {
11
11
  ml_dsa_44: "ML-DSA-44",
12
12
  ml_dsa_65: "ML-DSA-65",
@@ -50,12 +50,12 @@ module JWT
50
50
  def sign(data)
51
51
  raise KeyError, "Private key not available — cannot sign" unless @private_key
52
52
 
53
- @ml_dsa.sign(data, @private_key)
53
+ @ml_dsa.sign_with_sk_buffer(data, sk_buffer)
54
54
  end
55
55
 
56
56
  # Verify a signature using the public key.
57
57
  def verify(data, signature)
58
- @ml_dsa.verify(data, signature, @public_key)
58
+ @ml_dsa.verify_with_pk_buffer(data, signature, pk_buffer)
59
59
  end
60
60
 
61
61
  # Whether this key can be used for signing.
@@ -70,6 +70,8 @@ module JWT
70
70
  @private_key.replace("\0" * @private_key.bytesize)
71
71
  @private_key = nil
72
72
  end
73
+ @sk_buffer&.clear
74
+ @sk_buffer = nil
73
75
  true
74
76
  end
75
77
 
@@ -153,6 +155,14 @@ module JWT
153
155
 
154
156
  private
155
157
 
158
+ def sk_buffer
159
+ @sk_buffer ||= FFI::MemoryPointer.new(:uint8, @private_key.bytesize).put_bytes(0, @private_key)
160
+ end
161
+
162
+ def pk_buffer
163
+ @pk_buffer ||= FFI::MemoryPointer.new(:uint8, @public_key.bytesize).put_bytes(0, @public_key)
164
+ end
165
+
156
166
  def resolve_algorithm(algorithm)
157
167
  self.class.send(:resolve_algorithm, algorithm)
158
168
  end
data/lib/jwt/pq/ml_dsa.rb CHANGED
@@ -11,6 +11,34 @@ module JWT
11
11
  "ML-DSA-87" => { public_key: 2592, secret_key: 4896, signature: 4627, nist_level: 5 }
12
12
  }.freeze
13
13
 
14
+ @sign_handles = {}
15
+ @sign_handles_mutex = Mutex.new
16
+
17
+ def self.sign_handle(algorithm)
18
+ @sign_handles[algorithm] || @sign_handles_mutex.synchronize do
19
+ @sign_handles[algorithm] ||= begin
20
+ h = LibOQS.OQS_SIG_new(algorithm)
21
+ raise LiboqsError, "Failed to initialize #{algorithm}" if h.null?
22
+
23
+ h
24
+ end
25
+ end
26
+ end
27
+
28
+ @verify_handles = {}
29
+ @verify_handles_mutex = Mutex.new
30
+
31
+ def self.verify_handle(algorithm)
32
+ @verify_handles[algorithm] || @verify_handles_mutex.synchronize do
33
+ @verify_handles[algorithm] ||= begin
34
+ h = LibOQS.OQS_SIG_new(algorithm)
35
+ raise LiboqsError, "Failed to initialize #{algorithm}" if h.null?
36
+
37
+ h
38
+ end
39
+ end
40
+ end
41
+
14
42
  attr_reader :algorithm
15
43
 
16
44
  def initialize(algorithm)
@@ -48,24 +76,28 @@ module JWT
48
76
  def sign(message, secret_key)
49
77
  validate_key_size!(secret_key, :secret_key)
50
78
 
51
- sig = LibOQS.OQS_SIG_new(@algorithm)
52
- raise LiboqsError, "Failed to initialize #{@algorithm}" if sig.null?
79
+ sk_buf = FFI::MemoryPointer.new(:uint8, secret_key.bytesize)
80
+ sk_buf.put_bytes(0, secret_key)
81
+ sign_with_sk_buffer(message, sk_buf)
82
+ ensure
83
+ sk_buf&.clear
84
+ end
53
85
 
86
+ # Faster sign path: takes a pre-populated FFI::MemoryPointer holding the
87
+ # secret key. Caller is responsible for buffer lifecycle (allocation,
88
+ # zeroing). Used by JWT::PQ::Key to avoid re-allocating+copying the
89
+ # secret key on every sign call.
90
+ def sign_with_sk_buffer(message, sk_buf)
91
+ sig = self.class.sign_handle(@algorithm)
54
92
  sig_buf = FFI::MemoryPointer.new(:uint8, @sizes[:signature])
55
93
  sig_len = FFI::MemoryPointer.new(:size_t)
56
94
  msg_buf = FFI::MemoryPointer.from_string(message)
57
- sk_buf = FFI::MemoryPointer.new(:uint8, secret_key.bytesize)
58
- sk_buf.put_bytes(0, secret_key)
59
95
 
60
96
  status = LibOQS.OQS_SIG_sign(sig, sig_buf, sig_len,
61
97
  msg_buf, message.bytesize, sk_buf)
62
98
  raise SignatureError, "Signing failed for #{@algorithm}" unless status == LibOQS::OQS_SUCCESS
63
99
 
64
- actual_len = sig_len.read(:size_t)
65
- sig_buf.read_bytes(actual_len)
66
- ensure
67
- sk_buf&.clear
68
- LibOQS.OQS_SIG_free(sig) if sig && !sig.null?
100
+ sig_buf.read_bytes(sig_len.read(:size_t))
69
101
  end
70
102
 
71
103
  # Verify a signature against a message and public key.
@@ -73,20 +105,24 @@ module JWT
73
105
  def verify(message, signature, public_key)
74
106
  validate_key_size!(public_key, :public_key)
75
107
 
76
- sig = LibOQS.OQS_SIG_new(@algorithm)
77
- raise LiboqsError, "Failed to initialize #{@algorithm}" if sig.null?
108
+ pk_buf = FFI::MemoryPointer.new(:uint8, public_key.bytesize)
109
+ pk_buf.put_bytes(0, public_key)
110
+ verify_with_pk_buffer(message, signature, pk_buf)
111
+ end
78
112
 
113
+ # Faster verify path: takes a pre-populated FFI::MemoryPointer holding
114
+ # the public key. Caller is responsible for buffer lifecycle. Used by
115
+ # JWT::PQ::Key to avoid re-allocating+copying the public key on every
116
+ # verify call.
117
+ def verify_with_pk_buffer(message, signature, pk_buf)
118
+ sig = self.class.verify_handle(@algorithm)
79
119
  msg_buf = FFI::MemoryPointer.from_string(message)
80
120
  sig_buf = FFI::MemoryPointer.new(:uint8, signature.bytesize)
81
121
  sig_buf.put_bytes(0, signature)
82
- pk_buf = FFI::MemoryPointer.new(:uint8, public_key.bytesize)
83
- pk_buf.put_bytes(0, public_key)
84
122
 
85
123
  status = LibOQS.OQS_SIG_verify(sig, msg_buf, message.bytesize,
86
124
  sig_buf, signature.bytesize, pk_buf)
87
125
  status == LibOQS::OQS_SUCCESS
88
- ensure
89
- LibOQS.OQS_SIG_free(sig) if sig && !sig.null?
90
126
  end
91
127
 
92
128
  # Key sizes for this algorithm
@@ -2,6 +2,6 @@
2
2
 
3
3
  module JWT
4
4
  module PQ
5
- VERSION = "0.2.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marcelo Almeida