Ruby C extension for ML-DSA (NIST FIPS 204), the post-quantum digital signature algorithm formerly known as CRYSTALS-Dilithium.
Bundles the PQClean clean C implementation for all three standardized parameter sets:
| Parameter Set | NIST Security Level | Public Key | Secret Key | Signature |
|---|---|---|---|---|
| ML-DSA-44 | 2 | 1,312 B | 2,560 B | 2,420 B |
| ML-DSA-65 | 3 | 1,952 B | 4,032 B | 3,309 B |
| ML-DSA-87 | 5 | 2,592 B | 4,896 B | 4,627 B |
gem "ml_dsa"gem install ml_dsaCompile only a subset of parameter sets to reduce binary size:
gem install ml_dsa -- --with-ml-dsa-params=44,65
bundle config build.ml_dsa --with-ml-dsa-params=65- Ruby >= 2.7.2
- C11-compatible compiler (GCC, Clang, MSVC)
- Linux, macOS (Intel + ARM), or Windows
- No OpenSSL dependency
require "ml_dsa"
pk, sk = MlDsa.keygen(MlDsa::ML_DSA_65)Deterministic keygen from a 32-byte seed:
seed = SecureRandom.random_bytes(32)
pk, sk = MlDsa.keygen(MlDsa::ML_DSA_65, seed: seed)message = "Hello, post-quantum world!"
signature = sk.sign(message) # hedged (randomized)
signature = sk.sign(message, deterministic: true) # deterministic
signature = sk.sign(message, context: "app-v1") # with FIPS 204 context
pk.verify(message, signature) # => true
pk.verify(message, signature, context: "app-v1") # => trueSign or verify multiple messages in a single GVL release:
signatures = MlDsa.sign_many([
MlDsa::SignRequest.new(sk: sk, message: "msg1"),
MlDsa::SignRequest.new(sk: sk, message: "msg2"),
])
results = MlDsa.verify_many([
MlDsa::VerifyRequest.new(pk: pk, message: "msg1", signature: signatures[0]),
MlDsa::VerifyRequest.new(pk: pk, message: "msg2", signature: signatures[1]),
])
results.each do |r|
puts r.ok? ? "valid" : "failed: #{r.reason}"
endBlock-based batch builder:
sigs = MlDsa.batch { |b|
b.sign(sk: sk, message: "msg1")
b.sign(sk: sk, message: "msg2")
}# Raw bytes — param_set auto-detected from byte size
pk2 = MlDsa::PublicKey.from_bytes(pk.to_bytes)
# Hex — param_set auto-detected from byte size
pk3 = MlDsa::PublicKey.from_hex(pk.to_hex)
# DER (SubjectPublicKeyInfo / PKCS#8) — param_set auto-detected from OID
pk4 = MlDsa::PublicKey.from_der(pk.to_der)
sk2 = MlDsa::SecretKey.from_der(sk.to_der)
# PEM
pk5 = MlDsa::PublicKey.from_pem(pk.to_pem)
sk3 = MlDsa::SecretKey.from_pem(sk.to_pem)
# Seed-only compact storage (32 bytes instead of full key)
sk4 = MlDsa::SecretKey.from_seed(seed, MlDsa::ML_DSA_65)# Secret keys from keygen/from_seed carry the associated public key
sk.public_key == pk # => true
sk.seed # => 32-byte seed (nil if not created from seed)
# Keys deserialized from bytes/DER/PEM have no associated public key
MlDsa::SecretKey.from_der(sk.to_der).public_key # => nil
# Fingerprint for logs and UIs (SHA-256 prefix, 32 hex chars)
pk.fingerprint # => "a3b1c9f0e2d4..."
# Timestamps and metadata
pk.created_at # => Time
sk.key_usage = :signing # application-defined labelSecret keys live in C-managed memory with mlock (prevents swap) and
secure_zero on GC. There is no to_bytes or to_hex on secret keys.
# Controlled access — buffer is wiped on block exit, even on exception
sk.with_bytes do |buf|
# buf is a temporary binary String
end
# Explicit wipe — subsequent operations raise MlDsa::Error
sk.wipe!MlDsa.random_source = proc { |n| "\x42" * n } # for testing / HSM
MlDsa.random_source = nil # restore OS CSPRNGsubscriber = MlDsa.subscribe do |event|
logger.info "#{event[:operation]} #{event[:param_set].name} " \
"count=#{event[:count]} duration=#{event[:duration_ns]}ns"
end
MlDsa.unsubscribe(subscriber)Isolated configuration for per-Ractor or per-test contexts:
cfg = MlDsa::Config.new
cfg.random_source = proc { |n| SecureRandom.random_bytes(n) }
pk, sk = MlDsa.keygen(MlDsa::ML_DSA_65, config: cfg)PQC::MlDsa == MlDsa # => true
PQC.algorithms # => { ml_dsa: MlDsa }
PQC.algorithm(:ml_dsa) # => MlDsabegin
MlDsa::PublicKey.from_der(bad_data)
rescue MlDsa::Error::Deserialization => e
e.message # => "invalid DER: ..."
e.format # => "DER"
e.position # => 12
e.reason # => :unknown_oid
endMlDsa::Error— base classMlDsa::Error::KeyGenerationMlDsa::Error::SigningMlDsa::Error::Deserialization— includesformat,position,reason
| Property | Implementation |
|---|---|
| Secure zeroing | SecureZeroMemory / explicit_bzero / memset_s / volatile fallback |
| Constant-time comparison | XOR-accumulate with compiler fence for secret key equality |
| Memory locking | mlock prevents secret key pages from swapping to disk |
| Thread-safe wipe | C11 _Atomic with acquire/release semantics |
| GVL release | All crypto runs without the Global VM Lock |
| Ractor safety | PublicKey is Ractor-shareable (RUBY_TYPED_FROZEN_SHAREABLE) |
| Symbol isolation | -fvisibility=hidden prevents PQClean symbol clashes |
| No OpenSSL | DER/PEM via pqc_asn1 gem |
bundle install
bundle exec rake compile
bundle exec rake test
bundle exec rake bench # benchmarks (requires benchmark-ips)
bundle exec rake yard # API docs
bundle exec standardrb # Ruby lint
bundle exec rake lint:c # C static analysis (requires cppcheck)
bundle exec rake pqclean:verify # verify vendored PQClean patches
bundle exec rake generate:impl # regenerate amalgamation filesDual-licensed under MIT or Apache-2.0, at your option.