Skip to content
ecdsa.com

ML-KEM-768 encapsulation · Go

How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Go

To encapsulate and decapsulate a shared secret with ML-KEM-768 in Go, use the standard library's crypto/mlkem (Go 1.24 and later): mlkem.GenerateKey768() creates the key, EncapsulationKey768.Encapsulate() returns the secret and the ciphertext, and DecapsulationKey768.Decapsulate recovers the secret. This recipe also reads OpenSSL's SPKI encapsulation key and exchanges ciphertexts with OpenSSL.

Tested with Go 1.27.3 (crypto/mlkem, linux/arm64) and OpenSSL 3.5.7 (shared test vector and decapsulation of the recipe's ciphertext).

kem.go — key pair, encapsulation, decapsulation
package main
import (
"crypto/mlkem"
"crypto/subtle"
"fmt"
"log"
)
func agree() {
// Receiver: a key pair; only the encapsulation key bytes are handed out
dk, err := mlkem.GenerateKey768()
if err != nil {
log.Fatal(err)
}
ekBytes := dk.EncapsulationKey().Bytes()
// Sender: parse the encapsulation key, get a fresh secret and its ciphertext
ek, err := mlkem.NewEncapsulationKey768(ekBytes)
if err != nil {
log.Fatal(err)
}
sharedKey, ciphertext := ek.Encapsulate()
// Receiver: the same secret from the ciphertext
received, err := dk.Decapsulate(ciphertext)
if err != nil {
log.Fatal(err)
}
fmt.Println("encapsulation key", len(ekBytes), "bytes, ciphertext", len(ciphertext), "bytes, shared secret", len(sharedKey), "bytes")
fmt.Println("shared secret agreed:", subtle.ConstantTimeCompare(received, sharedKey) == 1)
}
spki.go — an encapsulation key from a BEGIN PUBLIC KEY file
package main
import (
"crypto/mlkem"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"fmt"
)
// id-alg-ml-kem-768 (RFC 9935)
var oidMLKEM768 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 4, 2}
// encapsulationKeyFromPEM reads an SPKI "PUBLIC KEY" file, e.g. written by OpenSSL 3.5.
// crypto/x509 does not parse ML-KEM keys, so the SubjectPublicKeyInfo is unwrapped here.
func encapsulationKeyFromPEM(data []byte) (*mlkem.EncapsulationKey768, error) {
block, _ := pem.Decode(data)
if block == nil || block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("no PUBLIC KEY block")
}
var spki struct {
Algorithm pkix.AlgorithmIdentifier
PublicKey asn1.BitString
}
if rest, err := asn1.Unmarshal(block.Bytes, &spki); err != nil || len(rest) != 0 {
return nil, fmt.Errorf("malformed SubjectPublicKeyInfo: %v", err)
}
if !spki.Algorithm.Algorithm.Equal(oidMLKEM768) {
return nil, fmt.Errorf("not an ML-KEM-768 key: %v", spki.Algorithm.Algorithm)
}
return mlkem.NewEncapsulationKey768(spki.PublicKey.Bytes)
}

How it works

  1. mlkem.GenerateKey768() returns a *DecapsulationKey768; EncapsulationKey().Bytes() is the 1184-byte key the sender needs.
  2. NewEncapsulationKey768(bytes) checks and parses that key; Encapsulate() returns (sharedKey, ciphertext) — 32 and 1088 bytes, in that order.
  3. Decapsulate(ciphertext) returns the same 32 bytes; compare with subtle.ConstantTimeCompare.
  4. NewDecapsulationKey768(seed) takes the 64-byte seed (d ‖ z): the shared seed gives OpenSSL's key, decapsulates OpenSSL's ciphertext to dee35810…30f9, and OpenSSL decapsulates Go's ciphertext.

Gotchas

  • crypto/x509 does not know ML-KEM: ParsePKCS8PrivateKey fails with x509: PKCS#8 wrapping contained private key with unknown algorithm: 2.16.840.1.101.3.4.4.2, and SPKI files have to be unwrapped by hand with encoding/asn1, as encapsulationKeyFromPEM does.
  • A tampered ciphertext returns a different secret and a nil error (implicit rejection); only a ciphertext of the wrong length errors (mlkem: invalid ciphertext length).
  • dk.Bytes() returns the 64-byte seed, not the expanded key: store that, because it is the only form NewDecapsulationKey768 accepts.

Related recipes