Repository: rustls/rustls (language: rust)

Issue:
ML-KEM-only key agreement
This may be relevant to https://github.com/rustls/rustls/issues/2056

TLSWG has [approved ML-KEM-only codepoints](https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-8) for key agreement in TLS 1.3, this is a first stab at supporting that. Restructuring feedback welcome.

This is ML-KEM-768 to start, 512 and 1024 are planned but wanted to get eyes on the approach first.


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 1737d57adb5..3055e41aaaf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -449,7 +449,7 @@ version = "0.1.0"
 dependencies = [
  "base64",
  "env_logger",
- "rustls 0.23.19",
+ "rustls 0.23.20",
  "rustls-post-quantum",
 ]
 
@@ -1172,7 +1172,7 @@ dependencies = [
  "ipnet",
  "once_cell",
  "rand",
- "rustls 0.23.19 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rustls 0.23.19",
  "thiserror 2.0.6",
  "tinyvec",
  "tokio",
@@ -1197,7 +1197,7 @@ dependencies = [
  "parking_lot",
  "rand",
  "resolv-conf",
- "rustls 0.23.19 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rustls 0.23.19",
  "smallvec",
  "thiserror 1.0.69",
  "tokio",
@@ -2216,6 +2216,21 @@ dependencies = [
 [[package]]
 name = "rustls"
 version = "0.23.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.20"
 dependencies = [
  "aws-lc-rs",
  "base64",
@@ -2243,27 +2258,12 @@ dependencies = [
  "zlib-rs",
 ]
 
-[[package]]
-name = "rustls"
-version = "0.23.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1"
-dependencies = [
- "log",
- "once_cell",
- "ring",
- "rustls-pki-types",
- "rustls-webpki",
- "subtle",
- "zeroize",
-]
-
 [[package]]
 name = "rustls-bench"
 version = "0.1.0"
 dependencies = [
  "clap",
- "rustls 0.23.19",
+ "rustls 0.23.20",
  "tikv-jemallocator",
 ]
 
@@ -2279,7 +2279,7 @@ dependencies = [
  "fxhash",
  "itertools 0.13.0",
  "rayon",
- "rustls 0.23.19",
+ "rustls 0.23.20",
  "tikv-jemallocator",
 ]
 
@@ -2290,7 +2290,7 @@ dependencies = [
  "hickory-resolver",
  "regex",
  "ring",
- "rustls 0.23.19",
+ "rustls 0.23.20",
  "tokio",
 ]
 
@@ -2305,7 +2305,7 @@ dependencies = [
  "log",
  "mio",
  "rcgen",
- "rustls 0.23.19",
+ "rustls 0.23.20",
  "serde",
  "tokio",
  "webpki-roots",
@@ -2320,7 +2320,7 @@ dependencies = [
  "num-bigint",
  "once_cell",
  "openssl",
- "rustls 0.23.19",
+ "rustls 0.23.20",
 ]
 
 [[package]]
@@ -2331,10 +2331,10 @@ checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b"
 
 [[package]]
 name = "rustls-post-quantum"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "aws-lc-rs",
- "rustls 0.23.19",
+ "rustls 0.23.20",
 ]
 
 [[package]]
@@ -2354,7 +2354,7 @@ dependencies = [
  "rand_core",
  "rcgen",
  "rsa",
- "rustls 0.23.19",
+ "rustls 0.23.20",
  "rustls-webpki",
  "sha2",
  "signature",
@@ -2367,7 +2367,7 @@ name = "rustls-provider-test"
 version = "0.1.0"
 dependencies = [
  "hex",
- "rustls 0.23.19",
+ "rustls 0.23.20",
  "rustls-provider-example",
  "serde",
  "serde_json",
@@ -2711,7 +2711,7 @@ version = "0.26.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37"
 dependencies = [
- "rustls 0.23.19 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rustls 0.23.19",
  "tokio",
 ]
 
diff --git a/Cargo.toml b/Cargo.toml
index 0f2aef698ef..48eadaed51d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -40,7 +40,7 @@ anyhow = "1.0.73"
 asn1 = "0.20"
 async-std = { version = "1.12.0", features = ["attributes"] }
 async-trait = "0.1.74"
-aws-lc-rs = { version = "1.9", default-features = false, features = ["aws-lc-sys", "prebuilt-nasm"] }
+aws-lc-rs = { version = "1.10", default-features = false, features = ["aws-lc-sys", "prebuilt-nasm"] }
 base64 = "0.22"
 bencher = "0.1.5"
 brotli = { version = "7", default-features = false, features = ["std"] }
diff --git a/bogo/src/main.rs b/bogo/src/main.rs
index 3a2c7470aa8..8b8656a3eda 100644
--- a/bogo/src/main.rs
+++ b/bogo/src/main.rs
@@ -17,8 +17,6 @@ use rustls::client::{
 };
 use rustls::crypto::aws_lc_rs::hpke;
 use rustls::crypto::hpke::{Hpke, HpkePublicKey};
-#[cfg(feature = "post-quantum")]
-use rustls::crypto::SupportedKxGroup;
 use rustls::crypto::{aws_lc_rs, ring, CryptoProvider};
 use rustls::internal::msgs::codec::{Codec, Reader};
 use rustls::internal::msgs::handshake::EchConfigPayload;
@@ -1543,7 +1541,7 @@ pub fn main() {
                 // if X25519MLKEM768 is requested, insert it from rustls_post_quantum
                 #[cfg(feature = "post-quantum")]
                 if group == rustls_post_quantum::X25519MLKEM768.name() && opts.selected_provider == SelectedProvider::PostQuantum {
-                    opts.provider.kx_groups.insert(0, &rustls_post_quantum::X25519MLKEM768);
+                    opts.provider.kx_groups.insert(0, rustls_post_quantum::X25519MLKEM768);
                 }
             }
             "-resumption-delay" => {
diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock
index 405b8429892..7ccf081f163 100644
--- a/fuzz/Cargo.lock
+++ b/fuzz/Cargo.lock
@@ -366,7 +366,7 @@ dependencies = [
 
 [[package]]
 name = "rustls"
-version = "0.23.19"
+version = "0.23.20"
 dependencies = [
  "aws-lc-rs",
  "log",
diff --git a/rustls-post-quantum/Cargo.lock b/rustls-post-quantum/Cargo.lock
index 8088fc344d9..2e5db8f9416 100644
--- a/rustls-post-quantum/Cargo.lock
+++ b/rustls-post-quantum/Cargo.lock
@@ -441,7 +441,7 @@ dependencies = [
 
 [[package]]
 name = "rustls"
-version = "0.23.19"
+version = "0.23.20"
 dependencies = [
  "aws-lc-rs",
  "log",
@@ -460,7 +460,7 @@ checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b"
 
 [[package]]
 name = "rustls-post-quantum"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "aws-lc-rs",
  "env_logger",
diff --git a/rustls-post-quantum/Cargo.toml b/rustls-post-quantum/Cargo.toml
index 3f9da5a153d..76aae2ab6f9 100644
--- a/rustls-post-quantum/Cargo.toml
+++ b/rustls-post-quantum/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "rustls-post-quantum"
-version = "0.2.0"
+version = "0.3.0"
 edition = "2021"
 rust-version = "1.71"
 license = "Apache-2.0 OR ISC OR MIT"
@@ -11,8 +11,8 @@ repository = "https://github.com/rustls/rustls"
 categories = ["network-programming", "cryptography"]
 
 [dependencies]
-rustls = { version = "0.23.2", features = ["aws_lc_rs"], path = "../rustls" }
-aws-lc-rs = { version = "1.9", features = ["non-fips", "unstable"], default-features = false }
+rustls = { version = "0.23.20", features = ["aws_lc_rs"], path = "../rustls" }
+aws-lc-rs = { version = "1.10", features = ["non-fips", "unstable"], default-features = false }
 
 [dev-dependencies]
 env_logger = "0.11"
diff --git a/rustls-post-quantum/src/hybrid.rs b/rustls-post-quantum/src/hybrid.rs
new file mode 100644
index 00000000000..2be2da77c21
--- /dev/null
+++ b/rustls-post-quantum/src/hybrid.rs
@@ -0,0 +1,175 @@
+use rustls::crypto::{ActiveKeyExchange, CompletedKeyExchange, SharedSecret, SupportedKxGroup};
+use rustls::ffdhe_groups::FfdheGroup;
+use rustls::{Error, NamedGroup, ProtocolVersion};
+
+use crate::INVALID_KEY_SHARE;
+
+/// A generalization of hybrid key exchange.
+#[derive(Debug)]
+pub(crate) struct Hybrid {
+    pub(crate) classical: &'static dyn SupportedKxGroup,
+    pub(crate) post_quantum: &'static dyn SupportedKxGroup,
+    pub(crate) name: NamedGroup,
+    pub(crate) layout: Layout,
+}
+
+impl SupportedKxGroup for Hybrid {
+    fn start(&self) -> Result<Box<dyn ActiveKeyExchange>, Error> {
+        let classical = self.classical.start()?;
+        let post_quantum = self.post_quantum.start()?;
+
+        let combined_pub_key = self
+            .layout
+            .concat(post_quantum.pub_key(), classical.pub_key());
+
+        Ok(Box::new(ActiveHybrid {
+            classical,
+            post_quantum,
+            name: self.name,
+            layout: self.layout,
+            combined_pub_key,
+        }))
+    }
+
+    fn start_and_complete(&self, client_share: &[u8]) -> Result<CompletedKeyExchange, Error> {
+        let (post_quantum_share, classical_share) = self
+            .layout
+            .split_received_client_share(client_share)
+            .ok_or(INVALID_KEY_SHARE)?;
+
+        let cl = self
+            .classical
+            .start_and_complete(classical_share)?;
+        let pq = self
+            .post_quantum
+            .start_and_complete(post_quantum_share)?;
+
+        let combined_pub_key = self
+            .layout
+            .concat(&pq.pub_key, &cl.pub_key);
+        let secret = self
+            .layout
+            .concat(pq.secret.secret_bytes(), cl.secret.secret_bytes());
+
+        Ok(CompletedKeyExchange {
+            group: self.name,
+            pub_key: combined_pub_key,
+            secret: SharedSecret::from(secret),
+        })
+    }
+
+    fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
+        None
+    }
+
+    fn name(&self) -> NamedGroup {
+        self.name
+    }
+
+    fn usable_for_version(&self, version: ProtocolVersion) -> bool {
+        version == ProtocolVersion::TLSv1_3
+    }
+}
+
+struct ActiveHybrid {
+    classical: Box<dyn ActiveKeyExchange>,
+    post_quantum: Box<dyn ActiveKeyExchange>,
+    name: NamedGroup,
+    layout: Layout,
+    combined_pub_key: Vec<u8>,
+}
+
+impl ActiveKeyExchange for ActiveHybrid {
+    fn complete(self: Box<Self>, peer_pub_key: &[u8]) -> Result<SharedSecret, Error> {
+        let (post_quantum_share, classical_share) = self
+            .layout
+            .split_received_server_share(peer_pub_key)
+            .ok_or(INVALID_KEY_SHARE)?;
+
+        let cl = self
+            .classical
+            .complete(classical_share)?;
+        let pq = self
+            .post_quantum
+            .complete(post_quantum_share)?;
+
+        let secret = self
+            .layout
+            .concat(pq.secret_bytes(), cl.secret_bytes());
+        Ok(SharedSecret::from(secret))
+    }
+
+    /// Allow the classical computation to be offered and selected separately.
+    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.combined_pub_key
+    }
+
+    fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
+        None
+    }
+
+    fn group(&self) -> NamedGroup {
+        self.name
+    }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct Layout {
+    /// Length of classical key share.
+    pub(crate) classical_share_len: usize,
+
+    /// Length of post-quantum key share sent by client
+    pub(crate) post_quantum_client_share_len: usize,
+
+    /// Length of post-quantum key share sent by server
+    pub(crate) post_quantum_server_share_len: usize,
+
+    /// Whether the post-quantum element comes first in shares and secrets.
+    ///
+    /// For dismal and unprincipled reasons, SECP256R1MLKEM768 has the
+    /// classical element first, while X25519MLKEM768 has it second.
+    pub(crate) post_quantum_first: bool,
+}
+
+impl Layout {
+    fn split_received_client_share<'a>(&self, share: &'a [u8]) -> Option<(&'a [u8], &'a [u8])> {
+        self.split(share, self.post_quantum_client_share_len)
+    }
+
+    fn split_received_server_share<'a>(&self, share: &'a [u8]) -> Option<(&'a [u8], &'a [u8])> {
+        self.split(share, self.post_quantum_server_share_len)
+    }
+
+    fn split<'a>(
+        &self,
+        share: &'a [u8],
+        post_quantum_share_len: usize,
+    ) -> Option<(&'a [u8], &'a [u8])> {
+        if share.len() != self.classical_share_len + post_quantum_share_len {
+            return None;
+        }
+
+        Some(match self.post_quantum_first {
+            true => share.split_at(post_quantum_share_len),
+            false => share.split_at(self.classical_share_len),
+        })
+    }
+
+    fn concat(&self, post_quantum: &[u8], classical: &[u8]) -> Vec<u8> {
+        match self.post_quantum_first {
+            true => [post_quantum, classical].concat(),
+            false => [classical, post_quantum].concat(),
+        }
+    }
+}
diff --git a/rustls-post-quantum/src/lib.rs b/rustls-post-quantum/src/lib.rs
index 9125d2edbcd..484909c2a16 100644
--- a/rustls-post-quantum/src/lib.rs
+++ b/rustls-post-quantum/src/lib.rs
@@ -1,6 +1,7 @@
 //! This crate provides a [`rustls::crypto::CryptoProvider`] that includes
 //! a hybrid[^1], post-quantum-secure[^2] key exchange algorithm --
-//! specifically [X25519MLKEM768].
+//! specifically [X25519MLKEM768], as well as a non-hybrid
+//! post-quantum-secure key exchange algorithm.
 //!
 //! X25519MLKEM768 is pre-standardization, so you should treat
 //! this as experimental.  You may see unexpected interop failures, and
@@ -35,213 +36,63 @@
 //! rustls_post_quantum::provider().install_default().unwrap();
 //! ```
 //!
-//! **To incorporate just the key exchange algorithm in a custom [`rustls::crypto::CryptoProvider`]**:
+//! **To incorporate just the key exchange algorithm(s) in a custom [`rustls::crypto::CryptoProvider`]**:
 //!
 //! ```rust
 //! use rustls::crypto::{aws_lc_rs, CryptoProvider};
 //! let parent = aws_lc_rs::default_provider();
 //! let my_provider = CryptoProvider {
 //!     kx_groups: vec![
-//!         &rustls_post_quantum::X25519MLKEM768,
+//!         rustls_post_quantum::X25519MLKEM768,
 //!         aws_lc_rs::kx_group::X25519,
+//!         rustls_post_quantum::MLKEM768,
 //!     ],
 //!     ..parent
 //! };
 //! ```
 //!
 
-use aws_lc_rs::kem;
-use aws_lc_rs::unstable::kem::ML_KEM_768;
 use rustls::crypto::aws_lc_rs::{default_provider, kx_group};
-use rustls::crypto::{
-    ActiveKeyExchange, CompletedKeyExchange, CryptoProvider, SharedSecret, SupportedKxGroup,
-};
-use rustls::ffdhe_groups::FfdheGroup;
-use rustls::{Error, NamedGroup, PeerMisbehaved, ProtocolVersion};
+use rustls::crypto::{CryptoProvider, SupportedKxGroup};
+use rustls::{Error, NamedGroup, PeerMisbehaved};
+
+mod hybrid;
+mod mlkem;
 
-/// A `CryptoProvider` which includes `X25519MLKEM768` key exchange.
+/// A `CryptoProvider` which includes `X25519MLKEM768` and `MLKEM768`
+/// key exchanges.
 pub fn provider() -> CryptoProvider {
     let mut parent = default_provider();
+
     parent
         .kx_groups
-        .insert(0, &X25519MLKEM768);
+        .splice(0..0, [X25519MLKEM768, MLKEM768]);
+
     parent
 }
 
 /// This is the [X25519MLKEM768] key exchange.
 ///
 /// [X25519MLKEM768]: <https://datatracker.ietf.org/doc/draft-kwiatkowski-tls-ecdhe-mlkem/>
-#[derive(Debug)]
-pub struct X25519MLKEM768;
-
-impl SupportedKxGroup for X25519MLKEM768 {
-    fn start(&self) -> Result<Box<dyn ActiveKeyExchange>, Error> {
-        let x25519 = kx_group::X25519.start()?;
-
-        let ml_kem = kem::DecapsulationKey::generate(&ML_KEM_768)
-            .map_err(|_| Error::FailedToGetRandomBytes)?;
-
-        let ml_kem_pub = ml_kem
-            .encapsulation_key()
-            .map_err(|_| Error::FailedToGetRandomBytes)?;
-
-        let mut combined_pub_key = Vec::with_capacity(COMBINED_PUBKEY_LEN);
-        combined_pub_key.extend_from_slice(ml_kem_pub.key_bytes().unwrap().as_ref());
-        combined_pub_key.extend_from_slice(x25519.pub_key());
-
-        Ok(Box::new(Active {
-            x25519,
-            decap_key: Box::new(ml_kem),
-            combined_pub_key,
-        }))
-    }
-
-    fn start_and_complete(&self, client_share: &[u8]) -> Result<CompletedKeyExchange, Error> {
-        let Some(share) = ReceivedShare::new(client_share) else {
-            return Err(INVALID_KEY_SHARE);
-        };
-
-        let x25519 = kx_group::X25519.start_and_complete(share.x25519)?;
-
-        let (ml_kem_share, ml_kem_secret) = kem::EncapsulationKey::new(&ML_KEM_768, share.ml_kem)
-            .map_err(|_| INVALID_KEY_SHARE)
-            .and_then(|pk| {
-                pk.encapsulate()
-                    .map_err(|_| INVALID_KEY_SHARE)
-            })?;
-
-        let combined_secret = CombinedSecret::combine(x25519.secret, ml_kem_secret);
-        let combined_share = CombinedShare::combine(&x25519.pub_key, ml_kem_share);
-
-        Ok(CompletedKeyExchange {
-            group: self.name(),
-            pub_key: combined_share.0,
-            secret: SharedSecret::from(&combined_secret.0[..]),
-        })
-    }
-
-    fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
-        None
-    }
-
-    fn name(&self) -> NamedGroup {
-        NAMED_GROUP
-    }
-
-    fn usable_for_version(&self, version: ProtocolVersion) -> bool {
-        version == ProtocolVersion::TLSv1_3
-    }
-}
-
-struct Active {
-    x25519: Box<dyn ActiveKeyExchange>,
-    decap_key: Box<kem::DecapsulationKey<kem::AlgorithmId>>,
-    combined_pub_key: Vec<u8>,
-}
-
-impl ActiveKeyExchange for Active {
-    fn complete(self: Box<Self>, peer_pub_key: &[u8]) -> Result<SharedSecret, Error> {
-        let Some(ciphertext) = ReceivedCiphertext::new(peer_pub_key) else {
-            return Err(INVALID_KEY_SHARE);
-        };
-
-        let combined = CombinedSecret::combine(
-            self.x25519
-                .complete(ciphertext.x25519)?,
-            self.decap_key
-                .decapsulate(ciphertext.ml_kem.into())
-                .map_err(|_| INVALID_KEY_SHARE)?,
-        );
-        Ok(SharedSecret::from(&combined.0[..]))
-    }
-
-    /// Allow the X25519 computation to be offered and selected separately.
-    fn hybrid_component(&self) -> Option<(NamedGroup, &[u8])> {
-        Some((self.x25519.group(), self.x25519.pub_key()))
-    }
-
-    fn complete_hybrid_component(
-        self: Box<Self>,
-        peer_pub_key: &[u8],
-    ) -> Result<SharedSecret, Error> {
-        self.x25519.complete(peer_pub_key)
-    }
-
-    fn pub_key(&self) -> &[u8] {
-        &self.combined_pub_key
-    }
-
-    fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
-        None
-    }
-
-    fn group(&self) -> NamedGroup {
-        NAMED_GROUP
-    }
-}
-
-struct ReceivedShare<'a> {
-    ml_kem: &'a [u8],
-    x25519: &'a [u8],
-}
-
-impl<'a> ReceivedShare<'a> {
-    fn new(buf: &'a [u8]) -> Option<ReceivedShare<'a>> {
-        if buf.len() != COMBINED_PUBKEY_LEN {
-            return None;
-        }
-
-        let (ml_kem, x25519) = buf.split_at(MLKEM768_ENCAP_LEN);
-        Some(ReceivedShare { ml_kem, x25519 })
-    }
-}
-
-struct ReceivedCiphertext<'a> {
-    ml_kem: &'a [u8],
-    x25519: &'a [u8],
-}
-
-impl<'a> ReceivedCiphertext<'a> {
-    fn new(buf: &'a [u8]) -> Option<ReceivedCiphertext<'a>> {
-        if buf.len() != COMBINED_CIPHERTEXT_LEN {
-            return None;
-        }
-
-        let (ml_kem, x25519) = buf.split_at(MLKEM768_CIPHERTEXT_LEN);
-        Some(ReceivedCiphertext { ml_kem, x25519 })
-    }
-}
-
-struct CombinedSecret([u8; COMBINED_SHARED_SECRET_LEN]);
-
-impl CombinedSecret {
-    fn combine(x25519: SharedSecret, ml_kem: kem::SharedSecret) -> Self {
-        let mut out = CombinedSecret([0u8; COMBINED_SHARED_SECRET_LEN]);
-        out.0[..MLKEM768_SECRET_LEN].copy_from_slice(ml_kem.as_ref());
-        out.0[MLKEM768_SECRET_LEN..].copy_from_slice(x25519.secret_bytes());
-        out
-    }
-}
-
-struct CombinedShare(Vec<u8>);
-
-impl CombinedShare {
-    fn combine(x25519: &[u8], ml_kem: kem::Ciphertext<'_>) -> Self {
-        let mut out = CombinedShare(vec![0u8; COMBINED_CIPHERTEXT_LEN]);
-        out.0[..MLKEM768_CIPHERTEXT_LEN].copy_from_slice(ml_kem.as_ref());
-        out.0[MLKEM768_CIPHERTEXT_LEN..].copy_from_slice(x25519);
-        out
-    }
-}
+pub static X25519MLKEM768: &dyn SupportedKxGroup = &hybrid::Hybrid {
+    classical: kx_group::X25519,
+    post_quantum: MLKEM768,
+    name: NamedGroup::X25519MLKEM768,
+    layout: hybrid::Layout {
+        classical_share_len: X25519_LEN,
+        post_quantum_client_share_len: MLKEM768_ENCAP_LEN,
+        post_quantum_server_share_len: MLKEM768_CIPHERTEXT_LEN,
+        post_quantum_first: true,
+    },
+};
 
-const NAMED_GROUP: NamedGroup = NamedGroup::Unknown(0x11ec);
+/// This is the [MLKEM] key exchange.
+///
+/// [MLKEM]: https://datatracker.ietf.org/doc/draft-connolly-tls-mlkem-key-agreement
+pub static MLKEM768: &dyn SupportedKxGroup = &mlkem::MlKem768;
 
 const INVALID_KEY_SHARE: Error = Error::PeerMisbehaved(PeerMisbehaved::InvalidKeyShare);
 
 const X25519_LEN: usize = 32;
 const MLKEM768_CIPHERTEXT_LEN: usize = 1088;
 const MLKEM768_ENCAP_LEN: usize = 1184;
-const MLKEM768_SECRET_LEN: usize = 32;
-const COMBINED_PUBKEY_LEN: usize = MLKEM768_ENCAP_LEN + X25519_LEN;
-const COMBINED_CIPHERTEXT_LEN: usize = MLKEM768_CIPHERTEXT_LEN + X25519_LEN;
-const COMBINED_SHARED_SECRET_LEN: usize = MLKEM768_SECRET_LEN + X25519_LEN;
diff --git a/rustls-post-quantum/src/mlkem.rs b/rustls-post-quantum/src/mlkem.rs
new file mode 100644
index 00000000000..8a410c03b11
--- /dev/null
+++ b/rustls-post-quantum/src/mlkem.rs
@@ -0,0 +1,85 @@
+use aws_lc_rs::kem;
+use aws_lc_rs::unstable::kem::ML_KEM_768;
+use rustls::crypto::{ActiveKeyExchange, CompletedKeyExchange, SharedSecret, SupportedKxGroup};
+use rustls::ffdhe_groups::FfdheGroup;
+use rustls::{Error, NamedGroup, ProtocolVersion};
+
+use crate::INVALID_KEY_SHARE;
+
+#[derive(Debug)]
+pub(crate) struct MlKem768;
+
+impl SupportedKxGroup for MlKem768 {
+    fn start(&self) -> Result<Box<dyn ActiveKeyExchange>, Error> {
+        let decaps_key = kem::DecapsulationKey::generate(&ML_KEM_768)
+            .map_err(|_| Error::General("key generation failed".into()))?;
+
+        let pub_key_bytes = decaps_key
+            .encapsulation_key()
+            .and_then(|encaps_key| encaps_key.key_bytes())
+            .map_err(|_| Error::General("encaps failed".into()))?;
+
+        Ok(Box::new(Active {
+            decaps_key: Box::new(decaps_key),
+            encaps_key_bytes: Vec::from(pub_key_bytes.as_ref()),
+        }))
+    }
+
+    fn start_and_complete(&self, client_share: &[u8]) -> Result<CompletedKeyExchange, Error> {
+        let encaps_key =
+            kem::EncapsulationKey::new(&ML_KEM_768, client_share).map_err(|_| INVALID_KEY_SHARE)?;
+
+        let (ciphertext, shared_secret) = encaps_key
+            .encapsulate()
+            .map_err(|_| INVALID_KEY_SHARE)?;
+
+        Ok(CompletedKeyExchange {
+            group: self.name(),
+            pub_key: Vec::from(ciphertext.as_ref()),
+            secret: SharedSecret::from(shared_secret.as_ref()),
+        })
+    }
+
+    fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
+        None
+    }
+
+    fn name(&self) -> NamedGroup {
+        NamedGroup::MLKEM768
+    }
+
+    fn usable_for_version(&self, version: ProtocolVersion) -> bool {
+        version == ProtocolVersion::TLSv1_3
+    }
+}
+
+struct Active {
+    decaps_key: Box<kem::DecapsulationKey<kem::AlgorithmId>>,
+    encaps_key_bytes: Vec<u8>,
+}
+
+impl ActiveKeyExchange for Active {
+    // The received 'peer_pub_key' is actually the ML-KEM ciphertext,
+    // which when decapsulated with our `decaps_key` produces the shared
+    // secret.
+    fn complete(self: Box<Self>, peer_pub_key: &[u8]) -> Result<SharedSecret, Error> {
+        let shared_secret = self
+            .decaps_key
+            .decapsulate(peer_pub_key.into())
+            .map_err(|_| INVALID_KEY_SHARE)?;
+
+        Ok(SharedSecret::from(shared_secret.as_ref()))
+    }
+
+    fn pub_key(&self) -> &[u8] {
+        &self.encaps_key_bytes
+    }
+
+    fn ffdhe_group(&self) -> Option<FfdheGroup<'static>> {
+        None
+    }
+
+    fn group(&self) -> NamedGroup {
+        NamedGroup::MLKEM768
+    }
+}
diff --git a/rustls/Cargo.toml b/rustls/Cargo.toml
index 6766516126d..59662a00e4c 100644
--- a/rustls/Cargo.toml
+++ b/rustls/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "rustls"
-version = "0.23.19"
+version = "0.23.20"
 edition = "2021"
 rust-version = "1.71"
 license = "Apache-2.0 OR ISC OR MIT"
diff --git a/rustls/src/crypto/mod.rs b/rustls/src/crypto/mod.rs
index 8d66a8a3235..c0b83995bda 100644
--- a/rustls/src/crypto/mod.rs
+++ b/rustls/src/crypto/mod.rs
@@ -647,6 +647,12 @@ impl From<&[u8]> for SharedSecret {
     }
 }
 
+impl From<Vec<u8>> for SharedSecret {
+    fn from(buf: Vec<u8>) -> Self {
+        Self { buf, offset: 0 }
+    }
+}
+
 /// This function returns a [`CryptoProvider`] that uses
 /// FIPS140-3-approved cryptography.
 ///
diff --git a/rustls/src/msgs/enums.rs b/rustls/src/msgs/enums.rs
index 2d9a64adde3..2e03e85593e 100644
--- a/rustls/src/msgs/enums.rs
+++ b/rustls/src/msgs/enums.rs
@@ -223,6 +223,11 @@ enum_builder! {
         FFDHE4096 => 0x0102,
         FFDHE6144 => 0x0103,
         FFDHE8192 => 0x0104,
+        MLKEM512 => 0x0200,
+        MLKEM768 => 0x0201,
+        MLKEM1024 => 0x0202,
+        secp256r1MLKEM768 => 0x11eb,
+        X25519MLKEM768 => 0x11ec,
     }
 }
 


Test patch: