Skip to content
ecdsa.com

ML-DSA-65 sign/verify · Go

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

To sign and verify with ML-DSA-65 in Go, use the standard library's crypto/mldsa (Go 1.26 and later): mldsa.GenerateKey(mldsa.MLDSA65()), key.Sign and mldsa.Verify, with a context string in mldsa.Options. crypto/x509 reads ML-DSA keys from SPKI and PKCS#8, so keys and signatures from OpenSSL 3.5 work directly, as this recipe shows.

Tested with Go 1.27.3 (crypto/mldsa and crypto/x509, linux/arm64) and OpenSSL 3.5.7 (shared test vector and the default key file).

sign.go — key pair, randomized signature, verification, context string
package main
import (
"crypto/mldsa"
"fmt"
"log"
)
func signAndVerify() {
key, err := mldsa.GenerateKey(mldsa.MLDSA65())
if err != nil {
log.Fatal(err)
}
message := []byte("hello, post-quantum world")
// Sign is randomized (hedged); the reader argument is ignored, opts may carry a context
signature, err := key.Sign(nil, message, nil)
if err != nil {
log.Fatal(err)
}
pub := key.PublicKey()
fmt.Println("public key", len(pub.Bytes()), "bytes, signature", len(signature), "bytes")
fmt.Println("verified:", mldsa.Verify(pub, message, signature, nil) == nil)
fmt.Println("tampered message:", mldsa.Verify(pub, []byte("hello, post-quantum world!"), signature, nil))
opts := &mldsa.Options{Context: "invoice-v1"} // must match on both sides
invoiceSig, err := key.Sign(nil, message, opts)
if err != nil {
log.Fatal(err)
}
fmt.Println("with the context:", mldsa.Verify(pub, message, invoiceSig, opts) == nil,
"without:", mldsa.Verify(pub, message, invoiceSig, nil) == nil)
}
verify.go — verify with an SPKI public key file, e.g. from OpenSSL
package main
import (
"crypto/mldsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
)
// verifyWithPEM checks a signature with an SPKI "PUBLIC KEY" file, e.g. one written by OpenSSL 3.5.
func verifyWithPEM(path string, message, signature []byte) (bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return false, err
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "PUBLIC KEY" {
return false, fmt.Errorf("%s: no PUBLIC KEY block", path)
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes) // *mldsa.PublicKey since Go 1.26
if err != nil {
return false, err
}
pub, ok := parsed.(*mldsa.PublicKey)
if !ok || pub.Parameters() != mldsa.MLDSA65() {
return false, fmt.Errorf("%s: not an ML-DSA-65 key", path)
}
return mldsa.Verify(pub, message, signature, nil) == nil, nil
}

How it works

  1. mldsa.GenerateKey(mldsa.MLDSA65()) creates a key; PublicKey().Bytes() is the 1952-byte public key and Sign returns a 3309-byte signature.
  2. key.Sign(nil, message, opts) signs the message itself (no pre-hashing); the io.Reader argument is ignored and opts may carry a Context.
  3. mldsa.Verify(pub, message, signature, opts) returns nil for a valid signature and mldsa: invalid signature otherwise.
  4. x509.ParsePKIXPublicKey returns *mldsa.PublicKey for an ML-DSA SPKI; the run verifies OpenSSL's signature with it, and NewPrivateKey plus SignDeterministic on the shared seed reproduce OpenSSL's deterministic signature byte for byte.

Gotchas

  • x509.ParsePKCS8PrivateKey reads seed-only keys but refuses OpenSSL's default file: x509: ML-DSA private keys with both seed and expanded key are not supported, use e.g. "openssl pkey -provparam ml-dsa.output_formats=seed-only" to convert to a seed-only key.
  • A context longer than 255 bytes fails with mldsa: context too long; and a signature made with a context does not verify without it.
  • Sign is randomized and SignDeterministic is not: use the deterministic variant only when you need reproducible signatures, for example in test vectors.
  • crypto/mldsa is unavailable when the program is built against the FIPS 140-3 Go Cryptographic Module v1.0.0; GenerateKey, NewPrivateKey, NewPublicKey and Verify then return an error.

Related recipes