pq_crypto-jwt
pq_crypto-jwt is a small adapter that connects pq_crypto to the ruby-jwt ecosystem.
The first public release intentionally focuses on one stable surface:
- ML-DSA JWS signing and verification for
ruby-jwt - public AKP JWK/JWKS helpers for ML-DSA verification keys
- PEM import helpers for ML-DSA SPKI/PKCS#8 keys
- ML-DSA-65 streaming detached JWS helper
ML-KEM/JWE is not included in this first release. Full JWE support needs a separate standards-compatible implementation and interoperability tests.
Install
gem "pq_crypto-jwt", "~> 0.1"
Register the algorithms
pq_crypto-jwt does not register algorithms implicitly. Register once during boot:
require "pq_crypto/jwt"
PQCrypto::JWT.register!
This registers the following JOSE alg values with ruby-jwt:
ML-DSA-44
ML-DSA-65
ML-DSA-87
JWS — sign and verify with ML-DSA
require "pq_crypto/jwt"
PQCrypto::JWT.register!
keypair = PQCrypto::JWT::Keys.generate("ML-DSA-65")
token = JWT.encode({ "sub" => "alice" }, keypair.secret_key, "ML-DSA-65")
payload, header = JWT.decode(token, keypair.public_key, true, algorithm: "ML-DSA-65")
The adapter validates both the JOSE algorithm string and the concrete pq_crypto key type. A token signed with ML-DSA-44, for example, will not verify under ML-DSA-65.
PEM import
SPKI public keys and PKCS#8 secret keys can be imported through the helper API:
public_key = PQCrypto::JWT::Keys.public_from_pem(spki_pem)
secret_key = PQCrypto::JWT::Keys.secret_from_pem(pkcs8_pem)
token = JWT.encode({ "sub" => "alice" }, secret_key, "ML-DSA-65")
JWT.decode(token, public_key, true, algorithm: "ML-DSA-65")
For stricter dispatch, pass expect: :signature:
public_key = PQCrypto::JWT::Keys.public_from_pem(spki_pem, expect: :signature)
secret_key = PQCrypto::JWT::Keys.secret_from_pem(pkcs8_pem, expect: :signature)
JWK and JWKS
Public AKP JWK round-trip:
keypair = PQCrypto::JWT::Keys.generate("ML-DSA-65")
jwk = PQCrypto::JWT::JWK.from_public_key(keypair.public_key, kid: "signing-key")
public_key = PQCrypto::JWT::JWK.public_key_from_jwk(jwk)
JWKS lookup with ruby-jwt:
PQCrypto::JWT.register!
keypair = PQCrypto::JWT::Keys.generate("ML-DSA-65")
jwks = PQCrypto::JWT::JWKS.from_keys([keypair.public_key], kids: ["signing-key"])
token = JWT.encode({ "sub" => "alice" }, keypair.secret_key, "ML-DSA-65", kid: "signing-key")
payload, header = JWT.decode(token, nil, true, algorithms: ["ML-DSA-65"], jwks: jwks)
For rotation, pass PQCrypto::JWT::JWKS.loader(callable_or_hash) as the jwks: value.
Streaming detached JWS
ML-DSA-65 also supports a streaming detached JWS helper. The compact form is header..signature; callers must supply the same payload stream separately for verification.
File.open("payload.bin", "rb") do |payload_io|
token = PQCrypto::JWT::JWA::MLDSA65.sign_io(
signing_key: keypair.secret_key,
payload_io: payload_io
)
end
File.open("payload.bin", "rb") do |payload_io|
PQCrypto::JWT::JWA::MLDSA65.verify_io!(
verification_key: keypair.public_key,
token: token,
payload_io: payload_io
)
end
Non-goals for the first release
The first release deliberately does not expose:
- ML-KEM JWE key agreement
- JWE compact or JSON serialization
- JWE content encryption, AAD, IV, or authentication tag handling
- private AKP JWK import/export; use PEM/PKCS#8 for signing keys
- general-purpose JWT claims policy beyond what
ruby-jwtalready provides
This keeps the public API small and avoids publishing draft-incompatible JWE behavior.
Security status
unaudited; tracks draft-ietf-cose-dilithium for ML-DSA JOSE identifiers;
identifiers and wire formats may change before RFC publication; backed by
pq_crypto, which should also be reviewed before production use.
Use in production only after your own security review and interoperability testing.
License
MIT.