WebAssembly and Post-Quantum TLS: ML-KEM Hybrid Key Exchange in WASM Network Clients

WebAssembly and Post-Quantum TLS: ML-KEM Hybrid Key Exchange in WASM Network Clients

The Problem

WASM applications do not have a single TLS story. Where a WASM module runs determines who controls TLS negotiation, which key exchange groups are offered, and whether post-quantum key exchange happens at all — without the application author knowing which outcome occurred.

The four deployment contexts produce four completely different control models:

Browser WASM uses the browser’s fetch() API or WebSocket. The browser’s TLS stack handles all negotiation. Chrome 124 (May 2024) enabled ML-KEM-768 + X25519 hybrid key exchange for all HTTPS connections by default. WASM running in Chrome automatically gets PQC on client-to-server TLS. The application code does nothing and gets nothing — it cannot configure TLS, cannot inspect the negotiated group, and cannot change the behaviour. Firefox shipped X25519MLKEM768 in Firefox 132 (October 2024). Safari had not shipped ML-KEM as of Q1 2026. If your browser WASM targets Safari users, those connections use classical X25519 only.

Edge WASM (Fermyon Spin, Cloudflare Workers, Fastly Compute) uses a platform-provided HTTP client for outbound calls. The WASM module calls a host function — spin_sdk::http::send(), Cloudflare’s fetch() binding, or Fastly’s backend API — and the platform’s network layer handles TLS. Cloudflare terminates and originates TLS at their edge; their network has supported ML-KEM at the client-facing termination point since 2024, but the origin-leg TLS (Workers to your backend) follows Cloudflare’s outbound TLS policy, which you do not control from WASM code. Fermyon Spin’s outbound HTTP is implemented via the WASI HTTP proposal; Spin’s PQC support on the outbound leg is a function of which TLS library the Spin runtime was compiled against and which version you are running. Spin 3.x uses rustls 0.23+, which negotiates ML-KEM when the server supports it — but only if the aws-lc-rs crypto backend is in use rather than ring, because ring does not implement ML-KEM.

Server-side WASM (Wasmtime + WASI sockets) gives the application full TLS control. A WASM module compiled from Rust can include rustls, configure it with specific cipher suites and key exchange groups, and establish TLS connections through WASI’s socket interfaces. This is the only deployment model where the application author makes explicit PQC decisions.

Embedded WASM (WAMR, Wasmi) running in IoT or firmware contexts typically implements TLS using MbedTLS or WolfSSL compiled alongside the runtime. PQC support is entirely dependent on the embedded TLS library version. WolfSSL has supported ML-KEM since 5.7.0 (2024). MbedTLS’s PQC support is in active development.

The central problem is that most WASM developers assume their application’s TLS is as modern as the libraries they write against. In practice, the TLS stack is usually outside the application’s control, and PQC negotiation either happens transparently (browsers, some platforms) or does not happen at all (older platform runtimes, edge functions calling classical backends, embedded runtimes). Harvest-now-decrypt-later attacks do not care whether a developer assumed their traffic was post-quantum.

The WebCrypto API limitation. Browser WASM that needs to perform PQC operations explicitly — generating ML-KEM key pairs, encapsulating shared secrets, storing PQC keys — cannot use WebCrypto (crypto.subtle). The WebCrypto API does not support ML-KEM or any other NIST PQC algorithm as of 2026. The W3C Web Cryptography API working group has an open proposal for PQC algorithm support, but no browser has shipped it. The options for browser WASM doing explicit PQC cryptography: compile the ml-kem crate to WASM and call it from JavaScript, or use a pure-JavaScript implementation like mlkem (npm). The compiled Rust approach is approximately 8x faster for ML-KEM-768 encapsulation and adds roughly 200–400KB to the WASM binary depending on optimisation level and whether WASM SIMD is enabled.

Threat Model

Harvest-now-decrypt-later against WASM edge functions. An adversary with persistent network access — an ISP, a cloud provider’s internal network monitor, or a nation-state on-path attacker — captures TLS handshake records from outbound HTTP calls made by a Fermyon Spin or Cloudflare Workers function. If those calls use X25519 key exchange (classical), the attacker stores the ciphertexts. The function handles sensitive payloads: authentication tokens, API keys passed to upstream services, user data returned from databases. A cryptographically relevant quantum computer arriving in 10–15 years decrypts all stored sessions. The WASM function processed thousands of sensitive requests per day; the blast radius is proportional to traffic volume multiplied by data sensitivity.

Misidentifying PQC coverage. A team deploys WASM in Chrome, verifies that Chrome negotiates X25519MLKEM768 with their CDN, and concludes their application is post-quantum. The CDN-to-origin leg (Cloudflare to the application backend) uses classical TLS because the origin server runs nginx 1.18 without ML-KEM support. A network-level attacker on the CDN-to-origin path — which is often less protected than the public internet — captures classical key exchange. The client-facing PQC provides no protection for the internal leg.

WebCrypto RSA key generation. A WASM application generates asymmetric key pairs using crypto.subtle.generateKey() with RSA-OAEP or ECDH algorithms for encrypting data at rest or for a local key exchange protocol. These keys are quantum-vulnerable. RSA-2048 and P-256 ECDH are both broken by Shor’s algorithm on a sufficiently large quantum computer. An operator who migrated TLS to ML-KEM but left their crypto.subtle-based key generation in place has done half the migration.

Spin runtime version mismatch. A team verifies that their Fermyon Cloud deployment negotiates ML-KEM by checking the TLS handshake against their production hostname. They later deploy the same WASM module to a self-hosted Spin installation running Spin 2.7, which uses an older rustls version without ML-KEM support. Outbound HTTP from the self-hosted instance reverts to X25519 silently. There is no error, no warning, and no observable difference in application behaviour.

Hardening Configuration

1. Audit Your Platform’s Actual TLS Negotiation

Before writing any code, establish what key exchange your WASM deployment actually uses today. This requires testing from the network path that matters — client-to-edge and edge-to-origin separately.

# Test if an endpoint supports ML-KEM key exchange
# Requires OpenSSL 3.4+ or BoringSSL with PQC group support

# Check client-facing TLS (what browsers connect to):
openssl s_client -connect spin-app.fermyon.app:443 \
  -groups X25519MLKEM768:X25519 \
  -no_tls1 -no_tls1_1 -no_tls1_2 \
  2>&1 | grep "Server Temp Key\|Supported groups"

# Expected with PQC negotiated:
#   Server Temp Key: X25519MLKEM768, 1216 bits
# Classical fallback:
#   Server Temp Key: X25519, 253 bits

# For Cloudflare Workers (TLS terminated at Cloudflare):
openssl s_client -connect myworker.workers.dev:443 \
  -groups X25519MLKEM768:X25519 2>&1 | grep "Server Temp Key"
# Cloudflare has supported X25519MLKEM768 since 2024

# Test the origin leg explicitly — this is what edge-to-origin traffic uses:
openssl s_client -connect your-origin-backend.internal:443 \
  -groups X25519MLKEM768:X25519 2>&1 | grep "Server Temp Key"
# If this returns "X25519" only, the origin leg is classical

The OpenSSL -groups flag lists the key share groups the client offers, in priority order. If the server supports the first group (X25519MLKEM768), it will use it and the Server Temp Key output shows the hybrid group. If the server doesn’t support ML-KEM groups, it falls back to X25519. A server running nginx built against OpenSSL 3.2 or earlier without the oqs-provider will always fall back to classical.

For a comprehensive audit across all service endpoints:

#!/bin/bash
# Audit PQC support across WASM application endpoints

ENDPOINTS=(
  "api.production.internal:443"
  "auth.service.internal:443"
  "database-proxy.internal:5432"
  "vault.service.internal:8200"
  "your-spin-app.fermyon.app:443"
)

echo "Endpoint PQC Audit - $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "---"

for endpoint in "${ENDPOINTS[@]}"; do
  result=$(timeout 5 openssl s_client -connect "$endpoint" \
    -groups X25519MLKEM768:X25519:P-256 \
    -brief 2>/dev/null | grep "Server Temp Key")

  if [ -z "$result" ]; then
    echo "TIMEOUT/ERROR: $endpoint"
  elif echo "$result" | grep -qiE "MLKEM|kyber|X25519MLKEM"; then
    echo "PQC:       $endpoint — $result"
  else
    echo "CLASSICAL: $endpoint — $result"
  fi
done

Run this audit from inside your network, not from the public internet. Edge-to-origin paths use internal network routing; the public internet path tests only the client-facing termination point.

2. ML-KEM-768 + X25519 Hybrid in Rust WASM

For server-side WASM (Wasmtime + WASI) or any Rust-to-WASM build where you control the cryptographic operations, the ml-kem crate implements FIPS 203 ML-KEM. The hybrid construction combines ML-KEM-768 with X25519: an attacker must break both to compromise the shared secret. If ML-KEM turns out to have an undiscovered flaw, X25519 maintains the classical security level. If X25519 is broken by a quantum computer, ML-KEM maintains post-quantum security.

# Cargo.toml
[dependencies]
ml-kem = "0.3"          # FIPS 203 ML-KEM, pure Rust, compiles to WASM
x25519-dalek = { version = "2", default-features = false }
sha2 = "0.10"           # KDF via HKDF or direct SHA-256 combination
hkdf = "0.12"
rand_core = { version = "0.6", features = ["getrandom"] }

# WASM-specific: getrandom needs the js feature to get entropy from the browser
# or from WASI's random_get in Wasmtime
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }

[lib]
crate-type = ["cdylib"]

[profile.release]
opt-level = "z"         # Minimise binary size for WASM
lto = true
codegen-units = 1

# Enable WASM SIMD for ML-KEM NTT polynomial operations (~2x faster)
[profile.release.package.ml-kem]
rustflags = ["-C", "target-feature=+simd128"]
// src/lib.rs — ML-KEM-768 + X25519 hybrid key encapsulation
//
// ML-KEM-768 parameters (FIPS 203):
//   Encapsulation key (ek): 1184 bytes
//   Decapsulation key (dk): 2400 bytes
//   Ciphertext (ct):        1088 bytes
//   Shared secret:          32 bytes
//
// X25519 parameters:
//   Public key:  32 bytes
//   Private key: 32 bytes
//   Shared secret: 32 bytes
//
// Combined hybrid public key:  1184 + 32 = 1216 bytes
// Combined hybrid ciphertext:  1088 + 32 = 1120 bytes  (ML-KEM ct + X25519 ephemeral pk)
// Combined hybrid secret input: 32 + 32 = 64 bytes  (fed into HKDF)

use ml_kem::{MlKem768, KemCore, EncapsulationKey, DecapsulationKey};
use ml_kem::kem::Encapsulate;
use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
use sha2::Sha256;
use hkdf::Hkdf;
use rand_core::OsRng;

// Size constants — validated against FIPS 203 Table 2
const MLKEM768_EK_SIZE: usize = 1184;
const MLKEM768_DK_SIZE: usize = 2400;
const MLKEM768_CT_SIZE: usize = 1088;
const MLKEM768_SS_SIZE: usize = 32;
const X25519_KEY_SIZE: usize = 32;

pub const HYBRID_PK_SIZE: usize = MLKEM768_EK_SIZE + X25519_KEY_SIZE;  // 1216
pub const HYBRID_CT_SIZE: usize = MLKEM768_CT_SIZE + X25519_KEY_SIZE;  // 1120
pub const HYBRID_SS_SIZE: usize = 32;  // HKDF output

pub struct HybridKeyPair {
    pub public_key: [u8; HYBRID_PK_SIZE],
    mlkem_dk: Box<[u8; MLKEM768_DK_SIZE]>,
    x25519_sk: [u8; X25519_KEY_SIZE],
}

impl HybridKeyPair {
    /// Generate a fresh ML-KEM-768 + X25519 hybrid key pair.
    /// The encapsulation key (public) is sent to the peer.
    /// The decapsulation key (private) is stored locally.
    pub fn generate() -> Self {
        let mut rng = OsRng;

        // ML-KEM-768 key generation
        // MlKem768::generate() produces (DecapsulationKey, EncapsulationKey)
        let (dk, ek) = MlKem768::generate(&mut rng);
        let ek_bytes = ek.as_bytes();
        let dk_bytes = dk.as_bytes();

        // X25519 key generation
        let x25519_sk = StaticSecret::random_from_rng(&mut rng);
        let x25519_pk = PublicKey::from(&x25519_sk);

        // Concatenate public keys: [ML-KEM-768 ek (1184B) || X25519 pk (32B)]
        let mut public_key = [0u8; HYBRID_PK_SIZE];
        public_key[..MLKEM768_EK_SIZE].copy_from_slice(ek_bytes.as_slice());
        public_key[MLKEM768_EK_SIZE..].copy_from_slice(x25519_pk.as_bytes());

        HybridKeyPair {
            public_key,
            mlkem_dk: Box::new(*dk_bytes),
            x25519_sk: x25519_sk.to_bytes(),
        }
    }

    /// Decapsulate a hybrid ciphertext received from a peer.
    /// Returns a 32-byte shared secret derived via HKDF from both
    /// the ML-KEM shared secret and the X25519 Diffie-Hellman result.
    pub fn decapsulate(&self, ciphertext: &[u8]) -> Result<[u8; HYBRID_SS_SIZE], HybridError> {
        if ciphertext.len() != HYBRID_CT_SIZE {
            return Err(HybridError::InvalidCiphertextSize {
                expected: HYBRID_CT_SIZE,
                got: ciphertext.len(),
            });
        }

        let mlkem_ct_bytes = &ciphertext[..MLKEM768_CT_SIZE];
        let x25519_ephemeral_pk_bytes: &[u8; X25519_KEY_SIZE] =
            ciphertext[MLKEM768_CT_SIZE..].try_into().unwrap();

        // ML-KEM decapsulation
        let dk_bytes: &[u8; MLKEM768_DK_SIZE] = self.mlkem_dk.as_ref();
        let dk = DecapsulationKey::<MlKem768>::from_bytes(dk_bytes);
        let mlkem_ct = ml_kem::Ciphertext::<MlKem768>::from_bytes(
            mlkem_ct_bytes.try_into().map_err(|_| HybridError::MalformedCiphertext)?,
        );
        let mlkem_ss = dk.decapsulate(&mlkem_ct)
            .map_err(|_| HybridError::DecapsulationFailed)?;

        // X25519 Diffie-Hellman
        let my_x25519_sk = StaticSecret::from(self.x25519_sk);
        let peer_x25519_pk = PublicKey::from(*x25519_ephemeral_pk_bytes);
        let x25519_ss = my_x25519_sk.diffie_hellman(&peer_x25519_pk);

        // Combine both shared secrets with HKDF-SHA256.
        // Concatenate inputs: [ML-KEM ss (32B) || X25519 ss (32B)]
        // Info string binds the algorithm identifiers per the hybrid KEM combiner spec.
        derive_hybrid_secret(mlkem_ss.as_bytes(), x25519_ss.as_bytes())
    }
}

/// Encapsulate to a peer's hybrid public key (sender side).
/// Returns (shared_secret, ciphertext). The ciphertext is sent to the peer;
/// the shared secret is used to derive a symmetric encryption key.
pub fn hybrid_encapsulate(
    recipient_public_key: &[u8; HYBRID_PK_SIZE],
) -> Result<([u8; HYBRID_SS_SIZE], [u8; HYBRID_CT_SIZE]), HybridError> {
    let mut rng = OsRng;

    // Split the hybrid public key
    let mlkem_ek_bytes: &[u8; MLKEM768_EK_SIZE] =
        recipient_public_key[..MLKEM768_EK_SIZE].try_into().unwrap();
    let x25519_pk_bytes: &[u8; X25519_KEY_SIZE] =
        recipient_public_key[MLKEM768_EK_SIZE..].try_into().unwrap();

    // ML-KEM encapsulation: produces (SharedSecret, Ciphertext)
    let mlkem_ek = EncapsulationKey::<MlKem768>::from_bytes(mlkem_ek_bytes);
    let (mlkem_ct, mlkem_ss) = mlkem_ek.encapsulate(&mut rng)
        .map_err(|_| HybridError::EncapsulationFailed)?;

    // X25519: generate ephemeral key pair and perform ECDH
    let my_ephemeral_sk = EphemeralSecret::random_from_rng(&mut rng);
    let my_ephemeral_pk = PublicKey::from(&my_ephemeral_sk);
    let peer_x25519_pk = PublicKey::from(*x25519_pk_bytes);
    let x25519_ss = my_ephemeral_sk.diffie_hellman(&peer_x25519_pk);

    // Derive combined shared secret
    let shared_secret = derive_hybrid_secret(mlkem_ss.as_bytes(), x25519_ss.as_bytes())?;

    // Assemble ciphertext: [ML-KEM ct (1088B) || X25519 ephemeral pk (32B)]
    let mut ciphertext = [0u8; HYBRID_CT_SIZE];
    ciphertext[..MLKEM768_CT_SIZE].copy_from_slice(mlkem_ct.as_bytes().as_slice());
    ciphertext[MLKEM768_CT_SIZE..].copy_from_slice(my_ephemeral_pk.as_bytes());

    Ok((shared_secret, ciphertext))
}

/// HKDF-SHA256 combiner for the two shared secrets.
/// Uses algorithm-identifying info string per draft-ietf-tls-hybrid-design.
fn derive_hybrid_secret(
    mlkem_ss: &[u8],
    x25519_ss: &[u8],
) -> Result<[u8; HYBRID_SS_SIZE], HybridError> {
    // Input keying material: concatenated shared secrets
    let mut ikm = [0u8; MLKEM768_SS_SIZE + X25519_KEY_SIZE];
    ikm[..MLKEM768_SS_SIZE].copy_from_slice(mlkem_ss);
    ikm[MLKEM768_SS_SIZE..].copy_from_slice(x25519_ss);

    // Info binds the algorithm combination to the derived key
    let info = b"ML-KEM-768+X25519 hybrid shared secret v1";

    let hk = Hkdf::<Sha256>::new(None, &ikm);
    let mut output = [0u8; HYBRID_SS_SIZE];
    hk.expand(info, &mut output)
        .map_err(|_| HybridError::HkdfExpand)?;

    Ok(output)
}

#[derive(Debug)]
pub enum HybridError {
    InvalidCiphertextSize { expected: usize, got: usize },
    MalformedCiphertext,
    DecapsulationFailed,
    EncapsulationFailed,
    HkdfExpand,
}

The HKDF combiner here matters. A naive XOR or concatenation of the two shared secrets is not a secure combiner — an attacker who controls ML-KEM key generation could force the ML-KEM shared secret to a known value, leaving only X25519 security. HKDF used as a combiner with both secrets as IKM and a binding info string is the construction from draft-ietf-tls-hybrid-design and the approach used in the IETF TLS 1.3 hybrid extension.

3. rustls with ML-KEM for Server-Side WASM (Wasmtime + WASI)

When compiling a Rust network client to WASM for execution under Wasmtime with WASI sockets, rustls gives the application direct control over TLS policy. rustls 0.23+ supports ML-KEM-768 + X25519 hybrid when built with the aws-lc-rs crypto provider. The ring backend does not support ML-KEM; aws-lc-rs does via its AWS-LC dependency.

# Cargo.toml — WASM network client using rustls with ML-KEM
[dependencies]
rustls = { version = "0.23", default-features = false, features = ["tls12", "logging"] }
rustls-pki-types = "1"
webpki-roots = "0.26"
# aws-lc-rs for ML-KEM support — ring does NOT support PQC key exchange
aws-lc-rs = { version = "1", default-features = false }

# WASI HTTP bindings for Wasmtime
wasi = { version = "0.13", features = ["wasi-io", "wasi-sockets"] }
// src/tls_client.rs — rustls TLS client with ML-KEM key exchange preference
use rustls::{ClientConfig, RootCertStore, CipherSuite};
use rustls::crypto::aws_lc_rs;
use rustls_pki_types::ServerName;

/// Build a TLS ClientConfig that prefers ML-KEM-768+X25519 hybrid key exchange.
///
/// rustls 0.23 with aws-lc-rs supports the following key exchange groups
/// (in preference order when configured this way):
///   1. X25519MLKEM768 — hybrid post-quantum (FIPS 203 + RFC 7748)
///   2. X25519         — classical fallback (server doesn't support PQC)
///   3. P-256          — classical fallback for legacy servers
///
/// The group negotiation happens during TLS 1.3 ClientHello in the
/// supported_groups and key_share extensions. The client sends a key share
/// for X25519MLKEM768 (1216 bytes) as the preferred group; if the server
/// supports it, no HelloRetryRequest is needed. Servers that don't recognise
/// the group respond with a HelloRetryRequest selecting X25519, and the
/// client regenerates.
pub fn build_pqc_tls_config() -> Result<ClientConfig, rustls::Error> {
    let mut root_store = RootCertStore::empty();
    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());

    // Use aws-lc-rs provider — this is required for ML-KEM support.
    // ring provider will NOT offer X25519MLKEM768 even if the same code is used.
    let provider = aws_lc_rs::default_provider();

    // Verify ML-KEM is available in this build
    let mlkem_available = provider.key_provider
        .supported_groups()
        .iter()
        .any(|g| g.name() == "X25519MLKEM768");

    if !mlkem_available {
        // Log but don't fail — classical TLS is better than no TLS
        // In production: emit a metric or alert
        eprintln!("WARNING: X25519MLKEM768 not available in TLS provider; \
                   using classical key exchange only");
    }

    ClientConfig::builder_with_provider(provider.into())
        .with_protocol_versions(&[&rustls::version::TLS13])
        .map_err(|e| rustls::Error::General(e.to_string()))?
        .with_root_certificates(root_store)
        .with_no_client_auth()
}

/// Establish a TLS connection and return the negotiated key exchange group name.
/// Use this during integration testing to confirm PQC negotiation.
pub fn get_negotiated_kex_group(conn: &rustls::ClientConnection) -> Option<String> {
    conn.negotiated_key_exchange_group()
        .map(|g| g.name().to_string())
}

The with_protocol_versions(&[&rustls::version::TLS13]) call is significant. ML-KEM key exchange is a TLS 1.3 feature only — the key share mechanism used by hybrid key exchange does not exist in TLS 1.2. Permitting TLS 1.2 fallback creates a downgrade path to classical-only key exchange. For connections where PQC is a requirement rather than a preference, restrict to TLS 1.3.

4. Fermyon Spin Outbound HTTP — Detecting Runtime PQC Support

Spin’s outbound HTTP is opaque to the WASM module. The module calls spin_sdk::http::send() and gets a response; TLS happens inside the runtime. There is no API to query the negotiated TLS parameters from WASM code.

// src/spin_client.rs — outbound HTTP from Fermyon Spin
// The WASM module cannot configure or inspect TLS; all TLS decisions
// are made by the Spin runtime's HTTP implementation.

use spin_sdk::http::{send, IntoResponse, OutgoingRequest, Method, Scheme};
use spin_sdk::http_component;

#[http_component]
async fn handle_request(req: spin_sdk::http::Request) -> anyhow::Result<impl IntoResponse> {
    // This call goes through Spin's TLS stack.
    // PQC support depends on the Spin runtime version:
    //   Spin 2.x: rustls 0.21, no ML-KEM support
    //   Spin 3.0+: rustls 0.23 with aws-lc-rs, ML-KEM-768 supported
    //
    // The WASM code cannot determine which is in use at runtime.
    let request = OutgoingRequest::new(
        Method::Get,
        Some("/api/sensitive-data"),
        Some(Scheme::Https),
        Some("api.example.com"),
        req.headers(),
    );

    let response: spin_sdk::http::Response = send(request).await?;
    Ok(response)
}

To determine whether your Spin deployment uses ML-KEM on outbound connections, you need to test from outside the WASM module:

# Check Spin runtime version
spin --version
# Spin 3.0+ uses rustls 0.23 with aws-lc-rs — ML-KEM capable

# Set up a TLS-capturing proxy and route Spin outbound through it.
# Use mitmproxy with a custom CA to inspect TLS handshakes:
mitmproxy --ssl-insecure --set connection_strategy=lazy \
  --set tls_version_client_min=TLS1_3 \
  --listen-port 8080

# Configure Spin to use the proxy:
HTTPS_PROXY=http://localhost:8080 spin up --listen 127.0.0.1:3000

# Trigger an outbound request and inspect the mitmproxy TLS column.
# The "TLS" column shows the key exchange group if mitmproxy captures it.

# Alternatively: run a local server with OpenSSL that logs key exchange groups
# and route Spin's outbound calls to it.
openssl s_server -accept 8443 -cert server.crt -key server.key \
  -tls1_3 -www -msg 2>&1 | grep "KeyShare\|Group:"

For self-hosted Spin, the most reliable verification is building Spin from source with a known crypto backend and checking the dependency tree:

# In the Spin source tree, check which TLS backend is compiled in:
cargo tree -p spin-core | grep "rustls\|aws-lc\|ring"

# If aws-lc-rs appears: ML-KEM is available
# If only ring appears: classical X25519 only, no ML-KEM

5. Browser WASM: Verifying PQC and the WebCrypto Gap

Browser WASM benefits from the browser’s PQC-capable TLS transparently, but WebCrypto does not expose ML-KEM for application-level cryptography. The gap matters when WASM generates or handles asymmetric keys directly.

// browser.js — Check browser TLS PQC support (indirect method)
// WebCrypto has no PQC algorithm support as of 2026.
// Browser TLS is transparently PQC in Chrome 124+ and Firefox 132+.

async function checkBrowserPQCStatus() {
  // Cloudflare's PQC detection endpoint returns the negotiated KEM group.
  // The fetch() call goes through the browser's TLS stack.
  try {
    const response = await fetch('https://pq.cloudflareresearch.com/check', {
      cache: 'no-store',
    });
    const data = await response.json();

    // data.kem is the key exchange group negotiated with Cloudflare's server.
    // Chrome 124+:   { kem: "X25519MLKEM768" }
    // Firefox 132+:  { kem: "X25519MLKEM768" }
    // Safari (2026): { kem: "X25519" }          -- no ML-KEM
    // Older Chrome:  { kem: "X25519" }          -- pre-124

    console.log('Browser TLS KEM:', data.kem);
    return data.kem.includes('MLKEM');
  } catch (e) {
    // Endpoint unreachable or CORS issue
    console.warn('PQC check failed:', e.message);
    return null;
  }
}

// Detect the WebCrypto gap:
// RSA-OAEP and ECDH key generation in browser WASM are quantum-vulnerable.
async function demonstrateWebCryptoGap() {
  // This generates a quantum-VULNERABLE key pair:
  const classicalKeyPair = await crypto.subtle.generateKey(
    { name: 'ECDH', namedCurve: 'P-256' },
    true,
    ['deriveKey', 'deriveBits']
  );
  // classicalKeyPair.publicKey is P-256 — broken by Shor's algorithm.

  // ML-KEM is NOT available in WebCrypto.
  // This call throws NotSupportedError in all browsers as of 2026:
  try {
    await crypto.subtle.generateKey(
      { name: 'ML-KEM-768' },  // Not a valid WebCrypto algorithm
      true,
      ['encrypt', 'decrypt']
    );
  } catch (e) {
    console.log('Expected error:', e.name); // NotSupportedError
  }
}

// For explicit ML-KEM in browser WASM: load the ml-kem Rust crate compiled to WASM.
// The WASM binary adds ~280KB (release build, wasm-opt applied, ML-KEM-768 only).
async function initPQCWasm() {
  const { default: init, generate_hybrid_keypair, hybrid_encapsulate } =
    await import('./ml_kem_wasm.js');

  await init(); // Loads and instantiates the WASM module

  // generate_hybrid_keypair() calls ml_kem::MlKem768::generate() + x25519
  // Uses getrandom with js feature — reads from crypto.getRandomValues()
  const { public_key, secret_key } = generate_hybrid_keypair();
  console.log('Hybrid public key size:', public_key.byteLength); // 1216 bytes

  // hybrid_encapsulate(recipient_pk) → { shared_secret, ciphertext }
  const result = hybrid_encapsulate(public_key);
  console.log('Ciphertext size:', result.ciphertext.byteLength); // 1120 bytes
  console.log('Shared secret size:', result.shared_secret.byteLength); // 32 bytes
}

The wasm-bindgen build for the ml-kem crate:

# Build the ml-kem WASM module for browser use
wasm-pack build --target web --release -- \
  --features "wasm-bindgen getrandom/js"

# Apply wasm-opt to reduce binary size
wasm-opt -Oz -o pkg/ml_kem_wasm_bg.wasm pkg/ml_kem_wasm_bg.wasm

# Verify final sizes
ls -lh pkg/ml_kem_wasm_bg.wasm
# Typical: 210-280KB for ML-KEM-768 only, no SIMD
# With simd128 target feature: ~180KB (smaller due to vectorised NTT)
# and approximately 2x faster encapsulation: ~0.8ms vs ~1.6ms in Chrome

# Check WASM binary exports
wasm-objdump -x pkg/ml_kem_wasm_bg.wasm | grep "Export\|Memory"

6. Continuous PQC Coverage Audit in CI

Integrate TLS PQC checking into your deployment pipeline so regressions are caught before they reach production.

#!/bin/bash
# ci-pqc-audit.sh — run in CI after deployment to verify PQC negotiation
# Fails the pipeline if any critical endpoint falls back to classical-only

set -euo pipefail

CRITICAL_ENDPOINTS=(
  "${PRODUCTION_API_HOST}:443"
  "${INTERNAL_AUTH_HOST}:443"
  "${VAULT_HOST}:8200"
)

WARN_ENDPOINTS=(
  "${STAGING_HOST}:443"
)

PQC_FAIL=0

check_pqc() {
  local endpoint="$1"
  local required="$2"  # "required" or "warn"

  local result
  result=$(timeout 10 openssl s_client \
    -connect "$endpoint" \
    -groups X25519MLKEM768:X25519 \
    -brief 2>/dev/null | grep "Server Temp Key" || echo "CONNECTION_FAILED")

  if echo "$result" | grep -qiE "MLKEM|kyber|X25519MLKEM"; then
    echo "PASS [PQC]      $endpoint — $result"
  elif [ "$required" = "required" ]; then
    echo "FAIL [CLASSICAL] $endpoint — $result"
    PQC_FAIL=1
  else
    echo "WARN [CLASSICAL] $endpoint — $result"
  fi
}

for ep in "${CRITICAL_ENDPOINTS[@]}"; do
  check_pqc "$ep" "required"
done

for ep in "${WARN_ENDPOINTS[@]}"; do
  check_pqc "$ep" "warn"
done

if [ $PQC_FAIL -ne 0 ]; then
  echo "PQC audit FAILED: one or more critical endpoints use classical key exchange only."
  echo "Check server TLS configuration and ensure OpenSSL 3.4+ or BoringSSL with PQC groups."
  exit 1
fi

echo "PQC audit PASSED."

Expected Behaviour

After deploying rustls 0.23 with aws-lc-rs in a server-side WASM module under Wasmtime, the TLS handshake to a PQC-capable server produces:

rustls debug log:
  Sending ClientHello with key_share for X25519MLKEM768 (1216 bytes)
  Received ServerHello selecting X25519MLKEM768
  Negotiated key exchange: X25519MLKEM768
  TLS session established

openssl s_server log (receiving the connection):
  SSL_accept:SSLv3/TLS write server hello
  SSL_accept:TLSv1.3 write encrypted extensions
  Server Temp Key: X25519MLKEM768, 1216 bits

The ML-KEM-768 EncapsulationKey (1184 bytes) plus the X25519 public key (32 bytes) produce a 1216-byte key_share extension payload in the ClientHello. This is approximately 24x larger than a classical X25519 key share (32 bytes), which increases the ClientHello size from roughly 300 bytes to roughly 1500 bytes — enough to spill from a single TCP segment on some paths. Wireshark will show the ClientHello fragmented across two TCP packets. This is expected and handled correctly by TLS 1.3 implementations.

When the Cloudflare PQC check endpoint is accessed from Chrome 124+:

{ "kem": "X25519MLKEM768", "tls": "1.3", "cipher": "TLS_AES_128_GCM_SHA256" }

From Safari (2026 releases without ML-KEM):

{ "kem": "X25519", "tls": "1.3", "cipher": "TLS_AES_128_GCM_SHA256" }

The WASM binary size impact after compiling the ml-kem crate and running wasm-opt -Oz:

Configuration Binary size ML-KEM-768 encapsulation
Without SIMD (wasm32-unknown-unknown) ~270KB ~1.6ms (Chrome V8)
With SIMD (+simd128) ~185KB ~0.8ms (Chrome V8)
aws-lc-rs in Wasmtime (native ML-KEM) N/A (native) ~0.06ms

The WASM SIMD implementation is faster because ML-KEM’s Number Theoretic Transform (NTT) — used in polynomial multiplication in the lattice arithmetic — maps well onto 128-bit vector operations. The simd128 target feature maps to i16x8 and i32x4 WASM SIMD instructions, which V8 and Wasmtime both lower to native SSE2/NEON instructions. Not all WASM runtimes support SIMD; Wasmi, for example, does not support simd128. Build a non-SIMD fallback for constrained embedded WASM targets.

Trade-offs

Compile ML-KEM to WASM vs. rely on platform TLS. Compiling the ml-kem crate to WASM gives the application explicit control over PQC operations and is necessary for application-level key management. The cost is binary size (+185–270KB), a SIMD compatibility constraint, and an additional dependency to audit. Relying on platform TLS is simpler and has zero WASM binary size impact, but the application cannot inspect TLS parameters, cannot enforce PQC as a hard requirement (only as a preference), and cannot do application-layer PQC key exchange independent of the TLS session.

Hybrid vs. pure ML-KEM. The X25519+ML-KEM-768 hybrid is the correct construction for 2026. NIST’s guidance and the IETF TLS hybrid design draft both recommend hybrid during the transition period. Pure ML-KEM has no known implementation flaws, but it has had less implementation experience than X25519 at scale. The hybrid provides a cryptographic ratchet: if either primitive is broken, the other maintains security. The cost is a larger key share (+32 bytes) and slightly more computation. Pure ML-KEM is not justified by any performance or simplicity argument significant enough to accept the reduced safety margin.

ML-KEM-768 vs. ML-KEM-1024. ML-KEM-768 targets NIST security level 3 (roughly AES-192 equivalent). ML-KEM-1024 targets level 5 (AES-256 equivalent) with a 1568-byte encapsulation key and a 1568-byte ciphertext. For most WASM applications, ML-KEM-768 is the appropriate choice — the additional security of level 5 does not change practical threat assessments for harvest-now-decrypt-later attacks at current capability projections. Choose ML-KEM-1024 only for applications with explicit regulatory requirements for level 5 security (some government contexts) or for long-lived key material that must remain secure past 2040.

TLS 1.3 restriction vs. TLS 1.2 fallback. Restricting to TLS 1.3 (with_protocol_versions(&[&rustls::version::TLS13])) is the correct choice for any endpoint you control. TLS 1.2 cannot negotiate hybrid key exchange; allowing fallback silently removes PQC for servers that prefer or only support TLS 1.2. Accept TLS 1.2 only when connecting to third-party services you cannot control and availability is more critical than PQC.

Failure Modes

Assuming browser PQC covers all network legs. A WASM developer verifies that Chrome negotiates X25519MLKEM768 with their CDN and marks the connection as post-quantum. The CDN then forwards requests to an origin server running nginx 1.18 with OpenSSL 1.1.1. The CDN-to-origin leg is classical TLS. If the CDN receives and re-encrypts data before forwarding (which is the standard CDN model), an attacker who can capture CDN-to-origin traffic — possible for attackers with access to CDN peering points or data centres — collects classically-protected ciphertext. The fix: test the origin endpoint directly with OpenSSL’s s_client using the -groups X25519MLKEM768 flag, not just the CDN hostname.

Spin runtime version assumed to be PQC-capable. A team running Spin 2.7 on a self-hosted deployment observes that their production Fermyon Cloud deployment (running Spin 3.x) shows ML-KEM in outbound TLS captures. They deploy the same WASM binary to their self-hosted Spin 2.7 instance and assume equivalent PQC behaviour. Spin 2.x uses an older rustls version without ML-KEM support. All outbound HTTP from the self-hosted instance uses X25519 only. There is no error. The fix: run spin --version, compare against the Spin changelog for the rustls 0.23 upgrade, and test outbound TLS directly.

WebCrypto RSA key generation left in place. A team migrates their Spin application’s outbound TLS to ML-KEM by upgrading to Spin 3.x, then considers PQC migration complete. Their browser WASM client still calls crypto.subtle.generateKey({ name: 'RSA-OAEP', modulusLength: 2048 }, ...) to generate ephemeral keys for encrypting sensitive user data in IndexedDB. These keys are quantum-vulnerable. The TLS migration protects data in transit; the WebCrypto key generation exposes data at rest. The fix: replace crypto.subtle.generateKey calls for asymmetric key exchange with the ml-kem WASM module for key encapsulation, and use crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, ...) for symmetric data-at-rest encryption.

HKDF combiner omitted. An implementation concatenates the ML-KEM shared secret and the X25519 shared secret and uses the first 32 bytes as the key, or XORs the two 32-byte values. Neither is a secure combiner. An attacker who can force the ML-KEM shared secret to a known value (possible if they can supply a malicious encapsulation key) reduces the combined secret to X25519 alone, defeating the hybrid construction. The fix: use HKDF-SHA256 with both secrets as IKM and an info string that identifies the algorithm combination, as shown in the implementation above.

Not auditing the backend-to-database leg. The WASM edge function uses ML-KEM-capable TLS for outbound API calls. The backend API server connects to PostgreSQL over an internal network using classical TLS or no TLS at all. Database queries include row-level session data. An attacker capturing internal network traffic harvests classically-protected database protocol data. Post-quantum TLS on the client-facing and API legs provides no protection for the database leg. The fix: audit every hop in the data path, not just the client-to-service leg, using the OpenSSL s_client audit script against each internal service port.