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 +4 -4
- data/CHANGELOG.md +50 -1
- data/Gemfile +1 -0
- data/README.md +1 -0
- data/jwt-pq.gemspec +1 -1
- data/lib/jwt/pq/algorithms/hybrid_eddsa.rb +20 -10
- data/lib/jwt/pq/algorithms/ml_dsa.rb +7 -2
- data/lib/jwt/pq/key.rb +13 -3
- data/lib/jwt/pq/ml_dsa.rb +51 -15
- data/lib/jwt/pq/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d662c64af196cc33ed4b288a641442317fe49881eef85bfadc2385c0bee7e63f
|
|
4
|
+
data.tar.gz: 599a753241ae95a168461d1a7f61b2a2b2147da13f1456479758873657fb18f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
data/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/jwt-pq)
|
|
4
4
|
[](https://github.com/marcelopazzo/jwt-pq/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/marcelopazzo/jwt-pq/actions/workflows/interop.yml)
|
|
5
6
|
[](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
|
-
|
|
25
|
+
@header
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def sign(data:, signing_key:)
|
|
27
|
-
|
|
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 =
|
|
30
|
-
ml_sig =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
data/lib/jwt/pq/version.rb
CHANGED