jwt-pq 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/Gemfile +16 -0
- data/LICENSE +21 -0
- data/README.md +143 -0
- data/Rakefile +8 -0
- data/jwt-pq.gemspec +47 -0
- data/lib/jwt/pq/algorithms/hybrid_eddsa.rb +94 -0
- data/lib/jwt/pq/algorithms/ml_dsa.rb +54 -0
- data/lib/jwt/pq/errors.rb +17 -0
- data/lib/jwt/pq/hybrid_key.rb +70 -0
- data/lib/jwt/pq/jwk.rb +98 -0
- data/lib/jwt/pq/key.rb +164 -0
- data/lib/jwt/pq/liboqs.rb +56 -0
- data/lib/jwt/pq/ml_dsa.rb +115 -0
- data/lib/jwt/pq/version.rb +7 -0
- data/lib/jwt/pq.rb +23 -0
- metadata +110 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 50b794a7ae8858987b6ec5608deef2c56e1e7ae89dca00da9e8cfbea14999f06
|
|
4
|
+
data.tar.gz: a2c66e4bca2430f9f443dcef4d2c8d6a92ce619576ae4c99f1fa61ddef7935aa
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ffb6709911168e143e1babc9d1c2209ab5ceaed573529b801f72ba1d20f13cd26575b8c6db6c754e2028c7e695fe492b5ce052fd5b6b002fa5ee7d530f0ea479
|
|
7
|
+
data.tar.gz: a2f9e6837f2995d09c67576ab317358cbf32b23349416c127c2d11bbbeb91c5ea81d9c914e6f302bb23bf874cd1cd48714af903d9b663f7e4dea62bb04b135d8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-04-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- ML-DSA-44, ML-DSA-65, and ML-DSA-87 signature algorithms via liboqs FFI
|
|
13
|
+
- JWT signing/verification through `JWT.encode` / `JWT.decode` (ruby-jwt >= 3.0)
|
|
14
|
+
- `JWT::PQ::Key` for keypair generation and management
|
|
15
|
+
- PEM serialization (SPKI/PKCS#8) via pqc_asn1
|
|
16
|
+
- JWK export/import (kty: "AKP") with RFC 7638 thumbprints
|
|
17
|
+
- Hybrid EdDSA + ML-DSA mode (`EdDSA+ML-DSA-{44,65,87}`)
|
|
18
|
+
- Concatenated signature format: Ed25519 (64B) || ML-DSA
|
|
19
|
+
- Optional dependency on jwt-eddsa / ed25519
|
|
20
|
+
- Error classes: `LiboqsError`, `KeyError`, `SignatureError`, `MissingDependencyError`
|
data/Gemfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gemspec
|
|
6
|
+
|
|
7
|
+
group :development, :test do
|
|
8
|
+
gem "rspec", "~> 3.13"
|
|
9
|
+
gem "rubocop", "~> 1.75"
|
|
10
|
+
gem "rubocop-rspec", "~> 3.0"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
group :test do
|
|
14
|
+
gem "jwt-eddsa", "~> 0.9"
|
|
15
|
+
gem "simplecov", require: false
|
|
16
|
+
end
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcelo Almeida
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# jwt-pq
|
|
2
|
+
|
|
3
|
+
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
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ML-DSA-44, ML-DSA-65, and ML-DSA-87 algorithms
|
|
8
|
+
- Hybrid EdDSA + ML-DSA dual signatures
|
|
9
|
+
- Drop-in integration with `JWT.encode` / `JWT.decode`
|
|
10
|
+
- PEM serialization (SPKI / PKCS#8) via [pqc_asn1](https://github.com/msuliq/pqc_asn1)
|
|
11
|
+
- JWK export/import with RFC 7638 thumbprints
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# Gemfile
|
|
42
|
+
gem "jwt-pq"
|
|
43
|
+
|
|
44
|
+
# For hybrid EdDSA + ML-DSA mode (optional):
|
|
45
|
+
gem "jwt-eddsa"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Basic ML-DSA signing
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
require "jwt/pq"
|
|
54
|
+
|
|
55
|
+
key = JWT::PQ::Key.generate(:ml_dsa_65)
|
|
56
|
+
|
|
57
|
+
# Encode
|
|
58
|
+
token = JWT.encode({ sub: "1234" }, key, "ML-DSA-65")
|
|
59
|
+
|
|
60
|
+
# Decode
|
|
61
|
+
decoded = JWT.decode(token, key, true, algorithms: ["ML-DSA-65"])
|
|
62
|
+
decoded.first # => { "sub" => "1234" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Verify with public key only
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
pub_key = JWT::PQ::Key.from_public_key("ML-DSA-65", key.public_key)
|
|
69
|
+
JWT.decode(token, pub_key, true, algorithms: ["ML-DSA-65"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Hybrid EdDSA + ML-DSA
|
|
73
|
+
|
|
74
|
+
Requires `jwt-eddsa` gem.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
require "jwt/pq"
|
|
78
|
+
|
|
79
|
+
hybrid_key = JWT::PQ::HybridKey.generate(:ml_dsa_65)
|
|
80
|
+
|
|
81
|
+
token = JWT.encode({ sub: "1234" }, hybrid_key, "EdDSA+ML-DSA-65")
|
|
82
|
+
|
|
83
|
+
# Verify — both Ed25519 and ML-DSA signatures must be valid
|
|
84
|
+
decoded = JWT.decode(token, hybrid_key, true, algorithms: ["EdDSA+ML-DSA-65"])
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The hybrid signature is a concatenation of `Ed25519 (64 bytes) || ML-DSA`, stored in the standard JWT signature field. The JWT header includes `"pq_alg": "ML-DSA-65"`.
|
|
88
|
+
|
|
89
|
+
### PEM serialization
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# Export
|
|
93
|
+
pub_pem = key.to_pem # SPKI format
|
|
94
|
+
priv_pem = key.private_to_pem # PKCS#8 format
|
|
95
|
+
|
|
96
|
+
# Import
|
|
97
|
+
pub_key = JWT::PQ::Key.from_pem(pub_pem)
|
|
98
|
+
full_key = JWT::PQ::Key.from_pem_pair(public_pem: pub_pem, private_pem: priv_pem)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### JWK
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
jwk = JWT::PQ::JWK.new(key)
|
|
105
|
+
|
|
106
|
+
# Export
|
|
107
|
+
jwk.export
|
|
108
|
+
# => { kty: "AKP", alg: "ML-DSA-65", pub: "...", kid: "..." }
|
|
109
|
+
|
|
110
|
+
jwk.export(include_private: true)
|
|
111
|
+
# => { kty: "AKP", alg: "ML-DSA-65", pub: "...", priv: "...", kid: "..." }
|
|
112
|
+
|
|
113
|
+
# Import
|
|
114
|
+
restored = JWT::PQ::JWK.import(jwk_hash)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Algorithms
|
|
118
|
+
|
|
119
|
+
| Algorithm | NIST Level | Public Key | Signature | JWT `alg` value |
|
|
120
|
+
|-----------|-----------|------------|-----------|-----------------|
|
|
121
|
+
| ML-DSA-44 | 2 | 1,312 B | 2,420 B | `ML-DSA-44` |
|
|
122
|
+
| ML-DSA-65 | 3 | 1,952 B | 3,309 B | `ML-DSA-65` |
|
|
123
|
+
| ML-DSA-87 | 5 | 2,592 B | 4,627 B | `ML-DSA-87` |
|
|
124
|
+
|
|
125
|
+
**Note on token size:** ML-DSA signatures are significantly larger than classical algorithms. A JWT with ML-DSA-65 will have a ~4.4 KB signature (base64url encoded), compared to ~86 bytes for Ed25519 or ~342 bytes for RS256.
|
|
126
|
+
|
|
127
|
+
## Hybrid mode details
|
|
128
|
+
|
|
129
|
+
The hybrid algorithms (`EdDSA+ML-DSA-{44,65,87}`) provide defense-in-depth: if either algorithm is broken, the other still protects the token.
|
|
130
|
+
|
|
131
|
+
The `alg` header values follow a `ClassicAlg+PQAlg` convention. The IETF draft `draft-ietf-cose-dilithium` is still evolving — these values may change in future versions to align with the final standard.
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
bundle install
|
|
137
|
+
OQS_LIB=/path/to/liboqs.dylib bundle exec rspec
|
|
138
|
+
bundle exec rubocop
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](LICENSE)
|
data/Rakefile
ADDED
data/jwt-pq.gemspec
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/jwt/pq/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "jwt-pq"
|
|
7
|
+
spec.version = JWT::PQ::VERSION
|
|
8
|
+
spec.authors = ["Marcelo Almeida"]
|
|
9
|
+
spec.email = ["contact@marcelopazzo.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Post-quantum JWT signatures (ML-DSA / FIPS 204) for Ruby"
|
|
12
|
+
spec.description = "Adds ML-DSA-44, ML-DSA-65, and ML-DSA-87 post-quantum signature " \
|
|
13
|
+
"algorithms to the ruby-jwt ecosystem, with optional hybrid " \
|
|
14
|
+
"EdDSA + ML-DSA mode. Uses liboqs via FFI."
|
|
15
|
+
spec.homepage = "https://github.com/marcelopazzo/jwt-pq"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 3.2"
|
|
18
|
+
|
|
19
|
+
spec.metadata = {
|
|
20
|
+
"source_code_uri" => spec.homepage,
|
|
21
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
|
|
22
|
+
"bug_tracker_uri" => "#{spec.homepage}/issues",
|
|
23
|
+
"documentation_uri" => "https://rubydoc.info/gems/jwt-pq",
|
|
24
|
+
"rubygems_mfa_required" => "true"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
spec.requirements = ["liboqs >= 0.15.0 (shared library) — https://github.com/open-quantum-safe/liboqs"]
|
|
28
|
+
|
|
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.
|
|
33
|
+
MSG
|
|
34
|
+
|
|
35
|
+
spec.files = Dir.chdir(__dir__) do
|
|
36
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
37
|
+
f.start_with?("spec/", "vendor/", ".github/") ||
|
|
38
|
+
f.match?(/\A(?:\.git|\.rspec|\.rubocop|jwt-pq-plan)/)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
spec.require_paths = ["lib"]
|
|
43
|
+
|
|
44
|
+
spec.add_dependency "ffi", "~> 1.15"
|
|
45
|
+
spec.add_dependency "jwt", "~> 3.0"
|
|
46
|
+
spec.add_dependency "pqc_asn1", "~> 0.1"
|
|
47
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module JWT
|
|
6
|
+
module PQ
|
|
7
|
+
module Algorithms
|
|
8
|
+
# JWT signing algorithm for hybrid EdDSA + ML-DSA signatures.
|
|
9
|
+
#
|
|
10
|
+
# The signature is a simple concatenation: ed25519_sig (64 bytes) || ml_dsa_sig.
|
|
11
|
+
# This allows PQ-aware verifiers to validate both, while the fixed 64-byte
|
|
12
|
+
# Ed25519 prefix makes it possible to split the signatures deterministically.
|
|
13
|
+
class HybridEdDsa
|
|
14
|
+
include ::JWT::JWA::SigningAlgorithm
|
|
15
|
+
|
|
16
|
+
ED25519_SIG_SIZE = 64
|
|
17
|
+
|
|
18
|
+
def initialize(alg)
|
|
19
|
+
@alg = alg
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def header(*)
|
|
23
|
+
{ "alg" => alg, "pq_alg" => ml_dsa_algorithm }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sign(data:, signing_key:)
|
|
27
|
+
key = resolve_signing_key(signing_key)
|
|
28
|
+
|
|
29
|
+
ed_sig = key.ed25519_signing_key.sign(data)
|
|
30
|
+
ml_sig = key.ml_dsa_key.sign(data)
|
|
31
|
+
|
|
32
|
+
# Concatenate: Ed25519 (64 bytes) || ML-DSA (variable)
|
|
33
|
+
ed_sig + ml_sig
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def verify(data:, signature:, verification_key:)
|
|
37
|
+
key = resolve_verification_key(verification_key)
|
|
38
|
+
|
|
39
|
+
return false if signature.bytesize <= ED25519_SIG_SIZE
|
|
40
|
+
|
|
41
|
+
ed_sig = signature.byteslice(0, ED25519_SIG_SIZE)
|
|
42
|
+
ml_sig = signature.byteslice(ED25519_SIG_SIZE..)
|
|
43
|
+
|
|
44
|
+
ed_valid = begin
|
|
45
|
+
key.ed25519_verify_key.verify(ed_sig, data)
|
|
46
|
+
true
|
|
47
|
+
rescue Ed25519::VerifyError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
ml_valid = key.ml_dsa_key.verify(data, ml_sig)
|
|
52
|
+
|
|
53
|
+
ed_valid && ml_valid
|
|
54
|
+
rescue JWT::PQ::Error
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def ml_dsa_algorithm
|
|
61
|
+
alg.sub("EdDSA+", "")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_signing_key(key)
|
|
65
|
+
case key
|
|
66
|
+
when JWT::PQ::HybridKey
|
|
67
|
+
raise_sign_error!("Both Ed25519 and ML-DSA private keys required") unless key.private?
|
|
68
|
+
key
|
|
69
|
+
else
|
|
70
|
+
raise_sign_error!(
|
|
71
|
+
"Expected a JWT::PQ::HybridKey, got #{key.class}. " \
|
|
72
|
+
"Use JWT::PQ::HybridKey.generate to create a hybrid key."
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve_verification_key(key)
|
|
78
|
+
case key
|
|
79
|
+
when JWT::PQ::HybridKey
|
|
80
|
+
key
|
|
81
|
+
else
|
|
82
|
+
raise_verify_error!(
|
|
83
|
+
"Expected a JWT::PQ::HybridKey, got #{key.class}."
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
register_algorithm(new("EdDSA+ML-DSA-44"))
|
|
89
|
+
register_algorithm(new("EdDSA+ML-DSA-65"))
|
|
90
|
+
register_algorithm(new("EdDSA+ML-DSA-87"))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module JWT
|
|
6
|
+
module PQ
|
|
7
|
+
module Algorithms
|
|
8
|
+
# JWT signing algorithm implementation for ML-DSA (FIPS 204).
|
|
9
|
+
# Registers ML-DSA-44, ML-DSA-65, and ML-DSA-87 with the ruby-jwt library.
|
|
10
|
+
class MlDsa
|
|
11
|
+
include ::JWT::JWA::SigningAlgorithm
|
|
12
|
+
|
|
13
|
+
def initialize(alg)
|
|
14
|
+
@alg = alg
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def sign(data:, signing_key:)
|
|
18
|
+
key = resolve_key(signing_key)
|
|
19
|
+
raise_sign_error!("Private key required for signing") unless key.private?
|
|
20
|
+
key.sign(data)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verify(data:, signature:, verification_key:)
|
|
24
|
+
key = resolve_key(verification_key)
|
|
25
|
+
key.verify(data, signature)
|
|
26
|
+
rescue JWT::PQ::Error
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def resolve_key(key)
|
|
33
|
+
case key
|
|
34
|
+
when JWT::PQ::Key
|
|
35
|
+
key
|
|
36
|
+
else
|
|
37
|
+
raise_sign_error!(
|
|
38
|
+
"Expected a JWT::PQ::Key, got #{key.class}. " \
|
|
39
|
+
"Use JWT::PQ::Key.generate(:#{alg_symbol}) to create a key."
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def alg_symbol
|
|
45
|
+
alg.downcase.tr("-", "_")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
register_algorithm(new("ML-DSA-44"))
|
|
49
|
+
register_algorithm(new("ML-DSA-65"))
|
|
50
|
+
register_algorithm(new("ML-DSA-87"))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JWT
|
|
4
|
+
module PQ
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
class LiboqsError < Error; end
|
|
8
|
+
|
|
9
|
+
class UnsupportedAlgorithmError < Error; end
|
|
10
|
+
|
|
11
|
+
class KeyError < Error; end
|
|
12
|
+
|
|
13
|
+
class MissingDependencyError < Error; end
|
|
14
|
+
|
|
15
|
+
class SignatureError < Error; end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JWT
|
|
4
|
+
module PQ
|
|
5
|
+
# Composite key combining an Ed25519 keypair with an ML-DSA keypair
|
|
6
|
+
# for hybrid EdDSA + ML-DSA JWT signatures.
|
|
7
|
+
class HybridKey
|
|
8
|
+
attr_reader :ed25519_signing_key, :ed25519_verify_key, :ml_dsa_key
|
|
9
|
+
|
|
10
|
+
# @param ed25519 [Ed25519::SigningKey, Ed25519::VerifyKey] Ed25519 key
|
|
11
|
+
# @param ml_dsa [JWT::PQ::Key] ML-DSA key
|
|
12
|
+
def initialize(ed25519:, ml_dsa:)
|
|
13
|
+
require_eddsa_dependency!
|
|
14
|
+
|
|
15
|
+
@ml_dsa_key = ml_dsa
|
|
16
|
+
|
|
17
|
+
case ed25519
|
|
18
|
+
when Ed25519::SigningKey
|
|
19
|
+
@ed25519_signing_key = ed25519
|
|
20
|
+
@ed25519_verify_key = ed25519.verify_key
|
|
21
|
+
when Ed25519::VerifyKey
|
|
22
|
+
@ed25519_signing_key = nil
|
|
23
|
+
@ed25519_verify_key = ed25519
|
|
24
|
+
else
|
|
25
|
+
raise KeyError, "Expected Ed25519::SigningKey or Ed25519::VerifyKey, got #{ed25519.class}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Generate a new hybrid keypair.
|
|
30
|
+
def self.generate(ml_dsa_algorithm = :ml_dsa_65)
|
|
31
|
+
require_eddsa_dependency!
|
|
32
|
+
|
|
33
|
+
ed_key = Ed25519::SigningKey.generate
|
|
34
|
+
ml_key = Key.generate(ml_dsa_algorithm)
|
|
35
|
+
|
|
36
|
+
new(ed25519: ed_key, ml_dsa: ml_key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Whether both keys have private components (can sign).
|
|
40
|
+
def private?
|
|
41
|
+
!@ed25519_signing_key.nil? && @ml_dsa_key.private?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The ML-DSA algorithm name (e.g., "ML-DSA-65").
|
|
45
|
+
def algorithm
|
|
46
|
+
@ml_dsa_key.algorithm
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The hybrid algorithm name (e.g., "EdDSA+ML-DSA-65").
|
|
50
|
+
def hybrid_algorithm
|
|
51
|
+
"EdDSA+#{@ml_dsa_key.algorithm}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.require_eddsa_dependency!
|
|
55
|
+
require "ed25519"
|
|
56
|
+
rescue LoadError
|
|
57
|
+
raise MissingDependencyError,
|
|
58
|
+
"The 'jwt-eddsa' gem (or 'ed25519' gem) is required for hybrid " \
|
|
59
|
+
"EdDSA+ML-DSA mode. Add it to your Gemfile: gem 'jwt-eddsa'"
|
|
60
|
+
end
|
|
61
|
+
private_class_method :require_eddsa_dependency!
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def require_eddsa_dependency!
|
|
66
|
+
self.class.send(:require_eddsa_dependency!)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/jwt/pq/jwk.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module JWT
|
|
7
|
+
module PQ
|
|
8
|
+
# JWK (JSON Web Key) support for ML-DSA keys.
|
|
9
|
+
#
|
|
10
|
+
# Follows the draft-ietf-cose-dilithium conventions:
|
|
11
|
+
# kty: "AKP" (Algorithm Key Pair)
|
|
12
|
+
# alg: "ML-DSA-44", "ML-DSA-65", or "ML-DSA-87"
|
|
13
|
+
# pub: base64url-encoded public key
|
|
14
|
+
# priv: base64url-encoded private key (optional)
|
|
15
|
+
class JWK
|
|
16
|
+
ALGORITHMS = %w[ML-DSA-44 ML-DSA-65 ML-DSA-87].freeze
|
|
17
|
+
KTY = "AKP"
|
|
18
|
+
|
|
19
|
+
attr_reader :key
|
|
20
|
+
|
|
21
|
+
def initialize(key)
|
|
22
|
+
raise KeyError, "Expected a JWT::PQ::Key, got #{key.class}" unless key.is_a?(JWT::PQ::Key)
|
|
23
|
+
|
|
24
|
+
@key = key
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Export the key as a JWK hash.
|
|
28
|
+
def export(include_private: false)
|
|
29
|
+
jwk = {
|
|
30
|
+
kty: KTY,
|
|
31
|
+
alg: @key.algorithm,
|
|
32
|
+
pub: base64url_encode(@key.public_key),
|
|
33
|
+
kid: thumbprint
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
jwk[:priv] = base64url_encode(@key.private_key) if include_private && @key.private?
|
|
37
|
+
|
|
38
|
+
jwk
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Import a Key from a JWK hash.
|
|
42
|
+
def self.import(jwk_hash)
|
|
43
|
+
jwk = normalize_keys(jwk_hash)
|
|
44
|
+
|
|
45
|
+
validate_kty!(jwk)
|
|
46
|
+
alg = validate_alg!(jwk)
|
|
47
|
+
pub_bytes = base64url_decode(jwk["pub"])
|
|
48
|
+
|
|
49
|
+
if jwk.key?("priv")
|
|
50
|
+
priv_bytes = base64url_decode(jwk["priv"])
|
|
51
|
+
Key.new(algorithm: alg, public_key: pub_bytes, private_key: priv_bytes)
|
|
52
|
+
else
|
|
53
|
+
Key.new(algorithm: alg, public_key: pub_bytes)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# JWK Thumbprint (RFC 7638) for key identification.
|
|
58
|
+
# Uses the required members: alg, kty, pub.
|
|
59
|
+
def thumbprint
|
|
60
|
+
canonical = "{\"alg\":\"#{@key.algorithm}\",\"kty\":\"#{KTY}\",\"pub\":\"#{base64url_encode(@key.public_key)}\"}"
|
|
61
|
+
digest = OpenSSL::Digest::SHA256.digest(canonical)
|
|
62
|
+
base64url_encode(digest)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.validate_kty!(jwk)
|
|
66
|
+
kty = jwk["kty"]
|
|
67
|
+
raise KeyError, "Missing 'kty' in JWK" unless kty
|
|
68
|
+
raise KeyError, "Expected kty '#{KTY}', got '#{kty}'" unless kty == KTY
|
|
69
|
+
end
|
|
70
|
+
private_class_method :validate_kty!
|
|
71
|
+
|
|
72
|
+
def self.validate_alg!(jwk)
|
|
73
|
+
alg = jwk["alg"]
|
|
74
|
+
raise KeyError, "Missing 'alg' in JWK" unless alg
|
|
75
|
+
raise KeyError, "Unsupported algorithm '#{alg}'" unless ALGORITHMS.include?(alg)
|
|
76
|
+
|
|
77
|
+
alg
|
|
78
|
+
end
|
|
79
|
+
private_class_method :validate_alg!
|
|
80
|
+
|
|
81
|
+
def self.normalize_keys(hash)
|
|
82
|
+
hash.transform_keys(&:to_s)
|
|
83
|
+
end
|
|
84
|
+
private_class_method :normalize_keys
|
|
85
|
+
|
|
86
|
+
def self.base64url_decode(str)
|
|
87
|
+
::Base64.urlsafe_decode64(str)
|
|
88
|
+
end
|
|
89
|
+
private_class_method :base64url_decode
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def base64url_encode(bytes)
|
|
94
|
+
::Base64.urlsafe_encode64(bytes, padding: false)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/jwt/pq/key.rb
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pqc_asn1"
|
|
4
|
+
|
|
5
|
+
module JWT
|
|
6
|
+
module PQ
|
|
7
|
+
# Represents an ML-DSA keypair (public + optional private key).
|
|
8
|
+
# Used as the signing/verification key for JWT operations.
|
|
9
|
+
class Key
|
|
10
|
+
ALGORITHM_ALIASES = {
|
|
11
|
+
ml_dsa_44: "ML-DSA-44",
|
|
12
|
+
ml_dsa_65: "ML-DSA-65",
|
|
13
|
+
ml_dsa_87: "ML-DSA-87"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
ALGORITHM_OIDS = {
|
|
17
|
+
"ML-DSA-44" => PqcAsn1::OID::ML_DSA_44,
|
|
18
|
+
"ML-DSA-65" => PqcAsn1::OID::ML_DSA_65,
|
|
19
|
+
"ML-DSA-87" => PqcAsn1::OID::ML_DSA_87
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
OID_TO_ALGORITHM = ALGORITHM_OIDS.invert.freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :algorithm, :public_key, :private_key
|
|
25
|
+
|
|
26
|
+
def initialize(algorithm:, public_key:, private_key: nil)
|
|
27
|
+
@algorithm = resolve_algorithm(algorithm)
|
|
28
|
+
@ml_dsa = MlDsa.new(@algorithm)
|
|
29
|
+
@public_key = public_key
|
|
30
|
+
@private_key = private_key
|
|
31
|
+
|
|
32
|
+
validate!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Generate a new keypair for the given algorithm.
|
|
36
|
+
def self.generate(algorithm)
|
|
37
|
+
alg_name = resolve_algorithm(algorithm)
|
|
38
|
+
ml_dsa = MlDsa.new(alg_name)
|
|
39
|
+
pk, sk = ml_dsa.keypair
|
|
40
|
+
|
|
41
|
+
new(algorithm: alg_name, public_key: pk, private_key: sk)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Create a Key from raw public key bytes (verification only).
|
|
45
|
+
def self.from_public_key(algorithm, public_key_bytes)
|
|
46
|
+
new(algorithm: algorithm, public_key: public_key_bytes)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Sign data using the private key.
|
|
50
|
+
def sign(data)
|
|
51
|
+
raise KeyError, "Private key not available — cannot sign" unless @private_key
|
|
52
|
+
|
|
53
|
+
@ml_dsa.sign(data, @private_key)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Verify a signature using the public key.
|
|
57
|
+
def verify(data, signature)
|
|
58
|
+
@ml_dsa.verify(data, signature, @public_key)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether this key can be used for signing.
|
|
62
|
+
def private?
|
|
63
|
+
!@private_key.nil?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Import a Key from a PEM string (SPKI or PKCS#8).
|
|
67
|
+
def self.from_pem(pem_string)
|
|
68
|
+
info = PqcAsn1::DER.parse_pem(pem_string)
|
|
69
|
+
alg_name = resolve_oid!(info.oid)
|
|
70
|
+
|
|
71
|
+
case info.format
|
|
72
|
+
when :spki then new(algorithm: alg_name, public_key: info.key)
|
|
73
|
+
when :pkcs8 then build_from_pkcs8(info, alg_name)
|
|
74
|
+
else raise KeyError, "Unsupported PEM format: #{info.format}"
|
|
75
|
+
end
|
|
76
|
+
ensure
|
|
77
|
+
info&.key&.wipe! if info&.format == :pkcs8
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Import a Key from separate public and private PEM strings.
|
|
81
|
+
def self.from_pem_pair(public_pem:, private_pem:)
|
|
82
|
+
pub_info = PqcAsn1::DER.parse_pem(public_pem)
|
|
83
|
+
priv_info = PqcAsn1::DER.parse_pem(private_pem)
|
|
84
|
+
|
|
85
|
+
pub_alg = OID_TO_ALGORITHM[pub_info.oid]
|
|
86
|
+
priv_alg = OID_TO_ALGORITHM[priv_info.oid]
|
|
87
|
+
|
|
88
|
+
raise KeyError, "Unknown OID in public PEM: #{pub_info.oid.dotted}" unless pub_alg
|
|
89
|
+
raise KeyError, "Unknown OID in private PEM: #{priv_info.oid.dotted}" unless priv_alg
|
|
90
|
+
raise KeyError, "Algorithm mismatch: public=#{pub_alg}, private=#{priv_alg}" unless pub_alg == priv_alg
|
|
91
|
+
|
|
92
|
+
sk_bytes = extract_secure_bytes(priv_info.key)
|
|
93
|
+
new(algorithm: pub_alg, public_key: pub_info.key, private_key: sk_bytes)
|
|
94
|
+
ensure
|
|
95
|
+
priv_info&.key&.wipe!
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Export the public key as PEM (SPKI format).
|
|
99
|
+
def to_pem
|
|
100
|
+
oid = ALGORITHM_OIDS[@algorithm]
|
|
101
|
+
der = PqcAsn1::DER.build_spki(oid, @public_key)
|
|
102
|
+
PqcAsn1::PEM.encode(der, "PUBLIC KEY")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Export the private key as PEM (PKCS#8 format).
|
|
106
|
+
def private_to_pem
|
|
107
|
+
raise KeyError, "Private key not available" unless @private_key
|
|
108
|
+
|
|
109
|
+
oid = ALGORITHM_OIDS[@algorithm]
|
|
110
|
+
secure_der = PqcAsn1::DER.build_pkcs8(oid, @private_key, public_key: @public_key)
|
|
111
|
+
secure_der.to_pem
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.resolve_algorithm(algorithm)
|
|
115
|
+
ALGORITHM_ALIASES.fetch(algorithm.to_sym) { algorithm.to_s }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Extract bytes from a PqcAsn1::SecureBuffer using the safe block API.
|
|
119
|
+
# The yielded String shares the SecureBuffer's C-level memory, so
|
|
120
|
+
# String.new / dup / b all get zeroed when the block exits.
|
|
121
|
+
# bytes.bytes.pack creates a fully independent copy via integer array.
|
|
122
|
+
def self.extract_secure_bytes(secure_buffer)
|
|
123
|
+
secure_buffer.use { |bytes| bytes.bytes.pack("C*") }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.resolve_oid!(oid)
|
|
127
|
+
OID_TO_ALGORITHM[oid] || raise(KeyError, "Unknown OID in PEM: #{oid.dotted}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.build_from_pkcs8(info, alg_name)
|
|
131
|
+
raise KeyError, "PKCS#8 PEM for #{alg_name} missing public key. Use from_pem_pair." unless info.public_key
|
|
132
|
+
|
|
133
|
+
sk_bytes = extract_secure_bytes(info.key)
|
|
134
|
+
new(algorithm: alg_name, public_key: info.public_key, private_key: sk_bytes)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private_class_method :extract_secure_bytes, :resolve_oid!, :build_from_pkcs8
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def resolve_algorithm(algorithm)
|
|
142
|
+
self.class.resolve_algorithm(algorithm)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate!
|
|
146
|
+
expected_pk = @ml_dsa.public_key_size
|
|
147
|
+
if @public_key.bytesize != expected_pk
|
|
148
|
+
raise KeyError,
|
|
149
|
+
"Invalid public key size for #{@algorithm}: " \
|
|
150
|
+
"expected #{expected_pk}, got #{@public_key.bytesize}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
return unless @private_key
|
|
154
|
+
|
|
155
|
+
expected_sk = @ml_dsa.secret_key_size
|
|
156
|
+
return if @private_key.bytesize == expected_sk
|
|
157
|
+
|
|
158
|
+
raise KeyError,
|
|
159
|
+
"Invalid private key size for #{@algorithm}: " \
|
|
160
|
+
"expected #{expected_sk}, got #{@private_key.bytesize}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
|
|
5
|
+
module JWT
|
|
6
|
+
module PQ
|
|
7
|
+
# FFI bindings for liboqs signature operations.
|
|
8
|
+
#
|
|
9
|
+
# The library search order:
|
|
10
|
+
# 1. OQS_LIB environment variable (explicit path)
|
|
11
|
+
# 2. System-installed liboqs (via standard library search)
|
|
12
|
+
module LibOQS
|
|
13
|
+
extend FFI::Library
|
|
14
|
+
|
|
15
|
+
OQS_SUCCESS = 0
|
|
16
|
+
OQS_ERROR = -1
|
|
17
|
+
|
|
18
|
+
# Determine library path
|
|
19
|
+
def self.lib_path
|
|
20
|
+
return ENV["OQS_LIB"] if ENV["OQS_LIB"]
|
|
21
|
+
|
|
22
|
+
"oqs"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
ffi_lib lib_path
|
|
27
|
+
rescue LoadError => e
|
|
28
|
+
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}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# OQS_SIG *OQS_SIG_new(const char *method_name)
|
|
35
|
+
attach_function :OQS_SIG_new, [:string], :pointer
|
|
36
|
+
|
|
37
|
+
# void OQS_SIG_free(OQS_SIG *sig)
|
|
38
|
+
attach_function :OQS_SIG_free, [:pointer], :void
|
|
39
|
+
|
|
40
|
+
# OQS_STATUS OQS_SIG_keypair(const OQS_SIG *sig, uint8_t *public_key, uint8_t *secret_key)
|
|
41
|
+
attach_function :OQS_SIG_keypair, %i[pointer pointer pointer], :int
|
|
42
|
+
|
|
43
|
+
# OQS_STATUS OQS_SIG_sign(const OQS_SIG *sig, uint8_t *signature, size_t *signature_len,
|
|
44
|
+
# const uint8_t *message, size_t message_len,
|
|
45
|
+
# const uint8_t *secret_key)
|
|
46
|
+
attach_function :OQS_SIG_sign, %i[pointer pointer pointer
|
|
47
|
+
pointer size_t pointer], :int
|
|
48
|
+
|
|
49
|
+
# OQS_STATUS OQS_SIG_verify(const OQS_SIG *sig, const uint8_t *message,
|
|
50
|
+
# size_t message_len, const uint8_t *signature,
|
|
51
|
+
# size_t signature_len, const uint8_t *public_key)
|
|
52
|
+
attach_function :OQS_SIG_verify, %i[pointer pointer size_t
|
|
53
|
+
pointer size_t pointer], :int
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JWT
|
|
4
|
+
module PQ
|
|
5
|
+
# Ruby wrapper around liboqs ML-DSA operations.
|
|
6
|
+
# Handles memory allocation, FFI calls, and cleanup.
|
|
7
|
+
class MlDsa
|
|
8
|
+
ALGORITHMS = {
|
|
9
|
+
"ML-DSA-44" => { public_key: 1312, secret_key: 2560, signature: 2420, nist_level: 2 },
|
|
10
|
+
"ML-DSA-65" => { public_key: 1952, secret_key: 4032, signature: 3309, nist_level: 3 },
|
|
11
|
+
"ML-DSA-87" => { public_key: 2592, secret_key: 4896, signature: 4627, nist_level: 5 }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :algorithm
|
|
15
|
+
|
|
16
|
+
def initialize(algorithm)
|
|
17
|
+
algorithm = algorithm.to_s
|
|
18
|
+
unless ALGORITHMS.key?(algorithm)
|
|
19
|
+
raise UnsupportedAlgorithmError,
|
|
20
|
+
"Unsupported algorithm: #{algorithm}. " \
|
|
21
|
+
"Supported: #{ALGORITHMS.keys.join(", ")}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@algorithm = algorithm
|
|
25
|
+
@sizes = ALGORITHMS[algorithm]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Generate a new keypair.
|
|
29
|
+
# Returns [public_key_bytes, secret_key_bytes]
|
|
30
|
+
def keypair
|
|
31
|
+
sig = LibOQS.OQS_SIG_new(@algorithm)
|
|
32
|
+
raise LiboqsError, "Failed to initialize #{@algorithm}" if sig.null?
|
|
33
|
+
|
|
34
|
+
pk = FFI::MemoryPointer.new(:uint8, @sizes[:public_key])
|
|
35
|
+
sk = FFI::MemoryPointer.new(:uint8, @sizes[:secret_key])
|
|
36
|
+
|
|
37
|
+
status = LibOQS.OQS_SIG_keypair(sig, pk, sk)
|
|
38
|
+
raise LiboqsError, "Keypair generation failed for #{@algorithm}" unless status == LibOQS::OQS_SUCCESS
|
|
39
|
+
|
|
40
|
+
[pk.read_bytes(@sizes[:public_key]), sk.read_bytes(@sizes[:secret_key])]
|
|
41
|
+
ensure
|
|
42
|
+
LibOQS.OQS_SIG_free(sig) if sig && !sig.null?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Sign a message with a secret key.
|
|
46
|
+
# Returns the signature bytes.
|
|
47
|
+
def sign(message, secret_key)
|
|
48
|
+
validate_key_size!(secret_key, :secret_key)
|
|
49
|
+
|
|
50
|
+
sig = LibOQS.OQS_SIG_new(@algorithm)
|
|
51
|
+
raise LiboqsError, "Failed to initialize #{@algorithm}" if sig.null?
|
|
52
|
+
|
|
53
|
+
sig_buf = FFI::MemoryPointer.new(:uint8, @sizes[:signature])
|
|
54
|
+
sig_len = FFI::MemoryPointer.new(:size_t)
|
|
55
|
+
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
|
+
|
|
59
|
+
status = LibOQS.OQS_SIG_sign(sig, sig_buf, sig_len,
|
|
60
|
+
msg_buf, message.bytesize, sk_buf)
|
|
61
|
+
raise SignatureError, "Signing failed for #{@algorithm}" unless status == LibOQS::OQS_SUCCESS
|
|
62
|
+
|
|
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?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Verify a signature against a message and public key.
|
|
70
|
+
# Returns true if valid, false otherwise.
|
|
71
|
+
def verify(message, signature, public_key)
|
|
72
|
+
validate_key_size!(public_key, :public_key)
|
|
73
|
+
|
|
74
|
+
sig = LibOQS.OQS_SIG_new(@algorithm)
|
|
75
|
+
raise LiboqsError, "Failed to initialize #{@algorithm}" if sig.null?
|
|
76
|
+
|
|
77
|
+
msg_buf = FFI::MemoryPointer.from_string(message)
|
|
78
|
+
sig_buf = FFI::MemoryPointer.new(:uint8, signature.bytesize)
|
|
79
|
+
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
|
+
|
|
83
|
+
status = LibOQS.OQS_SIG_verify(sig, msg_buf, message.bytesize,
|
|
84
|
+
sig_buf, signature.bytesize, pk_buf)
|
|
85
|
+
status == LibOQS::OQS_SUCCESS
|
|
86
|
+
ensure
|
|
87
|
+
LibOQS.OQS_SIG_free(sig) if sig && !sig.null?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Key sizes for this algorithm
|
|
91
|
+
def public_key_size
|
|
92
|
+
@sizes[:public_key]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def secret_key_size
|
|
96
|
+
@sizes[:secret_key]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def signature_size
|
|
100
|
+
@sizes[:signature]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def validate_key_size!(key, type)
|
|
106
|
+
expected = @sizes[type]
|
|
107
|
+
return if key.bytesize == expected
|
|
108
|
+
|
|
109
|
+
raise KeyError,
|
|
110
|
+
"Invalid #{type} size for #{@algorithm}: " \
|
|
111
|
+
"expected #{expected} bytes, got #{key.bytesize}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/jwt/pq.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pq/version"
|
|
4
|
+
require_relative "pq/errors"
|
|
5
|
+
require_relative "pq/liboqs"
|
|
6
|
+
require_relative "pq/ml_dsa"
|
|
7
|
+
require_relative "pq/key"
|
|
8
|
+
require_relative "pq/algorithms/ml_dsa"
|
|
9
|
+
require_relative "pq/jwk"
|
|
10
|
+
require_relative "pq/hybrid_key"
|
|
11
|
+
require_relative "pq/algorithms/hybrid_eddsa"
|
|
12
|
+
|
|
13
|
+
module JWT
|
|
14
|
+
module PQ
|
|
15
|
+
# Whether jwt-eddsa / ed25519 is available for hybrid mode.
|
|
16
|
+
def self.hybrid_available?
|
|
17
|
+
require "ed25519"
|
|
18
|
+
true
|
|
19
|
+
rescue LoadError
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: jwt-pq
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Marcelo Almeida
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ffi
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.15'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.15'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: jwt
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: pqc_asn1
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.1'
|
|
54
|
+
description: Adds ML-DSA-44, ML-DSA-65, and ML-DSA-87 post-quantum signature algorithms
|
|
55
|
+
to the ruby-jwt ecosystem, with optional hybrid EdDSA + ML-DSA mode. Uses liboqs
|
|
56
|
+
via FFI.
|
|
57
|
+
email:
|
|
58
|
+
- contact@marcelopazzo.com
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- CHANGELOG.md
|
|
64
|
+
- Gemfile
|
|
65
|
+
- LICENSE
|
|
66
|
+
- README.md
|
|
67
|
+
- Rakefile
|
|
68
|
+
- jwt-pq.gemspec
|
|
69
|
+
- lib/jwt/pq.rb
|
|
70
|
+
- lib/jwt/pq/algorithms/hybrid_eddsa.rb
|
|
71
|
+
- lib/jwt/pq/algorithms/ml_dsa.rb
|
|
72
|
+
- lib/jwt/pq/errors.rb
|
|
73
|
+
- lib/jwt/pq/hybrid_key.rb
|
|
74
|
+
- lib/jwt/pq/jwk.rb
|
|
75
|
+
- lib/jwt/pq/key.rb
|
|
76
|
+
- lib/jwt/pq/liboqs.rb
|
|
77
|
+
- lib/jwt/pq/ml_dsa.rb
|
|
78
|
+
- lib/jwt/pq/version.rb
|
|
79
|
+
homepage: https://github.com/marcelopazzo/jwt-pq
|
|
80
|
+
licenses:
|
|
81
|
+
- MIT
|
|
82
|
+
metadata:
|
|
83
|
+
source_code_uri: https://github.com/marcelopazzo/jwt-pq
|
|
84
|
+
changelog_uri: https://github.com/marcelopazzo/jwt-pq/blob/main/CHANGELOG.md
|
|
85
|
+
bug_tracker_uri: https://github.com/marcelopazzo/jwt-pq/issues
|
|
86
|
+
documentation_uri: https://rubydoc.info/gems/jwt-pq
|
|
87
|
+
rubygems_mfa_required: 'true'
|
|
88
|
+
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
|
+
rdoc_options: []
|
|
93
|
+
require_paths:
|
|
94
|
+
- lib
|
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '3.2'
|
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '0'
|
|
105
|
+
requirements:
|
|
106
|
+
- liboqs >= 0.15.0 (shared library) — https://github.com/open-quantum-safe/liboqs
|
|
107
|
+
rubygems_version: 3.6.9
|
|
108
|
+
specification_version: 4
|
|
109
|
+
summary: Post-quantum JWT signatures (ML-DSA / FIPS 204) for Ruby
|
|
110
|
+
test_files: []
|