How to do hybrid X25519 + ML-KEM key exchange in Python¶
Combine ML-KEM-768 (FIPS 203) with X25519 so two parties agree on a 32-byte secret that stays safe if either algorithm is broken. Uses pqblocks-primitives.
The problem¶
Key exchange is the part of cryptography most exposed to harvest now, decrypt later: an attacker who records an X25519 or RSA key exchange today can recover the session key once a quantum computer exists. ML-KEM fixes that, but it is new. A hybrid combines both, so the result is at least as strong as the stronger of the two. This is the same construction TLS 1.3 uses in the X25519MLKEM768 group.
Install¶
ML-KEM comes from liboqs; see the package page.
Complete working code¶
hybrid_kem.py
"""Hybrid X25519 + ML-KEM-768 key establishment with an HKDF-SHA256 combiner (pqblocks-primitives)."""
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import x25519
from pqblocks_primitives import hybrid_decaps, hybrid_encaps, kem_keygen
raw = serialization.Encoding.Raw
# Recipient: one ML-KEM-768 key pair and one X25519 key pair, concatenated.
kem_pk, kem_sk = kem_keygen("768")
x_sk = x25519.X25519PrivateKey.generate()
x_pk = x_sk.public_key().public_bytes(raw, serialization.PublicFormat.Raw)
x_sk_bytes = x_sk.private_bytes(raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
public_key = kem_pk + x_pk # 1184 + 32 bytes
private_key = kem_sk + x_sk_bytes # 2400 + 32 bytes
# Sender: encapsulate to the recipient's hybrid public key.
sent = hybrid_encaps(public_key, "768", kdf="HKDF-SHA256")
ciphertext = sent.serialize() # 1088 + 32 bytes on the wire
# Recipient: decapsulate. Both sides now hold the same 32-byte secret.
received = hybrid_decaps(private_key, ciphertext, "768", kdf="HKDF-SHA256")
assert received == sent.shared_secret
print("public key", len(public_key), "B | ciphertext", len(ciphertext), "B | shared secret", len(received), "B")
Output:
How it works¶
sequenceDiagram
autonumber
participant S as Sender
participant R as Recipient
R->>R: kem_keygen("768") + X25519 key pair
R-->>S: hybrid public key (1184 + 32 bytes)
S->>S: hybrid_encaps: ML-KEM encapsulate + X25519 ephemeral DH
S->>S: HKDF-SHA256(ML-KEM secret ‖ X25519 secret) → shared secret
S-->>R: hybrid ciphertext (1088 + 32 bytes)
R->>R: hybrid_decaps: ML-KEM decapsulate + X25519 DH
R->>R: same HKDF-SHA256 → same 32-byte shared secret
| Component | ML-KEM-768 | X25519 | Hybrid |
|---|---|---|---|
| Public key | 1,184 B | 32 B | 1,216 B |
| Private key | 2,400 B | 32 B | 2,432 B |
| Ciphertext | 1,088 B | 32 B | 1,120 B |
| Shared secret | 32 B | 32 B | 32 B (after HKDF) |
Both secrets feed one HKDF-SHA256 combiner, so an attacker must recover both to learn the output.
Things to know¶
- Use the shared secret as key material for an AEAD such as AES-256-GCM or ChaCha20-Poly1305. Do not use it directly as a password or log it.
- Choose ML-KEM-1024 (
"1024") where policy requires it; CNSA 2.0 specifies ML-KEM-1024 for national security systems. - For TLS, do not hand-build this: use a TLS stack with
X25519MLKEM768and check it withpqblocks-tls-interop.
Related¶
pqblocks-primitives·pqblocks-core(native hybrid SDK) ·pqblocks-files