crypto/mldsa
crypto/mldsa
Go 1.27 ships crypto/mldsa: FIPS 204 ML-DSA signatures in the standard library. Pair it with the existing crypto/mlkem story — ML-KEM for key exchange, ML-DSA for signing. The boring default for new post-quantum signing on Go 1.27+ is mldsa.MLDSA44() unless your threat model or peer explicitly requires a larger parameter set. Signatures and public keys are large; design wire formats and storage around that size before you ship.
Mental model
ML-DSA is a lattice-based signature scheme. You pick one of three fixed parameter sets:
| Helper | Public key | Signature | Typical use |
|---|---|---|---|
MLDSA44() |
1312 bytes | 2420 bytes | Default for most apps |
MLDSA65() |
1952 bytes | 3309 bytes | Higher security margin |
MLDSA87() |
2592 bytes | 4627 bytes | Highest NIST category |
Compare that to an Ed25519 public key (32 bytes) and signature (64 bytes). The private key seed is still only 32 bytes (mldsa.PrivateKeySize); store the seed, not an expanded form.
PrivateKey implements crypto.Signer, so it plugs into APIs that already accept a signer. crypto/x509 understands ML-DSA keys and signatures. crypto/tls exposes MLDSA44, MLDSA65, and MLDSA87 as TLS 1.3 SignatureScheme values.
Options.Context is domain separation: the same key can sign for different purposes if each purpose uses a distinct context string (at most 255 bytes). Verifying with the wrong context fails.
If the process is built against the FIPS 140-3 Go Cryptographic Module v1.0.0, GenerateKey / NewPublicKey / Verify return an error. Use module v1.26.0 or later, or a non-FIPS build, for this package.
Worked examples
Case 1: Generate, sign, verify
Save as mldsa_roundtrip.go. Generate an ML-DSA-44 key, publish the public key bytes, sign a desk message, and verify.
// mldsa_roundtrip.go
package main
import (
"crypto/mldsa"
"fmt"
"log"
)
func main() {
sk, err := mldsa.GenerateKey(mldsa.MLDSA44())
if err != nil {
log.Fatal(err)
}
pubEnc := sk.PublicKey().Bytes()
fmt.Printf("public key: %d bytes\n", len(pubEnc))
fmt.Printf("private seed: %d bytes\n", len(sk.Bytes()))
msg := []byte("shift-close: till-3 sealed")
opts := &mldsa.Options{Context: "desk/shift-close"}
sig, err := sk.Sign(nil, msg, opts)
if err != nil {
log.Fatal(err)
}
fmt.Printf("signature: %d bytes\n", len(sig))
pk, err := mldsa.NewPublicKey(mldsa.MLDSA44(), pubEnc)
if err != nil {
log.Fatal(err)
}
if err := mldsa.Verify(pk, msg, sig, opts); err != nil {
log.Fatal("invalid signature: ", err)
}
fmt.Println("verified: true")
}Run:
go run mldsa_roundtrip.goOutput:
public key: 1312 bytes
private seed: 32 bytes
signature: 2420 bytes
verified: true
Sign’s io.Reader argument is ignored for ML-DSA; pass nil. Always check errors from GenerateKey and Verify — a FIPS 140-3 v1.0.0 module returns an error instead of producing keys.
Case 2: Context mismatch fails verification
Save as mldsa_context.go. Sign under one context and verify under another. Domain separation is part of the signature, not a soft hint.
// mldsa_context.go
package main
import (
"crypto/mldsa"
"fmt"
"log"
)
func main() {
sk, err := mldsa.GenerateKey(mldsa.MLDSA44())
if err != nil {
log.Fatal(err)
}
msg := []byte("release the gophers")
sig, err := sk.Sign(nil, msg, &mldsa.Options{Context: "prod/release"})
if err != nil {
log.Fatal(err)
}
pk := sk.PublicKey()
err = mldsa.Verify(pk, msg, sig, &mldsa.Options{Context: "staging/release"})
fmt.Printf("wrong context: %v\n", err != nil)
err = mldsa.Verify(pk, msg, sig, &mldsa.Options{Context: "prod/release"})
fmt.Printf("matching context: %v\n", err == nil)
}Run:
go run mldsa_context.goOutput:
wrong context: true
matching context: true
Use a stable, purpose-specific context string ("api/v1/token", "git/tag", "desk/receipt"). Empty context is allowed and is the default when opts is nil, but then every consumer of that key shares one domain.
Case 3: Parameter set sizes
Save as mldsa_sizes.go. Print the public-key and signature sizes for each parameter set so wire budgets are visible before you pick one.
// mldsa_sizes.go
package main
import (
"crypto/mldsa"
"fmt"
)
func main() {
for _, p := range []mldsa.Parameters{
mldsa.MLDSA44(),
mldsa.MLDSA65(),
mldsa.MLDSA87(),
} {
fmt.Printf("%s: pubkey=%d sig=%d\n",
p.String(), p.PublicKeySize(), p.SignatureSize())
}
fmt.Printf("private seed always: %d bytes\n", mldsa.PrivateKeySize)
}Run:
go run mldsa_sizes.goOutput:
ML-DSA-44: pubkey=1312 sig=2420
ML-DSA-65: pubkey=1952 sig=3309
ML-DSA-87: pubkey=2592 sig=4627
private seed always: 32 bytes
Prefer MLDSA44() unless a peer or compliance table names a higher set. Bigger sets buy margin at a fixed cost in every packet and every stored certificate.
Case 4: Reload a key from its seed
Save as mldsa_seed.go. Persist only the 32-byte seed. Rebuild the private key with NewPrivateKey when you need to sign again.
// mldsa_seed.go
package main
import (
"crypto/mldsa"
"fmt"
"log"
)
func main() {
sk1, err := mldsa.GenerateKey(mldsa.MLDSA44())
if err != nil {
log.Fatal(err)
}
seed := sk1.Bytes() // 32-byte seed — this is what you store
sk2, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), seed)
if err != nil {
log.Fatal(err)
}
fmt.Printf("seeds equal: %v\n", sk1.Equal(sk2))
fmt.Printf("pubs equal: %v\n", sk1.PublicKey().Equal(sk2.PublicKey()))
msg := []byte("reload-ok")
opts := &mldsa.Options{Context: "desk/reload"}
sig, err := sk2.Sign(nil, msg, opts)
if err != nil {
log.Fatal(err)
}
if err := mldsa.Verify(sk1.PublicKey(), msg, sig, opts); err != nil {
log.Fatal(err)
}
fmt.Println("sign after reload: ok")
}Run:
go run mldsa_seed.goOutput:
seeds equal: true
pubs equal: true
sign after reload: ok
Treat the seed like any other private key material. Do not log it. Do not put it in a config repo. The expanded public key is what you publish.
The trap
Save as mldsa_size_trap.go. Treating an ML-DSA signature like an Ed25519 blob blows past fixed-size buffers and column widths that were fine for classical signatures.
// mldsa_size_trap.go
package main
import (
"crypto/mldsa"
"fmt"
"log"
)
func main() {
sk, err := mldsa.GenerateKey(mldsa.MLDSA44())
if err != nil {
log.Fatal(err)
}
sig, err := sk.Sign(nil, []byte("ticket-42"), nil)
if err != nil {
log.Fatal(err)
}
const classicalSigBudget = 64 // Ed25519-sized column / stack buffer
fmt.Printf("signature bytes: %d\n", len(sig))
fmt.Printf("fits classical 64-byte budget: %v\n", len(sig) <= classicalSigBudget)
fmt.Println("fix: size the wire and DB for PublicKeySize + SignatureSize of the chosen set")
}Run:
go run mldsa_size_trap.goOutput:
signature bytes: 2420
fits classical 64-byte budget: false
fix: size the wire and DB for PublicKeySize + SignatureSize of the chosen set
The second trap is mixing parameter sets: a key generated with MLDSA44() must be reconstructed and verified with MLDSA44(). Passing MLDSA65() to NewPublicKey with 44-encoding fails.
The boring rule
- New post-quantum signing on Go 1.27+: import
crypto/mldsa; default toMLDSA44(). - Store the 32-byte seed (
PrivateKey.Bytes()); publishPublicKey.Bytes(). - Always set a purpose-specific
Options.Contextwhen one key serves more than one role; verify with the same context. - Budget ~1.3 KiB for the public key and ~2.4 KiB for each ML-DSA-44 signature on the wire and in storage.
- Use
crypto/Signerintegration andcrypto/x509/crypto/tlssupport rather than inventing a custom encoding. - Check errors from
GenerateKey/Verifyunder FIPS builds; the package may be unavailable on module v1.0.0.
Try this
- In
mldsa_roundtrip.go, switch toMLDSA65()and confirm the printed public-key and signature lengths matchmldsa_sizes.go. - In
mldsa_context.go, verify withopts == nilafter signing with a non-empty context; confirm it fails. - In
mldsa_seed.go, truncate the seed to 31 bytes beforeNewPrivateKeyand confirm you get an error. - Call
p.PublicKeySize()andp.SignatureSize()forMLDSA87()and add those numbers to a comment next to anyVARBINARY/ protobuf field you would use in production.