diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e4eef6..29ccbbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,44 @@ jobs: - name: Build (logging,tls12) run: make build FEATURES="logging,tls12" + test-mlkem: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: sudo apt-get install -y cmake clang + - name: Lint (mlkem) + run: make lint FEATURES="mlkem" + - name: Test (mlkem) + run: make test FEATURES="mlkem" + - name: Build (mlkem) + run: make build FEATURES="mlkem" + + test-mlkem-tls12: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: sudo apt-get install -y cmake clang + - name: Lint (mlkem,tls12) + run: make lint FEATURES="mlkem,tls12" + - name: Test (mlkem,tls12) + run: make test FEATURES="mlkem,tls12" + - name: Build (mlkem,tls12) + run: make build FEATURES="mlkem,tls12" + + test-fips: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: sudo apt-get install -y cmake clang + - name: Test (fips) + run: cargo test -p boring-rustls-provider --all-targets --features fips + check-fips: runs-on: ubuntu-latest diff --git a/Readme.md b/Readme.md index 4a0ab65..6c86f82 100644 --- a/Readme.md +++ b/Readme.md @@ -13,8 +13,9 @@ out of the box; additional capabilities are opt-in. | Feature | Description | |---|---| -| `fips` | Build against FIPS-validated BoringSSL and restrict the provider to FIPS-approved algorithms only (SP 800-52r2). See [FIPS mode](#fips-mode) below. | +| `fips` | Build against FIPS-validated BoringSSL and restrict the provider to FIPS-approved algorithms only (SP 800-52r2). Implies `mlkem`. See [FIPS mode](#fips-mode) below. | | `fips-precompiled` | Deprecated alias for `fips`. Matches the `boring` crate's feature name. | +| `mlkem` | Enable the X25519MLKEM768 post-quantum hybrid key exchange group (`draft-ietf-tls-ecdhe-mlkem-00`). Uses ML-KEM-768 (FIPS 203) combined with X25519. See [Post-quantum key exchange](#post-quantum-key-exchange). | | `tls12` | Enable TLS 1.2 cipher suites (`ECDHE-ECDSA` and `ECDHE-RSA` with AES-GCM and ChaCha20-Poly1305). Without this only TLS 1.3 is available. | | `logging` | Enable debug logging of BoringSSL errors and provider internals via the `log` crate. | @@ -41,6 +42,11 @@ ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 ### Key Exchange Groups +Post-quantum hybrid (requires `mlkem` feature, TLS 1.3 only): +``` +X25519MLKEM768 (0x11ec) +``` + ECDHE: ``` X25519 @@ -55,6 +61,9 @@ FFDHE: ffdhe2048 ``` +When `mlkem` is enabled, X25519MLKEM768 is the preferred (first) group in both +FIPS and non-FIPS configurations. + ### Signature Algorithms ``` @@ -71,6 +80,25 @@ ED25519 ED448 ``` +## Post-Quantum Key Exchange + +The `mlkem` feature enables the **X25519MLKEM768** hybrid key exchange group +per `draft-ietf-tls-ecdhe-mlkem-00`. This combines classical X25519 +Diffie-Hellman with ML-KEM-768 (FIPS 203) post-quantum key encapsulation, +ensuring that connections are secure against both classical and quantum +adversaries. + +The `fips` feature implies `mlkem`, so X25519MLKEM768 is always available +in FIPS mode. + +Wire format (ML-KEM component first in all encodings): +- Client key share: `mlkem_pk(1184) || x25519_pk(32)` = 1216 bytes +- Server key share: `mlkem_ct(1088) || x25519_pk(32)` = 1120 bytes +- Shared secret: `mlkem_ss(32) || x25519_ss(32)` = 64 bytes + +Interoperability has been verified against Cloudflare's PQ endpoints +(`pq.cloudflareresearch.com`). + ## FIPS Mode When the `fips` feature is enabled the provider builds against a FIPS-validated @@ -79,13 +107,11 @@ under [SP 800-52r2](https://doi.org/10.6028/NIST.SP.800-52r2), aligned with boring's `fips202205` compliance policy: - **Cipher suites**: AES-GCM only (no ChaCha20-Poly1305). -- **Key exchange groups**: P-256 and P-384 only (no X25519, X448, P-521, or FFDHE). +- **Key exchange groups**: X25519MLKEM768 (preferred), P-256, and P-384 only + (no standalone X25519, X448, P-521, or FFDHE). - **Signature algorithms**: RSA PKCS#1 / PSS and ECDSA with P-256 or P-384 only (no P-521, Ed25519, or Ed448). -Post-quantum hybrid key exchange (`P256Kyber768Draft00`) is planned for the -FIPS group set but not yet implemented. - ## Workspace Structure | Crate | Purpose | diff --git a/boring-rustls-provider/Cargo.toml b/boring-rustls-provider/Cargo.toml index ffb9248..9956c2b 100644 --- a/boring-rustls-provider/Cargo.toml +++ b/boring-rustls-provider/Cargo.toml @@ -13,12 +13,18 @@ default = [] # Build against a FIPS-validated version of BoringSSL and restrict the # provider to FIPS-approved algorithms only. This affects: # - Cipher suites: AES-GCM only (no ChaCha20-Poly1305). -# - Key exchange groups: P-256 and P-384 only (no X25519, X448, P-521, -# or FFDHE). P256Kyber768Draft00 will be added once implemented. +# - Key exchange groups: X25519MLKEM768 (preferred), P-256, and P-384 +# only (no X25519, X448, P-521, or FFDHE). # - Signature algorithms: RSA PKCS#1 / PSS and ECDSA with P-256/P-384 # only (no P-521, Ed25519, or Ed448). +# Implies `mlkem` to ensure the post-quantum hybrid group is available. # Aligned with boring's `fips202205` compliance policy (SP 800-52r2). -fips = ["boring/fips"] +fips = ["boring/fips", "mlkem"] + +# Enable X25519MLKEM768 post-quantum hybrid key exchange group +# (draft-ietf-tls-ecdhe-mlkem-00). Uses ML-KEM-768 (FIPS 203) combined +# with X25519 for hybrid post-quantum/classical key agreement. +mlkem = ["boring/mlkem"] # Deprecated alias for `fips`. Matches the boring crate's feature name # for backwards compatibility. @@ -43,9 +49,11 @@ log = { version = "0.4.4", optional = true } rustls = { workspace = true } rustls-pki-types = { workspace = true } spki = "0.7" +zeroize = "1" [dev-dependencies] hex-literal = "1" rcgen = "0.12" tokio = { version = "1.34", features = ["macros", "rt", "net", "io-util", "io-std"] } tokio-rustls = { workspace = true } +webpki-roots = { workspace = true } diff --git a/boring-rustls-provider/src/kx.rs b/boring-rustls-provider/src/kx/mod.rs similarity index 96% rename from boring-rustls-provider/src/kx.rs rename to boring-rustls-provider/src/kx/mod.rs index 0796b69..9baf602 100644 --- a/boring-rustls-provider/src/kx.rs +++ b/boring-rustls-provider/src/kx/mod.rs @@ -4,6 +4,10 @@ use crate::helper::log_and_map; mod dh; mod ex; +#[cfg(feature = "mlkem")] +mod pq; +#[cfg(feature = "mlkem")] +pub(crate) use pq::X25519MlKem768; enum DhKeyType { EC((boring::ec::EcGroup, i32)), diff --git a/boring-rustls-provider/src/kx/pq.rs b/boring-rustls-provider/src/kx/pq.rs new file mode 100644 index 0000000..f5c91fe --- /dev/null +++ b/boring-rustls-provider/src/kx/pq.rs @@ -0,0 +1,364 @@ +//! X25519MLKEM768 post-quantum hybrid key exchange. +//! +//! Implements the X25519MLKEM768 hybrid key agreement per +//! `draft-ietf-tls-ecdhe-mlkem-00`. Composes ML-KEM-768 (FIPS 203) +//! with X25519, with the ML-KEM component first in all wire encodings. +//! +//! Wire format: +//! - Client key share: `mlkem_pk(1184) || x25519_pk(32)` = 1216 bytes +//! - Server key share: `mlkem_ct(1088) || x25519_pk(32)` = 1120 bytes +//! - Shared secret: `mlkem_ss(32) || x25519_ss(32)` = 64 bytes + +use boring::mlkem::{Algorithm, MlKemPrivateKey, MlKemPublicKey}; +use rustls::crypto::{self, CompletedKeyExchange, SharedSecret}; +use rustls::{Error, NamedGroup, ProtocolVersion}; +use zeroize::Zeroizing; + +const MLKEM768_PUBLIC_KEY_BYTES: usize = 1184; +const MLKEM768_CIPHERTEXT_BYTES: usize = 1088; +const X25519_PUBLIC_KEY_BYTES: usize = 32; +const X25519_PRIVATE_KEY_BYTES: usize = 32; +const X25519_SHARED_SECRET_BYTES: usize = 32; + +const CLIENT_SHARE_LEN: usize = MLKEM768_PUBLIC_KEY_BYTES + X25519_PUBLIC_KEY_BYTES; // 1216 +const SERVER_SHARE_LEN: usize = MLKEM768_CIPHERTEXT_BYTES + X25519_PUBLIC_KEY_BYTES; // 1120 + +/// X25519MLKEM768 post-quantum hybrid key exchange group. +#[derive(Debug)] +pub struct X25519MlKem768; + +impl crypto::SupportedKxGroup for X25519MlKem768 { + /// Client-side: generate ML-KEM-768 + X25519 keypairs. + fn start(&self) -> Result, Error> { + let (mlkem_pub, mlkem_priv) = + MlKemPrivateKey::generate(Algorithm::MlKem768).map_err(|e| { + crate::helper::log_and_map( + "X25519MlKem768::start mlkem generate", + e, + crypto::GetRandomFailed, + ) + })?; + + let mut x25519_pub = [0u8; X25519_PUBLIC_KEY_BYTES]; + let mut x25519_priv = Zeroizing::new([0u8; X25519_PRIVATE_KEY_BYTES]); + // SAFETY: X25519_keypair writes exactly 32 bytes to each output buffer. + unsafe { + boring_sys::X25519_keypair(x25519_pub.as_mut_ptr(), x25519_priv.as_mut_ptr()); + } + + // Wire format: mlkem_pk || x25519_pk + let mut pub_key = Vec::with_capacity(CLIENT_SHARE_LEN); + pub_key.extend_from_slice(mlkem_pub.as_bytes()); + pub_key.extend_from_slice(&x25519_pub); + + Ok(Box::new(ActiveX25519MlKem768 { + mlkem_priv, + x25519_priv, + x25519_pub, + pub_key, + })) + } + + /// Server-side: one-shot encapsulate + DH. + /// + /// Must be overridden for KEMs because the server's output (ciphertext) + /// depends on the client's input (encapsulation key). + fn start_and_complete(&self, client_share: &[u8]) -> Result { + if client_share.len() != CLIENT_SHARE_LEN { + return Err(Error::PeerMisbehaved( + rustls::PeerMisbehaved::InvalidKeyShare, + )); + } + + // Split client share: mlkem_pk(1184) || x25519_pk(32) + let (client_mlkem_pk_bytes, client_x25519_pk) = + client_share.split_at(MLKEM768_PUBLIC_KEY_BYTES); + + // ML-KEM encapsulate + let client_mlkem_pk = + MlKemPublicKey::from_slice(Algorithm::MlKem768, client_mlkem_pk_bytes).map_err( + |e| { + crate::helper::log_and_map( + "X25519MlKem768::start_and_complete mlkem parse", + e, + Error::PeerMisbehaved(rustls::PeerMisbehaved::InvalidKeyShare), + ) + }, + )?; + + let (mlkem_ct, mlkem_ss) = client_mlkem_pk.encapsulate().map_err(|e| { + crate::helper::log_and_map( + "X25519MlKem768::start_and_complete mlkem encap", + e, + Error::PeerMisbehaved(rustls::PeerMisbehaved::InvalidKeyShare), + ) + })?; + let mlkem_ss = Zeroizing::new(mlkem_ss); + + // X25519 key exchange + let mut x25519_server_pub = [0u8; X25519_PUBLIC_KEY_BYTES]; + let mut x25519_server_priv = Zeroizing::new([0u8; X25519_PRIVATE_KEY_BYTES]); + let mut x25519_ss = Zeroizing::new([0u8; X25519_SHARED_SECRET_BYTES]); + + // SAFETY: X25519_keypair writes exactly 32 bytes to each buffer. + // X25519 returns 1 on success, 0 on failure (e.g., low-order point). + unsafe { + boring_sys::X25519_keypair( + x25519_server_pub.as_mut_ptr(), + x25519_server_priv.as_mut_ptr(), + ); + let rc = boring_sys::X25519( + x25519_ss.as_mut_ptr(), + x25519_server_priv.as_ptr(), + client_x25519_pk.as_ptr(), + ); + if rc != 1 { + return Err(Error::PeerMisbehaved( + rustls::PeerMisbehaved::InvalidKeyShare, + )); + } + } + + // Server share: mlkem_ct(1088) || x25519_pk(32) + let mut server_share = Vec::with_capacity(SERVER_SHARE_LEN); + server_share.extend_from_slice(&mlkem_ct); + server_share.extend_from_slice(&x25519_server_pub); + + // Shared secret: mlkem_ss(32) || x25519_ss(32) + let mut secret = Vec::with_capacity(64); + secret.extend_from_slice(&mlkem_ss[..]); + secret.extend_from_slice(&x25519_ss[..]); + + Ok(CompletedKeyExchange { + group: self.name(), + pub_key: server_share, + secret: SharedSecret::from(secret), + }) + } + + fn name(&self) -> NamedGroup { + NamedGroup::X25519MLKEM768 + } + + fn fips(&self) -> bool { + cfg!(feature = "fips") + } + + fn usable_for_version(&self, version: ProtocolVersion) -> bool { + version == ProtocolVersion::TLSv1_3 + } +} + +/// Client-side active hybrid key exchange state. +/// +/// Holds the ML-KEM private key and X25519 private key generated +/// during [`X25519MlKem768::start`], waiting for the server's response. +struct ActiveX25519MlKem768 { + mlkem_priv: MlKemPrivateKey, + x25519_priv: Zeroizing<[u8; X25519_PRIVATE_KEY_BYTES]>, + x25519_pub: [u8; X25519_PUBLIC_KEY_BYTES], + pub_key: Vec, +} + +impl crypto::ActiveKeyExchange for ActiveX25519MlKem768 { + /// Client-side: decapsulate ML-KEM + derive X25519. + fn complete(self: Box, server_share: &[u8]) -> Result { + if server_share.len() != SERVER_SHARE_LEN { + return Err(Error::PeerMisbehaved( + rustls::PeerMisbehaved::InvalidKeyShare, + )); + } + + // Split server share: mlkem_ct(1088) || x25519_pk(32) + let (mlkem_ct, server_x25519_pk) = server_share.split_at(MLKEM768_CIPHERTEXT_BYTES); + + // ML-KEM decapsulate + let mlkem_ss = self.mlkem_priv.decapsulate(mlkem_ct).map_err(|e| { + crate::helper::log_and_map( + "ActiveX25519MlKem768::complete mlkem decap", + e, + Error::PeerMisbehaved(rustls::PeerMisbehaved::InvalidKeyShare), + ) + })?; + let mlkem_ss = Zeroizing::new(mlkem_ss); + + // X25519 derive + let mut x25519_ss = Zeroizing::new([0u8; X25519_SHARED_SECRET_BYTES]); + // SAFETY: X25519 reads 32 bytes from each input and writes 32 to output. + unsafe { + let rc = boring_sys::X25519( + x25519_ss.as_mut_ptr(), + self.x25519_priv.as_ptr(), + server_x25519_pk.as_ptr(), + ); + if rc != 1 { + return Err(Error::PeerMisbehaved( + rustls::PeerMisbehaved::InvalidKeyShare, + )); + } + } + + // Shared secret: mlkem_ss(32) || x25519_ss(32) + let mut secret = Vec::with_capacity(64); + secret.extend_from_slice(&mlkem_ss[..]); + secret.extend_from_slice(&x25519_ss[..]); + + Ok(SharedSecret::from(secret)) + } + + fn hybrid_component(&self) -> Option<(NamedGroup, &[u8])> { + Some((NamedGroup::X25519, &self.x25519_pub)) + } + + fn complete_hybrid_component( + self: Box, + peer_pub_key: &[u8], + ) -> Result { + if peer_pub_key.len() != X25519_PUBLIC_KEY_BYTES { + return Err(Error::PeerMisbehaved( + rustls::PeerMisbehaved::InvalidKeyShare, + )); + } + + let mut x25519_ss = Zeroizing::new([0u8; X25519_SHARED_SECRET_BYTES]); + + // SAFETY: X25519 reads 32 bytes from each input and writes 32 to output. + unsafe { + let rc = boring_sys::X25519( + x25519_ss.as_mut_ptr(), + self.x25519_priv.as_ptr(), + peer_pub_key.as_ptr(), + ); + if rc != 1 { + return Err(Error::PeerMisbehaved( + rustls::PeerMisbehaved::InvalidKeyShare, + )); + } + } + + Ok(SharedSecret::from(Vec::from(&x25519_ss[..]))) + } + + fn pub_key(&self) -> &[u8] { + &self.pub_key + } + + fn group(&self) -> NamedGroup { + NamedGroup::X25519MLKEM768 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustls::crypto::SupportedKxGroup; + + #[test] + fn hybrid_round_trip() { + let group = X25519MlKem768; + + // Client generates keypair + let client = group.start().unwrap(); + assert_eq!(client.pub_key().len(), CLIENT_SHARE_LEN); + assert_eq!(client.group(), NamedGroup::X25519MLKEM768); + + // Server encapsulates + derives + let server = group.start_and_complete(client.pub_key()).unwrap(); + assert_eq!(server.pub_key.len(), SERVER_SHARE_LEN); + assert_eq!(server.group, NamedGroup::X25519MLKEM768); + + // Client decapsulates + derives + let client_secret = client.complete(&server.pub_key).unwrap(); + + // Shared secrets must match + assert_eq!( + client_secret.secret_bytes(), + server.secret.secret_bytes(), + "client and server shared secrets differ" + ); + assert_eq!(client_secret.secret_bytes().len(), 64); + } + + #[test] + fn rejects_invalid_client_share() { + let group = X25519MlKem768; + let result = group.start_and_complete(&[0u8; 100]); + assert!(result.is_err()); + } + + #[test] + fn rejects_invalid_server_share() { + let group = X25519MlKem768; + let client = group.start().unwrap(); + let result = client.complete(&[0u8; 100]); + assert!(result.is_err()); + } + + #[test] + fn exposes_x25519_hybrid_component() { + let group = X25519MlKem768; + let client = group.start().unwrap(); + + let (component_group, component_pub_key) = client + .hybrid_component() + .expect("hybrid component should be available"); + + assert_eq!(component_group, NamedGroup::X25519); + assert_eq!(component_pub_key.len(), X25519_PUBLIC_KEY_BYTES); + assert_eq!( + component_pub_key, + &client.pub_key()[MLKEM768_PUBLIC_KEY_BYTES..CLIENT_SHARE_LEN] + ); + } + + #[test] + fn complete_hybrid_component_matches_x25519() { + let group = X25519MlKem768; + let client = group.start().unwrap(); + let (_, client_x25519_pub) = client + .hybrid_component() + .expect("hybrid component should be available"); + + let mut server_x25519_pub = [0u8; X25519_PUBLIC_KEY_BYTES]; + let mut server_x25519_priv = [0u8; X25519_PRIVATE_KEY_BYTES]; + let mut server_x25519_ss = [0u8; X25519_SHARED_SECRET_BYTES]; + + // SAFETY: X25519_keypair writes 32-byte buffers. X25519 returns 1 on success. + unsafe { + boring_sys::X25519_keypair( + server_x25519_pub.as_mut_ptr(), + server_x25519_priv.as_mut_ptr(), + ); + let rc = boring_sys::X25519( + server_x25519_ss.as_mut_ptr(), + server_x25519_priv.as_ptr(), + client_x25519_pub.as_ptr(), + ); + assert_eq!(rc, 1); + } + + let client_secret = client + .complete_hybrid_component(&server_x25519_pub) + .unwrap(); + assert_eq!(client_secret.secret_bytes(), &server_x25519_ss); + } + + #[test] + fn usable_only_for_tls13() { + let group = X25519MlKem768; + assert!(group.usable_for_version(ProtocolVersion::TLSv1_3)); + assert!(!group.usable_for_version(ProtocolVersion::TLSv1_2)); + } + + #[test] + #[cfg(feature = "fips")] + fn reports_fips() { + assert!(X25519MlKem768.fips()); + } + + #[test] + #[cfg(not(feature = "fips"))] + fn reports_non_fips() { + assert!(!X25519MlKem768.fips()); + } +} diff --git a/boring-rustls-provider/src/lib.rs b/boring-rustls-provider/src/lib.rs index b4579ec..fe822f6 100644 --- a/boring-rustls-provider/src/lib.rs +++ b/boring-rustls-provider/src/lib.rs @@ -102,16 +102,31 @@ static ALL_CIPHER_SUITES: &[SupportedCipherSuite] = &[ /// Allowed KX groups for FIPS per [SP 800-52r2](https://doi.org/10.6028/NIST.SP.800-52r2), /// aligned with boring's `fips202205` compliance policy. /// -/// See Section 3.3.1 and 3.4.2.2. -// TODO: Add P256Kyber768Draft00 once the PQ hybrid KEM is implemented (Step 3). +/// X25519MLKEM768 is preferred when the `mlkem` feature is enabled. +/// The `fips` feature implies `mlkem`, so the PQ hybrid is always +/// available in FIPS mode. +#[cfg(feature = "mlkem")] #[allow(unused)] -pub const ALL_FIPS_KX_GROUPS: &[&dyn SupportedKxGroup] = &[ +static ALL_FIPS_KX_GROUPS: &[&dyn SupportedKxGroup] = &[ + &kx::X25519MlKem768 as _, // PQ hybrid preferred + &kx::Secp256r1 as _, // P-256 + &kx::Secp384r1 as _, // P-384 +]; + +/// See [`ALL_FIPS_KX_GROUPS`] (mlkem variant). +#[cfg(not(feature = "mlkem"))] +#[allow(unused)] +static ALL_FIPS_KX_GROUPS: &[&dyn SupportedKxGroup] = &[ &kx::Secp256r1 as _, // P-256 &kx::Secp384r1 as _, // P-384 ]; +/// All supported KX groups, ordered by preference. +/// Matches boring's default group preference order. +#[cfg(feature = "mlkem")] #[allow(unused)] -pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[ +static ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[ + &kx::X25519MlKem768 as _, // PQ hybrid preferred &kx::X25519 as _, &kx::X448 as _, &kx::Secp256r1 as _, @@ -119,3 +134,15 @@ pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[ &kx::Secp521r1 as _, &kx::FfDHe2048 as _, ]; + +/// See [`ALL_KX_GROUPS`] (mlkem variant). +#[cfg(not(feature = "mlkem"))] +#[allow(unused)] +static ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[ + &kx::X25519 as _, + &kx::Secp256r1 as _, + &kx::Secp384r1 as _, + &kx::Secp521r1 as _, + &kx::X448 as _, + &kx::FfDHe2048 as _, +]; diff --git a/boring-rustls-provider/tests/e2e.rs b/boring-rustls-provider/tests/e2e.rs index 9411fd2..68aadb1 100644 --- a/boring-rustls-provider/tests/e2e.rs +++ b/boring-rustls-provider/tests/e2e.rs @@ -41,6 +41,159 @@ async fn test_tls13_crypto() { } } +/// Self-to-self TLS 1.3 handshake using only the X25519MLKEM768 PQ hybrid group. +#[cfg(feature = "mlkem")] +#[tokio::test] +async fn test_tls13_pq_x25519_mlkem768() { + use rustls::NamedGroup; + + let pki = TestPki::new(&rcgen::PKCS_ECDSA_P256_SHA256); + + let root_store = pki.client_root_store(); + + // Build server with only X25519MLKEM768 + let server_provider = + boring_rustls_provider::provider_with_ciphers(vec![SupportedCipherSuite::Tls13( + &tls13::AES_256_GCM_SHA384, + )]); + let server_config = { + let mut cfg = ServerConfig::builder_with_provider(Arc::new(server_provider)) + .with_protocol_versions(&[&TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![pki.server_cert_der.clone()], + pki.server_key_der.clone_key(), + ) + .unwrap(); + cfg.key_log = Arc::new(rustls::KeyLogFile::new()); + Arc::new(cfg) + }; + + // Build client with only X25519MLKEM768 + let client_provider = + boring_rustls_provider::provider_with_ciphers(vec![SupportedCipherSuite::Tls13( + &tls13::AES_256_GCM_SHA384, + )]); + let config = ClientConfig::builder_with_provider(Arc::new(client_provider)) + .with_protocol_versions(&[&TLS13]) + .unwrap() + .with_root_certificates(root_store) + .with_no_client_auth(); + + let negotiated_group = do_exchange(config, server_config).await; + assert_eq!(negotiated_group, Some(NamedGroup::X25519MLKEM768)); +} + +/// Connect to Cloudflare's PQ test endpoint and verify X25519MLKEM768 +/// was actually negotiated by checking the `/cdn-cgi/trace` response. +/// Marked `#[ignore]` because it depends on an external service. +#[cfg(feature = "mlkem")] +#[ignore] +#[tokio::test] +async fn test_pq_interop_cloudflare() { + let mut root_store = rustls::RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let provider = boring_rustls_provider::provider(); + let config = ClientConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&TLS13]) + .unwrap() + .with_root_certificates(root_store) + .with_no_client_auth(); + + let connector = TlsConnector::from(Arc::new(config)); + let stream = TcpStream::connect("pq.cloudflareresearch.com:443") + .await + .unwrap(); + + let mut stream = connector + .connect( + rustls_pki_types::ServerName::try_from("pq.cloudflareresearch.com").unwrap(), + stream, + ) + .await + .expect("TLS handshake with pq.cloudflareresearch.com failed"); + + // Hit the trace endpoint which reports negotiated TLS parameters + stream + .write_all( + b"GET /cdn-cgi/trace HTTP/1.1\r\nHost: pq.cloudflareresearch.com\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await.unwrap(); + let response = String::from_utf8_lossy(&buf); + + // Verify TLS 1.3 was used + assert!( + response.contains("tls=TLSv1.3"), + "expected TLSv1.3, got: {response}" + ); + + // Verify X25519MLKEM768 was negotiated as the key exchange + assert!( + response.contains("kex=X25519MLKEM768"), + "expected kex=X25519MLKEM768, got: {response}" + ); +} + +/// Connect to Cloudflare with TLS 1.2 forced and verify that a classical +/// key exchange is used (PQ groups are TLS 1.3 only). +/// Marked `#[ignore]` because it depends on an external service. +#[cfg(all(feature = "mlkem", feature = "tls12"))] +#[ignore] +#[tokio::test] +async fn test_tls12_interop_cloudflare_no_pq() { + let mut root_store = rustls::RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let provider = boring_rustls_provider::provider(); + let config = ClientConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&TLS12]) + .unwrap() + .with_root_certificates(root_store) + .with_no_client_auth(); + + let connector = TlsConnector::from(Arc::new(config)); + let stream = TcpStream::connect("pq.cloudflareresearch.com:443") + .await + .unwrap(); + + let mut stream = connector + .connect( + rustls_pki_types::ServerName::try_from("pq.cloudflareresearch.com").unwrap(), + stream, + ) + .await + .expect("TLS handshake with pq.cloudflareresearch.com (TLS 1.2) failed"); + + stream + .write_all( + b"GET /cdn-cgi/trace HTTP/1.1\r\nHost: pq.cloudflareresearch.com\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await.unwrap(); + let response = String::from_utf8_lossy(&buf); + + // Verify TLS 1.2 was used + assert!( + response.contains("tls=TLSv1.2"), + "expected TLSv1.2, got: {response}" + ); + + // Verify a classical key exchange was used (not PQ) + assert!( + !response.contains("kex=X25519MLKEM768"), + "TLS 1.2 should not use PQ key exchange, got: {response}" + ); +} + #[test] #[cfg(feature = "fips")] fn is_fips_enabled() { @@ -80,11 +233,20 @@ fn fips_provider_restricts_kx_groups() { .map(|group| group.name()) .collect::>(); + // fips implies mlkem, so X25519MLKEM768 must be present and preferred + assert_eq!( + groups[0], + NamedGroup::X25519MLKEM768, + "X25519MLKEM768 should be the first (preferred) FIPS KX group" + ); assert!(groups.contains(&NamedGroup::secp256r1)); assert!(groups.contains(&NamedGroup::secp384r1)); - for group in groups { + for group in &groups { assert!( - matches!(group, NamedGroup::secp256r1 | NamedGroup::secp384r1), + matches!( + group, + NamedGroup::X25519MLKEM768 | NamedGroup::secp256r1 | NamedGroup::secp384r1 + ), "FIPS provider exposed disallowed KX group: {group:?}" ); } @@ -142,6 +304,35 @@ fn non_fips_provider_keeps_non_fips_algorithms() { .any(|group| group.name() == NamedGroup::X25519)); } +#[test] +#[cfg(all(not(feature = "fips"), feature = "mlkem"))] +fn non_fips_provider_includes_pq_group() { + use rustls::NamedGroup; + + let provider = boring_rustls_provider::provider(); + let groups = provider + .kx_groups + .iter() + .map(|group| group.name()) + .collect::>(); + + assert_eq!( + groups[0], + NamedGroup::X25519MLKEM768, + "X25519MLKEM768 should be the first (preferred) KX group" + ); + assert_eq!( + groups[1], + NamedGroup::X25519, + "X25519 should remain the first classical fallback" + ); + assert_eq!( + groups[2], + NamedGroup::X448, + "X448 should remain ahead of NIST P-curves in non-FIPS mode" + ); +} + #[cfg(feature = "tls12")] #[tokio::test] async fn test_tls12_ec_crypto() { @@ -200,7 +391,10 @@ async fn new_listener() -> TcpListener { TcpListener::bind("localhost:0").await.unwrap() } -async fn do_exchange(config: ClientConfig, server_config: Arc) { +async fn do_exchange( + config: ClientConfig, + server_config: Arc, +) -> Option { let listener = new_listener().await; let addr = listener.local_addr().unwrap(); tokio::spawn(spawn_echo_server(listener, server_config.clone())); @@ -216,10 +410,18 @@ async fn do_exchange(config: ClientConfig, server_config: Arc) { .await .unwrap(); + let negotiated_group = stream + .get_ref() + .1 + .negotiated_key_exchange_group() + .map(|group| group.name()); + stream.write_all(b"HELLO").await.unwrap(); let mut buf = Vec::new(); let bytes = stream.read_to_end(&mut buf).await.unwrap(); assert_eq!(&buf[..bytes], b"HELLO"); + + negotiated_group } async fn spawn_echo_server(listener: TcpListener, config: Arc) {