Skip to content
ecdsa.com

ML-DSA-65 sign/verify · OpenSSL

How to sign and verify with ML-DSA-65 with OpenSSL

To sign and verify with ML-DSA-65 with OpenSSL, you need OpenSSL 3.5 or later: openssl genpkey -algorithm ML-DSA-65 creates the key, and openssl pkeyutl -sign / -verify with -rawin signs the whole message. This recipe also uses a context string, deterministic signing and a key derived from a fixed seed, and checks that the signature verifies in the other languages of this page.

Tested with OpenSSL 3.5.7 (Debian 13).

key pair, signature and verification
# An ML-DSA-65 key pair (OpenSSL 3.5 or later)
openssl genpkey -algorithm ML-DSA-65 -out key.pem
openssl pkey -in key.pem -pubout -out pub.pem
# -rawin: the whole message goes in, ML-DSA hashes it internally
openssl pkeyutl -sign -inkey key.pem -rawin -in message.txt -out message.sig
openssl pkeyutl -verify -pubin -inkey pub.pem -rawin -in message.txt -sigfile message.sig
context string, deterministic signing, a key from a known seed
# A context string binds the signature to one purpose; verify must pass the same one
openssl pkeyutl -sign -inkey key.pem -rawin -in message.txt -pkeyopt context-string:invoice-v1 -out ctx.sig
openssl pkeyutl -verify -pubin -inkey pub.pem -rawin -in message.txt -sigfile ctx.sig -pkeyopt context-string:invoice-v1
# Deterministic signing and a key from a known 32-byte seed: the same signature every time
openssl genpkey -algorithm ML-DSA-65 -pkeyopt hexseed:$SEED_HEX -out seeded.pem
openssl pkeyutl -sign -inkey seeded.pem -rawin -in message.txt -pkeyopt deterministic:1 -out det.sig

How it works

  1. openssl genpkey -algorithm ML-DSA-65 creates the private key; openssl pkey -pubout writes the public key as SubjectPublicKeyInfo (BEGIN PUBLIC KEY, 1974 bytes of DER).
  2. openssl pkeyutl -sign -rawin passes the whole message to ML-DSA, which hashes it internally; the signature is always 3309 bytes for ML-DSA-65.
  3. -pkeyopt context-string:… binds the signature to a purpose (FIPS 204 allows up to 255 bytes); verification must pass the same context.
  4. -pkeyopt hexseed: derives the key from a 32-byte seed and -pkeyopt deterministic:1 turns off the signing randomness: the run reproduces the shared test signature (SHA-256 9456b955…68a6) exactly, and verifies it with the shared public key.

Gotchas

  • Default signing is randomized ("hedged"): two signatures of the same message differ, and both verify. Do not compare signatures to check them; compare only with deterministic signing on both sides.
  • OpenSSL writes the private key as seed plus expanded key by default (4098 bytes of DER). Go and pyca/cryptography refuse that form; openssl pkey -provparam ml-dsa.output_formats=seed-only writes the 54-byte seed-only PKCS#8 every library reads.
  • ML-DSA takes no separate digest: openssl dgst -sha256 -sign key.pem fails with error:1C80007A:Provider routines:ml_dsa_digest_signverify_init:invalid digest. Use pkeyutl -rawin.
  • A signature made with a context string fails without it (Signature Verification Failure), as does a tampered message.

Related recipes