pq_crypto

pq_crypto is a primitive-first Ruby gem for post-quantum cryptography.

It exposes three public building blocks:

  • PQCrypto::KEM — pure ML-KEM-768 (FIPS 203)
  • PQCrypto::SignatureML-DSA-65 (FIPS 204)
  • PQCrypto::HybridKEMML-KEM-768 + X25519 combined via the X-Wing SHA3-256 combiner

The gem is backed by vendored PQClean sources for ML-KEM-768 / ML-DSA-65 and by OpenSSL for X25519 and SHA3-256. Every piece of conventional-crypto functionality goes through standard library calls (EVP_*, RAND_bytes, CRYPTO_memcmp, streaming EVP_Encode* / EVP_Decode*) — nothing roll-your-own where a library primitive exists.

Status

  • primitive-first API only
  • no protocol/session helpers in the public surface
  • serialization uses pq_crypto-specific pqc_container_* wrappers
  • not audited
  • not yet positioned as production-ready

Installation

Add the gem to your project and compile the extension:

# Gemfile
gem "pq_crypto"
bundle install
bundle exec rake compile

Native dependencies

  • Ruby 3.4.x
  • a C toolchain with C11 support (for _Static_assert / _Thread_local)
  • OpenSSL 3.0 or later with SHA3-256 available (default provider)

Async / Fiber scheduler support

pq_crypto does not require any gem-specific Async configuration. On Ruby 3.4, sign and verify use Ruby's scheduler-aware rb_nogvl(..., RB_NOGVL_OFFLOAD_SAFE) path automatically.

That means:

  • without a Fiber scheduler, these methods fall back to the ordinary no-GVL behavior;
  • with a scheduler that implements blocking_operation_wait (for example Async with a worker pool), the blocking native work can be moved off the event loop.

This integration is intentionally limited to sign and verify; the faster primitive operations keep the lower-overhead path.

Example with Async:

require "async"
require "pq_crypto"

keypair = PQCrypto::Signature.generate(:ml_dsa_65)
message = "hello" * 100_000

reactor = Async::Reactor.new(worker_pool: true)
root = reactor.async do |task|
  task.async do
    signature = keypair.secret_key.sign(message)
    keypair.public_key.verify(message, signature)
  end

  task.async do
    sleep 0.01
    puts "event loop stayed responsive"
  end
end

reactor.run
root.wait
reactor.close

Primitive API

ML-KEM-768

keypair = PQCrypto::KEM.generate(:ml_kem_768)
result = keypair.public_key.encapsulate
shared_secret = keypair.secret_key.decapsulate(result.ciphertext)

ML-DSA-65

keypair = PQCrypto::Signature.generate(:ml_dsa_65)
signature = keypair.secret_key.sign("hello")

keypair.public_key.verify("hello", signature)    # => true / false
keypair.public_key.verify!("hello", signature)   # raises on mismatch

Note: verify returns a plain boolean for normal outcomes. verify! raises PQCrypto::VerificationError when the signature does not match.

Hybrid ML-KEM-768 + X25519 (X-Wing)

keypair = PQCrypto::HybridKEM.generate(:ml_kem_768_x25519_xwing)
result = keypair.public_key.encapsulate
shared_secret = keypair.secret_key.decapsulate(result.ciphertext)

The combiner is exactly:

ss = SHA3-256( "\.//^\" || ss_M || ss_X || ct_X || pk_X )

as specified by draft-connolly-cfrg-xwing-kem. See SECURITY.md for audit status and interoperability caveats.

Serialization

Key import/export is available through pq_crypto-specific containers:

  • to_pqc_container_der
  • to_pqc_container_pem
  • *_from_pqc_container_der
  • *_from_pqc_container_pem

Example:

keypair = PQCrypto::KEM.generate(:ml_kem_768)
der = keypair.public_key.to_pqc_container_der
imported = PQCrypto::KEM.public_key_from_pqc_container_der(der)

These containers are not real ASN.1 SPKI or PKCS#8. They are intended for stable import/export inside pq_crypto itself and are not advertised as interoperable with external PKI tooling.

Secure wiping

PQCrypto.secure_wipe(str) zeros the bytes of a mutable Ruby string in place. Key objects hold a private copy of their bytes, so wipe! on a SecretKey zeroes only that internal copy — any prior Ruby string the caller holds is untouched. If you need to wipe the caller-side buffer, do so explicitly:

raw = File.binread(path)
key = PQCrypto::KEM.secret_key_from_bytes(:ml_kem_768, raw)
PQCrypto.secure_wipe(raw)  # scrub the original input
# ... use key ...
key.wipe!                  # scrub the key's internal copy

Constant-time comparison

== on PublicKey / SecretKey instances uses OpenSSL CRYPTO_memcmp through a PQCrypto.ct_equals helper so comparisons do not leak timing information about a prefix match.

Introspection

PQCrypto.version
PQCrypto.backend
PQCrypto.supported_kems
PQCrypto.supported_hybrid_kems
PQCrypto.supported_signatures
PQCrypto::KEM.details(:ml_kem_768)
PQCrypto::HybridKEM.details(:ml_kem_768_x25519_xwing)
PQCrypto::Signature.details(:ml_dsa_65)

Testing helpers

Deterministic test hooks are exposed under PQCrypto::Testing for regression coverage:

  • ml_kem_keypair_from_seed — requires a 64-byte d||z seed (FIPS 203)
  • ml_kem_encapsulate_from_seed — requires a 32-byte seed
  • ml_dsa_keypair_from_seed — requires a 32-byte seed
  • ml_dsa_sign_from_seed — requires a 32-byte seed

These helpers are intended for tests only. They work by installing a thread-local seed-replay mode inside the gem's randombytes() for the duration of the call, then call the stock PQClean entrypoints. No internal PQClean algorithm logic is reimplemented in this gem.

Development

Run the test suite with:

bundle exec rake test

Refresh vendored PQClean sources manually only when you intentionally update the vendor snapshot. The refresh script has a safe pinned default and records the exact vendored snapshot in ext/pqcrypto/vendor/.vendored:

bundle exec ruby script/vendor_libs.rb

To intentionally change the upstream snapshot, override all four pinning inputs together:

PQCLEAN_VERSION=<full-git-commit> \
PQCLEAN_URL=https://github.com/PQClean/PQClean/archive/<full-git-commit>.tar.gz \
PQCLEAN_SHA256=<archive-sha256> \
PQCLEAN_STRIP=PQClean-<full-git-commit> \
  bundle exec ruby script/vendor_libs.rb