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 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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWT
4
+ module PQ
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []