jwt-pq 0.1.0 → 0.3.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: 50b794a7ae8858987b6ec5608deef2c56e1e7ae89dca00da9e8cfbea14999f06
4
- data.tar.gz: a2c66e4bca2430f9f443dcef4d2c8d6a92ce619576ae4c99f1fa61ddef7935aa
3
+ metadata.gz: 9fc1d22df85af9d7c753800dbd77e08ce874d7c1bf177aa9afafab54a3ef6aa9
4
+ data.tar.gz: 3f8d5ef4114c28e79eeb609eac5bcff0b5e5bbe0b4869006b20e1e076cf7142f
5
5
  SHA512:
6
- metadata.gz: ffb6709911168e143e1babc9d1c2209ab5ceaed573529b801f72ba1d20f13cd26575b8c6db6c754e2028c7e695fe492b5ce052fd5b6b002fa5ee7d530f0ea479
7
- data.tar.gz: a2f9e6837f2995d09c67576ab317358cbf32b23349416c127c2d11bbbeb91c5ea81d9c914e6f302bb23bf874cd1cd48714af903d9b663f7e4dea62bb04b135d8
6
+ metadata.gz: 333d7706c667a66e7858813a4de3b813d46c097b283822ed228f3fa23bd5a58fe67dfd0b2cc3a0b7a3e71aceac70ff604c3d8084871828585ce633beba6f7199
7
+ data.tar.gz: d4ba329c764b33f0aea5dd37f10d0c9eb9623eb34d999bc003382096a18c58e2b914ad51fdd7e472b0e400a5504690acb21744a922944ded0b67cb5ee9bf9e70
data/CHANGELOG.md CHANGED
@@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-04-19
11
+
12
+ ### Added
13
+
14
+ - 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`
15
+ - Verify-throughput benchmark at `bench/verify_throughput.rb`
16
+ - 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
17
+ - `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
18
+
19
+ ### Changed
20
+
21
+ - **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
22
+ - **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`
23
+ - `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
24
+
25
+ ### Dependencies
26
+
27
+ - Add `benchmark-ips ~> 2.14` as a development/test dependency (powers the bench harnesses)
28
+ - Bump `ruby/setup-ruby` from 1.299.0 to 1.301.0 (#2)
29
+
30
+ ## [0.2.0] - 2026-04-06
31
+
32
+ ### Added
33
+
34
+ - Vendored liboqs build — `gem install jwt-pq` now compiles liboqs from source automatically
35
+ - `Key#destroy!` and `HybridKey#destroy!` for explicit zeroization of private key material
36
+ - `--use-system-libraries` escape hatch for users with pre-installed liboqs
37
+ - `JWT_PQ_LIBOQS_SOURCE` env var for air-gapped environments
38
+ - Path traversal protection in tarball extraction (defense-in-depth)
39
+ - Smoke test job in CI (builds gem, installs, runs end-to-end verification)
40
+ - Weekly CI schedule to catch dependency breakage
41
+ - Dependabot for automated dependency updates
42
+ - Secret scanning and push protection
43
+ - Code coverage with SimpleCov and Codecov
44
+
45
+ ### Changed
46
+
47
+ - CMake and a C compiler (gcc/clang) are now required at install time
48
+ - `Key#inspect` and `HybridKey#inspect` no longer expose private key material
49
+ - `Key.resolve_algorithm` is now a private class method
50
+ - `JWK::ALGORITHMS` derived from `MlDsa::ALGORITHMS` (single source of truth)
51
+ - Pin CI actions to commit SHAs for security
52
+ - Use `Net::HTTP` instead of `URI.open` for tarball download
53
+ - Restrict CI workflow GITHUB_TOKEN permissions to `contents: read`
54
+
55
+ ### Fixed
56
+
57
+ - ML-DSA verify with invalid key type now raises `DecodeError` instead of `EncodeError`
58
+ - JWK import now validates missing `pub` field and malformed base64url input
59
+ - FFI memory holding secret keys is now zeroed after use
60
+
61
+ ### Dependencies
62
+
63
+ - Bump codecov/codecov-action from 5.5.4 to 6.0.0
64
+
8
65
  ## [0.1.0] - 2026-04-04
9
66
 
10
67
  ### Added
@@ -18,3 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
75
  - Concatenated signature format: Ed25519 (64B) || ML-DSA
19
76
  - Optional dependency on jwt-eddsa / ed25519
20
77
  - Error classes: `LiboqsError`, `KeyError`, `SignatureError`, `MissingDependencyError`
78
+
79
+ [Unreleased]: https://github.com/marcelopazzo/jwt-pq/compare/v0.3.0...HEAD
80
+ [0.3.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.2.0...v0.3.0
81
+ [0.2.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.1.0...v0.2.0
82
+ [0.1.0]: https://github.com/marcelopazzo/jwt-pq/releases/tag/v0.1.0
data/Gemfile CHANGED
@@ -5,6 +5,8 @@ source "https://rubygems.org"
5
5
  gemspec
6
6
 
7
7
  group :development, :test do
8
+ gem "benchmark-ips", "~> 2.14"
9
+ gem "rake"
8
10
  gem "rspec", "~> 3.13"
9
11
  gem "rubocop", "~> 1.75"
10
12
  gem "rubocop-rspec", "~> 3.0"
@@ -13,4 +15,5 @@ end
13
15
  group :test do
14
16
  gem "jwt-eddsa", "~> 0.9"
15
17
  gem "simplecov", require: false
18
+ gem "simplecov-cobertura", require: false
16
19
  end
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # jwt-pq
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/jwt-pq.svg)](https://rubygems.org/gems/jwt-pq)
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
+ [![codecov](https://codecov.io/gh/marcelopazzo/jwt-pq/graph/badge.svg)](https://codecov.io/gh/marcelopazzo/jwt-pq)
6
+
3
7
  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.
4
8
 
5
9
  ## Features
@@ -13,27 +17,7 @@ Post-quantum JWT signatures for Ruby. Adds **ML-DSA** (FIPS 204) support to the
13
17
  ## Requirements
14
18
 
15
19
  - Ruby >= 3.2
16
- - [liboqs](https://github.com/open-quantum-safe/liboqs) (shared library)
17
-
18
- ### Installing liboqs
19
-
20
- ```bash
21
- # macOS
22
- brew install cmake ninja
23
- git clone --depth 1 https://github.com/open-quantum-safe/liboqs
24
- cd liboqs && mkdir build && cd build
25
- cmake -GNinja -DBUILD_SHARED_LIBS=ON ..
26
- ninja && sudo ninja install
27
-
28
- # Ubuntu / Debian
29
- sudo apt-get install cmake ninja-build
30
- git clone --depth 1 https://github.com/open-quantum-safe/liboqs
31
- cd liboqs && mkdir build && cd build
32
- cmake -GNinja -DBUILD_SHARED_LIBS=ON ..
33
- ninja && sudo ninja install && sudo ldconfig
34
- ```
35
-
36
- You can also set the `OQS_LIB` environment variable to point to a custom `liboqs.so` / `liboqs.dylib` path.
20
+ - CMake >= 3.15 and a C compiler — gcc or clang (for building the bundled liboqs)
37
21
 
38
22
  ## Installation
39
23
 
@@ -45,6 +29,22 @@ gem "jwt-pq"
45
29
  gem "jwt-eddsa"
46
30
  ```
47
31
 
32
+ liboqs is automatically compiled from source during gem installation (ML-DSA algorithms only, ~30 seconds).
33
+
34
+ ### Using system liboqs
35
+
36
+ If you prefer to use a system-installed liboqs:
37
+
38
+ ```bash
39
+ gem install jwt-pq -- --use-system-libraries
40
+ # or
41
+ JWT_PQ_USE_SYSTEM_LIBRARIES=1 gem install jwt-pq
42
+ # or in Bundler
43
+ bundle config build.jwt-pq --use-system-libraries
44
+ ```
45
+
46
+ You can also point to a specific library with `OQS_LIB=/path/to/liboqs.dylib`.
47
+
48
48
  ## Usage
49
49
 
50
50
  ### Basic ML-DSA signing
@@ -133,9 +133,10 @@ The `alg` header values follow a `ClassicAlg+PQAlg` convention. The IETF draft `
133
133
  ## Development
134
134
 
135
135
  ```bash
136
- bundle install
137
- OQS_LIB=/path/to/liboqs.dylib bundle exec rspec
138
- bundle exec rubocop
136
+ bundle install # compiles liboqs automatically
137
+ bundle exec rspec # run tests
138
+ bundle exec rubocop # lint
139
+ rake compile # recompile liboqs manually
139
140
  ```
140
141
 
141
142
  ## License
data/Rakefile CHANGED
@@ -5,4 +5,12 @@ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
+ desc "Compile the liboqs extension"
9
+ task :compile do
10
+ Dir.chdir("ext/jwt/pq") do
11
+ ruby "extconf.rb"
12
+ sh "make"
13
+ end
14
+ end
15
+
8
16
  task default: :spec
@@ -0,0 +1,128 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ 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
127
+ aEqLBCQtoXub0y7W
128
+ -----END PRIVATE KEY-----
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark/ips"
4
+ require "jwt"
5
+ require "jwt/pq"
6
+
7
+ ALG = "ML-DSA-65"
8
+ PAYLOAD = { sub: "user-123", iat: 1_700_000_000, exp: 1_700_003_600 }.freeze
9
+ FIXTURE = File.expand_path("fixtures/ml_dsa_65_sk.pem", __dir__)
10
+
11
+ abort "Missing bench fixture: #{FIXTURE}" unless File.exist?(FIXTURE)
12
+ key = JWT::PQ::Key.from_pem(File.read(FIXTURE))
13
+
14
+ 100.times { JWT.encode(PAYLOAD, key, ALG) }
15
+
16
+ report = Benchmark.ips(quiet: true) do |x|
17
+ x.config(time: 5, warmup: 2)
18
+ x.report("sign") { JWT.encode(PAYLOAD, key, ALG) }
19
+ end
20
+
21
+ entry = report.entries.first
22
+ ips = entry.stats.central_tendency
23
+ us_per_op = 1_000_000.0 / ips
24
+
25
+ puts "METRIC sigs_per_sec=#{ips.round(2)}"
26
+ puts "METRIC us_per_sig=#{us_per_op.round(2)}"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark/ips"
4
+ require "jwt"
5
+ require "jwt/pq"
6
+
7
+ ALG = "ML-DSA-65"
8
+ PAYLOAD = { sub: "user-123", iat: 1_700_000_000, exp: 1_700_003_600 }.freeze
9
+ FIXTURE = File.expand_path("fixtures/ml_dsa_65_sk.pem", __dir__)
10
+
11
+ abort "Missing bench fixture: #{FIXTURE}" unless File.exist?(FIXTURE)
12
+ key = JWT::PQ::Key.from_pem(File.read(FIXTURE))
13
+ pub_key = JWT::PQ::Key.from_public_key(ALG, key.public_key)
14
+
15
+ TOKEN = JWT.encode(PAYLOAD, key, ALG)
16
+
17
+ 100.times { JWT.decode(TOKEN, pub_key, true, algorithms: [ALG], verify_expiration: false) }
18
+
19
+ report = Benchmark.ips(quiet: true) do |x|
20
+ x.config(time: 5, warmup: 2)
21
+ x.report("verify") { JWT.decode(TOKEN, pub_key, true, algorithms: [ALG], verify_expiration: false) }
22
+ end
23
+
24
+ entry = report.entries.first
25
+ ips = entry.stats.central_tendency
26
+ us_per_op = 1_000_000.0 / ips
27
+
28
+ puts "METRIC verifies_per_sec=#{ips.round(2)}"
29
+ puts "METRIC us_per_verify=#{us_per_op.round(2)}"
data/bin/smoke.rb ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "jwt/pq"
5
+
6
+ puts "jwt-pq v#{JWT::PQ::VERSION} smoke test"
7
+
8
+ # ML-DSA-65 sign/verify round-trip
9
+ key = JWT::PQ::Key.generate(:ml_dsa_65)
10
+ token = JWT.encode({ "sub" => "smoke-test" }, key, "ML-DSA-65")
11
+ decoded = JWT.decode(token, key, true, algorithms: ["ML-DSA-65"])
12
+
13
+ raise "Decode failed" unless decoded.first["sub"] == "smoke-test"
14
+
15
+ puts "ML-DSA-65 sign/verify: OK"
16
+
17
+ # PEM round-trip
18
+ pub_pem = key.to_pem
19
+ restored = JWT::PQ::Key.from_pem(pub_pem)
20
+ decoded = JWT.decode(token, restored, true, algorithms: ["ML-DSA-65"])
21
+
22
+ raise "PEM round-trip failed" unless decoded.first["sub"] == "smoke-test"
23
+
24
+ puts "PEM round-trip: OK"
25
+
26
+ # JWK round-trip
27
+ jwk = JWT::PQ::JWK.new(key)
28
+ imported = JWT::PQ::JWK.import(jwk.export)
29
+ decoded = JWT.decode(token, imported, true, algorithms: ["ML-DSA-65"])
30
+
31
+ raise "JWK round-trip failed" unless decoded.first["sub"] == "smoke-test"
32
+
33
+ puts "JWK round-trip: OK"
34
+
35
+ # Key destroy
36
+ key.destroy!
37
+ raise "destroy! failed" if key.private?
38
+
39
+ puts "Key#destroy!: OK"
40
+ puts "All smoke tests passed"
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "fileutils"
5
+ require "net/http"
6
+ require "uri"
7
+ require "digest"
8
+ require "rubygems/package"
9
+ require "zlib"
10
+ require "etc"
11
+ require "tmpdir"
12
+
13
+ LIBOQS_VERSION = "0.15.0"
14
+ LIBOQS_SHA256 = "3983f7cd1247f37fb76a040e6fd684894d44a84cecdcfbdb90559b3216684b5c"
15
+ LIBOQS_URL = "https://github.com/open-quantum-safe/liboqs/archive/refs/tags/#{LIBOQS_VERSION}.tar.gz"
16
+
17
+ PACKAGE_ROOT_DIR = File.expand_path("../../..", __dir__)
18
+ VENDOR_DIR = File.join(PACKAGE_ROOT_DIR, "lib", "jwt", "pq", "vendor")
19
+
20
+ def write_dummy_makefile
21
+ File.write("Makefile", <<~MAKEFILE)
22
+ all:
23
+ \t@echo "jwt-pq: nothing to compile"
24
+ install:
25
+ \t@echo "jwt-pq: nothing to install"
26
+ clean:
27
+ \t@echo "jwt-pq: nothing to clean"
28
+ MAKEFILE
29
+ end
30
+
31
+ def use_system_libraries?
32
+ ENV.key?("JWT_PQ_USE_SYSTEM_LIBRARIES") ||
33
+ enable_config("system-libraries", false)
34
+ end
35
+
36
+ def check_cmake!
37
+ cmake = find_executable("cmake")
38
+ unless cmake
39
+ abort <<~MSG
40
+ ERROR: cmake is required to compile liboqs for jwt-pq.
41
+
42
+ Install it with:
43
+ macOS: brew install cmake
44
+ Ubuntu: sudo apt-get install cmake
45
+
46
+ Alternatively, install liboqs manually and use:
47
+ gem install jwt-pq -- --use-system-libraries
48
+ MSG
49
+ end
50
+ cmake
51
+ end
52
+
53
+ def download_via_http(url, dest_path, redirect_limit = 5)
54
+ abort "ERROR: too many redirects downloading liboqs" if redirect_limit.zero?
55
+
56
+ uri = URI.parse(url)
57
+ response = Net::HTTP.get_response(uri)
58
+
59
+ case response
60
+ when Net::HTTPSuccess
61
+ File.binwrite(dest_path, response.body)
62
+ when Net::HTTPRedirection
63
+ download_via_http(response["location"], dest_path, redirect_limit - 1)
64
+ else
65
+ abort "ERROR: failed to download liboqs (HTTP #{response.code}): #{uri}"
66
+ end
67
+ end
68
+
69
+ def download_source(dest_dir)
70
+ tarball_path = File.join(dest_dir, "liboqs-#{LIBOQS_VERSION}.tar.gz")
71
+
72
+ # Support local tarball via env var (for air-gapped environments)
73
+ source = ENV.fetch("JWT_PQ_LIBOQS_SOURCE", LIBOQS_URL)
74
+
75
+ $stdout.puts "jwt-pq: downloading liboqs #{LIBOQS_VERSION}..."
76
+ if source.start_with?("http")
77
+ download_via_http(source, tarball_path)
78
+ else
79
+ FileUtils.cp(source, tarball_path)
80
+ end
81
+
82
+ # Verify checksum
83
+ actual = Digest::SHA256.file(tarball_path).hexdigest
84
+ unless actual == LIBOQS_SHA256
85
+ abort "ERROR: SHA-256 mismatch for liboqs tarball.\n" \
86
+ " Expected: #{LIBOQS_SHA256}\n" \
87
+ " Got: #{actual}"
88
+ end
89
+
90
+ tarball_path
91
+ end
92
+
93
+ def extract_tarball(tarball_path, dest_dir)
94
+ $stdout.puts "jwt-pq: extracting liboqs #{LIBOQS_VERSION}..."
95
+ File.open(tarball_path, "rb") do |file|
96
+ Zlib::GzipReader.wrap(file) do |gz|
97
+ Gem::Package::TarReader.new(gz) do |tar|
98
+ tar.each do |entry|
99
+ dest = File.expand_path(File.join(dest_dir, entry.full_name))
100
+ unless dest.start_with?("#{File.expand_path(dest_dir)}/")
101
+ abort "ERROR: path traversal detected in tarball: #{entry.full_name}"
102
+ end
103
+
104
+ if entry.directory?
105
+ FileUtils.mkdir_p(dest)
106
+ elsif entry.file?
107
+ FileUtils.mkdir_p(File.dirname(dest))
108
+ File.binwrite(dest, entry.read)
109
+ File.chmod(entry.header.mode, dest) if entry.header.mode && entry.header.mode > 0
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ File.join(dest_dir, "liboqs-#{LIBOQS_VERSION}")
117
+ end
118
+
119
+ def build_liboqs(source_dir)
120
+ build_dir = File.join(source_dir, "build")
121
+ FileUtils.mkdir_p(build_dir)
122
+ FileUtils.mkdir_p(VENDOR_DIR)
123
+
124
+ nproc = begin
125
+ Etc.nprocessors
126
+ rescue StandardError
127
+ 2
128
+ end
129
+
130
+ # Semicolons are cmake list separators (safe here — system() uses execvp, no shell)
131
+ cmake_args = %W[
132
+ -DBUILD_SHARED_LIBS=ON
133
+ -DOQS_MINIMAL_BUILD=SIG_ml_dsa_44;SIG_ml_dsa_65;SIG_ml_dsa_87
134
+ -DOQS_BUILD_ONLY_LIB=ON
135
+ -DOQS_USE_OPENSSL=OFF
136
+ -DCMAKE_BUILD_TYPE=Release
137
+ -DCMAKE_INSTALL_PREFIX=#{VENDOR_DIR}
138
+ -DCMAKE_POSITION_INDEPENDENT_CODE=ON
139
+ ]
140
+
141
+ # Use Ninja if available (faster), fall back to Make
142
+ if find_executable("ninja")
143
+ cmake_args << "-GNinja"
144
+ end
145
+
146
+ $stdout.puts "jwt-pq: configuring liboqs #{LIBOQS_VERSION} (ML-DSA only)..."
147
+ unless system("cmake", "-S", source_dir, "-B", build_dir, *cmake_args)
148
+ abort "ERROR: cmake configure failed for liboqs"
149
+ end
150
+
151
+ $stdout.puts "jwt-pq: compiling liboqs (using #{nproc} cores)..."
152
+ unless system("cmake", "--build", build_dir, "--parallel", nproc.to_s)
153
+ abort "ERROR: cmake build failed for liboqs"
154
+ end
155
+
156
+ $stdout.puts "jwt-pq: installing liboqs to vendor directory..."
157
+ unless system("cmake", "--install", build_dir)
158
+ abort "ERROR: cmake install failed for liboqs"
159
+ end
160
+ end
161
+
162
+ def find_vendored_library
163
+ %w[dylib so].each do |ext|
164
+ path = File.join(VENDOR_DIR, "lib", "liboqs.#{ext}")
165
+ return path if File.exist?(path)
166
+ end
167
+ nil
168
+ end
169
+
170
+ # --- Main ---
171
+
172
+ if use_system_libraries?
173
+ $stdout.puts "jwt-pq: using system liboqs (--use-system-libraries)"
174
+ write_dummy_makefile
175
+ exit 0
176
+ end
177
+
178
+ check_cmake!
179
+
180
+ Dir.mktmpdir("jwt-pq-build") do |tmp_dir|
181
+ tarball = download_source(tmp_dir)
182
+ source_dir = extract_tarball(tarball, tmp_dir)
183
+ build_liboqs(source_dir)
184
+ end
185
+
186
+ lib_path = find_vendored_library
187
+ if lib_path
188
+ $stdout.puts "jwt-pq: liboqs #{LIBOQS_VERSION} installed at #{lib_path}"
189
+ else
190
+ abort "ERROR: liboqs shared library not found after build"
191
+ end
192
+
193
+ write_dummy_makefile
data/jwt-pq.gemspec CHANGED
@@ -24,12 +24,14 @@ Gem::Specification.new do |spec|
24
24
  "rubygems_mfa_required" => "true"
25
25
  }
26
26
 
27
- spec.requirements = ["liboqs >= 0.15.0 (shared library) — https://github.com/open-quantum-safe/liboqs"]
27
+ spec.requirements = ["cmake >= 3.15", "C compiler (gcc or clang)"]
28
28
 
29
29
  spec.post_install_message = <<~MSG
30
- jwt-pq requires liboqs (shared library) to be installed on your system.
31
- See https://github.com/marcelopazzo/jwt-pq#installing-liboqs for instructions.
32
- For hybrid EdDSA+ML-DSA mode, also add 'jwt-eddsa' to your Gemfile.
30
+ jwt-pq compiles liboqs from source during installation.
31
+ If the build failed, ensure cmake and a C compiler are installed.
32
+ To use a system-installed liboqs instead:
33
+ gem install jwt-pq -- --use-system-libraries
34
+ For hybrid EdDSA+ML-DSA mode, add 'jwt-eddsa' to your Gemfile.
33
35
  MSG
34
36
 
35
37
  spec.files = Dir.chdir(__dir__) do
@@ -39,6 +41,7 @@ Gem::Specification.new do |spec|
39
41
  end
40
42
  end
41
43
 
44
+ spec.extensions = ["ext/jwt/pq/extconf.rb"]
42
45
  spec.require_paths = ["lib"]
43
46
 
44
47
  spec.add_dependency "ffi", "~> 1.15"
@@ -15,23 +15,28 @@ module JWT
15
15
  end
16
16
 
17
17
  def sign(data:, signing_key:)
18
- key = resolve_key(signing_key)
19
- raise_sign_error!("Private key required for signing") unless key.private?
18
+ key = resolve_signing_key(signing_key)
20
19
  key.sign(data)
21
20
  end
22
21
 
23
22
  def verify(data:, signature:, verification_key:)
24
- key = resolve_key(verification_key)
25
- 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)
26
30
  rescue JWT::PQ::Error
27
31
  false
28
32
  end
29
33
 
30
34
  private
31
35
 
32
- def resolve_key(key)
36
+ def resolve_signing_key(key)
33
37
  case key
34
38
  when JWT::PQ::Key
39
+ raise_sign_error!("Private key required for signing") unless key.private?
35
40
  key
36
41
  else
37
42
  raise_sign_error!(
@@ -41,6 +46,18 @@ module JWT
41
46
  end
42
47
  end
43
48
 
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
+
44
61
  def alg_symbol
45
62
  alg.downcase.tr("-", "_")
46
63
  end
@@ -51,6 +51,23 @@ module JWT
51
51
  "EdDSA+#{@ml_dsa_key.algorithm}"
52
52
  end
53
53
 
54
+ # Zero and discard private key material from both key components.
55
+ # After calling this, the key can only be used for verification.
56
+ def destroy!
57
+ @ml_dsa_key.destroy!
58
+ if @ed25519_signing_key
59
+ seed = @ed25519_signing_key.to_bytes
60
+ seed.replace("\0" * seed.bytesize)
61
+ @ed25519_signing_key = nil
62
+ end
63
+ true
64
+ end
65
+
66
+ def inspect
67
+ "#<#{self.class} algorithm=#{hybrid_algorithm} private=#{private?}>"
68
+ end
69
+ alias to_s inspect
70
+
54
71
  def self.require_eddsa_dependency!
55
72
  require "ed25519"
56
73
  rescue LoadError
data/lib/jwt/pq/jwk.rb CHANGED
@@ -13,7 +13,7 @@ module JWT
13
13
  # pub: base64url-encoded public key
14
14
  # priv: base64url-encoded private key (optional)
15
15
  class JWK
16
- ALGORITHMS = %w[ML-DSA-44 ML-DSA-65 ML-DSA-87].freeze
16
+ ALGORITHMS = MlDsa::ALGORITHMS.keys.freeze
17
17
  KTY = "AKP"
18
18
 
19
19
  attr_reader :key
@@ -44,10 +44,12 @@ module JWT
44
44
 
45
45
  validate_kty!(jwk)
46
46
  alg = validate_alg!(jwk)
47
- pub_bytes = base64url_decode(jwk["pub"])
47
+ raise KeyError, "Missing 'pub' in JWK" unless jwk.key?("pub")
48
+
49
+ pub_bytes = decode_field(jwk, "pub")
48
50
 
49
51
  if jwk.key?("priv")
50
- priv_bytes = base64url_decode(jwk["priv"])
52
+ priv_bytes = decode_field(jwk, "priv")
51
53
  Key.new(algorithm: alg, public_key: pub_bytes, private_key: priv_bytes)
52
54
  else
53
55
  Key.new(algorithm: alg, public_key: pub_bytes)
@@ -83,10 +85,12 @@ module JWT
83
85
  end
84
86
  private_class_method :normalize_keys
85
87
 
86
- def self.base64url_decode(str)
87
- ::Base64.urlsafe_decode64(str)
88
+ def self.decode_field(jwk, field)
89
+ ::Base64.urlsafe_decode64(jwk[field])
90
+ rescue ArgumentError => e
91
+ raise KeyError, "Invalid base64url in JWK '#{field}': #{e.message}"
88
92
  end
89
- private_class_method :base64url_decode
93
+ private_class_method :decode_field
90
94
 
91
95
  private
92
96
 
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.
@@ -63,6 +63,23 @@ module JWT
63
63
  !@private_key.nil?
64
64
  end
65
65
 
66
+ # Zero and discard private key material from Ruby memory.
67
+ # After calling this, the key can only be used for verification.
68
+ def destroy!
69
+ if @private_key
70
+ @private_key.replace("\0" * @private_key.bytesize)
71
+ @private_key = nil
72
+ end
73
+ @sk_buffer&.clear
74
+ @sk_buffer = nil
75
+ true
76
+ end
77
+
78
+ def inspect
79
+ "#<#{self.class} algorithm=#{@algorithm} private=#{private?}>"
80
+ end
81
+ alias to_s inspect
82
+
66
83
  # Import a Key from a PEM string (SPKI or PKCS#8).
67
84
  def self.from_pem(pem_string)
68
85
  info = PqcAsn1::DER.parse_pem(pem_string)
@@ -134,12 +151,20 @@ module JWT
134
151
  new(algorithm: alg_name, public_key: info.public_key, private_key: sk_bytes)
135
152
  end
136
153
 
137
- private_class_method :extract_secure_bytes, :resolve_oid!, :build_from_pkcs8
154
+ private_class_method :resolve_algorithm, :extract_secure_bytes, :resolve_oid!, :build_from_pkcs8
138
155
 
139
156
  private
140
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
+
141
166
  def resolve_algorithm(algorithm)
142
- self.class.resolve_algorithm(algorithm)
167
+ self.class.send(:resolve_algorithm, algorithm)
143
168
  end
144
169
 
145
170
  def validate!
data/lib/jwt/pq/liboqs.rb CHANGED
@@ -6,29 +6,46 @@ module JWT
6
6
  module PQ
7
7
  # FFI bindings for liboqs signature operations.
8
8
  #
9
- # The library search order:
9
+ # Library search order:
10
10
  # 1. OQS_LIB environment variable (explicit path)
11
- # 2. System-installed liboqs (via standard library search)
11
+ # 2. Vendored liboqs (compiled during gem install)
12
+ # 3. System-installed liboqs (via standard library search)
12
13
  module LibOQS
13
14
  extend FFI::Library
14
15
 
15
16
  OQS_SUCCESS = 0
16
17
  OQS_ERROR = -1
17
18
 
18
- # Determine library path
19
19
  def self.lib_path
20
+ # 1. Explicit path from environment
20
21
  return ENV["OQS_LIB"] if ENV["OQS_LIB"]
21
22
 
23
+ # 2. Vendored library (compiled during gem install)
24
+ vendored = vendored_lib_path
25
+ return vendored if vendored
26
+
27
+ # 3. System library
22
28
  "oqs"
23
29
  end
24
30
 
31
+ def self.vendored_lib_path
32
+ %w[dylib so].each do |ext|
33
+ path = File.join(__dir__, "vendor", "lib", "liboqs.#{ext}")
34
+ return path if File.exist?(path)
35
+ end
36
+ nil
37
+ end
38
+ private_class_method :vendored_lib_path
39
+
25
40
  begin
26
41
  ffi_lib lib_path
27
42
  rescue LoadError => e
28
43
  raise JWT::PQ::LiboqsError,
29
- "liboqs not found. Install it via: brew install liboqs (macOS) or " \
30
- "apt install liboqs-dev (Ubuntu). You can also set OQS_LIB to the " \
31
- "full path of the shared library. Original error: #{e.message}"
44
+ "liboqs not found. The vendored library may not have been compiled " \
45
+ "during gem install. Ensure cmake and a C compiler are installed, " \
46
+ "then reinstall: gem install jwt-pq. Alternatively, install liboqs " \
47
+ "manually and set OQS_LIB to the full path. " \
48
+ "Original error: #{e.message}"
32
49
  end
33
50
 
34
51
  # OQS_SIG *OQS_SIG_new(const char *method_name)
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)
@@ -39,6 +67,7 @@ module JWT
39
67
 
40
68
  [pk.read_bytes(@sizes[:public_key]), sk.read_bytes(@sizes[:secret_key])]
41
69
  ensure
70
+ sk&.clear
42
71
  LibOQS.OQS_SIG_free(sig) if sig && !sig.null?
43
72
  end
44
73
 
@@ -47,23 +76,28 @@ module JWT
47
76
  def sign(message, secret_key)
48
77
  validate_key_size!(secret_key, :secret_key)
49
78
 
50
- sig = LibOQS.OQS_SIG_new(@algorithm)
51
- 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
52
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)
53
92
  sig_buf = FFI::MemoryPointer.new(:uint8, @sizes[:signature])
54
93
  sig_len = FFI::MemoryPointer.new(:size_t)
55
94
  msg_buf = FFI::MemoryPointer.from_string(message)
56
- sk_buf = FFI::MemoryPointer.new(:uint8, secret_key.bytesize)
57
- sk_buf.put_bytes(0, secret_key)
58
95
 
59
96
  status = LibOQS.OQS_SIG_sign(sig, sig_buf, sig_len,
60
97
  msg_buf, message.bytesize, sk_buf)
61
98
  raise SignatureError, "Signing failed for #{@algorithm}" unless status == LibOQS::OQS_SUCCESS
62
99
 
63
- actual_len = sig_len.read(:size_t)
64
- sig_buf.read_bytes(actual_len)
65
- ensure
66
- LibOQS.OQS_SIG_free(sig) if sig && !sig.null?
100
+ sig_buf.read_bytes(sig_len.read(:size_t))
67
101
  end
68
102
 
69
103
  # Verify a signature against a message and public key.
@@ -71,20 +105,24 @@ module JWT
71
105
  def verify(message, signature, public_key)
72
106
  validate_key_size!(public_key, :public_key)
73
107
 
74
- sig = LibOQS.OQS_SIG_new(@algorithm)
75
- 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
76
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)
77
119
  msg_buf = FFI::MemoryPointer.from_string(message)
78
120
  sig_buf = FFI::MemoryPointer.new(:uint8, signature.bytesize)
79
121
  sig_buf.put_bytes(0, signature)
80
- pk_buf = FFI::MemoryPointer.new(:uint8, public_key.bytesize)
81
- pk_buf.put_bytes(0, public_key)
82
122
 
83
123
  status = LibOQS.OQS_SIG_verify(sig, msg_buf, message.bytesize,
84
124
  sig_buf, signature.bytesize, pk_buf)
85
125
  status == LibOQS::OQS_SUCCESS
86
- ensure
87
- LibOQS.OQS_SIG_free(sig) if sig && !sig.null?
88
126
  end
89
127
 
90
128
  # Key sizes for this algorithm
@@ -2,6 +2,6 @@
2
2
 
3
3
  module JWT
4
4
  module PQ
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marcelo Almeida
@@ -57,7 +57,8 @@ description: Adds ML-DSA-44, ML-DSA-65, and ML-DSA-87 post-quantum signature alg
57
57
  email:
58
58
  - contact@marcelopazzo.com
59
59
  executables: []
60
- extensions: []
60
+ extensions:
61
+ - ext/jwt/pq/extconf.rb
61
62
  extra_rdoc_files: []
62
63
  files:
63
64
  - CHANGELOG.md
@@ -65,6 +66,11 @@ files:
65
66
  - LICENSE
66
67
  - README.md
67
68
  - Rakefile
69
+ - bench/fixtures/ml_dsa_65_sk.pem
70
+ - bench/sign_throughput.rb
71
+ - bench/verify_throughput.rb
72
+ - bin/smoke.rb
73
+ - ext/jwt/pq/extconf.rb
68
74
  - jwt-pq.gemspec
69
75
  - lib/jwt/pq.rb
70
76
  - lib/jwt/pq/algorithms/hybrid_eddsa.rb
@@ -86,9 +92,11 @@ metadata:
86
92
  documentation_uri: https://rubydoc.info/gems/jwt-pq
87
93
  rubygems_mfa_required: 'true'
88
94
  post_install_message: |
89
- jwt-pq requires liboqs (shared library) to be installed on your system.
90
- See https://github.com/marcelopazzo/jwt-pq#installing-liboqs for instructions.
91
- For hybrid EdDSA+ML-DSA mode, also add 'jwt-eddsa' to your Gemfile.
95
+ jwt-pq compiles liboqs from source during installation.
96
+ If the build failed, ensure cmake and a C compiler are installed.
97
+ To use a system-installed liboqs instead:
98
+ gem install jwt-pq -- --use-system-libraries
99
+ For hybrid EdDSA+ML-DSA mode, add 'jwt-eddsa' to your Gemfile.
92
100
  rdoc_options: []
93
101
  require_paths:
94
102
  - lib
@@ -103,7 +111,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
111
  - !ruby/object:Gem::Version
104
112
  version: '0'
105
113
  requirements:
106
- - liboqs >= 0.15.0 (shared library) — https://github.com/open-quantum-safe/liboqs
114
+ - cmake >= 3.15
115
+ - C compiler (gcc or clang)
107
116
  rubygems_version: 3.6.9
108
117
  specification_version: 4
109
118
  summary: Post-quantum JWT signatures (ML-DSA / FIPS 204) for Ruby