crypto/tls: omit PSK in ECH outer client hello

When using ECH, do not include the PSK extension in the outer hello.
Including the PSK extension allows for a degradation in privacy, as an
on-path attacker can harvest outer client hellos, and then construct new
hellos using the PSK extension and arbitrary guessed SNI values,
replaying them to the target server. If the server rejects the PSK, the
handshake will continue, but if the PSK is accepted, the binder check
will fail.

Thanks to Coia Prant (github.com/rbqvq) for
reporting this issue.

Fixes CVE-2026-42505
Fixes #79282

Change-Id: Ib3a3c948106a57c1b07b9e61a58cbf757848be18
Reviewed-on: https://go-review.googlesource.com/c/go/+/775960
Auto-Submit: Roland Shoemaker <roland@golang.org>
TryBot-Bypass: Roland Shoemaker <roland@golang.org>
Reviewed-by: Daniel McCarney <daniel@binaryparadox.net>
Reviewed-by: Carlos Amedee <carlos@golang.org>
diff --git a/src/crypto/tls/handshake_messages.go b/src/crypto/tls/handshake_messages.go
index aa0b7db..511e073 100644
--- a/src/crypto/tls/handshake_messages.go
+++ b/src/crypto/tls/handshake_messages.go
@@ -5,6 +5,7 @@
 package tls
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
 	"slices"
@@ -317,7 +318,8 @@
 			})
 		})
 	}
-	if len(m.pskIdentities) > 0 { // pre_shared_key must be the last extension
+	// pre_shared_key must be the last extension
+	if len(m.pskIdentities) > 0 && (echInner || len(m.encryptedClientHello) == 0 || bytes.Equal(m.encryptedClientHello, []byte{byte(innerECHExt)})) {
 		// RFC 8446, Section 4.2.11
 		exts.AddUint16(extensionPreSharedKey)
 		exts.AddUint16LengthPrefixed(func(exts *cryptobyte.Builder) {
diff --git a/src/crypto/tls/handshake_messages_test.go b/src/crypto/tls/handshake_messages_test.go
index aa4659a..d025ce8 100644
--- a/src/crypto/tls/handshake_messages_test.go
+++ b/src/crypto/tls/handshake_messages_test.go
@@ -215,7 +215,16 @@
 	case 2:
 		m.pskModes = []uint8{pskModeDHE, pskModePlain}
 	}
-	for i := 0; i < rand.Intn(5); i++ {
+	// clientHelloMsg.marshal uses echInner == false. If m.encryptedClientHello > 0 and
+	// does not equal []byte{innerECHExt}, then the psk extension will be omitted,
+	// so either only emit empty encryptedClientHello and psk, encryptedClientHello with
+	// []byte{innerECHExt} and psk, or random encryptedClientHello and no psk.
+	if rand.Intn(10) > 5 {
+		m.encryptedClientHello = randomBytes(rand.Intn(50)+1, rand)
+	} else {
+		if rand.Intn(10) > 5 {
+			m.encryptedClientHello = []byte{byte(innerECHExt)}
+		}
 		var psk pskIdentity
 		psk.obfuscatedTicketAge = uint32(rand.Intn(500000))
 		psk.label = randomBytes(rand.Intn(500)+1, rand)
@@ -228,9 +237,6 @@
 	if rand.Intn(10) > 5 {
 		m.earlyData = true
 	}
-	if rand.Intn(10) > 5 {
-		m.encryptedClientHello = randomBytes(rand.Intn(50)+1, rand)
-	}
 
 	return reflect.ValueOf(m)
 }
@@ -579,3 +585,88 @@
 		t.Fatal("Unmarshaled ServerHello with duplicate extensions")
 	}
 }
+
+func TestECHRemoveOuterPSK(t *testing.T) {
+	r := rand.New(rand.NewSource(0))
+
+	for _, tc := range []struct {
+		name          string
+		echInner      bool
+		echExt        []byte
+		expectRemoved bool
+	}{
+		{
+			name:          "echInner true",
+			echInner:      true,
+			expectRemoved: false,
+		},
+		{
+			name:          "echInner true, no ech ext",
+			echInner:      true,
+			expectRemoved: false,
+		},
+		{
+			name:          "echInner true, ech ext present",
+			echInner:      true,
+			echExt:        []byte{254},
+			expectRemoved: false,
+		},
+		{
+			name:          "echInner true, ech ext present, inner ech sentinel",
+			echInner:      true,
+			echExt:        []byte{byte(innerECHExt)},
+			expectRemoved: false,
+		},
+		{
+			name:          "echInner false, no ech ext",
+			echInner:      false,
+			expectRemoved: false,
+		},
+		{
+			name:          "echInner false, ech ext present",
+			echInner:      false,
+			echExt:        []byte{254},
+			expectRemoved: true,
+		},
+		{
+			name:          "echInner false, ech ext present, inner ech sentinel",
+			echInner:      false,
+			echExt:        []byte{byte(innerECHExt)},
+			expectRemoved: false,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			ch := (&clientHelloMsg{}).Generate(r, 0).Interface().(*clientHelloMsg)
+
+			ch.pskBinders = [][]byte{[]byte("test")}
+			ch.pskIdentities = []pskIdentity{{label: []byte("test")}}
+			ch.encryptedClientHello = tc.echExt
+
+			b, err := ch.marshalMsg(tc.echInner)
+			if err != nil {
+				t.Fatal(err)
+			}
+			var rch clientHelloMsg
+			if !rch.unmarshal(b) {
+				t.Fatal("Failed to unmarshal ClientHello")
+			}
+
+			if tc.expectRemoved {
+				if rch.pskIdentities != nil {
+					t.Error("expected PSK identities to be removed")
+				}
+				if rch.pskBinders != nil {
+					t.Error("expected PSK binders to be removed")
+				}
+			} else {
+				if rch.pskIdentities == nil {
+					t.Error("expected PSK identities to be present")
+				}
+				if rch.pskBinders == nil {
+					t.Error("expected PSK binders to be present")
+				}
+			}
+		})
+	}
+
+}
diff --git a/src/crypto/tls/tls_test.go b/src/crypto/tls/tls_test.go
index d1f229b..6fe4490 100644
--- a/src/crypto/tls/tls_test.go
+++ b/src/crypto/tls/tls_test.go
@@ -2678,6 +2678,7 @@
 	clientConfig.RootCAs.AddCert(secretCert)
 	clientConfig.RootCAs.AddCert(publicCert)
 	clientConfig.EncryptedClientHelloConfigList = echConfigList
+	clientConfig.ClientSessionCache = NewLRUClientSessionCache(2)
 	serverConfig.InsecureSkipVerify = false
 	serverConfig.Time = nil
 	serverConfig.MinVersion = VersionTLS13