Implement X25519MLKEM768 for TLS

ML-KEM is now finalized, so uses of Kyber should migrate to ML-KEM. This
adds the new codepoint for TLS, X25519MLKEM768 from
draft-kwiatkowski-tls-ecdhe-mlkem-01.

One detail to call out: where the Kyber hybrid put X25519 first, this
one places MLKEM768 first. Section 3 of the draft discusses why.

Bug: 40910498
Change-Id: I18862cd5d25d6ab6c4b38514e8333684dc5e3778
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/70547
Reviewed-by: Adam Langley <agl@google.com>
Commit-Queue: David Benjamin <davidben@google.com>
diff --git a/crypto/obj/obj_dat.h b/crypto/obj/obj_dat.h
index 71ef2d2..f1b7063 100644
--- a/crypto/obj/obj_dat.h
+++ b/crypto/obj/obj_dat.h
@@ -57,7 +57,7 @@
 /* This file is generated by crypto/obj/objects.go. */
 
 
-#define NUM_NID 965
+#define NUM_NID 966
 
 static const uint8_t kObjectData[] = {
     /* NID_rsadsi */
@@ -8783,6 +8783,7 @@
     {"HKDF", "hkdf", NID_hkdf, 0, NULL, 0},
     {"X25519Kyber768Draft00", "X25519Kyber768Draft00",
      NID_X25519Kyber768Draft00, 0, NULL, 0},
+    {"X25519MLKEM768", "X25519MLKEM768", NID_X25519MLKEM768, 0, NULL, 0},
 };
 
 static const uint16_t kNIDsInShortNameOrder[] = {
@@ -8981,6 +8982,7 @@
     458 /* UID */,
     948 /* X25519 */,
     964 /* X25519Kyber768Draft00 */,
+    965 /* X25519MLKEM768 */,
     961 /* X448 */,
     11 /* X500 */,
     378 /* X500algorithms */,
@@ -9852,6 +9854,7 @@
     375 /* Trust Root */,
     948 /* X25519 */,
     964 /* X25519Kyber768Draft00 */,
+    965 /* X25519MLKEM768 */,
     961 /* X448 */,
     12 /* X509 */,
     402 /* X509v3 AC Targeting */,
diff --git a/crypto/obj/obj_mac.num b/crypto/obj/obj_mac.num
index a0519ac..6e2a2ae 100644
--- a/crypto/obj/obj_mac.num
+++ b/crypto/obj/obj_mac.num
@@ -952,3 +952,4 @@
 sha512_256		962
 hkdf		963
 X25519Kyber768Draft00		964
+X25519MLKEM768		965
diff --git a/crypto/obj/objects.txt b/crypto/obj/objects.txt
index 3ad32ea..6553407 100644
--- a/crypto/obj/objects.txt
+++ b/crypto/obj/objects.txt
@@ -1334,6 +1334,7 @@
 
 # NIDs for post quantum hybrid KEMs in TLS (no corresponding OIDs).
  : X25519Kyber768Draft00
+ : X25519MLKEM768
 
 # See RFC 8410.
 1 3 101 110 : X25519
diff --git a/go.mod b/go.mod
index b1cb93e..b87a3c1 100644
--- a/go.mod
+++ b/go.mod
@@ -1,13 +1,14 @@
 module boringssl.googlesource.com/boringssl
 
-go 1.21
+go 1.22
 
 require (
-	golang.org/x/crypto v0.25.0
+	golang.org/x/crypto v0.26.0
 	golang.org/x/net v0.27.0
 )
 
 require (
-	golang.org/x/sys v0.22.0 // indirect
-	golang.org/x/term v0.22.0 // indirect
+	filippo.io/mlkem768 v0.0.0-20240821141156-859a9b3f2ff6 // indirect
+	golang.org/x/sys v0.24.0 // indirect
+	golang.org/x/term v0.23.0 // indirect
 )
diff --git a/go.sum b/go.sum
index 709415c..7424815 100644
--- a/go.sum
+++ b/go.sum
@@ -1,8 +1,10 @@
-golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
-golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+filippo.io/mlkem768 v0.0.0-20240821141156-859a9b3f2ff6 h1:A7gTX0HxgkmTtCgRtpWlhIuMBBszxW/02MXv55wHk4U=
+filippo.io/mlkem768 v0.0.0-20240821141156-859a9b3f2ff6/go.mod h1:IkpYfciLz5fI/S4/Z0NlhR4cpv6ubCMDnIwAe0XiojA=
+golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
+golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
 golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
 golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
-golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
-golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
+golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
+golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
+golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
diff --git a/include/openssl/nid.h b/include/openssl/nid.h
index 4dd8841..02d78c6 100644
--- a/include/openssl/nid.h
+++ b/include/openssl/nid.h
@@ -4255,6 +4255,9 @@
 #define SN_X25519Kyber768Draft00 "X25519Kyber768Draft00"
 #define NID_X25519Kyber768Draft00 964
 
+#define SN_X25519MLKEM768 "X25519MLKEM768"
+#define NID_X25519MLKEM768 965
+
 
 #if defined(__cplusplus)
 } /* extern C */
diff --git a/include/openssl/ssl.h b/include/openssl/ssl.h
index 1dbc1e7..a0797cd 100644
--- a/include/openssl/ssl.h
+++ b/include/openssl/ssl.h
@@ -2548,6 +2548,7 @@
 #define SSL_GROUP_SECP384R1 24
 #define SSL_GROUP_SECP521R1 25
 #define SSL_GROUP_X25519 29
+#define SSL_GROUP_X25519_MLKEM768 0x11ec
 #define SSL_GROUP_X25519_KYBER768_DRAFT00 0x6399
 
 // SSL_CTX_set1_group_ids sets the preferred groups for |ctx| to |group_ids|.
diff --git a/ssl/extensions.cc b/ssl/extensions.cc
index 7f06ded..30591a6 100644
--- a/ssl/extensions.cc
+++ b/ssl/extensions.cc
@@ -207,6 +207,7 @@
 static bool is_post_quantum_group(uint16_t id) {
   switch (id) {
     case SSL_GROUP_X25519_KYBER768_DRAFT00:
+    case SSL_GROUP_X25519_MLKEM768:
       return true;
     default:
       return false;
diff --git a/ssl/ssl_key_share.cc b/ssl/ssl_key_share.cc
index 419724c..923b8bb 100644
--- a/ssl/ssl_key_share.cc
+++ b/ssl/ssl_key_share.cc
@@ -28,6 +28,7 @@
 #include <openssl/experimental/kyber.h>
 #include <openssl/hrss.h>
 #include <openssl/mem.h>
+#include <openssl/mlkem.h>
 #include <openssl/nid.h>
 #include <openssl/rand.h>
 #include <openssl/span.h>
@@ -192,6 +193,7 @@
   uint8_t private_key_[32];
 };
 
+// draft-tls-westerbaan-xyber768d00-03
 class X25519Kyber768KeyShare : public SSLKeyShare {
  public:
   X25519Kyber768KeyShare() {}
@@ -225,9 +227,7 @@
     uint8_t x25519_public_key[32];
     X25519_keypair(x25519_public_key, x25519_private_key_);
     KYBER_public_key peer_kyber_pub;
-    CBS peer_key_cbs;
-    CBS peer_x25519_cbs;
-    CBS peer_kyber_cbs;
+    CBS peer_key_cbs, peer_x25519_cbs, peer_kyber_cbs;
     CBS_init(&peer_key_cbs, peer_key.data(), peer_key.size());
     if (!CBS_get_bytes(&peer_key_cbs, &peer_x25519_cbs, 32) ||
         !CBS_get_bytes(&peer_key_cbs, &peer_kyber_cbs,
@@ -282,6 +282,97 @@
   KYBER_private_key kyber_private_key_;
 };
 
+// draft-kwiatkowski-tls-ecdhe-mlkem-01
+class X25519MLKEM768KeyShare : public SSLKeyShare {
+ public:
+  X25519MLKEM768KeyShare() {}
+
+  uint16_t GroupID() const override { return SSL_GROUP_X25519_MLKEM768; }
+
+  bool Generate(CBB *out) override {
+    uint8_t mlkem_public_key[MLKEM768_PUBLIC_KEY_BYTES];
+    MLKEM768_generate_key(mlkem_public_key, /*optional_out_seed=*/nullptr,
+                          &mlkem_private_key_);
+
+    uint8_t x25519_public_key[X25519_PUBLIC_VALUE_LEN];
+    X25519_keypair(x25519_public_key, x25519_private_key_);
+
+    if (!CBB_add_bytes(out, mlkem_public_key, sizeof(mlkem_public_key)) ||
+        !CBB_add_bytes(out, x25519_public_key, sizeof(x25519_public_key))) {
+      return false;
+    }
+
+    return true;
+  }
+
+  bool Encap(CBB *out_ciphertext, Array<uint8_t> *out_secret,
+             uint8_t *out_alert, Span<const uint8_t> peer_key) override {
+    Array<uint8_t> secret;
+    if (!secret.Init(MLKEM_SHARED_SECRET_BYTES + X25519_SHARED_KEY_LEN)) {
+      return false;
+    }
+
+    MLKEM768_public_key peer_mlkem_pub;
+    uint8_t x25519_public_key[X25519_PUBLIC_VALUE_LEN];
+    X25519_keypair(x25519_public_key, x25519_private_key_);
+    CBS peer_key_cbs, peer_mlkem_cbs, peer_x25519_cbs;
+    CBS_init(&peer_key_cbs, peer_key.data(), peer_key.size());
+    if (!CBS_get_bytes(&peer_key_cbs, &peer_mlkem_cbs,
+                       MLKEM768_PUBLIC_KEY_BYTES) ||
+        !MLKEM768_parse_public_key(&peer_mlkem_pub, &peer_mlkem_cbs) ||
+        !CBS_get_bytes(&peer_key_cbs, &peer_x25519_cbs,
+                       X25519_PUBLIC_VALUE_LEN) ||
+        CBS_len(&peer_key_cbs) != 0 ||
+        !X25519(secret.data() + MLKEM_SHARED_SECRET_BYTES, x25519_private_key_,
+                CBS_data(&peer_x25519_cbs))) {
+      *out_alert = SSL_AD_DECODE_ERROR;
+      OPENSSL_PUT_ERROR(SSL, SSL_R_BAD_ECPOINT);
+      return false;
+    }
+
+    uint8_t mlkem_ciphertext[MLKEM768_CIPHERTEXT_BYTES];
+    MLKEM768_encap(mlkem_ciphertext, secret.data(), &peer_mlkem_pub);
+
+    if (!CBB_add_bytes(out_ciphertext, mlkem_ciphertext,
+                       sizeof(mlkem_ciphertext)) ||
+        !CBB_add_bytes(out_ciphertext, x25519_public_key,
+                       sizeof(x25519_public_key))) {
+      return false;
+    }
+
+    *out_secret = std::move(secret);
+    return true;
+  }
+
+  bool Decap(Array<uint8_t> *out_secret, uint8_t *out_alert,
+             Span<const uint8_t> ciphertext) override {
+    *out_alert = SSL_AD_INTERNAL_ERROR;
+
+    Array<uint8_t> secret;
+    if (!secret.Init(MLKEM_SHARED_SECRET_BYTES + X25519_SHARED_KEY_LEN)) {
+      return false;
+    }
+
+    if (ciphertext.size() !=
+            MLKEM768_CIPHERTEXT_BYTES + X25519_PUBLIC_VALUE_LEN ||
+        !MLKEM768_decap(secret.data(), ciphertext.data(),
+                        MLKEM768_CIPHERTEXT_BYTES, &mlkem_private_key_) ||
+        !X25519(secret.data() + MLKEM_SHARED_SECRET_BYTES, x25519_private_key_,
+                ciphertext.data() + MLKEM768_CIPHERTEXT_BYTES)) {
+      *out_alert = SSL_AD_DECODE_ERROR;
+      OPENSSL_PUT_ERROR(SSL, SSL_R_BAD_ECPOINT);
+      return false;
+    }
+
+    *out_secret = std::move(secret);
+    return true;
+  }
+
+ private:
+  uint8_t x25519_private_key_[32];
+  MLKEM768_private_key mlkem_private_key_;
+};
+
 constexpr NamedGroup kNamedGroups[] = {
     {NID_secp224r1, SSL_GROUP_SECP224R1, "P-224", "secp224r1"},
     {NID_X9_62_prime256v1, SSL_GROUP_SECP256R1, "P-256", "prime256v1"},
@@ -290,6 +381,7 @@
     {NID_X25519, SSL_GROUP_X25519, "X25519", "x25519"},
     {NID_X25519Kyber768Draft00, SSL_GROUP_X25519_KYBER768_DRAFT00,
      "X25519Kyber768Draft00", ""},
+    {NID_X25519MLKEM768, SSL_GROUP_X25519_MLKEM768, "X25519MLKEM768", ""},
 };
 
 }  // namespace
@@ -312,6 +404,8 @@
       return MakeUnique<X25519KeyShare>();
     case SSL_GROUP_X25519_KYBER768_DRAFT00:
       return MakeUnique<X25519Kyber768KeyShare>();
+    case SSL_GROUP_X25519_MLKEM768:
+      return MakeUnique<X25519MLKEM768KeyShare>();
     default:
       return nullptr;
   }
diff --git a/ssl/ssl_test.cc b/ssl/ssl_test.cc
index 1b71c97..393b48e 100644
--- a/ssl/ssl_test.cc
+++ b/ssl/ssl_test.cc
@@ -489,6 +489,10 @@
     "P-256:X25519Kyber768Draft00",
     { SSL_GROUP_SECP256R1, SSL_GROUP_X25519_KYBER768_DRAFT00 },
   },
+  {
+    "P-256:X25519MLKEM768",
+    { SSL_GROUP_SECP256R1, SSL_GROUP_X25519_MLKEM768 },
+  },
 
   {
     "P-256:P-384:P-521:X25519",
diff --git a/ssl/test/fuzzer.h b/ssl/test/fuzzer.h
index e6d2d02..ca7b55a 100644
--- a/ssl/test/fuzzer.h
+++ b/ssl/test/fuzzer.h
@@ -419,8 +419,9 @@
     }
 
     static const uint16_t kGroups[] = {
-        SSL_GROUP_X25519_KYBER768_DRAFT00, SSL_GROUP_X25519,
-        SSL_GROUP_SECP256R1, SSL_GROUP_SECP384R1, SSL_GROUP_SECP521R1};
+        SSL_GROUP_X25519_MLKEM768, SSL_GROUP_X25519_KYBER768_DRAFT00,
+        SSL_GROUP_X25519,          SSL_GROUP_SECP256R1,
+        SSL_GROUP_SECP384R1,       SSL_GROUP_SECP521R1};
     if (!SSL_CTX_set1_group_ids(ctx_.get(), kGroups,
                                 OPENSSL_ARRAY_SIZE(kGroups))) {
       return false;
diff --git a/ssl/test/runner/common.go b/ssl/test/runner/common.go
index 5907a35..2eedd62 100644
--- a/ssl/test/runner/common.go
+++ b/ssl/test/runner/common.go
@@ -158,6 +158,7 @@
 	CurveP384           CurveID = 24
 	CurveP521           CurveID = 25
 	CurveX25519         CurveID = 29
+	CurveX25519MLKEM768 CurveID = 0x11ec
 	CurveX25519Kyber768 CurveID = 0x6399
 )
 
@@ -1962,9 +1963,9 @@
 	// hello retry.
 	FailIfHelloRetryRequested bool
 
-	// FailedIfKyberOffered will cause a server to reject a ClientHello if Kyber
-	// is supported.
-	FailIfKyberOffered bool
+	// FailIfPostQuantumOffered will cause a server to reject a ClientHello if
+	// post-quantum curves are supported.
+	FailIfPostQuantumOffered bool
 
 	// ExpectKeyShares, if not nil, lists (in order) the curves that a ClientHello
 	// should have key shares for.
@@ -2067,7 +2068,7 @@
 	return ret
 }
 
-var defaultCurvePreferences = []CurveID{CurveX25519Kyber768, CurveX25519, CurveP256, CurveP384, CurveP521}
+var defaultCurvePreferences = []CurveID{CurveX25519MLKEM768, CurveX25519Kyber768, CurveX25519, CurveP256, CurveP384, CurveP521}
 
 func (c *Config) curvePreferences() []CurveID {
 	if c == nil || len(c.CurvePreferences) == 0 {
diff --git a/ssl/test/runner/handshake_server.go b/ssl/test/runner/handshake_server.go
index 9e647df..fa7eb9c 100644
--- a/ssl/test/runner/handshake_server.go
+++ b/ssl/test/runner/handshake_server.go
@@ -281,10 +281,10 @@
 		}
 	}
 
-	if config.Bugs.FailIfKyberOffered {
+	if config.Bugs.FailIfPostQuantumOffered {
 		for _, offeredCurve := range hs.clientHello.supportedCurves {
 			if isPqGroup(offeredCurve) {
-				return errors.New("tls: X25519Kyber768 was offered")
+				return errors.New("tls: post-quantum group was offered")
 			}
 		}
 	}
diff --git a/ssl/test/runner/key_agreement.go b/ssl/test/runner/key_agreement.go
index aff0820..a4bbfa5 100644
--- a/ssl/test/runner/key_agreement.go
+++ b/ssl/test/runner/key_agreement.go
@@ -16,8 +16,10 @@
 	"fmt"
 	"io"
 	"math/big"
+	"slices"
 
 	"boringssl.googlesource.com/boringssl/ssl/test/runner/kyber"
+	"filippo.io/mlkem768"
 	"golang.org/x/crypto/curve25519"
 )
 
@@ -233,6 +235,9 @@
 
 // A kemImplementation is an instance of KEM-style construction for TLS.
 type kemImplementation interface {
+	encapsulationKeySize() int
+	ciphertextSize() int
+
 	// generate generates a keypair using rand. It returns the encoded public key.
 	generate(rand io.Reader) (publicKey []byte, err error)
 
@@ -253,6 +258,15 @@
 	sendCompressed bool
 }
 
+func (e *ecdhKEM) encapsulationKeySize() int {
+	fieldBytes := (e.curve.Params().BitSize + 7) / 8
+	return 1 + 2*fieldBytes
+}
+
+func (e *ecdhKEM) ciphertextSize() int {
+	return e.encapsulationKeySize()
+}
+
 func (e *ecdhKEM) generate(rand io.Reader) (publicKey []byte, err error) {
 	var x, y *big.Int
 	e.privateKey, x, y, err = elliptic.GenerateKey(e.curve, rand)
@@ -300,6 +314,14 @@
 	setHighBit bool
 }
 
+func (e *x25519KEM) encapsulationKeySize() int {
+	return curve25519.PointSize
+}
+
+func (e *x25519KEM) ciphertextSize() int {
+	return curve25519.PointSize
+}
+
 func (e *x25519KEM) generate(rand io.Reader) (publicKey []byte, err error) {
 	_, err = io.ReadFull(rand, e.privateKey[:])
 	if err != nil {
@@ -341,53 +363,35 @@
 	return out[:], nil
 }
 
-// kyberKEM implements Kyber combined with X25519.
+// kyberKEM implements Kyber-768
 type kyberKEM struct {
-	x25519PrivateKey [32]byte
-	kyberPrivateKey  *kyber.PrivateKey
+	kyberPrivateKey *kyber.PrivateKey
+}
+
+func (e *kyberKEM) encapsulationKeySize() int {
+	return kyber.PublicKeySize
+}
+
+func (e *kyberKEM) ciphertextSize() int {
+	return kyber.CiphertextSize
 }
 
 func (e *kyberKEM) generate(rand io.Reader) (publicKey []byte, err error) {
-	if _, err := io.ReadFull(rand, e.x25519PrivateKey[:]); err != nil {
-		return nil, err
-	}
-	var x25519Public [32]byte
-	curve25519.ScalarBaseMult(&x25519Public, &e.x25519PrivateKey)
-
 	var kyberEntropy [64]byte
 	if _, err := io.ReadFull(rand, kyberEntropy[:]); err != nil {
 		return nil, err
 	}
 	var kyberPublic *[kyber.PublicKeySize]byte
 	e.kyberPrivateKey, kyberPublic = kyber.NewPrivateKey(&kyberEntropy)
-
-	var ret []byte
-	ret = append(ret, x25519Public[:]...)
-	ret = append(ret, kyberPublic[:]...)
-	return ret, nil
+	return kyberPublic[:], nil
 }
 
 func (e *kyberKEM) encap(rand io.Reader, peerKey []byte) (ciphertext []byte, secret []byte, err error) {
-	if len(peerKey) != 32+kyber.PublicKeySize {
+	if len(peerKey) != kyber.PublicKeySize {
 		return nil, nil, errors.New("tls: bad length Kyber offer")
 	}
 
-	if _, err := io.ReadFull(rand, e.x25519PrivateKey[:]); err != nil {
-		return nil, nil, err
-	}
-
-	var x25519Shared, x25519PeerKey, x25519Public [32]byte
-	copy(x25519PeerKey[:], peerKey)
-	curve25519.ScalarBaseMult(&x25519Public, &e.x25519PrivateKey)
-	curve25519.ScalarMult(&x25519Shared, &e.x25519PrivateKey, &x25519PeerKey)
-
-	// Per RFC 7748, reject the all-zero value in constant time.
-	var zeros [32]byte
-	if subtle.ConstantTimeCompare(zeros[:], x25519Shared[:]) == 1 {
-		return nil, nil, errors.New("tls: X25519 value with wrong order")
-	}
-
-	kyberPublicKey, ok := kyber.UnmarshalPublicKey((*[kyber.PublicKeySize]byte)(peerKey[32:]))
+	kyberPublicKey, ok := kyber.UnmarshalPublicKey((*[kyber.PublicKeySize]byte)(peerKey))
 	if !ok {
 		return nil, nil, errors.New("tls: bad Kyber offer")
 	}
@@ -397,37 +401,105 @@
 		return nil, nil, err
 	}
 	kyberCiphertext := kyberPublicKey.Encap(kyberShared[:], &kyberEntropy)
-
-	ciphertext = append(ciphertext, x25519Public[:]...)
-	ciphertext = append(ciphertext, kyberCiphertext[:]...)
-	secret = append(secret, x25519Shared[:]...)
-	secret = append(secret, kyberShared[:]...)
-
-	return ciphertext, secret, nil
+	return kyberCiphertext[:], kyberShared[:], nil
 }
 
 func (e *kyberKEM) decap(ciphertext []byte) (secret []byte, err error) {
-	if len(ciphertext) != 32+kyber.CiphertextSize {
+	if len(ciphertext) != kyber.CiphertextSize {
 		return nil, errors.New("tls: bad length Kyber reply")
 	}
 
-	var x25519Shared, x25519PeerKey [32]byte
-	copy(x25519PeerKey[:], ciphertext)
-	curve25519.ScalarMult(&x25519Shared, &e.x25519PrivateKey, &x25519PeerKey)
-
-	// Per RFC 7748, reject the all-zero value in constant time.
-	var zeros [32]byte
-	if subtle.ConstantTimeCompare(zeros[:], x25519Shared[:]) == 1 {
-		return nil, errors.New("tls: X25519 value with wrong order")
-	}
-
 	var kyberShared [32]byte
-	e.kyberPrivateKey.Decap(kyberShared[:], (*[kyber.CiphertextSize]byte)(ciphertext[32:]))
+	e.kyberPrivateKey.Decap(kyberShared[:], (*[kyber.CiphertextSize]byte)(ciphertext))
+	return kyberShared[:], nil
+}
 
-	secret = append(secret, x25519Shared[:]...)
-	secret = append(secret, kyberShared[:]...)
+// mlkem768KEM implements ML-KEM-768
+type mlkem768KEM struct {
+	decapKey *mlkem768.DecapsulationKey
+}
 
-	return secret, nil
+func (e *mlkem768KEM) encapsulationKeySize() int {
+	return mlkem768.EncapsulationKeySize
+}
+
+func (e *mlkem768KEM) ciphertextSize() int {
+	return mlkem768.CiphertextSize
+}
+
+func (m *mlkem768KEM) generate(rand io.Reader) (publicKey []byte, err error) {
+	m.decapKey, err = mlkem768.GenerateKey()
+	if err != nil {
+		return
+	}
+	return m.decapKey.EncapsulationKey(), nil
+}
+
+func (m *mlkem768KEM) encap(rand io.Reader, peerKey []byte) (ciphertext []byte, secret []byte, err error) {
+	return mlkem768.Encapsulate(peerKey)
+}
+
+func (m *mlkem768KEM) decap(ciphertext []byte) (secret []byte, err error) {
+	return mlkem768.Decapsulate(m.decapKey, ciphertext)
+}
+
+// concatKEM concatenates two kemImplementations.
+type concatKEM struct {
+	kem1, kem2 kemImplementation
+}
+
+func (c *concatKEM) encapsulationKeySize() int {
+	return c.kem1.encapsulationKeySize() + c.kem2.encapsulationKeySize()
+}
+
+func (c *concatKEM) ciphertextSize() int {
+	return c.kem1.ciphertextSize() + c.kem2.ciphertextSize()
+}
+
+func (c *concatKEM) generate(rand io.Reader) (publicKey []byte, err error) {
+	publicKey1, err := c.kem1.generate(rand)
+	if err != nil {
+		return nil, err
+	}
+	publicKey2, err := c.kem2.generate(rand)
+	if err != nil {
+		return nil, err
+	}
+	return slices.Concat(publicKey1, publicKey2), nil
+}
+
+func (c *concatKEM) encap(rand io.Reader, peerKey []byte) (ciphertext []byte, secret []byte, err error) {
+	encapKeySize1 := c.kem1.encapsulationKeySize()
+	if len(peerKey) < encapKeySize1 {
+		return nil, nil, errors.New("tls: invalid peer key")
+	}
+	peerKey1, peerKey2 := peerKey[:encapKeySize1], peerKey[encapKeySize1:]
+	ciphertext1, secret1, err := c.kem1.encap(rand, peerKey1)
+	if err != nil {
+		return nil, nil, err
+	}
+	ciphertext2, secret2, err := c.kem2.encap(rand, peerKey2)
+	if err != nil {
+		return nil, nil, err
+	}
+	return slices.Concat(ciphertext1, ciphertext2), slices.Concat(secret1, secret2), nil
+}
+
+func (c *concatKEM) decap(ciphertext []byte) (secret []byte, err error) {
+	ciphertextSize1 := c.kem1.ciphertextSize()
+	if len(ciphertext) < ciphertextSize1 {
+		return nil, errors.New("tls: invalid ciphertext")
+	}
+	ciphertext1, ciphertext2 := ciphertext[:ciphertextSize1], ciphertext[ciphertextSize1:]
+	secret1, err := c.kem1.decap(ciphertext1)
+	if err != nil {
+		return nil, err
+	}
+	secret2, err := c.kem2.decap(ciphertext2)
+	if err != nil {
+		return nil, err
+	}
+	return slices.Concat(secret1, secret2), nil
 }
 
 func kemForCurveID(id CurveID, config *Config) (kemImplementation, bool) {
@@ -443,7 +515,11 @@
 	case CurveX25519:
 		return &x25519KEM{setHighBit: config.Bugs.SetX25519HighBit}, true
 	case CurveX25519Kyber768:
-		return &kyberKEM{}, true
+		// draft-tls-westerbaan-xyber768d00-03
+		return &concatKEM{kem1: &x25519KEM{setHighBit: config.Bugs.SetX25519HighBit}, kem2: &kyberKEM{}}, true
+	case CurveX25519MLKEM768:
+		// draft-kwiatkowski-tls-ecdhe-mlkem-01
+		return &concatKEM{kem1: &mlkem768KEM{}, kem2: &x25519KEM{setHighBit: config.Bugs.SetX25519HighBit}}, true
 	default:
 		return nil, false
 	}
diff --git a/ssl/test/runner/runner.go b/ssl/test/runner/runner.go
index 16cebb2..6179ee0 100644
--- a/ssl/test/runner/runner.go
+++ b/ssl/test/runner/runner.go
@@ -11756,12 +11756,13 @@
 	{"P-521", CurveP521},
 	{"X25519", CurveX25519},
 	{"Kyber", CurveX25519Kyber768},
+	{"MLKEM", CurveX25519MLKEM768},
 }
 
 const bogusCurve = 0x1234
 
 func isPqGroup(r CurveID) bool {
-	return r == CurveX25519Kyber768
+	return r == CurveX25519Kyber768 || r == CurveX25519MLKEM768
 }
 
 func addCurveTests() {
@@ -12225,78 +12226,100 @@
 		},
 	})
 
-	// Kyber should not be offered by a TLS < 1.3 client.
-	testCases = append(testCases, testCase{
-		name: "KyberNotInTLS12",
-		config: Config{
-			Bugs: ProtocolBugs{
-				FailIfKyberOffered: true,
-			},
-		},
-		flags: []string{
-			"-max-version", strconv.Itoa(VersionTLS12),
-			"-curves", strconv.Itoa(int(CurveX25519Kyber768)),
-			"-curves", strconv.Itoa(int(CurveX25519)),
-		},
-	})
+	// Post-quantum groups require TLS 1.3.
+	for _, curve := range testCurves {
+		if !isPqGroup(curve.id) {
+			continue
+		}
 
-	// Kyber should not crash a TLS < 1.3 client if the server mistakenly
-	// selects it.
-	testCases = append(testCases, testCase{
-		name: "KyberNotAcceptedByTLS12Client",
-		config: Config{
-			Bugs: ProtocolBugs{
-				SendCurve: CurveX25519Kyber768,
+		// Post-quantum groups should not be offered by a TLS 1.2 client.
+		testCases = append(testCases, testCase{
+			name: "TLS12ClientShouldNotOffer-" + curve.name,
+			config: Config{
+				Bugs: ProtocolBugs{
+					FailIfPostQuantumOffered: true,
+				},
 			},
-		},
-		flags: []string{
-			"-max-version", strconv.Itoa(VersionTLS12),
-			"-curves", strconv.Itoa(int(CurveX25519Kyber768)),
-			"-curves", strconv.Itoa(int(CurveX25519)),
-		},
-		shouldFail:    true,
-		expectedError: ":WRONG_CURVE:",
-	})
+			flags: []string{
+				"-max-version", strconv.Itoa(VersionTLS12),
+				"-curves", strconv.Itoa(int(curve.id)),
+				"-curves", strconv.Itoa(int(CurveX25519)),
+			},
+		})
 
-	// Kyber should not be offered by default as a client.
+		// Post-quantum groups should not be selected by a TLS 1.2 server.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			name:     "TLS12ServerShouldNotSelect-" + curve.name,
+			flags: []string{
+				"-max-version", strconv.Itoa(VersionTLS12),
+				"-curves", strconv.Itoa(int(curve.id)),
+				"-curves", strconv.Itoa(int(CurveX25519)),
+			},
+			expectations: connectionExpectations{
+				curveID: CurveX25519,
+			},
+		})
+
+		// If a TLS 1.2 server selects a post-quantum group anyway, the client
+		// should not accept it.
+		testCases = append(testCases, testCase{
+			name: "ClientShouldNotAllowInTLS12-" + curve.name,
+			config: Config{
+				MaxVersion: VersionTLS12,
+				Bugs: ProtocolBugs{
+					SendCurve: curve.id,
+				},
+			},
+			flags: []string{
+				"-curves", strconv.Itoa(int(curve.id)),
+				"-curves", strconv.Itoa(int(CurveX25519)),
+			},
+			shouldFail:         true,
+			expectedError:      ":WRONG_CURVE:",
+			expectedLocalError: "remote error: illegal parameter",
+		})
+	}
+
+	// ML-KEM and Kyber should not be offered by default as a client.
 	testCases = append(testCases, testCase{
-		name: "KyberNotEnabledByDefaultInClients",
+		name: "PostQuantumNotEnabledByDefaultInClients",
 		config: Config{
 			MinVersion: VersionTLS13,
 			Bugs: ProtocolBugs{
-				FailIfKyberOffered: true,
+				FailIfPostQuantumOffered: true,
 			},
 		},
 	})
 
-	// If Kyber is offered, both X25519 and Kyber should have a key-share.
+	// If ML-KEM is offered, both X25519 and ML-KEM should have a key-share.
 	testCases = append(testCases, testCase{
-		name: "NotJustKyberKeyShare",
+		name: "NotJustMLKEMKeyShare",
 		config: Config{
 			MinVersion: VersionTLS13,
 			Bugs: ProtocolBugs{
-				ExpectedKeyShares: []CurveID{CurveX25519Kyber768, CurveX25519},
+				ExpectedKeyShares: []CurveID{CurveX25519MLKEM768, CurveX25519},
 			},
 		},
 		flags: []string{
-			"-curves", strconv.Itoa(int(CurveX25519Kyber768)),
+			"-curves", strconv.Itoa(int(CurveX25519MLKEM768)),
 			"-curves", strconv.Itoa(int(CurveX25519)),
-			"-expect-curve-id", strconv.Itoa(int(CurveX25519Kyber768)),
+			"-expect-curve-id", strconv.Itoa(int(CurveX25519MLKEM768)),
 		},
 	})
 
 	// ... and the other way around
 	testCases = append(testCases, testCase{
-		name: "KyberKeyShareIncludedSecond",
+		name: "MLKEMKeyShareIncludedSecond",
 		config: Config{
 			MinVersion: VersionTLS13,
 			Bugs: ProtocolBugs{
-				ExpectedKeyShares: []CurveID{CurveX25519, CurveX25519Kyber768},
+				ExpectedKeyShares: []CurveID{CurveX25519, CurveX25519MLKEM768},
 			},
 		},
 		flags: []string{
 			"-curves", strconv.Itoa(int(CurveX25519)),
-			"-curves", strconv.Itoa(int(CurveX25519Kyber768)),
+			"-curves", strconv.Itoa(int(CurveX25519MLKEM768)),
 			"-expect-curve-id", strconv.Itoa(int(CurveX25519)),
 		},
 	})
@@ -12305,44 +12328,61 @@
 	// first classical and first post-quantum "curves" that get key shares
 	// included.
 	testCases = append(testCases, testCase{
-		name: "KyberKeyShareIncludedThird",
+		name: "MLKEMKeyShareIncludedThird",
 		config: Config{
 			MinVersion: VersionTLS13,
 			Bugs: ProtocolBugs{
-				ExpectedKeyShares: []CurveID{CurveX25519, CurveX25519Kyber768},
+				ExpectedKeyShares: []CurveID{CurveX25519, CurveX25519MLKEM768},
 			},
 		},
 		flags: []string{
 			"-curves", strconv.Itoa(int(CurveX25519)),
 			"-curves", strconv.Itoa(int(CurveP256)),
-			"-curves", strconv.Itoa(int(CurveX25519Kyber768)),
+			"-curves", strconv.Itoa(int(CurveX25519MLKEM768)),
 			"-expect-curve-id", strconv.Itoa(int(CurveX25519)),
 		},
 	})
 
-	// If Kyber is the only configured curve, the key share is sent.
+	// If ML-KEM is the only configured curve, the key share is sent.
 	testCases = append(testCases, testCase{
-		name: "JustConfiguringKyberWorks",
+		name: "JustConfiguringMLKEMWorks",
 		config: Config{
 			MinVersion: VersionTLS13,
 			Bugs: ProtocolBugs{
-				ExpectedKeyShares: []CurveID{CurveX25519Kyber768},
+				ExpectedKeyShares: []CurveID{CurveX25519MLKEM768},
 			},
 		},
 		flags: []string{
-			"-curves", strconv.Itoa(int(CurveX25519Kyber768)),
-			"-expect-curve-id", strconv.Itoa(int(CurveX25519Kyber768)),
+			"-curves", strconv.Itoa(int(CurveX25519MLKEM768)),
+			"-expect-curve-id", strconv.Itoa(int(CurveX25519MLKEM768)),
 		},
 	})
 
-	// As a server, Kyber is not yet supported by default.
+	// If both ML-KEM and Kyber are configured, only the preferred one's
+	// key share should be sent.
+	testCases = append(testCases, testCase{
+		name: "BothMLKEMAndKyber",
+		config: Config{
+			MinVersion: VersionTLS13,
+			Bugs: ProtocolBugs{
+				ExpectedKeyShares: []CurveID{CurveX25519MLKEM768},
+			},
+		},
+		flags: []string{
+			"-curves", strconv.Itoa(int(CurveX25519MLKEM768)),
+			"-curves", strconv.Itoa(int(CurveX25519Kyber768)),
+			"-expect-curve-id", strconv.Itoa(int(CurveX25519MLKEM768)),
+		},
+	})
+
+	// As a server, ML-KEM is not yet supported by default.
 	testCases = append(testCases, testCase{
 		testType: serverTest,
-		name:     "KyberNotEnabledByDefaultForAServer",
+		name:     "PostQuantumNotEnabledByDefaultForAServer",
 		config: Config{
 			MinVersion:       VersionTLS13,
-			CurvePreferences: []CurveID{CurveX25519Kyber768, CurveX25519},
-			DefaultCurves:    []CurveID{CurveX25519Kyber768},
+			CurvePreferences: []CurveID{CurveX25519MLKEM768, CurveX25519Kyber768, CurveX25519},
+			DefaultCurves:    []CurveID{CurveX25519MLKEM768, CurveX25519Kyber768},
 		},
 		flags: []string{
 			"-server-preference",