skip to content

How do you use Go's crypto/mlkem package to agree a shared secret outside TLS?

level: middleimportance: nice to knowfreq 12%

answer

  1. encapsulate, don't encrypt
  2. only one of the two returned values travels
  3. the private key is stored as a seed
  4. the result is a key, not a channel
  5. TLS pairs it with a curve deliberately

basics

~20 s

The receiver calls mlkem.GenerateKey768 and publishes dk.EncapsulationKey().Bytes(). The sender parses it with mlkem.NewEncapsulationKey768 and calls Encapsulate, which returns a 32-byte shared key and a ciphertext. Sending only that ciphertext lets the receiver Decapsulate the same 32 bytes.

solid answer

~50 s

`crypto/mlkem`, added in Go 1.24, exposes ML-KEM (FIPS 203) at the 768 and 1024 parameter sets. The flow is one round: the receiver generates a decapsulation key with `mlkem.GenerateKey768()` and publishes `dk.EncapsulationKey().Bytes()`, 1184 bytes. The sender parses that with `mlkem.NewEncapsulationKey768`, calls `ek.Encapsulate()` — which returns a shared key and a ciphertext, no error — and transmits only the 1088-byte ciphertext. The receiver recovers the identical `mlkem.SharedKeySize` bytes with `dk.Decapsulate(ct)`. Two things trip people up. A KEM encrypts nothing you chose: it hands you a random symmetric key, and you still need to derive per-purpose keys from it and encrypt with an AEAD. And `dk.Bytes()` returns the 64-byte seed, not an expanded private key, so that seed is what you store and feed back to `mlkem.NewDecapsulationKey768`. In a real design, mix an X25519 secret in alongside it rather than trusting the lattice scheme alone.

code

go · 16 lines
go
// Receiver: generate once, publish the encapsulation key.
dk, err := mlkem.GenerateKey768()
if err != nil {
	return err
}
pub := dk.EncapsulationKey().Bytes() // 1184 bytes, sent to the peer

// Sender: derive a secret locally, send only the ciphertext back.
ek, err := mlkem.NewEncapsulationKey768(pub)
if err != nil {
	return err
}
secret, ct := ek.Encapsulate() // secret is mlkem.SharedKeySize bytes

// Receiver: recover the identical secret from the ciphertext.
same, err := dk.Decapsulate(ct)

go deeper

for a junior

Recall the three-step shape: the receiver publishes an encapsulation key, the sender encapsulates and sends the ciphertext, the receiver decapsulates the same secret.

for a middle

Explain the API precisely — GenerateKey768, EncapsulationKey().Bytes(), NewEncapsulationKey768, Encapsulate returning a key and a ciphertext, Decapsulate returning an error — and the 1184, 1088 and 32-byte sizes.

for a senior

Show what the package leaves to you: authenticating the encapsulation key, deriving per-purpose keys from the shared secret, and mixing in a classical agreement rather than betting on one scheme.

for a principal

Judge whether a hand-built KEM protocol should exist at all, given that most confidentiality boundaries are TLS connections that get this for free from the toolchain default.

## What a KEM is, in API terms A key encapsulation mechanism is not encryption of a message you supply. It is a one-round protocol that leaves both parties holding the same fresh random symmetric key: 1. The receiver generates a keypair and publishes the public half (the *encapsulation key*). 2. The sender feeds that public half into `Encapsulate`, which returns two things: a shared key that the sender keeps, and a ciphertext. 3. The sender transmits **only the ciphertext**. 4. The receiver runs `Decapsulate` on it with the private half (the *decapsulation key*) and obtains the same shared key. The naming in `crypto/mlkem` follows that vocabulary exactly, which is why there is no `PublicKey`/`PrivateKey` pair — the types are `EncapsulationKey768` and `DecapsulationKey768`. ## The concrete Go flow ```go dk, err := mlkem.GenerateKey768() // receiver, once pub := dk.EncapsulationKey().Bytes() // 1184 bytes, published ek, err := mlkem.NewEncapsulationKey768(pub) // sender parses it secret, ct := ek.Encapsulate() // secret stays here, ct goes on the wire same, err := dk.Decapsulate(ct) // receiver recovers the same secret ``` Sizes worth carrying in your head, because they decide whether this fits your transport: the encapsulation key is 1184 bytes, the ciphertext 1088, and the shared key is `mlkem.SharedKeySize` — 32 bytes. The 1024 parameter set exists as `GenerateKey1024` and friends with larger values; 768 is the level TLS uses and the sensible default. `Encapsulate` returns no error. It draws its own randomness internally, and a failure of the system random source is treated as unrecoverable rather than something you branch on. `Decapsulate` does return an error — a malformed ciphertext is a real, attacker-controlled input. ## Seeds, not blobs `DecapsulationKey768.Bytes()` returns a **64-byte seed** (`mlkem.SeedSize`), not the expanded private key. `mlkem.NewDecapsulationKey768(seed)` reconstructs the key from it. This is a deliberate API choice with two practical consequences. What you persist or put in a secret manager is 64 bytes, which is pleasant. And the expanded form never leaves the package, so there is no invalid intermediate representation for you to mishandle. ## What the package does not do for you **It does not encrypt your data.** The 32 bytes are a key, not a channel. You still choose an AEAD, manage nonces, and — as with any raw shared secret — derive separate per-purpose keys rather than using those bytes directly for several jobs at once. **It does not authenticate anybody.** Nothing in the exchange proves who generated the encapsulation key. If an attacker can substitute their own encapsulation key in transit, they hold the shared secret and you will never notice. Authenticating the encapsulation key — signing it, pinning it, delivering it over an already-authenticated channel — is your problem, and it is the mistake most likely to sink a homegrown design. **It does not hedge for you.** Using ML-KEM alone stakes everything on a scheme that is young relative to the elliptic-curve constructions it supplements. This is precisely why TLS pairs it with X25519 rather than replacing X25519 with it. If you are building a protocol yourself, do the same: run both agreements and mix both secrets, so a break in either leaves you standing. ## When you would reach for this at all Most services should never touch `crypto/mlkem` directly — they get post-quantum protection from TLS by leaving `tls.Config.CurvePreferences` alone. The direct API earns its place when the confidentiality boundary is not a TLS connection: a payload sealed for a recipient and stored for years, an application-level channel inside an existing transport, a message-passing protocol with its own framing. Those are the cases where store-now-decrypt-later actually bites, because the ciphertext genuinely sits somewhere an adversary can copy it. ## Reviewing someone else's use Four questions cover most of it. Is anything other than the ciphertext being transmitted from the sender — if the shared key is on the wire, the whole exercise is void. Is the encapsulation key authenticated, or could it have been swapped? Is the 32-byte secret being run through a proper derivation before use, or spent directly as an AEAD key for several purposes? And is there a classical agreement mixed in, or is the design betting on one scheme?

  • Which value does the sender put on the wire after calling Encapsulate?
    The ciphertext only. The other return value is the shared key, which the sender keeps locally; transmitting it would hand the secret to anyone watching. The receiver reconstructs the identical key by running `Decapsulate` on the ciphertext with its decapsulation key.
  • Why would you still pair ML-KEM with X25519 in your own protocol?
    Same reason TLS does. ML-KEM is standardised but far younger than elliptic-curve Diffie-Hellman, so betting a protocol solely on it removes your fallback if cryptanalysis improves. Running both and mixing both secrets means an attacker has to break both, and costs one extra cheap operation.
  • What does DecapsulationKey768.Bytes() give you, and why does it matter?
    The 64-byte seed the key was derived from, not an expanded private key. That is what you persist or store in a secret manager, and `mlkem.NewDecapsulationKey768` reconstructs the key from it. It keeps stored key material small and keeps the expanded form inside the package.
  • What must you add before this is a usable secure channel?
    Authentication of the encapsulation key, so an attacker cannot substitute their own; a key derivation step so the 32 bytes become distinct per-purpose keys; and an AEAD with proper nonce handling to actually protect data. The KEM only gets both sides holding the same random secret.

Think of a padlock published open. Anyone can snap it shut on a box of freshly minted keys, but only the holder of the original key can open it again and read what was minted.

saying these in an interview costs you the question

  • Transmits the shared key instead of only the ciphertext
  • Calls ML-KEM encryption of a chosen message
  • Assumes the exchange authenticates the peer
  • Uses the raw 32 bytes directly as several different keys
  • Ships ML-KEM alone with no classical agreement mixed in