ML-KEM-768 Post-Quantum Hybrid Encryption

ML-KEM (FIPS 203, August 2024) is the NIST-standardized successor to CRYSTALS-Kyber — same lattice construction, new name. As a KEM it cannot encrypt data directly; it encapsulates a fresh 32-byte shared secret against a recipient’s public key. The standard pattern is KEM-DEM: use that secret to key a symmetric AEAD (here AES-256-GCM), which mirrors how TLS hybrid key exchange deploys post-quantum crypto today.

MlKem768HybridAlgorithm in lib/algorithms implements this construction:

Material Size
Public key 1,184 B
Secret key 2,400 B
KEM ciphertext (per message) 1,088 B
Shared secret 32 B

References: - https://csrc.nist.gov/pubs/fips/203/final - https://github.com/GiacomoPope/kyber-py

1.0 — Direct Usage: Sender / Recipient Roles

Unlike the symmetric specimens, key management is keypair-based: a sender holding only the public key can encrypt, but only the secret-key holder can decapsulate. Every encrypt() performs a fresh encapsulation, so each message is keyed independently — there is no long-lived symmetric key to rotate or leak.

from lib.algorithms import MlKem768HybridAlgorithm, MlKem768HybridInput

# Recipient generates a keypair and publishes the public key
public_key, secret_key = MlKem768HybridAlgorithm.generate_keypair()

sender = MlKem768HybridAlgorithm(public_key=public_key)
recipient = MlKem768HybridAlgorithm(public_key=public_key, secret_key=secret_key)

encrypted = sender.encrypt(MlKem768HybridInput(plaintext=b"post-quantum hello"))
decrypted = recipient.decrypt(encrypted)

print("plaintext:", decrypted.data)
print("kem ciphertext:", len(encrypted.kem_ciphertext), "bytes")
print("encrypt metrics:", encrypted.metrics)
print("decrypt metrics:", decrypted.metrics)
plaintext: b'post-quantum hello'
kem ciphertext: 1088 bytes
encrypt metrics: {'algorithm': 'ML-KEM-768+AES-256-GCM', 'operation': 'encrypt', 'plaintext_bytes': 18, 'ciphertext_bytes': 34, 'kem_ciphertext_bytes': 1088, 'kem_elapsed_ms': 0.079, 'elapsed_ms': 0.952}
decrypt metrics: {'algorithm': 'ML-KEM-768+AES-256-GCM', 'operation': 'decrypt', 'ciphertext_bytes': 34, 'plaintext_bytes': 18, 'kem_elapsed_ms': 0.095, 'elapsed_ms': 0.104}

2.0 — Riding the Composer: PQ Hybrid vs Classical AEADs

How much does post-quantum security cost? We register the hybrid alongside the two classical production AEADs and let the composer answer. Note the hybrid is adapted without a key — it manages its own keypair, and the adapter persists the per-message KEM ciphertext and nonce through the CryptoRegistry.

import os

from lib.notebook import adapt, ComposerSession, ReportBuilder
from lib.algorithms import Aes256GcmAlgorithm, ChaCha20Poly1305Algorithm

KEY = os.urandom(32)
report = ReportBuilder()
session = ComposerSession()

session.register(adapt(Aes256GcmAlgorithm, KEY, name="AES-256-GCM", profile_memory=True))
session.register(adapt(ChaCha20Poly1305Algorithm, KEY, name="ChaCha20-Poly1305", profile_memory=True))
session.register(adapt(MlKem768HybridAlgorithm, name="ML-KEM-768+AES-GCM", profile_memory=True))

report.heading("Round-trip Tests", level=2)
report.test_results(session.test_all())

report.heading("Comparison (10KB, 50 iterations)", level=2)
report.comparison_table(session.compare(data_size=10_000, iterations=50))
Round-trip Tests
       Round-trip Tests        
┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┓
 Algorithm           Status 
┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━┩
 AES-256-GCM         ✓ PASS 
 ChaCha20-Poly1305   ✓ PASS 
 ML-KEM-768+AES-GCM  ✓ PASS 
└────────────────────┴────────┘
Comparison (10KB, 50 iterations)
                                               Algorithm Comparison                                                
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━┓
                                            Throughput              P99 Encrypt                           
 Algorithm      Encrypt (ms)  Decrypt (ms)  (MB/s)        Ops/sec   (ms)           Expansion  Peak Memory 
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━┩
 AES-256-GCM    0.033         0.028         303.03        30303.03  0.133          1.0016     10.6 KB     
 ChaCha20-Pol…  0.034         0.03          294.12        29411.76  0.07           1.0016     10.6 KB     
 ML-KEM-768+A…  0.105         0.109         95.24         9523.81   0.238          1.0016     11.9 KB     
└───────────────┴──────────────┴──────────────┴──────────────┴──────────┴───────────────┴───────────┴─────────────┘
report.heading("Scaling Analysis", level=2)
report.benchmark_table(
    session.benchmark_all(data_sizes=[100, 1_000, 10_000, 100_000], iterations=20)
)
Scaling Analysis
                              AES-256-GCM                               
┏━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓
 Size       Encrypt (ms)  Decrypt (ms)    Throughput  Peak Memory 
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩
 100 B             0.044          0.03     2.27 MB/s        898 B 
 1,000 B           0.045          0.04    22.22 MB/s       1.8 KB 
 10,000 B          0.051         0.044   196.08 MB/s      10.6 KB 
 100,000 B         0.082         0.067  1219.51 MB/s      98.5 KB 
└───────────┴──────────────┴──────────────┴──────────────┴─────────────┘
 Scaling factor (large/small throughput): 537.229x

                           ChaCha20-Poly1305                           
┏━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓
 Size       Encrypt (ms)  Decrypt (ms)   Throughput  Peak Memory 
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩
 100 B             0.034          0.03    2.94 MB/s        898 B 
 1,000 B            0.04         0.037    25.0 MB/s       1.8 KB 
 10,000 B          0.071          0.07  140.85 MB/s      10.6 KB 
 100,000 B         0.134         0.119  746.27 MB/s      98.5 KB 
└───────────┴──────────────┴──────────────┴─────────────┴─────────────┘
 Scaling factor (large/small throughput): 253.833x

                          ML-KEM-768+AES-GCM                           
┏━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓
 Size       Encrypt (ms)  Decrypt (ms)   Throughput  Peak Memory 
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩
 100 B             0.102         0.111    0.98 MB/s       2.2 KB 
 1,000 B           0.101         0.111     9.9 MB/s       3.1 KB 
 10,000 B          0.106         0.117   94.34 MB/s      11.9 KB 
 100,000 B          0.13         0.136  769.23 MB/s      99.8 KB 
└───────────┴──────────────┴──────────────┴─────────────┴─────────────┘
 Scaling factor (large/small throughput): 784.929x

3.0 — Analyzing our Results

The hybrid pays a fixed per-message tax: one encapsulation (~0.1 ms) plus 1,088 bytes of KEM ciphertext, regardless of payload size. At 100 B that tax dominates; at 100 KB it is noise and throughput converges toward plain AES-256-GCM, since the DEM does all the bulk work.

That is the whole argument for KEM-DEM in practice: post-quantum key establishment costs are O(1) per message, so the classical/PQ throughput gap shrinks as payloads grow. The interesting research direction for this lab is layering — running the KEM step and a classical key exchange and combining the secrets, which is exactly what the Multi Encryption composer on the roadmap is for.

Back to top