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 +4 -4
- data/CHANGELOG.md +41 -0
- data/Gemfile +2 -0
- data/README.md +25 -24
- data/Rakefile +8 -0
- data/bin/smoke.rb +40 -0
- data/ext/jwt/pq/extconf.rb +193 -0
- data/jwt-pq.gemspec +7 -4
- data/lib/jwt/pq/algorithms/ml_dsa.rb +16 -4
- data/lib/jwt/pq/hybrid_key.rb +17 -0
- data/lib/jwt/pq/jwk.rb +10 -6
- data/lib/jwt/pq/key.rb +17 -2
- data/lib/jwt/pq/liboqs.rb +23 -6
- data/lib/jwt/pq/ml_dsa.rb +2 -0
- data/lib/jwt/pq/version.rb +1 -1
- metadata +12 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc80be78087261982368d78df297e89720290aa2c1252ed7b3b1f44298dd4856
|
|
4
|
+
data.tar.gz: f4c95127b8f0aab465029c1eedff1e0ef37c6e1342348f3286bc396e26ef6961
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](https://rubygems.org/gems/jwt-pq)
|
|
4
|
+
[](https://github.com/marcelopazzo/jwt-pq/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
-
-
|
|
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
|
-
|
|
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
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 = ["
|
|
27
|
+
spec.requirements = ["cmake >= 3.15", "C compiler (gcc or clang)"]
|
|
28
28
|
|
|
29
29
|
spec.post_install_message = <<~MSG
|
|
30
|
-
jwt-pq
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
data/lib/jwt/pq/hybrid_key.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
87
|
-
::Base64.urlsafe_decode64(
|
|
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 :
|
|
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
|
|
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
|
-
#
|
|
9
|
+
# Library search order:
|
|
10
10
|
# 1. OQS_LIB environment variable (explicit path)
|
|
11
|
-
# 2.
|
|
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.
|
|
30
|
-
"
|
|
31
|
-
"
|
|
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
|
|
data/lib/jwt/pq/version.rb
CHANGED
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.
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
-
|
|
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
|