jwt-pq

Gem Version CI Cross-interop codecov

Post-quantum JWT signatures for Ruby. Adds ML-DSA (FIPS 204) support to the ruby-jwt ecosystem, with an optional hybrid EdDSA + ML-DSA mode.

Features

  • ML-DSA-44, ML-DSA-65, and ML-DSA-87 algorithms
  • Hybrid EdDSA + ML-DSA dual signatures
  • Drop-in integration with JWT.encode / JWT.decode
  • PEM serialization (SPKI / PKCS#8) via pqc_asn1
  • JWK export/import with RFC 7638 thumbprints

Requirements

  • Ruby >= 3.2
  • CMake >= 3.15 and a C compiler — gcc or clang (for building the bundled liboqs)

Installation

# Gemfile
gem "jwt-pq"

# For hybrid EdDSA + ML-DSA mode (optional):
gem "jwt-eddsa"

liboqs is automatically compiled from source during gem installation (ML-DSA algorithms only, ~30 seconds).

Using system liboqs

If you prefer to use a system-installed liboqs:

gem install jwt-pq -- --use-system-libraries
# or
JWT_PQ_USE_SYSTEM_LIBRARIES=1 gem install jwt-pq
# or in Bundler
bundle config build.jwt-pq --use-system-libraries

You can also point to a specific library with OQS_LIB=/path/to/liboqs.dylib.

Usage

Basic ML-DSA signing

require "jwt/pq"

key = JWT::PQ::Key.generate(:ml_dsa_65)

# Encode
token = JWT.encode({ sub: "1234" }, key, "ML-DSA-65")

# Decode
decoded = JWT.decode(token, key, true, algorithms: ["ML-DSA-65"])
decoded.first # => { "sub" => "1234" }

Verify with public key only

pub_key = JWT::PQ::Key.from_public_key("ML-DSA-65", key.public_key)
JWT.decode(token, pub_key, true, algorithms: ["ML-DSA-65"])

Hybrid EdDSA + ML-DSA

Requires jwt-eddsa gem.

require "jwt/pq"

hybrid_key = JWT::PQ::HybridKey.generate(:ml_dsa_65)

token = JWT.encode({ sub: "1234" }, hybrid_key, "EdDSA+ML-DSA-65")

# Verify — both Ed25519 and ML-DSA signatures must be valid
decoded = JWT.decode(token, hybrid_key, true, algorithms: ["EdDSA+ML-DSA-65"])

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".

PEM serialization

# Export
pub_pem  = key.to_pem          # SPKI format
priv_pem = key.private_to_pem  # PKCS#8 format

# Import
pub_key  = JWT::PQ::Key.from_pem(pub_pem)
full_key = JWT::PQ::Key.from_pem_pair(public_pem: pub_pem, private_pem: priv_pem)

JWK

jwk = JWT::PQ::JWK.new(key)

# Export
jwk.export
# => { kty: "AKP", alg: "ML-DSA-65", pub: "...", kid: "..." }

jwk.export(include_private: true)
# => { kty: "AKP", alg: "ML-DSA-65", pub: "...", priv: "...", kid: "..." }

# Import
restored = JWT::PQ::JWK.import(jwk_hash)

JWK Set (JWKS)

For publishing multiple verification keys (e.g. during key rotation) or consuming a remote JWKS endpoint:

# Producer — publish verification keys on /.well-known/jwks.json
jwks = JWT::PQ::JWKSet.new([key_current, key_next])
File.write("jwks.json", jwks.to_json)

# Consumer — resolve the verification key by kid
jwks = JWT::PQ::JWKSet.import(JSON.parse(fetch_jwks))
_payload, header = JWT.decode(token, nil, false)        # unverified peek
key = jwks[header["kid"]] or raise "unknown kid"
payload, = JWT.decode(token, key, true, algorithms: [header["alg"]])

Members are indexed by their RFC 7638 thumbprint (the same value JWK#export emits as kid). Remember to set the kid header when signing: JWT.encode(payload, key, alg, { kid: key.jwk_thumbprint }). Key#jwk_thumbprint memoizes the digest, so it's cheap to call repeatedly on the same key.

Fetching a remote JWKS

For consuming a JWKS from an identity provider or sibling service, JWT::PQ::JWKSet.fetch wraps Net::HTTP with a TTL cache and ETag-based revalidation:

jwks = JWT::PQ::JWKSet.fetch("https://issuer.example/.well-known/jwks.json")

_payload, header = JWT.decode(token, nil, false)
key = jwks[header["kid"]] or raise "unknown kid"
payload, = JWT.decode(token, key, true, algorithms: [header["alg"]])

The cache is process-global and keyed by URL, so repeated calls inside the TTL window (default: 300 s) return the cached set without touching the network. Once the TTL expires, the next call issues a conditional GET with If-None-Match; a 304 Not Modified refreshes the cache timestamp without re-parsing. Tune via kwargs:

JWT::PQ::JWKSet.fetch(url,
  cache_ttl: 600,         # seconds; default 300
  timeout: 3,             # read timeout; default 5
  open_timeout: 3,        # connect timeout; default 5
  max_body_bytes: 65_536, # response body cap; default 1 MB
  allow_http: false)      # reject plain http:// (default)

Defense-in-depth defaults: HTTPS only, redirects rejected, body capped at 1 MB, 5 s timeouts. Network/HTTP failures raise JWT::PQ::JWKSFetchError; malformed JWKS bodies raise JWT::PQ::KeyError.

Algorithms

Algorithm NIST Level Public Key Signature JWT alg value
ML-DSA-44 2 1,312 B 2,420 B ML-DSA-44
ML-DSA-65 3 1,952 B 3,309 B ML-DSA-65
ML-DSA-87 5 2,592 B 4,627 B ML-DSA-87

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.

Hybrid mode details

The hybrid algorithms (EdDSA+ML-DSA-{44,65,87}) provide defense-in-depth: if either algorithm is broken, the other still protects the token.

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.

Correctness

  • NIST ACVP known-answer tests. spec/jwt/pq/kat_spec.rb runs the full sigVer KAT subset shipped with liboqs against JWT::PQ::Key#verify for ML-DSA-44/65/87, covering both positive and negative cases. These vectors are executed in CI on every push. (sigGen KATs are not used because FIPS 204 specifies hedged signing with internal randomness, which makes signature output non-deterministic.)
  • Cross-language interop. The Cross-interop workflow signs with jwt-pq and verifies with dilithium-py, and vice versa, for all three parameter sets. It runs on every push and weekly on cron.

Performance

Measured with bench/sign_throughput.rb / bench/verify_throughput.rb (and the hybrid variants) using benchmark-ips. Hardware: Intel Core i9-9880H @ 2.30 GHz, macOS, Ruby 3.4.6, bundled liboqs 0.15.0, single-threaded.

Algorithm Sign Verify
ML-DSA-44 8,026 ops/s (125 µs) 11,074 ops/s (90 µs)
ML-DSA-65 5,972 ops/s (167 µs) 9,339 ops/s (107 µs)
ML-DSA-87 4,911 ops/s (204 µs) 6,471 ops/s (155 µs)
EdDSA+ML-DSA-65 4,695 ops/s (213 µs) 3,924 ops/s (255 µs)

Numbers are illustrative — rerun bundle exec ruby bench/sign_throughput.rb on your target hardware before capacity-planning. ML-DSA is ~1–2 orders of magnitude slower than Ed25519 (~70 k sigs/s on the same box); plan accordingly.

Backends

ML-DSA operations are delegated to liboqs, bundled and compiled during gem install. An alternative OpenSSL 3.5+ backend is tracked in #14 and will be added once OpenSSL 3.5 ships widely in distros.

Specification tracking

jwt-pq targets the current IETF specs for JOSE/COSE post-quantum signatures:

  • draft-ietf-cose-dilithium — ML-DSA in JOSE/COSE, including the AKP key type (draft)
  • RFC 9864 — Fully-Specified Algorithms for JOSE and COSE (published October 2025)
  • FIPS 204 — ML-DSA itself (final)

See SPEC.md for the full tracked-specs table, the current-draft revisions the shipped alg/kty values target, the hybrid-mode convention, known divergences, and the compatibility policy for draft transitions and the eventual RFC finalization.

Thread safety

JWT::PQ::Key and JWT::PQ::HybridKey are safe to share across threads for #sign, #verify, #destroy!, and Key#private_to_pem (hybrid sign also covers the Ed25519 + ML-DSA compound atomically). Each instance carries its own mutex that serializes those operations against each other, so a concurrent #destroy! waits for any in-flight signing/verification/PEM export to finish before zeroing the private key material. Read-only exporters on immutable state (#to_pem, #public_key, #algorithm, #jwk_thumbprint) do not take the mutex.

The mutex cost is negligible against ML-DSA signing latency (~130–200 µs): single-threaded ML-DSA-65 throughput stays within run-to-run noise of the pre-mutex baseline (~7300 sigs/s on an i9-9880H). Under MRI the GVL already serializes signing within a single process; the mutex exists to guarantee atomicity against destroy!, not to extract parallelism.

Fork caveat. A Mutex held by a live thread at fork(2) time is inherited locked-with-no-owner in the child. If you fork workers (Puma, Unicorn, Resque, etc.), do so before any thread begins signing on a shared Key, or let each worker construct its own keys post-fork. Do not call destroy! on a parent-side key and then fork.

Algorithm registration

require "jwt/pq" registers ML-DSA-{44,65,87} and EdDSA+ML-DSA-{44,65,87} with ruby-jwt via the public JWT::JWA::SigningAlgorithm.register_algorithm API — this is not a monkey-patch. The registration is idempotent and coexists with other custom algorithms (e.g. jwt-eddsa). Load order between jwt and jwt/pq does not matter.

Security

See SECURITY.md for the supported-versions policy, vulnerability reporting process, and how upstream liboqs advisories are handled.

Development

bundle install          # compiles liboqs automatically
bundle exec rspec       # run tests
bundle exec rubocop     # lint
rake compile            # recompile liboqs manually

License

MIT