crypto/mlkem

Updated

October 31, 2026

crypto/mlkem

Go ships crypto/mlkem: FIPS 203 ML-KEM (formerly Kyber) in the standard library. It is a key encapsulation mechanism (KEM), not a general-purpose encrypt-this-file API. Alice holds a decapsulation key; Bob uses the matching encapsulation key to produce a shared secret plus a ciphertext; Alice recovers the same shared secret. The boring default is ML-KEM-768 (GenerateKey768). Pair the idea with post-quantum signatures (crypto/mldsa on Go 1.27+) when you need both exchange and signing — this chapter stays on the KEM.

Mental model

A KEM has three steps:

  1. Generate a decapsulation key (secret). Publish its encapsulation key (public).
  2. Encapsulate with the public key → (sharedKey, ciphertext). Send only the ciphertext.
  3. Decapsulate with the secret key + ciphertext → sharedKey (same 32 bytes on success).
Parameter set Encap key Ciphertext Shared key Seed
ML-KEM-768 (default) 1184 bytes 1088 bytes 32 bytes 64 bytes
ML-KEM-1024 1568 bytes 1568 bytes 32 bytes 64 bytes

Constants live on the package: EncapsulationKeySize768, CiphertextSize768, SharedKeySize, SeedSize, and the 1024 twins.

Store the 64-byte seed (DecapsulationKey768.Bytes() / NewDecapsulationKey768), not an ad-hoc encoding of expanded material. Publish EncapsulationKey().Bytes().

Implicit rejection: if the ciphertext has the wrong length, Decapsulate returns an error. If the length is correct but the ciphertext is garbage or for another key, Decapsulate still returns a 32-byte value — it just will not match the sender’s shared key. Do not treat “no error” as “authenticated peer.” Derive session keys with a KDF and bind identity/transcript the way TLS does; do not invent a protocol that branches on decapsulate errors alone.

Hybrid TLS (high level): since Go 1.24, crypto/tls can negotiate the hybrid group X25519MLKEM768 by default when Config.CurvePreferences is nil. Go 1.27 also supports MLKEM1024 (and related hybrid preferences) when you opt in via CurvePreferences. That path uses ML-KEM inside the handshake; most applications never call crypto/mlkem directly. Call this package when you design your own KEM-based protocol, store PQ encapsulation keys, or test vectors — not to reimplement TLS.

Worked examples

Case 1: Generate, encapsulate, decapsulate

Save as mlkem_roundtrip.go. Alice generates ML-KEM-768, publishes the encapsulation key bytes, Bob encapsulates, Alice decapsulates, and both shared secrets match.

// mlkem_roundtrip.go
package main

import (
    "bytes"
    "crypto/mlkem"
    "fmt"
    "log"
)

func main() {
    dk, err := mlkem.GenerateKey768()
    if err != nil {
        log.Fatal(err)
    }
    pub := dk.EncapsulationKey().Bytes()
    fmt.Printf("encapsulation key: %d bytes\n", len(pub))
    fmt.Printf("seed: %d bytes\n", len(dk.Bytes()))

    ek, err := mlkem.NewEncapsulationKey768(pub)
    if err != nil {
        log.Fatal(err)
    }
    sharedBob, ciphertext := ek.Encapsulate()
    fmt.Printf("ciphertext: %d bytes\n", len(ciphertext))
    fmt.Printf("shared key: %d bytes\n", len(sharedBob))

    sharedAlice, err := dk.Decapsulate(ciphertext)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("match: %v\n", bytes.Equal(sharedAlice, sharedBob))
}

Run:

go run mlkem_roundtrip.go

Output:

encapsulation key: 1184 bytes
seed: 64 bytes
ciphertext: 1088 bytes
shared key: 32 bytes
match: true

Encapsulate draws randomness from a secure source. Keep both the seed and the shared key secret. The ciphertext is what you put on the wire.

Case 2: Reload a key from its seed

Save as mlkem_seed.go. Persist only the 64-byte seed. Rebuild with NewDecapsulationKey768 when you need to decapsulate again.

// mlkem_seed.go
package main

import (
    "bytes"
    "crypto/mlkem"
    "fmt"
    "log"
)

func main() {
    dk1, err := mlkem.GenerateKey768()
    if err != nil {
        log.Fatal(err)
    }
    seed := dk1.Bytes() // 64-byte d||z seed — this is what you store

    dk2, err := mlkem.NewDecapsulationKey768(seed)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("seeds equal: %v\n", bytes.Equal(dk1.Bytes(), dk2.Bytes()))
    fmt.Printf("pubs equal: %v\n",
        bytes.Equal(dk1.EncapsulationKey().Bytes(), dk2.EncapsulationKey().Bytes()))

    ek := dk2.EncapsulationKey()
    shared, ct := ek.Encapsulate()
    got, err := dk1.Decapsulate(ct)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("decapsulate after reload: %v\n", bytes.Equal(shared, got))
}

Run:

go run mlkem_seed.go

Output:

seeds equal: true
pubs equal: true
decapsulate after reload: true

Treat the seed like any other private key material. Do not log it. Do not put it in a config repo. The encapsulation key bytes are what you publish.

Case 3: Parameter set sizes

Save as mlkem_sizes.go. Print the fixed sizes so wire and storage budgets are visible before you pick 768 vs 1024.

// mlkem_sizes.go
package main

import (
    "crypto/mlkem"
    "fmt"
)

func main() {
    fmt.Printf("ML-KEM-768: encap=%d ct=%d\n",
        mlkem.EncapsulationKeySize768, mlkem.CiphertextSize768)
    fmt.Printf("ML-KEM-1024: encap=%d ct=%d\n",
        mlkem.EncapsulationKeySize1024, mlkem.CiphertextSize1024)
    fmt.Printf("shared key always: %d bytes\n", mlkem.SharedKeySize)
    fmt.Printf("seed always: %d bytes\n", mlkem.SeedSize)
}

Run:

go run mlkem_sizes.go

Output:

ML-KEM-768: encap=1184 ct=1088
ML-KEM-1024: encap=1568 ct=1568
shared key always: 32 bytes
seed always: 64 bytes

Prefer 768 unless a peer or compliance table names 1024. Mixing sets fails: a 768 encapsulation key must be parsed with NewEncapsulationKey768, not NewEncapsulationKey1024.

Case 4: Ciphertext length vs garbage (real API pitfall)

Save as mlkem_decap_pitfall.go. Wrong-length ciphertexts error. Correct-length garbage does not error — it yields a different shared key. That is FIPS 203 implicit rejection, not a bug.

// mlkem_decap_pitfall.go
package main

import (
    "bytes"
    "crypto/mlkem"
    "fmt"
    "log"
)

func main() {
    dk, err := mlkem.GenerateKey768()
    if err != nil {
        log.Fatal(err)
    }
    shared, ct := dk.EncapsulationKey().Encapsulate()

    _, err = dk.Decapsulate(ct[:10])
    fmt.Printf("short ciphertext error: %v\n", err != nil)

    bad := bytes.Clone(ct)
    bad[0] ^= 0xff
    got, err := dk.Decapsulate(bad)
    fmt.Printf("garbage ciphertext error: %v\n", err != nil)
    fmt.Printf("garbage matches sender shared key: %v\n", bytes.Equal(shared, got))
    fmt.Println("fix: compare derived keys / transcripts; do not rely on Decapsulate error alone")
}

Run:

go run mlkem_decap_pitfall.go

Output:

short ciphertext error: true
garbage ciphertext error: false
garbage matches sender shared key: false
fix: compare derived keys / transcripts; do not rely on Decapsulate error alone

Ciphertext malleability myths: age-style file formats and TLS AEAD layers make ciphertext tampering fail closed at a higher layer. Raw ML-KEM ciphertexts are not a substitute for an AEAD. After both sides share sharedKey, feed it into a proper KDF and use the result with an AEAD (or use TLS and skip hand-rolling).

The trap

Save as mlkem_size_trap.go. Treating an ML-KEM encapsulation key or ciphertext like an X25519 blob blows past 32-byte columns and stack buffers that were fine for classical ECDH.

// mlkem_size_trap.go
package main

import (
    "crypto/mlkem"
    "fmt"
)

func main() {
    const classicalECDHPub = 32 // X25519-sized column / stack buffer
    fmt.Printf("ML-KEM-768 encap key: %d (fits 32? %v)\n",
        mlkem.EncapsulationKeySize768, mlkem.EncapsulationKeySize768 <= classicalECDHPub)
    fmt.Printf("ML-KEM-768 ciphertext: %d (fits 32? %v)\n",
        mlkem.CiphertextSize768, mlkem.CiphertextSize768 <= classicalECDHPub)
    fmt.Println("fix: size the wire and DB for EncapsulationKeySize* + CiphertextSize* of the chosen set")
}

Run:

go run mlkem_size_trap.go

Output:

ML-KEM-768 encap key: 1184 (fits 32? false)
ML-KEM-768 ciphertext: 1088 (fits 32? false)
fix: size the wire and DB for EncapsulationKeySize* + CiphertextSize* of the chosen set

The second trap is rolling your own “PQ TLS” with this package when crypto/tls already speaks hybrid ML-KEM. Prefer the stdlib handshake unless you are writing a specialized protocol and have a reviewed design.

The boring rule

  • New direct KEMs in Go: import crypto/mlkem; default to ML-KEM-768 (GenerateKey768 / NewEncapsulationKey768 / Decapsulate).
  • Store the 64-byte seed; publish encapsulation key bytes; send ciphertext; keep shared keys secret.
  • After both sides have sharedKey, run a KDF and an AEAD (or use TLS). Do not treat Decapsulate’s error channel as peer authentication.
  • Budget ~1.2 KiB for the public key and ~1.1 KiB for each ML-KEM-768 ciphertext on the wire.
  • For ordinary HTTPS/gRPC, rely on crypto/tls hybrid groups (X25519MLKEM768 and friends) instead of calling this package yourself.
  • Check errors from GenerateKey* / New*Key* / wrong-length Decapsulate; never ignore a length mismatch.

Try this

  1. In mlkem_roundtrip.go, switch to GenerateKey1024 / NewEncapsulationKey1024 and confirm the printed lengths match mlkem_sizes.go.
  2. In mlkem_seed.go, truncate the seed to 63 bytes before NewDecapsulationKey768 and confirm you get an error.
  3. In mlkem_decap_pitfall.go, pass a ciphertext from a second key pair into the first Decapsulate and show the shared keys diverge with err == nil.
  4. Read go doc crypto/tls.CurveID (or your Go version’s TLS docs) and list which ML-KEM-related curve IDs your toolchain advertises by default.