nordisk-pairing 0.5.29

The boxes of an estate find each other: a seed each box is installed with, a one-time join against the front, a tunnel (WireGuard by default) they meet inside, roles decided once, a pairing loop each box runs itself, and the signal it reports. Shared by gunnar and holger; the laptop's xtask only lays machines, writes seeds and reads the signal. Design: .nornir/pairing-design.md (2026-09-24).
Documentation
//! **X25519MLKEM768 for rustls, in pure Rust** (2026-10-06, the owner: "ALL
//! force on quantum").
//!
//! The hybrid key exchange Chrome, Firefox, curl and OpenSSL 3.5 offer by
//! default — draft-ietf-tls-ecdhe-mlkem, IANA TLS group `0x11EC`. A recording
//! of a handshake made with it cannot be opened later by a quantum computer
//! (the ML-KEM half) nor today by anyone (the X25519 half): the session key is
//! derived from BOTH secrets.
//!
//! **Composed, never implemented here.** ML-KEM-768 is RustCrypto `ml-kem`
//! 0.3 (FIPS 203 — the same crate russh's `mlkem768x25519-sha256` SSH key
//! exchange runs on, so it costs no package in any graph that has russh), and
//! X25519 is rustls's own ring group. This module only lays the two shares
//! side by side in the draft's order and concatenates the two secrets — the
//! composition rustls's aws-lc-rs provider makes (`crypto::aws_lc_rs::pq`),
//! which needs C (aws-lc-sys) and is therefore not the one used here.
//!
//! The wire, draft-ietf-tls-ecdhe-mlkem §3 (X25519MLKEM768 puts ML-KEM FIRST,
//! unlike SecP256r1MLKEM768):
//!
//! | | ML-KEM-768 | X25519 |
//! |---|---|---|
//! | client share | encapsulation key, 1184 bytes | public key, 32 bytes |
//! | server share | ciphertext, 1088 bytes | public key, 32 bytes |
//! | shared secret | 32 bytes | 32 bytes |
//!
//! [`provider`] is rustls's ring provider with this group FIRST and the
//! classical groups kept after it: a client that offers it gets it, an older
//! client still connects on X25519 (prefer, never require). TLS 1.3 only — the
//! group is never offered or chosen under TLS 1.2.

use ml_kem::kem::{Decapsulate, Encapsulate, Kem};
use ml_kem::{DecapsulationKey, EncapsulationKey, KeyExport, MlKem768, TryKeyInit};
use rustls::crypto::{ActiveKeyExchange, CompletedKeyExchange, CryptoProvider, SharedSecret, SupportedKxGroup};
use rustls::ffdhe_groups::FfdheGroup;
use rustls::{Error, NamedGroup, PeerMisbehaved, ProtocolVersion};
use zeroize::Zeroize;

/// ML-KEM-768's encapsulation key: the client's post-quantum share.
pub const MLKEM768_ENCAP_LEN: usize = 1184;
/// ML-KEM-768's ciphertext: the server's post-quantum share.
pub const MLKEM768_CIPHERTEXT_LEN: usize = 1088;
/// An X25519 public key.
pub const X25519_LEN: usize = 32;
/// Each half's shared secret (ML-KEM-768's, X25519's).
const SECRET_LEN: usize = 32;

/// **The X25519MLKEM768 group** (`0x11EC`), for a provider of one's own.
pub static X25519MLKEM768: &dyn SupportedKxGroup = &X25519MlKem768;

/// **rustls's ring provider with X25519MLKEM768 first** — every TLS end the
/// estate runs (the front's HTTPS, the installer's mutual TLS) is built on it.
pub fn provider() -> CryptoProvider {
    let mut p = rustls::crypto::ring::default_provider();
    p.kx_groups.insert(0, X25519MLKEM768);
    p
}

/// The classical half: rustls's own X25519, on ring.
fn classical() -> &'static dyn SupportedKxGroup {
    rustls::crypto::ring::kx_group::X25519
}

fn invalid() -> Error {
    Error::PeerMisbehaved(PeerMisbehaved::InvalidKeyShare)
}

/// `pq || classical` into exactly-sized storage (no reallocation leaves a
/// copy of a secret behind); the ML-KEM half is wiped once copied.
fn joined(mut pq: [u8; SECRET_LEN], classical: &[u8]) -> SharedSecret {
    let mut secret = Vec::with_capacity(SECRET_LEN + classical.len());
    secret.extend_from_slice(&pq);
    secret.extend_from_slice(classical);
    pq.zeroize();
    SharedSecret::from(secret)
}

#[derive(Debug)]
struct X25519MlKem768;

impl SupportedKxGroup for X25519MlKem768 {
    /// The client: a fresh ML-KEM-768 key pair and a fresh X25519 key; the
    /// share is the encapsulation key, then the X25519 public key.
    fn start(&self) -> Result<Box<dyn ActiveKeyExchange>, Error> {
        let classical = classical().start()?;
        let (dk, ek) = MlKem768::generate_keypair();
        let mut pub_key = Vec::with_capacity(MLKEM768_ENCAP_LEN + X25519_LEN);
        pub_key.extend_from_slice(&ek.to_bytes());
        pub_key.extend_from_slice(classical.pub_key());
        Ok(Box::new(Active { dk, classical, pub_key }))
    }

    /// The server: encapsulate to the client's key (checked as FIPS 203
    /// §7.2 asks) and complete X25519 with its public key; the share is the
    /// ciphertext, then our X25519 public key.
    fn start_and_complete(&self, client_share: &[u8]) -> Result<CompletedKeyExchange, Error> {
        if client_share.len() != MLKEM768_ENCAP_LEN + X25519_LEN {
            return Err(invalid());
        }
        let (pq_share, classical_share) = client_share.split_at(MLKEM768_ENCAP_LEN);
        let ek = EncapsulationKey::<MlKem768>::new_from_slice(pq_share).map_err(|_| invalid())?;
        let cl = classical().start_and_complete(classical_share)?;
        let (ct, ss) = ek.encapsulate();
        let mut pub_key = Vec::with_capacity(MLKEM768_CIPHERTEXT_LEN + X25519_LEN);
        pub_key.extend_from_slice(&ct);
        pub_key.extend_from_slice(&cl.pub_key);
        Ok(CompletedKeyExchange { group: NamedGroup::X25519MLKEM768, pub_key, secret: joined(ss.into(), cl.secret.secret_bytes()) })
    }

    fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
        None
    }

    fn name(&self) -> NamedGroup {
        NamedGroup::X25519MLKEM768
    }

    fn fips(&self) -> bool {
        false
    }

    /// Defined for TLS 1.3 only: never offered, never chosen under 1.2.
    fn usable_for_version(&self, version: ProtocolVersion) -> bool {
        version == ProtocolVersion::TLSv1_3
    }
}

/// A client's key exchange in flight.
struct Active {
    dk: DecapsulationKey<MlKem768>,
    classical: Box<dyn ActiveKeyExchange>,
    pub_key: Vec<u8>,
}

impl ActiveKeyExchange for Active {
    fn complete(self: Box<Self>, server_share: &[u8]) -> Result<SharedSecret, Error> {
        if server_share.len() != MLKEM768_CIPHERTEXT_LEN + X25519_LEN {
            return Err(invalid());
        }
        let (ct, classical_share) = server_share.split_at(MLKEM768_CIPHERTEXT_LEN);
        let ss = self.dk.decapsulate_slice(ct).map_err(|_| invalid())?;
        let cl = self.classical.complete(classical_share)?;
        Ok(joined(ss.into(), cl.secret_bytes()))
    }

    /// The X25519 half on its own: rustls offers it as a second key share in
    /// the same ClientHello, so a server without ML-KEM answers at once.
    fn hybrid_component(&self) -> Option<(NamedGroup, &[u8])> {
        Some((self.classical.group(), self.classical.pub_key()))
    }

    fn complete_hybrid_component(self: Box<Self>, peer_pub_key: &[u8]) -> Result<SharedSecret, Error> {
        self.classical.complete(peer_pub_key)
    }

    fn pub_key(&self) -> &[u8] {
        &self.pub_key
    }

    fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
        None
    }

    fn group(&self) -> NamedGroup {
        NamedGroup::X25519MLKEM768
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::mtls::InstallerCa;
    use rustls::crypto::SupportedKxGroup;
    use rustls::pki_types::pem::PemObject;
    use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
    use rustls::{NamedGroup, ProtocolVersion};
    use std::io::{Read, Write};
    use std::net::{Ipv4Addr, TcpListener, TcpStream};
    use std::sync::Arc;
    use std::time::Duration;

    const H: Duration = Duration::from_secs(3600);

    fn the_group() -> &'static dyn SupportedKxGroup {
        *provider().kx_groups.iter().find(|g| g.name() == NamedGroup::X25519MLKEM768).expect("the provider offers X25519MLKEM768")
    }

    /// A server certificate for 127.0.0.1 (the installer CA's door leaf).
    fn server_cert() -> (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>, String) {
        let ca = InstallerCa::mint("pq.test", H).unwrap();
        let door = ca.issue_door(Ipv4Addr::LOCALHOST, H).unwrap();
        let certs = CertificateDer::pem_slice_iter(door.cert_pem.as_bytes()).collect::<Result<Vec<_>, _>>().unwrap();
        (certs, PrivateKeyDer::from_pem_slice(door.key_pem.as_bytes()).unwrap(), ca.cert_pem)
    }

    fn server_config(p: CryptoProvider) -> (Arc<rustls::ServerConfig>, String) {
        let (certs, key, ca) = server_cert();
        let cfg = rustls::ServerConfig::builder_with_provider(Arc::new(p)).with_safe_default_protocol_versions().unwrap().with_no_client_auth().with_single_cert(certs, key).unwrap();
        (Arc::new(cfg), ca)
    }

    fn client_config(p: CryptoProvider, ca_pem: &str) -> Arc<rustls::ClientConfig> {
        let mut roots = rustls::RootCertStore::empty();
        for c in CertificateDer::pem_slice_iter(ca_pem.as_bytes()) {
            roots.add(c.unwrap()).unwrap();
        }
        Arc::new(rustls::ClientConfig::builder_with_provider(Arc::new(p)).with_safe_default_protocol_versions().unwrap().with_root_certificates(roots).with_no_client_auth())
    }

    /// One real handshake over loopback; `(server's group, client's group)`,
    /// or why it failed. Data crosses both ways, so the secret both ends
    /// derived is the same one.
    fn handshake(server: CryptoProvider, client: CryptoProvider) -> Result<(NamedGroup, NamedGroup), String> {
        let (scfg, ca) = server_config(server);
        let l = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = l.local_addr().unwrap();
        let t = std::thread::spawn(move || -> Result<NamedGroup, String> {
            let (s, _) = l.accept().map_err(|e| e.to_string())?;
            let mut tls = rustls::StreamOwned::new(rustls::ServerConnection::new(scfg).unwrap(), s);
            let mut b = [0u8; 4];
            tls.read_exact(&mut b).map_err(|e| format!("server read: {e}"))?;
            tls.write_all(b"pong").and_then(|_| tls.flush()).map_err(|e| format!("server write: {e}"))?;
            tls.conn.negotiated_key_exchange_group().map(|g| g.name()).ok_or_else(|| "no group".to_string())
        });
        let s = TcpStream::connect(addr).unwrap();
        s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
        let conn = rustls::ClientConnection::new(client_config(client, &ca), ServerName::IpAddress(Ipv4Addr::LOCALHOST.into())).unwrap();
        let mut tls = rustls::StreamOwned::new(conn, s);
        let sent = tls.write_all(b"ping").and_then(|_| tls.flush());
        let mut b = [0u8; 4];
        let read = tls.read_exact(&mut b);
        let client_group = tls.conn.negotiated_key_exchange_group().map(|g| g.name());
        drop(tls);
        let server_group = t.join().unwrap();
        match (sent, read, server_group, client_group) {
            (Ok(()), Ok(()), Ok(s), Some(c)) if &b == b"pong" => Ok((s, c)),
            (s, r, sg, cg) => Err(format!("sent {s:?} read {r:?} server {sg:?} client {cg:?}")),
        }
    }

    fn only(groups: &[&'static dyn SupportedKxGroup]) -> CryptoProvider {
        CryptoProvider { kx_groups: groups.to_vec(), ..rustls::crypto::ring::default_provider() }
    }

    #[test]
    fn the_provider_offers_x25519mlkem768_first_and_keeps_every_classical_group_after_it() {
        let names: Vec<NamedGroup> = provider().kx_groups.iter().map(|g| g.name()).collect();
        assert_eq!(names.first(), Some(&NamedGroup::X25519MLKEM768), "{names:?}");
        assert_eq!(&names[1..], &[NamedGroup::X25519, NamedGroup::secp256r1, NamedGroup::secp384r1], "the classical groups stay, in ring's order");
        let g = the_group();
        assert!(g.usable_for_version(ProtocolVersion::TLSv1_3) && !g.usable_for_version(ProtocolVersion::TLSv1_2), "TLS 1.3 only");
        assert_eq!(u16::from(g.name()), 0x11EC, "the IANA codepoint");
    }

    #[test]
    fn two_ends_on_the_provider_negotiate_x25519mlkem768() {
        assert_eq!(handshake(provider(), provider()), Ok((NamedGroup::X25519MLKEM768, NamedGroup::X25519MLKEM768)));
    }

    #[test]
    fn a_client_offering_only_x25519mlkem768_is_answered() {
        assert_eq!(handshake(provider(), only(&[the_group()])), Ok((NamedGroup::X25519MLKEM768, NamedGroup::X25519MLKEM768)));
    }

    #[test]
    fn an_older_client_offering_only_classical_groups_still_connects_on_x25519() {
        assert_eq!(handshake(provider(), rustls::crypto::ring::default_provider()), Ok((NamedGroup::X25519, NamedGroup::X25519)));
    }

    #[test]
    fn a_post_quantum_client_still_reaches_an_older_server_on_x25519() {
        assert_eq!(handshake(rustls::crypto::ring::default_provider(), provider()), Ok((NamedGroup::X25519, NamedGroup::X25519)));
    }

    /// A client share of the wrong length, and an encapsulation key that
    /// fails FIPS 203's modulus check, are refused as an invalid key share; a
    /// server share of the wrong length likewise.
    #[test]
    fn malformed_shares_are_refused_as_invalid_key_shares() {
        let g = the_group();
        let invalid = rustls::Error::PeerMisbehaved(rustls::PeerMisbehaved::InvalidKeyShare);
        assert_eq!(g.start_and_complete(&[0u8; 32]).err(), Some(invalid.clone()), "an X25519 share alone");
        assert_eq!(g.start_and_complete(&[0u8; MLKEM768_ENCAP_LEN + X25519_LEN + 1]).err(), Some(invalid.clone()), "one byte too many");
        // Every coefficient 0xFFF (≥ q = 3329): not an encapsulation key.
        let mut bad = vec![0xFFu8; MLKEM768_ENCAP_LEN + X25519_LEN];
        bad[MLKEM768_ENCAP_LEN..].copy_from_slice(&[9u8; X25519_LEN]);
        assert_eq!(g.start_and_complete(&bad).err(), Some(invalid.clone()), "fails the modulus check");
        let client = g.start().unwrap();
        assert_eq!(client.pub_key().len(), MLKEM768_ENCAP_LEN + X25519_LEN);
        assert_eq!(client.complete(&[0u8; MLKEM768_CIPHERTEXT_LEN]).err(), Some(invalid), "a ciphertext with no X25519 half");
    }

    /// Both halves count: the client and the server derive one 64-byte
    /// secret, ML-KEM's first; and the client's ActiveKeyExchange offers its
    /// X25519 half alone (rustls sends both shares in one ClientHello).
    #[test]
    fn the_secret_is_the_ml_kem_secret_then_the_x25519_secret() {
        let g = the_group();
        let client = g.start().unwrap();
        assert_eq!(client.hybrid_component().map(|(n, k)| (n, k.len())), Some((NamedGroup::X25519, X25519_LEN)));
        assert_eq!(&client.pub_key()[MLKEM768_ENCAP_LEN..], client.hybrid_component().unwrap().1, "the X25519 half is LAST in the share");
        let server = g.start_and_complete(client.pub_key()).unwrap();
        assert_eq!(server.group, NamedGroup::X25519MLKEM768);
        assert_eq!(server.pub_key.len(), MLKEM768_CIPHERTEXT_LEN + X25519_LEN);
        let theirs = server.secret.secret_bytes().to_vec();
        let ours = client.complete(&server.pub_key).unwrap();
        assert_eq!(ours.secret_bytes().len(), 64);
        assert_eq!(ours.secret_bytes(), &theirs[..]);
    }

    /// **Interop with an independent implementation**: OpenSSL ≥ 3.5 (its own
    /// ML-KEM), as a client offering ONLY X25519MLKEM768 against our server,
    /// and as a server accepting ONLY it from our client. Skipped when no
    /// such openssl is on PATH — unless `PQ_INTEROP_REQUIRED=1`, which makes
    /// the absence a failure (the lane's runs set it).
    #[test]
    fn openssl_negotiates_x25519mlkem768_with_us_both_ways() {
        if !openssl_has_the_group() {
            assert!(std::env::var("PQ_INTEROP_REQUIRED").as_deref() != Ok("1"), "PQ_INTEROP_REQUIRED=1 and no openssl ≥ 3.5 with X25519MLKEM768 on PATH");
            eprintln!("SKIP: no openssl with X25519MLKEM768 on PATH");
            return;
        }
        // OpenSSL the client.
        let (scfg, _) = server_config(provider());
        let l = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = l.local_addr().unwrap().port();
        let t = std::thread::spawn(move || {
            let (s, _) = l.accept().unwrap();
            s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
            let mut tls = rustls::StreamOwned::new(rustls::ServerConnection::new(scfg).unwrap(), s);
            let mut b = [0u8; 1];
            let _ = tls.read(&mut b);
            tls.conn.negotiated_key_exchange_group().map(|g| g.name())
        });
        let out = std::process::Command::new("openssl")
            .args(["s_client", "-connect", &format!("127.0.0.1:{port}"), "-groups", "X25519MLKEM768", "-brief"])
            .stdin(std::process::Stdio::piped())
            .output()
            .unwrap();
        let text = String::from_utf8_lossy(&out.stdout).to_string() + &String::from_utf8_lossy(&out.stderr);
        assert!(text.contains("Negotiated TLS1.3 group: X25519MLKEM768"), "openssl s_client said:\n{text}");
        assert_eq!(t.join().unwrap(), Some(NamedGroup::X25519MLKEM768), "our server's own view");

        // OpenSSL the server, accepting X25519MLKEM768 alone.
        let dir = std::env::temp_dir().join(format!("pqtls-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let ca = InstallerCa::mint("pq.test", H).unwrap();
        let door = ca.issue_door(Ipv4Addr::LOCALHOST, H).unwrap();
        std::fs::write(dir.join("c.pem"), &door.cert_pem).unwrap();
        std::fs::write(dir.join("k.pem"), &door.key_pem).unwrap();
        let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
        let mut server = std::process::Command::new("openssl")
            .args(["s_server", "-accept", &format!("127.0.0.1:{port}"), "-groups", "X25519MLKEM768", "-naccept", "1", "-www", "-cert"])
            .arg(dir.join("c.pem"))
            .arg("-key")
            .arg(dir.join("k.pem"))
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .spawn()
            .unwrap();
        let mut s = None;
        for _ in 0..100 {
            if let Ok(c) = TcpStream::connect(("127.0.0.1", port)) {
                s = Some(c);
                break;
            }
            std::thread::sleep(Duration::from_millis(50));
        }
        let s = s.expect("openssl s_server listens");
        s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
        let conn = rustls::ClientConnection::new(client_config(provider(), &ca.cert_pem), ServerName::IpAddress(Ipv4Addr::LOCALHOST.into())).unwrap();
        let mut tls = rustls::StreamOwned::new(conn, s);
        let sent = tls.write_all(b"GET / HTTP/1.0\r\n\r\n").and_then(|_| tls.flush());
        let mut page = Vec::new();
        let _ = tls.read_to_end(&mut page);
        let group = tls.conn.negotiated_key_exchange_group().map(|g| g.name());
        let _ = server.kill();
        let _ = server.wait();
        let _ = std::fs::remove_dir_all(&dir);
        assert!(sent.is_ok(), "{sent:?}");
        assert_eq!(group, Some(NamedGroup::X25519MLKEM768));
        assert!(String::from_utf8_lossy(&page).contains("X25519MLKEM768"), "s_server's -www page names the group it shared");
    }

    fn openssl_has_the_group() -> bool {
        std::process::Command::new("openssl")
            .args(["list", "-tls-groups"])
            .output()
            .map(|o| String::from_utf8_lossy(&o.stdout).split(':').any(|g| g.trim() == "X25519MLKEM768"))
            .unwrap_or(false)
    }
}