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