Support ML-DSA in libssl

Bug: 497880128
Change-Id: I2026f4eb43ef0ec671babb0daf46a1d9e09053c3
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/93807
Auto-Submit: Nick Harper <nharper@chromium.org>
Commit-Queue: Nick Harper <nharper@chromium.org>
Reviewed-by: David Benjamin <davidben@google.com>
diff --git a/go.mod b/go.mod
index 8d0e6cd..b185cca 100644
--- a/go.mod
+++ b/go.mod
@@ -1,12 +1,12 @@
 module boringssl.googlesource.com/boringssl.git
 
-go 1.24.0
-
-toolchain go1.24.8
+go 1.25.0
 
 require (
 	cloud.google.com/go/storage v1.59.2
 	filippo.io/edwards25519 v1.2.0
+	// TODO(crbug.com/505771670): Replace with crypto/mldsa once that exists.
+	filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e
 	github.com/hexops/gotextdiff v1.0.3
 	golang.org/x/crypto v0.43.0
 	golang.org/x/net v0.46.0
diff --git a/go.sum b/go.sum
index 8877b88..3ca048b 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,8 @@
 cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
 filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
 filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU=
+filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY=
 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
diff --git a/include/openssl/ssl.h b/include/openssl/ssl.h
index 182cef2..453318b 100644
--- a/include/openssl/ssl.h
+++ b/include/openssl/ssl.h
@@ -1154,6 +1154,9 @@
 #define SSL_SIGN_RSA_PSS_RSAE_SHA384 0x0805
 #define SSL_SIGN_RSA_PSS_RSAE_SHA512 0x0806
 #define SSL_SIGN_ED25519 0x0807
+#define SSL_SIGN_ML_DSA_44 0x0904
+#define SSL_SIGN_ML_DSA_65 0x0905
+#define SSL_SIGN_ML_DSA_87 0x0906
 
 // SSL_SIGN_RSA_PKCS1_SHA256_LEGACY is a backport of RSASSA-PKCS1-v1_5 with
 // SHA-256 to TLS 1.3. It is disabled by default and only defined for client
diff --git a/ssl/extensions.cc b/ssl/extensions.cc
index 5292268..50ce434 100644
--- a/ssl/extensions.cc
+++ b/ssl/extensions.cc
@@ -309,6 +309,11 @@
 // algorithms for signing.
 static const uint16_t kSignSignatureAlgorithms[] = {
     // List our preferred algorithms first.
+    SSL_SIGN_ML_DSA_44,
+    SSL_SIGN_ML_DSA_65,
+    SSL_SIGN_ML_DSA_87,
+
+    // Classical signature algorithms.
     SSL_SIGN_ED25519,
     SSL_SIGN_ECDSA_SECP256R1_SHA256,
     SSL_SIGN_RSA_PSS_RSAE_SHA256,
diff --git a/ssl/ssl_privkey.cc b/ssl/ssl_privkey.cc
index 190eed4..0262233 100644
--- a/ssl/ssl_privkey.cc
+++ b/ssl/ssl_privkey.cc
@@ -34,8 +34,16 @@
 BSSL_NAMESPACE_BEGIN
 
 bool ssl_is_key_type_supported(int key_type) {
-  return key_type == EVP_PKEY_RSA || key_type == EVP_PKEY_EC ||
-         key_type == EVP_PKEY_ED25519;
+  switch (key_type) {
+    case EVP_PKEY_RSA:
+    case EVP_PKEY_EC:
+    case EVP_PKEY_ED25519:
+    case EVP_PKEY_ML_DSA_44:
+    case EVP_PKEY_ML_DSA_65:
+    case EVP_PKEY_ML_DSA_87:
+      return true;
+  }
+  return false;
 }
 
 typedef struct {
@@ -99,6 +107,16 @@
     {SSL_SIGN_ED25519, EVP_PKEY_ED25519, NID_undef, nullptr,
      /*is_rsa_pss=*/false, /*tls12_ok=*/true, /*tls13_ok=*/true,
      /*client_only=*/false},
+
+    {SSL_SIGN_ML_DSA_44, EVP_PKEY_ML_DSA_44, NID_undef, nullptr,
+     /*is_rsa_pss=*/false, /*tls12_ok=*/false, /*tls13_ok=*/true,
+     /*client_only=*/false},
+    {SSL_SIGN_ML_DSA_65, EVP_PKEY_ML_DSA_65, NID_undef, nullptr,
+     /*is_rsa_pss=*/false, /*tls12_ok=*/false, /*tls13_ok=*/true,
+     /*client_only=*/false},
+    {SSL_SIGN_ML_DSA_87, EVP_PKEY_ML_DSA_87, NID_undef, nullptr,
+     /*is_rsa_pss=*/false, /*tls12_ok=*/false, /*tls13_ok=*/true,
+     /*client_only=*/false},
 };
 
 static const SSL_SIGNATURE_ALGORITHM *get_signature_algorithm(uint16_t sigalg) {
@@ -116,8 +134,9 @@
   // code elimination, but for now we just specify every algorithm that might be
   // reachable from libssl.
   const EVP_PKEY_ALG *const algs[] = {
-      EVP_pkey_rsa(),     EVP_pkey_ec_p256(), EVP_pkey_ec_p384(),
-      EVP_pkey_ec_p521(), EVP_pkey_ed25519(),
+      EVP_pkey_rsa(),       EVP_pkey_ec_p256(),   EVP_pkey_ec_p384(),
+      EVP_pkey_ec_p521(),   EVP_pkey_ed25519(),   EVP_pkey_ml_dsa_44(),
+      EVP_pkey_ml_dsa_65(), EVP_pkey_ml_dsa_87(),
   };
   return bssl::UniquePtr<EVP_PKEY>(EVP_PKEY_from_subject_public_key_info(
       spki.data(), spki.size(), algs, std::size(algs)));
@@ -481,6 +500,9 @@
     {SSL_SIGN_RSA_PSS_RSAE_SHA384, "rsa_pss_rsae_sha384"},
     {SSL_SIGN_RSA_PSS_RSAE_SHA512, "rsa_pss_rsae_sha512"},
     {SSL_SIGN_ED25519, "ed25519"},
+    {SSL_SIGN_ML_DSA_44, "mldsa44"},
+    {SSL_SIGN_ML_DSA_65, "mldsa65"},
+    {SSL_SIGN_ML_DSA_87, "mldsa87"},
 };
 
 const char *SSL_get_signature_algorithm_name(uint16_t sigalg,
@@ -645,6 +667,9 @@
     {EVP_PKEY_EC, NID_sha384, SSL_SIGN_ECDSA_SECP384R1_SHA384},
     {EVP_PKEY_EC, NID_sha512, SSL_SIGN_ECDSA_SECP521R1_SHA512},
     {EVP_PKEY_ED25519, NID_undef, SSL_SIGN_ED25519},
+    {EVP_PKEY_ML_DSA_44, NID_undef, SSL_SIGN_ML_DSA_44},
+    {EVP_PKEY_ML_DSA_65, NID_undef, SSL_SIGN_ML_DSA_65},
+    {EVP_PKEY_ML_DSA_87, NID_undef, SSL_SIGN_ML_DSA_87},
 };
 
 static bool parse_sigalg_pairs(Array<uint16_t> *out, const int *values,
diff --git a/ssl/ssl_test.cc b/ssl/ssl_test.cc
index 49adcaf..7a6da95 100644
--- a/ssl/ssl_test.cc
+++ b/ssl/ssl_test.cc
@@ -36,8 +36,10 @@
 #include <openssl/crypto.h>
 #include <openssl/curve25519.h>
 #include <openssl/err.h>
+#include <openssl/evp.h>
 #include <openssl/hmac.h>
 #include <openssl/hpke.h>
+#include <openssl/nid.h>
 #include <openssl/pem.h>
 #include <openssl/rand.h>
 #include <openssl/sha.h>
@@ -6888,6 +6890,7 @@
       {{1, 2, 3}, false, {}},
       {{NID_sha256, EVP_PKEY_ED25519}, false, {}},
       {{NID_sha256, EVP_PKEY_RSA, NID_sha256, EVP_PKEY_RSA}, false, {}},
+      {{NID_sha256, EVP_PKEY_ML_DSA_44}, false, {}},
 
       {{NID_sha256, EVP_PKEY_RSA}, true, {SSL_SIGN_RSA_PKCS1_SHA256}},
       {{NID_sha512, EVP_PKEY_RSA}, true, {SSL_SIGN_RSA_PKCS1_SHA512}},
@@ -6896,6 +6899,9 @@
       {{NID_undef, EVP_PKEY_ED25519, NID_sha384, EVP_PKEY_EC},
        true,
        {SSL_SIGN_ED25519, SSL_SIGN_ECDSA_SECP384R1_SHA384}},
+      {{NID_undef, EVP_PKEY_ML_DSA_44}, true, {SSL_SIGN_ML_DSA_44}},
+      {{NID_undef, EVP_PKEY_ML_DSA_65}, true, {SSL_SIGN_ML_DSA_65}},
+      {{NID_undef, EVP_PKEY_ML_DSA_87}, true, {SSL_SIGN_ML_DSA_87}},
   };
 
   UniquePtr<SSL_CTX> ctx(SSL_CTX_new(TLS_method()));
@@ -6952,6 +6958,9 @@
        {SSL_SIGN_ECDSA_SECP256R1_SHA256, SSL_SIGN_RSA_PSS_RSAE_SHA256}},
       {"RSA-PSS+SHA256", true, {SSL_SIGN_RSA_PSS_RSAE_SHA256}},
       {"PSS+SHA256", true, {SSL_SIGN_RSA_PSS_RSAE_SHA256}},
+      {"mldsa44:mldsa65:mldsa87",
+       true,
+       {SSL_SIGN_ML_DSA_44, SSL_SIGN_ML_DSA_65, SSL_SIGN_ML_DSA_87}},
   };
 
   UniquePtr<SSL_CTX> ctx(SSL_CTX_new(TLS_method()));
diff --git a/ssl/test/runner/certs.go b/ssl/test/runner/certs.go
index 34c9f5f..06f8ee7 100644
--- a/ssl/test/runner/certs.go
+++ b/ssl/test/runner/certs.go
@@ -32,6 +32,7 @@
 	"sync/atomic"
 	"time"
 
+	"filippo.io/mldsa"
 	"golang.org/x/crypto/cryptobyte"
 	cbasn1 "golang.org/x/crypto/cryptobyte/asn1"
 )
@@ -47,6 +48,10 @@
 	oidEd25519                 = asn1.ObjectIdentifier{1, 3, 101, 112}
 	oidPSS                     = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 10}
 
+	oidMLDSA44 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 3, 17}
+	oidMLDSA65 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 3, 18}
+	oidMLDSA87 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 3, 19}
+
 	oidSHA256 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}
 
 	oidMGF1 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 8}
@@ -69,6 +74,9 @@
 	X509SignRSAWithSHA256
 	X509SignECDSAWithSHA256
 	X509SignEd25519
+	X509SignMLDSA44
+	X509SignMLDSA65
+	X509SignMLDSA87
 )
 
 func (alg X509SignatureAlgorithm) Marshal(bb *cryptobyte.Builder) error {
@@ -86,12 +94,37 @@
 		bb.AddASN1(cbasn1.SEQUENCE, func(algID *cryptobyte.Builder) {
 			algID.AddASN1ObjectIdentifier(oidEd25519)
 		})
+	case X509SignMLDSA44:
+		bb.AddASN1(cbasn1.SEQUENCE, func(algID *cryptobyte.Builder) {
+			algID.AddASN1ObjectIdentifier(oidMLDSA44)
+		})
+	case X509SignMLDSA65:
+		bb.AddASN1(cbasn1.SEQUENCE, func(algID *cryptobyte.Builder) {
+			algID.AddASN1ObjectIdentifier(oidMLDSA65)
+		})
+	case X509SignMLDSA87:
+		bb.AddASN1(cbasn1.SEQUENCE, func(algID *cryptobyte.Builder) {
+			algID.AddASN1ObjectIdentifier(oidMLDSA87)
+		})
 	default:
 		return errors.New("unknown algorithm")
 	}
 	return nil
 }
 
+func mldsaX509SignatureAlgorithm(key *mldsa.PrivateKey) (X509SignatureAlgorithm, error) {
+	params := key.PublicKey().Parameters()
+	switch params {
+	case mldsa.MLDSA44():
+		return X509SignMLDSA44, nil
+	case mldsa.MLDSA65():
+		return X509SignMLDSA65, nil
+	case mldsa.MLDSA87():
+		return X509SignMLDSA87, nil
+	}
+	return 0, fmt.Errorf("unsupported ML-DSA Parameters: %v", params)
+}
+
 func chooseDefaultX509SignatureAlgorithm(signer crypto.Signer) (X509SignatureAlgorithm, error) {
 	if _, ok := signer.(*rsa.PrivateKey); ok {
 		return X509SignRSAWithSHA256, nil
@@ -102,6 +135,9 @@
 	if _, ok := signer.(ed25519.PrivateKey); ok {
 		return X509SignEd25519, nil
 	}
+	if mldsaKey, ok := signer.(*mldsa.PrivateKey); ok {
+		return mldsaX509SignatureAlgorithm(mldsaKey)
+	}
 	return 0, fmt.Errorf("unsupported key type: %T", signer)
 }
 
@@ -128,17 +164,20 @@
 		default:
 			return nil, errors.New("unknown algorithm")
 		}
+	} else if mldsaKey, ok := signer.(*mldsa.PrivateKey); ok {
+		params := mldsaKey.PublicKey().Parameters()
+		algMatchesParams := (alg == X509SignMLDSA44 && params == mldsa.MLDSA44()) ||
+			(alg == X509SignMLDSA65 && params == mldsa.MLDSA65()) ||
+			(alg == X509SignMLDSA87 && params == mldsa.MLDSA87())
+		if !algMatchesParams {
+			return nil, errors.New("unknown algorithm")
+		}
+		opts = crypto.Hash(0)
 	} else {
 		return nil, fmt.Errorf("unsupported key type: %T", signer)
 	}
 
-	digest := in
-	if hash := opts.HashFunc(); hash != crypto.Hash(0) {
-		h := hash.New()
-		h.Write(in)
-		digest = h.Sum(nil)
-	}
-	return signer.Sign(rand.Reader, digest, opts)
+	return crypto.SignMessage(signer, rand.Reader, in, opts)
 }
 
 func addX509Time(bb *cryptobyte.Builder, t time.Time) {
@@ -160,6 +199,24 @@
 	})
 }
 
+func addMLDSASubjectPublicKeyInfo(bb *cryptobyte.Builder, key *mldsa.PublicKey) {
+	bb.AddASN1(cbasn1.SEQUENCE, func(spki *cryptobyte.Builder) {
+		spki.AddASN1(cbasn1.SEQUENCE, func(algID *cryptobyte.Builder) {
+			switch key.Parameters() {
+			case mldsa.MLDSA44():
+				algID.AddASN1ObjectIdentifier(oidMLDSA44)
+			case mldsa.MLDSA65():
+				algID.AddASN1ObjectIdentifier(oidMLDSA65)
+			case mldsa.MLDSA87():
+				algID.AddASN1ObjectIdentifier(oidMLDSA87)
+			default:
+				panic("Unknown ML-DSA parameter set")
+			}
+		})
+		spki.AddASN1BitString(key.Bytes())
+	})
+}
+
 func addRSAPSSSubjectPublicKeyInfo(bb *cryptobyte.Builder, key *rsa.PublicKey) {
 	bb.AddASN1(cbasn1.SEQUENCE, func(spki *cryptobyte.Builder) {
 		spki.AddASN1(cbasn1.SEQUENCE, func(algID *cryptobyte.Builder) {
@@ -264,7 +321,11 @@
 			return
 		}
 		tbs.AddBytes(subjectDER)
-		if subject.EncodeSPKIAsRSAPSS {
+		if mldsaKey, ok := subject.PrivateKey.Public().(*mldsa.PublicKey); ok {
+			// TODO(crbug.com/505771670): Once crypto/x509 supports ML-DSA, remove
+			// this and use x509.MarshalPKIXPublicKey (below) directly.
+			addMLDSASubjectPublicKeyInfo(tbs, mldsaKey)
+		} else if subject.EncodeSPKIAsRSAPSS {
 			addRSAPSSSubjectPublicKeyInfo(tbs, subject.PrivateKey.Public().(*rsa.PublicKey))
 		} else {
 			spki, err := x509.MarshalPKIXPublicKey(subject.PrivateKey.Public())
@@ -385,6 +446,57 @@
 	}
 }
 
+func reparseSPKI(cert *x509.Certificate) {
+	spkiSeq := cryptobyte.String(cert.RawSubjectPublicKeyInfo)
+	var spki, alg cryptobyte.String
+	var algOID asn1.ObjectIdentifier
+	if !spkiSeq.ReadASN1(&spki, cbasn1.SEQUENCE) ||
+		!spki.ReadASN1(&alg, cbasn1.SEQUENCE) ||
+		!alg.ReadASN1ObjectIdentifier(&algOID) {
+		return
+	}
+	// This function only supports ML-DSA SPKIs
+	var mldsaParams *mldsa.Parameters
+	if algOID.Equal(oidMLDSA44) {
+		mldsaParams = mldsa.MLDSA44()
+	} else if algOID.Equal(oidMLDSA65) {
+		mldsaParams = mldsa.MLDSA65()
+	} else if algOID.Equal(oidMLDSA87) {
+		mldsaParams = mldsa.MLDSA87()
+	} else {
+		// Not an ML-DSA OID
+		return
+	}
+	// The AlgorithmIdentifier (for ML-DSA) should have empty params
+	if !alg.Empty() {
+		return
+	}
+	var publicKey []byte
+	if !spki.ReadASN1BitStringAsBytes(&publicKey) {
+		return
+	}
+	mldsaPubKey, err := mldsa.NewPublicKey(mldsaParams, publicKey)
+	if err != nil {
+		return
+	}
+	cert.PublicKey = mldsaPubKey
+}
+
+// ParseX509Certificate is a wrapper around x509.ParseCertificate, but it also
+// handles parsing ML-DSA public keys.
+//
+// TODO(crbug.com/505771670): Remove this once crypto/x509 supports ML-DSA.
+func ParseX509Certificate(der []byte) (*x509.Certificate, error) {
+	cert, err := x509.ParseCertificate(der)
+	if err != nil {
+		return nil, err
+	}
+	if cert.PublicKey == nil {
+		reparseSPKI(cert)
+	}
+	return cert, nil
+}
+
 func writeTempCertFile(certs [][]byte) string {
 	f, err := os.CreateTemp(tmpDir, "test-cert")
 	if err != nil {
@@ -407,9 +519,32 @@
 	if err != nil {
 		panic(fmt.Sprintf("failed to create temp file: %s", err))
 	}
-	keyDER, err := x509.MarshalPKCS8PrivateKey(privKey)
-	if err != nil {
-		panic(fmt.Sprintf("failed to marshal test key: %s", err))
+	var keyDER []byte
+	// TODO(crbug.com/505771670): Remove this once crypto/x509 supports ML-DSA.
+	if mldsaKey, ok := privKey.(*mldsa.PrivateKey); ok {
+		sigAlg, err := mldsaX509SignatureAlgorithm(mldsaKey)
+		if err != nil {
+			panic(fmt.Sprintf("failed to get SignatureAlgorithm for ML-DSA key: %s", err))
+		}
+		bb := cryptobyte.NewBuilder(make([]byte, 0, 54))
+		bb.AddASN1(cbasn1.SEQUENCE, func(pkcs8 *cryptobyte.Builder) {
+			pkcs8.AddASN1Uint64(0)
+			sigAlg.Marshal(pkcs8)
+			pkcs8.AddASN1(cbasn1.OCTET_STRING, func(privKey *cryptobyte.Builder) {
+				privKey.AddASN1(cbasn1.Tag(0).ContextSpecific(), func(b *cryptobyte.Builder) {
+					b.AddBytes(mldsaKey.Bytes())
+				})
+			})
+		})
+		keyDER, err = bb.Bytes()
+		if err != nil {
+			panic(fmt.Sprintf("Failed to marshal test ML-DSA key: %s", err))
+		}
+	} else {
+		keyDER, err = x509.MarshalPKCS8PrivateKey(privKey)
+		if err != nil {
+			panic(fmt.Sprintf("failed to marshal test key: %s", err))
+		}
 	}
 	if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}); err != nil {
 		panic(fmt.Sprintf("failed to write test key: %s", err))
diff --git a/ssl/test/runner/common.go b/ssl/test/runner/common.go
index 2d17483..ad8e3f7 100644
--- a/ssl/test/runner/common.go
+++ b/ssl/test/runner/common.go
@@ -329,6 +329,11 @@
 	signatureEd25519 signatureAlgorithm = 0x0807
 	signatureEd448   signatureAlgorithm = 0x0808
 
+	// ML-DSA algorithms (draft-ietf-tls-mldsa-02)
+	signatureMLDSA44 signatureAlgorithm = 0x0904
+	signatureMLDSA65 signatureAlgorithm = 0x0905
+	signatureMLDSA87 signatureAlgorithm = 0x0906
+
 	// draft-ietf-tls-tls13-pkcs1-00
 	signatureRSAPKCS1WithSHA256Legacy signatureAlgorithm = 0x0420
 
diff --git a/ssl/test/runner/handshake_client.go b/ssl/test/runner/handshake_client.go
index 4f26321..b5d7084 100644
--- a/ssl/test/runner/handshake_client.go
+++ b/ssl/test/runner/handshake_client.go
@@ -23,6 +23,7 @@
 
 	"boringssl.googlesource.com/boringssl.git/ssl/test/runner/hpke"
 	"boringssl.googlesource.com/boringssl.git/ssl/test/runner/spake2plus"
+	"filippo.io/mldsa"
 	"golang.org/x/crypto/cryptobyte"
 )
 
@@ -1924,7 +1925,7 @@
 	certs := make([]*x509.Certificate, len(certMsg.certificates))
 	if certMsg.certificateType == certTypeX509 {
 		for i, certEntry := range certMsg.certificates {
-			cert, err := x509.ParseCertificate(certEntry.data)
+			cert, err := ParseX509Certificate(certEntry.data)
 			if err != nil {
 				c.sendAlert(alertBadCertificate)
 				return errors.New("tls: failed to parse certificate from server: " + err.Error())
@@ -1971,7 +1972,7 @@
 	}
 
 	switch peerPublicKey.(type) {
-	case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey:
+	case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey, *mldsa.PublicKey:
 		break
 	default:
 		c.sendAlert(alertUnsupportedCertificate)
diff --git a/ssl/test/runner/handshake_server.go b/ssl/test/runner/handshake_server.go
index 636c932..8425f74 100644
--- a/ssl/test/runner/handshake_server.go
+++ b/ssl/test/runner/handshake_server.go
@@ -22,6 +22,7 @@
 
 	"boringssl.googlesource.com/boringssl.git/ssl/test/runner/hpke"
 	"boringssl.googlesource.com/boringssl.git/ssl/test/runner/spake2plus"
+	"filippo.io/mldsa"
 	"golang.org/x/crypto/cryptobyte"
 )
 
@@ -2351,7 +2352,7 @@
 	certs := make([]*x509.Certificate, len(certificates))
 	var err error
 	for i, asn1Data := range certificates {
-		if certs[i], err = x509.ParseCertificate(asn1Data); err != nil {
+		if certs[i], err = ParseX509Certificate(asn1Data); err != nil {
 			c.sendAlert(alertBadCertificate)
 			return nil, errors.New("tls: failed to parse client certificate: " + err.Error())
 		}
@@ -2386,7 +2387,7 @@
 	if len(certs) > 0 {
 		pub := certs[0].PublicKey
 		switch pub.(type) {
-		case *ecdsa.PublicKey, *rsa.PublicKey, ed25519.PublicKey:
+		case *ecdsa.PublicKey, *rsa.PublicKey, ed25519.PublicKey, *mldsa.PublicKey:
 			break
 		default:
 			c.sendAlert(alertUnsupportedCertificate)
diff --git a/ssl/test/runner/runner.go b/ssl/test/runner/runner.go
index 31bffa2..f092873 100644
--- a/ssl/test/runner/runner.go
+++ b/ssl/test/runner/runner.go
@@ -45,6 +45,7 @@
 	"time"
 
 	"boringssl.googlesource.com/boringssl.git/util/testresult"
+	"filippo.io/mldsa"
 	"golang.org/x/crypto/cryptobyte"
 )
 
@@ -139,6 +140,10 @@
 
 	ed25519Key ed25519.PrivateKey
 
+	mldsa44Key *mldsa.PrivateKey
+	mldsa65Key *mldsa.PrivateKey
+	mldsa87Key *mldsa.PrivateKey
+
 	channelIDKey ecdsa.PrivateKey
 )
 
@@ -187,6 +192,21 @@
 	}
 	ed25519Key = k.(ed25519.PrivateKey)
 
+	for _, k := range []struct {
+		params *mldsa.Parameters
+		key    **mldsa.PrivateKey
+	}{
+		{mldsa.MLDSA44(), &mldsa44Key},
+		{mldsa.MLDSA65(), &mldsa65Key},
+		{mldsa.MLDSA87(), &mldsa87Key},
+	} {
+		key, err := mldsa.GenerateKey(k.params)
+		if err != nil {
+			panic(fmt.Sprintf("Failed to generate ML-DSA test key: %s", err))
+		}
+		*k.key = key
+	}
+
 	channelIDKeyPath = writeTempKeyFile(&channelIDKey)
 }
 
@@ -214,6 +234,9 @@
 	ecdsaP384Certificate Credential
 	ecdsaP521Certificate Credential
 	ed25519Certificate   Credential
+	mldsa44Certificate   Credential
+	mldsa65Certificate   Credential
+	mldsa87Certificate   Credential
 	garbageCertificate   Credential
 	pssCertificate       Credential
 )
@@ -230,6 +253,9 @@
 		{"ECDSA P-384", &ecdsaP384Key, &ecdsaP384Certificate},
 		{"ECDSA P-521", &ecdsaP521Key, &ecdsaP521Certificate},
 		{"Ed25519", ed25519Key, &ed25519Certificate},
+		{"ML-DSA 44", mldsa44Key, &mldsa44Certificate},
+		{"ML-DSA 65", mldsa65Key, &mldsa65Certificate},
+		{"ML-DSA 87", mldsa87Key, &mldsa87Certificate},
 	} {
 		// For each test key, make a self-signed root that issues a leaf, using
 		// the same algorithm.
diff --git a/ssl/test/runner/sign.go b/ssl/test/runner/sign.go
index 2dc8359..8d799fd 100644
--- a/ssl/test/runner/sign.go
+++ b/ssl/test/runner/sign.go
@@ -19,6 +19,8 @@
 	"fmt"
 	"math/big"
 	"slices"
+
+	"filippo.io/mldsa"
 )
 
 type signer interface {
@@ -286,6 +288,31 @@
 	return nil
 }
 
+type mldsaSigner struct{}
+
+func (m *mldsaSigner) supportsKey(key crypto.PrivateKey) bool {
+	_, ok := key.(*mldsa.PrivateKey)
+	return ok
+}
+
+func (m *mldsaSigner) signMessage(key crypto.PrivateKey, config *Config, msg []byte) ([]byte, error) {
+	privKey, ok := key.(*mldsa.PrivateKey)
+	if !ok {
+		return nil, errors.New("invalid key type for ML-DSA")
+	}
+
+	return privKey.Sign(nil, msg, nil)
+}
+
+func (m *mldsaSigner) verifyMessage(key crypto.PublicKey, msg, sig []byte) error {
+	pubKey, ok := key.(*mldsa.PublicKey)
+	if !ok {
+		return errors.New("invalid key type for ML-DSA")
+	}
+
+	return mldsa.Verify(pubKey, msg, sig, nil)
+}
+
 func getSigner(isClient bool, version version, key any, config *Config, sigAlg signatureAlgorithm, isVerify bool) (signer, error) {
 	// TLS 1.1 and below use legacy signature algorithms.
 	protoVers := version.protocolVersion()
@@ -347,6 +374,12 @@
 		return &rsaPSSSigner{crypto.SHA512}, nil
 	case signatureEd25519:
 		return &ed25519Signer{}, nil
+	case signatureMLDSA44:
+		return &mldsaSigner{}, nil
+	case signatureMLDSA65:
+		return &mldsaSigner{}, nil
+	case signatureMLDSA87:
+		return &mldsaSigner{}, nil
 	}
 
 	return nil, fmt.Errorf("unsupported signature algorithm %04x", sigAlg)
diff --git a/ssl/test/runner/signature_algorithm_tests.go b/ssl/test/runner/signature_algorithm_tests.go
index 6e56228..bb93eb4 100644
--- a/ssl/test/runner/signature_algorithm_tests.go
+++ b/ssl/test/runner/signature_algorithm_tests.go
@@ -40,6 +40,9 @@
 	{"RSA_PSS_SHA384", signatureRSAPSSWithSHA384, &rsaCertificate, 0},
 	{"RSA_PSS_SHA512", signatureRSAPSSWithSHA512, &rsaCertificate, 0},
 	{"Ed25519", signatureEd25519, &ed25519Certificate, 0},
+	{"ML-DSA-44", signatureMLDSA44, &mldsa44Certificate, 0},
+	{"ML-DSA-65", signatureMLDSA65, &mldsa65Certificate, 0},
+	{"ML-DSA-87", signatureMLDSA87, &mldsa87Certificate, 0},
 	// Tests for key types prior to TLS 1.2.
 	{"RSA", 0, &rsaCertificate, 0},
 	{"ECDSA", 0, &ecdsaP256Certificate, CurveP256},
@@ -95,6 +98,7 @@
 				var shouldFail bool
 				isTLS12PKCS1 := hasComponent(alg.name, "PKCS1") && !hasComponent(alg.name, "LEGACY")
 				isTLS13PKCS1 := hasComponent(alg.name, "PKCS1") && hasComponent(alg.name, "LEGACY")
+				isMLDSA := alg.id == signatureMLDSA44 || alg.id == signatureMLDSA65 || alg.id == signatureMLDSA87
 
 				// TLS 1.3 removes a number of signature algorithms.
 				if ver.version >= VersionTLS13 && (alg.id == signatureECDSAWithSHA1 || isTLS12PKCS1) {
@@ -107,6 +111,11 @@
 					shouldFail = true
 				}
 
+				// ML-DSA is only supported for TLS 1.3
+				if ver.version < VersionTLS13 && isMLDSA {
+					shouldFail = true
+				}
+
 				// By default, BoringSSL does not sign with these algorithms.
 				signDefault := !shouldFail
 				if isTLS13PKCS1 {
@@ -115,7 +124,7 @@
 
 				// By default, BoringSSL does not accept these algorithms.
 				verifyDefault := !shouldFail
-				if alg.id == signatureECDSAWithSHA1 || alg.id == signatureECDSAWithP521AndSHA512 || alg.id == signatureEd25519 || isTLS13PKCS1 {
+				if alg.id == signatureECDSAWithSHA1 || alg.id == signatureECDSAWithP521AndSHA512 || alg.id == signatureEd25519 || isTLS13PKCS1 || isMLDSA {
 					verifyDefault = false
 				}
 
@@ -140,6 +149,12 @@
 					if ver.version <= VersionTLS12 && signTestType == serverTest {
 						return ":NO_SHARED_CIPHER:"
 					}
+					if ver.version <= VersionTLS12 && isMLDSA {
+						// In TLS 1.2, there's no valid way for the server to request an
+						// ML-DSA client certificate (there's no ClientCertificateType for
+						// ML-DSA).
+						return ":UNKNOWN_CERTIFICATE_TYPE:"
+					}
 					return ":NO_COMMON_SIGNATURE_ALGORITHMS:"
 				}
 				signLocalError := func(shouldFail bool) string {
@@ -154,6 +169,12 @@
 					if !shouldFail {
 						return ""
 					}
+					if ver.version <= VersionTLS12 && verifyTestType == clientTest && isMLDSA {
+						// TLS 1.2 has no support for ML-DSA signatures. The client code
+						// rejects a server's attempt to sign with ML-DSA when it sees the
+						// certificate.
+						return ":WRONG_CERTIFICATE_TYPE:"
+					}
 					// If the shim rejects the signature algorithm, but the
 					// runner forcibly selects it anyway, the shim should notice.
 					return ":WRONG_SIGNATURE_TYPE:"