crypto/mldsa

Updated

October 31, 2026

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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 to MLDSA44().
  • Store the 32-byte seed (PrivateKey.Bytes()); publish PublicKey.Bytes().
  • Always set a purpose-specific Options.Context when 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/Signer integration and crypto/x509 / crypto/tls support rather than inventing a custom encoding.
  • Check errors from GenerateKey / Verify under FIPS builds; the package may be unavailable on module v1.0.0.

Try this

  1. In mldsa_roundtrip.go, switch to MLDSA65() and confirm the printed public-key and signature lengths match mldsa_sizes.go.
  2. In mldsa_context.go, verify with opts == nil after signing with a non-empty context; confirm it fails.
  3. In mldsa_seed.go, truncate the seed to 31 bytes before NewPrivateKey and confirm you get an error.
  4. Call p.PublicKeySize() and p.SignatureSize() for MLDSA87() and add those numbers to a comment next to any VARBINARY / protobuf field you would use in production.