Add X25519MLKEM768 post-quantum hybrid key exchange

Implement the X25519MLKEM768 hybrid key exchange group per
draft-ietf-tls-ecdhe-mlkem-00, combining ML-KEM-768 (FIPS 203) with
X25519 for post-quantum/classical hybrid key agreement.

- New mlkem feature gating X25519MLKEM768 via boring's mlkem module
- fips feature now implies mlkem so PQ is always available in FIPS mode
- X25519MLKEM768 is the preferred (first) group in both FIPS and
  non-FIPS configurations when mlkem is enabled
- Uses boring::mlkem for ML-KEM-768 and direct X25519 FFI for the
  classical component (no SPKI overhead)
- Overrides start_and_complete() for server-side KEM encapsulation
- Wire format: ML-KEM component first in all encodings (client share
  1216 bytes, server share 1120 bytes, shared secret 64 bytes)
- TLS 1.3 only (usable_for_version rejects TLS 1.2)
- Unit tests: hybrid round-trip, invalid share rejection, version
  and FIPS flag checks
- E2E tests: self-to-self PQ TLS handshake, FIPS group assertions
- Cloudflare interop tests (ignored): verify kex=X25519MLKEM768 via
  /cdn-cgi/trace for TLS 1.3, verify classical fallback for TLS 1.2
- Update README with PQ section, feature docs, and FIPS KX updates
This commit is contained in:
Jan Rüth 2026-04-10 13:08:05 +02:00 committed by Jan
commit fafbf296b1
7 changed files with 684 additions and 15 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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 }

View file

@ -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)),

View file

@ -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<Box<dyn crypto::ActiveKeyExchange>, 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<CompletedKeyExchange, Error> {
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<u8>,
}
impl crypto::ActiveKeyExchange for ActiveX25519MlKem768 {
/// Client-side: decapsulate ML-KEM + derive X25519.
fn complete(self: Box<Self>, server_share: &[u8]) -> Result<SharedSecret, Error> {
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<Self>,
peer_pub_key: &[u8],
) -> Result<SharedSecret, Error> {
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());
}
}

View file

@ -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)]
pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[
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)]
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 _,
];

View file

@ -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::<Vec<_>>();
// 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::<Vec<_>>();
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<ServerConfig>) {
async fn do_exchange(
config: ClientConfig,
server_config: Arc<ServerConfig>,
) -> Option<rustls::NamedGroup> {
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<ServerConfig>) {
.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<ServerConfig>) {