# Receiver: an ML-KEM-768 key pair (OpenSSL 3.5 or later); only ek.pem is handed outopenssl genpkey -algorithm ML-KEM-768 -out dk.pemopenssl pkey -in dk.pem -pubout -out ek.pem # Sender: a fresh shared secret and the ciphertext that carries it, from the public key aloneopenssl pkeyutl -encap -pubin -inkey ek.pem -out ciphertext.bin -secret sender.key # Receiver: the same secret from the ciphertext and the private keyopenssl pkeyutl -decap -inkey dk.pem -in ciphertext.bin -secret receiver.keycmp sender.key receiver.key && echo "shared secret agreed: $(wc -c < sender.key) bytes"# Keep only the 64-byte seed: the PKCS#8 form every library readsopenssl 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 vectorsopenssl genpkey -algorithm ML-KEM-768 -pkeyopt hexseed:$SEED_HEX -out seeded.pemHow it works
openssl genpkey -algorithm ML-KEM-768creates the decapsulation (private) key;openssl pkey -puboutwrites the encapsulation key as SPKI (BEGIN PUBLIC KEY, 1206 bytes of DER).- The sender runs
pkeyutl -encap -pubin -inkey ek.pem:-outreceives the 1088-byte ciphertext to send,-secretthe 32-byte shared secret to keep. - The receiver runs
pkeyutl -decap -inkey dk.pem -in ciphertext.bin -secret …and gets the same 32 bytes — the run compares them withcmp. -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 secretdee35810…30f9that 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 errorand exit code 1. - Encapsulation is randomized: running
-encaptwice 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-onlywrites the 86-byte seed-only PKCS#8. pyca/cryptography reads only that form; Node.js reads both.
Related recipes
- How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Python
- How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Go
- How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Node.js
- How to sign and verify with ML-DSA-65 with OpenSSL
- How to derive a shared key with ECDH and HKDF with OpenSSL