← Week 2: ML-KEM and ML-DSA

Day 11: ML-KEM Encapsulation and Decapsulation

Phase 3 · July 18, 2026

← Week 2: ML-KEM and ML-DSA

Agenda (2–3 hours)

  • Read (20 min): aws-lc-rs kem module docs (now switch to aws-lc-rs)
  • Build (120 min): Implement the full KEM flow with aws-lc-rs; wire into a simulated key exchange
  • Verify (30 min): Benchmark ML-KEM vs X25519
← Week 2: ML-KEM and ML-DSA

Switching to aws-lc-rs

The ml-kem crate is great for learning, but aws-lc-rs is what you'd use in production
and what integrates with rustls. Switch to it today.

use aws_lc_rs::kem::{Ciphertext, DecapsulationKey, EncapsulationKey, ML_KEM_768};

// Generate key pair
let decaps_key = DecapsulationKey::generate(&ML_KEM_768)?;
let encaps_key = decaps_key.encapsulation_key()?;

// Serialize public key for transmission
let pub_key_bytes = encaps_key.key_bytes()?;
println!("Public key: {} bytes", pub_key_bytes.as_ref().len()); // 1184
← Week 2: ML-KEM and ML-DSA

Full KEM Exchange

use aws_lc_rs::kem::{DecapsulationKey, ML_KEM_768};

// --- Recipient side ---
let recipient_dk = DecapsulationKey::generate(&ML_KEM_768)?;
let recipient_ek = recipient_dk.encapsulation_key()?;
let pub_key_bytes = recipient_ek.key_bytes()?;

// --- Sender side (has pub_key_bytes) ---
let sender_ek = EncapsulationKey::new(&ML_KEM_768, pub_key_bytes.as_ref())?;
let (ciphertext, sender_secret) = sender_ek.encapsulate()?;

// Sender sends ciphertext to recipient

// --- Recipient side ---
let recipient_secret = recipient_dk.decapsulate(Ciphertext::from(ciphertext.as_ref()))?;

// Both have the same 32-byte shared secret
assert_eq!(sender_secret.as_ref(), recipient_secret.as_ref());
println!("Shared secret (hex): {}", hex::encode(sender_secret.as_ref()));
← Week 2: ML-KEM and ML-DSA

Error Handling: Implicit Rejection

If you pass a malformed ciphertext to decapsulate(), aws-lc-rs returns
a consistent-time garbage value (not an error) — this is the FO transform's
implicit rejection in action.

// Tamper with ciphertext
let mut bad_ct = ciphertext.as_ref().to_vec();
bad_ct[0] ^= 0xFF;

let bad_secret = recipient_dk.decapsulate(Ciphertext::from(bad_ct.as_ref()))?;

// bad_secret ≠ sender_secret, but no error returned
// The decapsulation "succeeds" but produces a random-looking value
assert_ne!(sender_secret.as_ref(), bad_secret.as_ref());
println!("Implicit rejection: different secrets for bad ciphertext (as expected)");

This is by design — timing-safe rejection of invalid ciphertexts.

← Week 2: ML-KEM and ML-DSA

Benchmarking: ML-KEM-768 vs X25519

use std::time::Instant;

fn bench_ml_kem_768(iterations: u32) {
    let start = Instant::now();
    for _ in 0..iterations {
        let dk = DecapsulationKey::generate(&ML_KEM_768).unwrap();
        let ek = dk.encapsulation_key().unwrap();
        let (ct, _ss1) = ek.encapsulate().unwrap();
        let _ss2 = dk.decapsulate(Ciphertext::from(ct.as_ref())).unwrap();
    }
    let elapsed = start.elapsed();
    println!("ML-KEM-768: {} iterations in {:.2?} ({:.1} µs/op)",
             iterations, elapsed, elapsed.as_micros() as f64 / iterations as f64);
}

// Compare with aws_lc_rs::agreement for X25519
← Week 2: ML-KEM and ML-DSA

Challenge Assignment

Implement pqc-demo/src/kem.rs using aws-lc-rs:

pub struct KemExchange {
    pub shared_secret: Vec<u8>,
    pub public_key_size: usize,
    pub ciphertext_size: usize,
    pub time_micros: u64,
}

pub fn ml_kem_768_exchange() -> anyhow::Result<KemExchange>;
pub fn x25519_exchange() -> anyhow::Result<KemExchange>;
pub fn benchmark_comparison(iterations: u32);

Then write tests:

  1. Verify shared secrets match after a normal exchange
  2. Verify implicit rejection: bad ciphertext → different secret (no error)
  3. Run the benchmark and print a comparison table
  4. Verify ML-KEM-768 key sizes match FIPS 203 exactly
← Week 2: ML-KEM and ML-DSA

Resources

  • aws-lc-rs kem module: docs.rs/aws-lc-rs — DecapsulationKey, EncapsulationKey
  • aws-lc-rs agreement module: X25519 for comparison
  • aws-lc-rs GitHub: github.com/aws/aws-lc-rs — examples directory