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:
parent
271acbb315
commit
fafbf296b1
7 changed files with 684 additions and 15 deletions
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
36
Readme.md
36
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 |
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
364
boring-rustls-provider/src/kx/pq.rs
Normal file
364
boring-rustls-provider/src/kx/pq.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 _,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue