jwt-pq 0.1.0 → 0.2.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: cc80be78087261982368d78df297e89720290aa2c1252ed7b3b1f44298dd4856
4
+ data.tar.gz: f4c95127b8f0aab465029c1eedff1e0ef37c6e1342348f3286bc396e26ef6961
5
5
  SHA512:
6
- metadata.gz: ffb6709911168e143e1babc9d1c2209ab5ceaed573529b801f72ba1d20f13cd26575b8c6db6c754e2028c7e695fe492b5ce052fd5b6b002fa5ee7d530f0ea479
7
- data.tar.gz: a2f9e6837f2995d09c67576ab317358cbf32b23349416c127c2d11bbbeb91c5ea81d9c914e6f302bb23bf874cd1cd48714af903d9b663f7e4dea62bb04b135d8
6
+ metadata.gz: 3498399528c54d624c93ca00f1c6a884cb6db6591dd861cc26409aa58502a8ecf1ae1bd6901c7fb5b8d1ebe44cd5462661757b299dc8f7127100b60d9153d5ed
7
+ data.tar.gz: 22a25bf0c7f3562f226a88832dba44ee164964328dcf2c2841a0c6ea286a585bd7f85c5237d5356d6272fc1ff3ef1ca3cf63f5b4057e91ef8895a22a2d75ff3b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ 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.2.0] - 2026-04-06
11
+
12
+ ### Added
13
+
14
+ - Vendored liboqs build — `gem install jwt-pq` now compiles liboqs from source automatically
15
+ - `Key#destroy!` and `HybridKey#destroy!` for explicit zeroization of private key material
16
+ - `--use-system-libraries` escape hatch for users with pre-installed liboqs
17
+ - `JWT_PQ_LIBOQS_SOURCE` env var for air-gapped environments
18
+ - Path traversal protection in tarball extraction (defense-in-depth)
19
+ - Smoke test job in CI (builds gem, installs, runs end-to-end verification)
20
+ - Weekly CI schedule to catch dependency breakage
21
+ - Dependabot for automated dependency updates
22
+ - Secret scanning and push protection
23
+ - Code coverage with SimpleCov and Codecov
24
+
25
+ ### Changed
26
+
27
+ - CMake and a C compiler (gcc/clang) are now required at install time
28
+ - `Key#inspect` and `HybridKey#inspect` no longer expose private key material
29
+ - `Key.resolve_algorithm` is now a private class method
30
+ - `JWK::ALGORITHMS` derived from `MlDsa::ALGORITHMS` (single source of truth)
31
+ - Pin CI actions to commit SHAs for security
32
+ - Use `Net::HTTP` instead of `URI.open` for tarball download
33
+ - Restrict CI workflow GITHUB_TOKEN permissions to `contents: read`
34
+
35
+ ### Fixed
36
+
37
+ - ML-DSA verify with invalid key type now raises `DecodeError` instead of `EncodeError`
38
+ - JWK import now validates missing `pub` field and malformed base64url input
39
+ - FFI memory holding secret keys is now zeroed after use
40
+
41
+ ### Dependencies
42
+
43
+ - Bump codecov/codecov-action from 5.5.4 to 6.0.0
44
+
8
45
  ## [0.1.0] - 2026-04-04
9
46
 
10
47
  ### Added
@@ -18,3 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
55
  - Concatenated signature format: Ed25519 (64B) || ML-DSA
19
56
  - Optional dependency on jwt-eddsa / ed25519
20
57
  - Error classes: `LiboqsError`, `KeyError`, `SignatureError`, `MissingDependencyError`
58
+
59
+ [Unreleased]: https://github.com/marcelopazzo/jwt-pq/compare/v0.2.0...HEAD
60
+ [0.2.0]: https://github.com/marcelopazzo/jwt-pq/compare/v0.1.0...v0.2.0
61
+ [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 "rake"
8
9
  gem "rspec", "~> 3.13"
9
10
  gem "rubocop", "~> 1.75"
10
11
  gem "rubocop-rspec", "~> 3.0"
@@ -13,4 +14,5 @@ end
13
14
  group :test do
14
15
  gem "jwt-eddsa", "~> 0.9"
15
16
  gem "simplecov", require: false
17
+ gem "simplecov-cobertura", require: false
16
18
  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
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,13 +15,12 @@ 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)
23
+ key = resolve_verification_key(verification_key)
25
24
  key.verify(data, signature)
26
25
  rescue JWT::PQ::Error
27
26
  false
@@ -29,9 +28,10 @@ module JWT
29
28
 
30
29
  private
31
30
 
32
- def resolve_key(key)
31
+ def resolve_signing_key(key)
33
32
  case key
34
33
  when JWT::PQ::Key
34
+ raise_sign_error!("Private key required for signing") unless key.private?
35
35
  key
36
36
  else
37
37
  raise_sign_error!(
@@ -41,6 +41,18 @@ module JWT
41
41
  end
42
42
  end
43
43
 
44
+ def resolve_verification_key(key)
45
+ case key
46
+ when JWT::PQ::Key
47
+ key
48
+ else
49
+ raise_verify_error!(
50
+ "Expected a JWT::PQ::Key, got #{key.class}. " \
51
+ "Use JWT::PQ::Key.generate(:#{alg_symbol}) to create a key."
52
+ )
53
+ end
54
+ end
55
+
44
56
  def alg_symbol
45
57
  alg.downcase.tr("-", "_")
46
58
  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
@@ -63,6 +63,21 @@ 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
+ true
74
+ end
75
+
76
+ def inspect
77
+ "#<#{self.class} algorithm=#{@algorithm} private=#{private?}>"
78
+ end
79
+ alias to_s inspect
80
+
66
81
  # Import a Key from a PEM string (SPKI or PKCS#8).
67
82
  def self.from_pem(pem_string)
68
83
  info = PqcAsn1::DER.parse_pem(pem_string)
@@ -134,12 +149,12 @@ module JWT
134
149
  new(algorithm: alg_name, public_key: info.public_key, private_key: sk_bytes)
135
150
  end
136
151
 
137
- private_class_method :extract_secure_bytes, :resolve_oid!, :build_from_pkcs8
152
+ private_class_method :resolve_algorithm, :extract_secure_bytes, :resolve_oid!, :build_from_pkcs8
138
153
 
139
154
  private
140
155
 
141
156
  def resolve_algorithm(algorithm)
142
- self.class.resolve_algorithm(algorithm)
157
+ self.class.send(:resolve_algorithm, algorithm)
143
158
  end
144
159
 
145
160
  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
@@ -39,6 +39,7 @@ module JWT
39
39
 
40
40
  [pk.read_bytes(@sizes[:public_key]), sk.read_bytes(@sizes[:secret_key])]
41
41
  ensure
42
+ sk&.clear
42
43
  LibOQS.OQS_SIG_free(sig) if sig && !sig.null?
43
44
  end
44
45
 
@@ -63,6 +64,7 @@ module JWT
63
64
  actual_len = sig_len.read(:size_t)
64
65
  sig_buf.read_bytes(actual_len)
65
66
  ensure
67
+ sk_buf&.clear
66
68
  LibOQS.OQS_SIG_free(sig) if sig && !sig.null?
67
69
  end
68
70
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module JWT
4
4
  module PQ
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.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.2.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,8 @@ files:
65
66
  - LICENSE
66
67
  - README.md
67
68
  - Rakefile
69
+ - bin/smoke.rb
70
+ - ext/jwt/pq/extconf.rb
68
71
  - jwt-pq.gemspec
69
72
  - lib/jwt/pq.rb
70
73
  - lib/jwt/pq/algorithms/hybrid_eddsa.rb
@@ -86,9 +89,11 @@ metadata:
86
89
  documentation_uri: https://rubydoc.info/gems/jwt-pq
87
90
  rubygems_mfa_required: 'true'
88
91
  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.
92
+ jwt-pq compiles liboqs from source during installation.
93
+ If the build failed, ensure cmake and a C compiler are installed.
94
+ To use a system-installed liboqs instead:
95
+ gem install jwt-pq -- --use-system-libraries
96
+ For hybrid EdDSA+ML-DSA mode, add 'jwt-eddsa' to your Gemfile.
92
97
  rdoc_options: []
93
98
  require_paths:
94
99
  - lib
@@ -103,7 +108,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
108
  - !ruby/object:Gem::Version
104
109
  version: '0'
105
110
  requirements:
106
- - liboqs >= 0.15.0 (shared library) — https://github.com/open-quantum-safe/liboqs
111
+ - cmake >= 3.15
112
+ - C compiler (gcc or clang)
107
113
  rubygems_version: 3.6.9
108
114
  specification_version: 4
109
115
  summary: Post-quantum JWT signatures (ML-DSA / FIPS 204) for Ruby