Skip to content
ecdsa.com

ML-KEM-768 encapsulation · OpenSSL

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

To encapsulate and decapsulate a shared secret with ML-KEM-768 with OpenSSL, you need OpenSSL 3.5 or later: openssl pkeyutl -encap turns the receiver's public key into a ciphertext and a 32-byte secret, and openssl pkeyutl -decap recovers the same secret with the private key. This recipe also stores the key as a 64-byte seed and shows what a tampered ciphertext does.

Tested with OpenSSL 3.5.7 (Debian 13).

key pair, encapsulation by the sender, decapsulation by the receiver
# Receiver: an ML-KEM-768 key pair (OpenSSL 3.5 or later); only ek.pem is handed out
openssl genpkey -algorithm ML-KEM-768 -out dk.pem
openssl pkey -in dk.pem -pubout -out ek.pem
# Sender: a fresh shared secret and the ciphertext that carries it, from the public key alone
openssl pkeyutl -encap -pubin -inkey ek.pem -out ciphertext.bin -secret sender.key
# Receiver: the same secret from the ciphertext and the private key
openssl pkeyutl -decap -inkey dk.pem -in ciphertext.bin -secret receiver.key
cmp sender.key receiver.key && echo "shared secret agreed: $(wc -c < sender.key) bytes"
seed-only key file, and a key from a known seed
# Keep only the 64-byte seed: the PKCS#8 form every library reads
openssl pkey -in dk.pem -provparam ml-kem.output_formats=seed-only -out dk-seed.pem
# A key from a known seed, e.g. for test vectors
openssl genpkey -algorithm ML-KEM-768 -pkeyopt hexseed:$SEED_HEX -out seeded.pem

How it works

  1. openssl genpkey -algorithm ML-KEM-768 creates the decapsulation (private) key; openssl pkey -pubout writes the encapsulation key as SPKI (BEGIN PUBLIC KEY, 1206 bytes of DER).
  2. The sender runs pkeyutl -encap -pubin -inkey ek.pem: -out receives the 1088-byte ciphertext to send, -secret the 32-byte shared secret to keep.
  3. The receiver runs pkeyutl -decap -inkey dk.pem -in ciphertext.bin -secret … and gets the same 32 bytes — the run compares them with cmp.
  4. -pkeyopt hexseed: derives the key from a 64-byte seed (d ‖ z); the run decapsulates the shared test ciphertext with the seed file and gets the shared secret dee35810…30f9 that every language on this page gets.

Gotchas

  • A tampered ciphertext does not fail: decapsulation exits with code 0 and returns a different, unrelated secret (FIPS 203 "implicit rejection"). ML-KEM does not authenticate anything; the mismatch only shows when the derived key fails to decrypt or verify the next message.
  • A ciphertext of the wrong length does fail, with only Public Key operation error and exit code 1.
  • Encapsulation is randomized: running -encap twice for the same key gives two different secrets and ciphertexts.
  • OpenSSL writes the private key as seed plus expanded key by default (2498 bytes of DER); -provparam ml-kem.output_formats=seed-only writes the 86-byte seed-only PKCS#8. pyca/cryptography reads only that form; Node.js reads both.

Related recipes