Skip to content
ecdsa.com

ML-DSA-65 sign/verify · Rust

How to sign and verify with ML-DSA-65 in Rust

To sign and verify with ML-DSA-65 in Rust, use RustCrypto's ml-dsa crate: SigningKey::<MlDsa65>::generate(), the Signer and Verifier traits for sign and verify, and the pkcs8 traits for PEM keys. This recipe also signs with a context string and checks interoperability with OpenSSL 3.5 — down to reproducing OpenSSL's deterministic signature.

Tested with ml-dsa 0.1.1 (Rust 1.99.1, RustCrypto, pkcs8 0.11.0).

Cargo.toml — ml-dsa (RustCrypto) and pkcs8 with PEM support
[dependencies]
ml-dsa = "=0.1.1"
pkcs8 = { version = "=0.11.0", features = ["pem"] }
src/sign.rs — sign, verify, context string, SPKI public key from PEM
use ml_dsa::{Generate, Keypair, MlDsa65, Signer, SigningKey, Verifier, VerifyingKey};
use pkcs8::DecodePublicKey;
/// An ML-DSA-65 public key from an SPKI ("BEGIN PUBLIC KEY") file, e.g. written by OpenSSL 3.5.
pub fn read_public_key(path: &str) -> Result<VerifyingKey<MlDsa65>, Box<dyn std::error::Error>> {
Ok(VerifyingKey::<MlDsa65>::from_public_key_pem(&std::fs::read_to_string(path)?)?)
}
pub fn sign_and_verify(message: &[u8]) -> Result<(), ml_dsa::Error> {
let signing_key = SigningKey::<MlDsa65>::generate(); // OS randomness ("getrandom" feature)
let verifying_key = signing_key.verifying_key();
let signature = signing_key.sign(message); // deterministic: Signer has no randomness
println!("public key {} bytes, signature {} bytes", verifying_key.encode().len(), signature.encode().len());
println!("verified: {}", verifying_key.verify(message, &signature).is_ok());
println!("tampered message: {}", verifying_key.verify(b"hello, post-quantum world!", &signature).is_ok());
// A context string: sign and verify with the same one
let context = b"invoice-v1";
let invoice_signature = signing_key.expanded_key().sign_deterministic(message, context)?;
println!(
"with the context: {}, without: {}",
verifying_key.verify_with_context(message, context, &invoice_signature),
verifying_key.verify(message, &invoice_signature).is_ok()
);
Ok(())
}

How it works

  1. SigningKey::<MlDsa65>::generate() uses OS randomness (the crate's default getrandom feature); verifying_key() gives the 1952-byte public key.
  2. Signer::sign returns a Signature that encodes to 3309 bytes; Verifier::verify returns Ok(()) or an error.
  3. For a context string, sign with expanded_key().sign_deterministic(message, context) and check with verify_with_context(message, context, &signature), which returns a bool.
  4. VerifyingKey::from_public_key_pem reads OpenSSL's SPKI file and SigningKey::from_pkcs8_pem its seed-only private key; the run verifies OpenSSL's signature.

Gotchas

  • Signer::sign is deterministic — it has no randomness input — unlike the default signing of OpenSSL, Java, .NET or Python. Signing the shared message with the shared seed reproduces OpenSSL's deterministic signature exactly (SHA-256 9456b955…68a6).
  • A signature made with a context does not verify through Verifier::verify, which uses an empty context (false in the run).
  • generate() comes from the crate's default getrandom feature; with default-features = false it disappears, and keys must be built from a seed (SigningKey::from_seed) or a random generator you pass in.

Related recipes