Skip to content
ecdsa.com

ML-KEM-768 encapsulation · Python

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

To encapsulate and decapsulate a shared secret with ML-KEM-768 in Python, use the mlkem module of the cryptography package (version 50): public_key.encapsulate() returns the shared secret and the ciphertext, and private_key.decapsulate(ciphertext) recovers the secret. This recipe also loads OpenSSL 3.5 keys from PEM and exchanges ciphertexts with OpenSSL in both directions.

Tested with cryptography 50.0.2 (Python 3.14.8, wheel with OpenSSL 4.0.3) and OpenSSL 3.5.7 (shared test vector and decapsulation of the recipe's ciphertext).

kem.py — key pair, encapsulation, decapsulation
from cryptography.hazmat.primitives.asymmetric import mlkem
# Receiver: a key pair; only the encapsulation (public) key is handed out
private_key = mlkem.MLKEM768PrivateKey.generate()
public_key = private_key.public_key()
# Sender: a fresh 32-byte shared secret and the ciphertext that carries it
shared_secret, ciphertext = public_key.encapsulate()
# Receiver: the same secret from the ciphertext
received = private_key.decapsulate(ciphertext)
print(f"encapsulation key {len(public_key.public_bytes_raw())} bytes, "
f"ciphertext {len(ciphertext)} bytes, shared secret {len(shared_secret)} bytes")
print("shared secret agreed:", received == shared_secret)
interop.py — the encapsulation key from SPKI and the decapsulation key from PKCS#8
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import mlkem
# Keys in the standard containers, e.g. written by OpenSSL 3.5:
# the encapsulation key as SPKI ("BEGIN PUBLIC KEY"), the decapsulation key as PKCS#8
with open("openssl-pub.pem", "rb") as f:
encapsulation_key = serialization.load_pem_public_key(f.read())
with open("seed.pem", "rb") as f:
decapsulation_key = serialization.load_pem_private_key(f.read(), password=None)
if not isinstance(encapsulation_key, mlkem.MLKEM768PublicKey):
raise TypeError(f"expected an ML-KEM-768 key, got {type(encapsulation_key).__name__}")
if not isinstance(decapsulation_key, mlkem.MLKEM768PrivateKey):
raise TypeError(f"expected an ML-KEM-768 key, got {type(decapsulation_key).__name__}")

How it works

  1. mlkem.MLKEM768PrivateKey.generate() creates the decapsulation key; public_key().public_bytes_raw() is the 1184-byte encapsulation key you hand out.
  2. encapsulate() returns a tuple (shared_secret, ciphertext) — 32 and 1088 bytes — in that order.
  3. decapsulate(ciphertext) returns the same 32 bytes on the receiver's side.
  4. load_pem_public_key and load_pem_private_key read OpenSSL's SPKI and seed-only PKCS#8 files; the run decapsulates OpenSSL's ciphertext to the shared secret dee35810…30f9, and OpenSSL decapsulates a ciphertext Python made to the same secret Python holds.

Gotchas

  • A tampered ciphertext raises nothing: decapsulate returns a different secret (implicit rejection, FIPS 203). The parties only notice when a key derived from the secret fails to decrypt or authenticate.
  • A ciphertext of the wrong length does raise: ValueError: Invalid ML-KEM-768 ciphertext.
  • cryptography 50.0.2 reads seed-only PKCS#8 but not OpenSSL's default private key file (seed plus expanded key): Could not deserialize key data… ASN.1 parsing error: unexpected tag. Convert with openssl pkey -provparam ml-kem.output_formats=seed-only.
  • from_seed_bytes takes the 64-byte seed (d ‖ z), not a 32-byte one as for ML-DSA; the shared seed gives OpenSSL's exact key.

Related recipes