ML-KEM post-quantum key encapsulation
ML-KEM is the module-lattice key-encapsulation mechanism standardized by NIST FIPS 203 - the post-quantum replacement for Diffie-Hellman-style key agreement. Its security rests on the Module-LWE problem, believed hard even for a large-scale quantum computer. This guide is for developers bootstrapping a symmetric session that must stay confidential against future quantum attack.
A KEM works differently from ECDH: instead of both parties contributing a public key, the receiver publishes an encapsulation key, and any sender calls Encapsulate() to produce a ciphertext plus a fresh 32-byte shared secret. The receiver recovers the same secret from the ciphertext with Decapsulate(ReadOnlySpan<byte>).
Bodu.Security.Cryptography ships the three FIPS 203 parameter sets as sealed types over the shared MLKem base: MLKem512, MLKem768, and MLKem1024.
Parameter sets and sizes
| Type | NIST category | Comparable to | Encapsulation key | Decapsulation key | Ciphertext | Shared secret |
|---|---|---|---|---|---|---|
| MLKem512 | 1 | AES-128 | 800 B | 1632 B | 768 B | 32 B |
| MLKem768 | 3 | AES-192 | 1184 B | 2400 B | 1088 B | 32 B |
| MLKem1024 | 5 | AES-256 | 1568 B | 3168 B | 1568 B | 32 B |
The shared secret is always 32 bytes (MLKem.SharedSecretSizeInBytes); the private seed accepted by ImportPrivateSeed is always 64 bytes (MLKem.PrivateSeedSizeInBytes). Each instance also reports its sizes at runtime through EncapsulationKeySizeInBytes, DecapsulationKeySizeInBytes, and CiphertextSizeInBytes.
When to pick which
- ML-KEM-768 is the default. It is the parameter set most widely deployed for TLS hybrid key exchange and the right balance of margin and size for almost everyone.
- ML-KEM-512 trades margin for the smallest keys and ciphertext; choose it only when bandwidth or storage is tight and category-1 security is acceptable.
- ML-KEM-1024 is the conservative high-margin choice for the most sensitive, longest-lived secrets.
The encapsulate / decapsulate flow
The receiver generates a key pair and publishes the encapsulation (public) key. The sender imports it, encapsulates, and transmits only the ciphertext. The receiver decapsulates to recover the matching secret.
using Bodu.Security.Cryptography;
// Receiver: generate a key pair and publish the encapsulation key.
using var receiver = MLKem768.Create();
receiver.GenerateKey();
byte[] encapsulationKey = receiver.ExportEncapsulationKey(); // 1184 bytes, public
// Sender: import the receiver's public key and encapsulate a fresh secret.
using var sender = MLKem768.Create();
sender.ImportEncapsulationKey(encapsulationKey);
(byte[] ciphertext, byte[] senderSecret) = sender.Encapsulate();
// Sender transmits 'ciphertext' (1088 bytes); 'senderSecret' stays local.
// Receiver: recover the same secret from the ciphertext.
byte[] receiverSecret = receiver.Decapsulate(ciphertext);
// senderSecret and receiverSecret are identical (32 bytes each).
As with ECDH, run the 32-byte shared secret through a KDF (HKDF, or Blake2b in keyed mode) before using it as a symmetric key, binding it to an application-specific context.
Both Encapsulate and Decapsulate have span overloads - Encapsulate(ciphertext, sharedSecret) and Decapsulate(ciphertext, sharedSecret) - that write into caller-supplied buffers (the ciphertext span must be exactly CiphertextSizeInBytes, the secret span exactly 32 bytes). Query HasEncapsulationKey / HasDecapsulationKey to see which halves an instance holds: a sender that imported only the public key has HasEncapsulationKey true and HasDecapsulationKey false, and so can encapsulate but not decapsulate.
Persisting and restoring keys
Export the decapsulation (private) key, or the compact 64-byte seed, to persist a receiver across runs. Importing the seed regenerates the full key pair; importing an encapsulation key onto an instance discards any decapsulation key it held, leaving an encapsulate-only instance.
using Bodu.Security.Cryptography;
using var receiver = MLKem768.Create();
receiver.GenerateKey();
byte[] decapsulationKey = receiver.ExportDecapsulationKey(); // private, store in a vault
// Later, restore a receiver from the encoded decapsulation key:
using var restored = MLKem768.Create();
restored.ImportDecapsulationKey(decapsulationKey);
ImportDecapsulationKey and ImportEncapsulationKey apply the FIPS 203 §7.3 hash-consistency and §7.2 modulus checks respectively, throwing ArgumentException on a malformed or wrong-length key.
An instance keeps what FIPS 203 derives from its key on every operation - the matrix Â, the decoded vectors t̂ and ŝ, and H(ek) - from the moment the key is generated or imported, so reuse one instance for many encapsulations or decapsulations rather than importing the key for each. Beside the encoded keys these take about 8, 15 and 24 KiB for ML-KEM-512, 768 and 1024, or 6, 12 and 20 KiB for an encapsulation key alone; the secret vector is zeroed with the decapsulation key when the instance is disposed.
Implicit rejection - decapsulation does not throw on tampering
A tampered ciphertext of the correct length does not throw. Per FIPS 203, Decapsulate silently returns an unrelated key (the implicit-rejection value), so an attacker cannot use decapsulation failures as an oracle. Both parties simply end up with different secrets, and the mismatch surfaces later when the symmetric session fails to authenticate. Only a wrong-length ciphertext throws ArgumentException.
This means you must not treat a successful Decapsulate as proof the ciphertext was genuine - confirm the secret matches by using it in an authenticated channel (an AEAD or a MAC).
Hybrid with X25519
A KEM resists quantum attack but is newer and less battle-tested than the curve algorithms. The standard mitigation is a hybrid: run both X25519 and ML-KEM, then derive the session key from both secrets concatenated. The result is secure as long as either primitive holds - quantum-safe from ML-KEM, classically conservative from X25519.
using System.Security.Cryptography;
using Bodu.Security.Cryptography;
// Classic half: X25519 ECDH (each side already exchanged public keys).
using var x = X25519.Create();
x.GenerateKey();
byte[] classicSecret = x.DeriveSharedSecret(peerX25519PublicKey);
// Post-quantum half: ML-KEM encapsulation to the receiver's key.
using var kem = MLKem768.Create();
kem.ImportEncapsulationKey(receiverEncapsulationKey);
(byte[] kemCiphertext, byte[] pqSecret) = kem.Encapsulate();
// Combine both secrets, then derive the session key with a KDF.
byte[] combined = new byte[classicSecret.Length + pqSecret.Length];
classicSecret.CopyTo(combined, 0);
pqSecret.CopyTo(combined, classicSecret.Length);
byte[] sessionKey = Hkdf.DeriveKey(
HashAlgorithmName.SHA256,
inputKeyingMaterial: combined,
outputLength: 32,
salt: default,
info: "myapp v1 hybrid session key"u8);
CryptographicOperations.ZeroMemory(classicSecret);
CryptographicOperations.ZeroMemory(pqSecret);
CryptographicOperations.ZeroMemory(combined);
The sender transmits both the X25519 public key and the ML-KEM ciphertext; the receiver performs the matching X25519 derivation and ML-KEM decapsulation and runs the identical KDF.
Fix the concatenation order (here classic, then post-quantum) and keep it identical on both sides - the KDF is order-sensitive, so a swapped order yields a different, mismatched key. The same info label must also match. This is the pattern standardized as X25519MLKEM768 for TLS hybrid key exchange; the principle is the same here.
See also
- Asymmetric algorithms overview - where ML-KEM sits in the family.
- Key agreement with X25519 - the classic counterpart and the hybrid partner.
- ML-DSA post-quantum signatures - the post-quantum signature companion.
- Using HKDF - the KDF that binds the shared secret to a context before use.
- MLKem, MLKem512, MLKem768, MLKem1024 - API reference.