Repository: XTLS/Xray-core

Issue:
Suggestion on REALITY Authentication
In short: Although there is currently no practical attack, the authentication scheme in REALITY protocol could improved by switching to more standard cryptographic constructions.

I am concerned about REALTY's authentication strength. In REALITY protocol, a long term x25519 public key is used as the credential for authentication, i.e. a server confirms the client's identity by checking whether the secret derived opens the AEAD'ed data in SessionID.
First, the usage of x25519 is different from the standard ECDH, and might have different security requirements. I am concerned that it is not guaranteed to be as secure as the alternatives, e.g., ed25519 (as in SSHv2), or private key based x25519 authentication (as in WireGuard®).
For example, according to the [RFC7748, Section 6.1](https://datatracker.ietf.org/doc/html/rfc7748#section-6.1), a x25519 function "MAY check, without leaking extra information about the value of K, whether K is the all-zero value and abort if so". Since the check is not enforced as per the standard, suppose an implementation of REALITY has messed up and does not check for the x25519 secret. A malicious client is to probe whether a server is running REALITY. It could force an all-zero shared secret by sending a low-order point, i.e., one from [libsodium](https://github.com/jedisct1/libsodium/blob/cec56d867f741e66f78b9fde37d9081643599a2a/src/libsodium/crypto_scalarmult/curve25519/ref10/x25519_ref10.c#L21) as an ephemeral public key. In such case, the server will derive a x25519 secret of all zeros and derive a AEAD key that is clear to the malicious client, hence accepting the connection, if the ShortId was guessed correctly. This is not of an issue per the current implementation, and will probably not be an issue for a third party implementation as the check is done in almost every x25519 provider in practice. However, I still consider this as a showcase how x25519 is not designed with public key based authentication in mind, and so the security is not guaranteed.

Second, REALITY uses non key-committing AEADs to authenticate the HMAC'ed keys. The current choice of AEADs, AES-256-GCM and ChaCha20-Poly1305, does not ensure key-commitment, i.e., the difficulty for an adversary to construct messages that decrypt successfully, without knowing the key. In fact, [it was shown](https://www.usenix.org/system/files/sec21-len.pdf) that it is possible to identify the key at a significant higher rate than brute-forcing using a decryption oracle, which a REALITY server is - edit: In fact, it is not, as only a single ciphertext decrypts into the desired plaintext.

(RPRX, please be kind if I said anything wrong or my concern is invalid. 我没有开小号，也没有用 LLM 生成以上内容)

Hints from issue comments:
@wyx2685: @Cl-He-O 感觉纯放屁@Fangliding: "suppose an implementation of REALITY has messed up" 是个啥理由，总不能RSA加密的时候实现可能有问题导致生成的大素数pq来自某个更小子集就说某个使用RSA方案有问题吧，是他们的实现问题，类似的你还能把每一环的 Security Considerations 都拉出来说一遍，而且这个有问题的keyshare也会被先丢到dest然后估计会被dest的tls实现拒掉，甚至到不了reality这一关。

至于后面的论文提到的这个神谕攻击也不成立 它也就说了要求 不知道你细看过这篇文章没有

> efficiently craft ciphertexts that successfully decrypt
> under a large number of potential keys

这个条件完全不成立 large number of potential keys 根本就不存在 执行身份验证的隐藏共享秘密在每次TLS连接中有且只有一个，一旦失败下次又是新的，根本没有执行这种攻击的前提。这个场景只可能出现在vmess/ss(不包括2022)且有超级多用户的情况下(估计只有机场) @Cl-He-O: > "suppose an implementation of REALITY has messed up" 是个啥理由，总不能RSA加密的时候实现可能有问题导致生成的大素数pq来自某个更小子集就说某个使用RSA方案有问题吧，是他们的实现问题，类似的你还能把每一环的 Security Considerations 都拉出来说一遍，而且这个有问题的keyshare也会被先丢到dest然后估计会被dest的tls实现拒掉，甚至到不了reality这一关。

这里我的意思是安全的 ECDH 库被 REALITY 使用可能产生问题。dest 我确实不是很清楚，我以为会先认证再转发给 dest。
我可能是过分担忧了。@Cl-He-O: > 至于后面的论文提到的这个神谕攻击也不成立 它也就说了要求 不知道你细看过这篇文章没有
> 
> > efficiently craft ciphertexts that successfully decrypt
> > under a large number of potential keys
> 
> 这个条件完全不成立 large number of potential keys 根本就不存在 执行身份验证的隐藏共享秘密在每次TLS连接中有且只有一个，一旦失败下次又是新的，根本没有执行这种攻击的前提。这个场景只可能出现在vmess/ss(不包括2022)且有超级多用户的情况下(估计只有机场)

这里的 'large number of potential keys' 是指所有能够成功解密这个 ciphertext 的 keys，而不一定是真的 key。论文的意思是能通过这个 ciphertext 有没有被成功解密得知真的 key 有没有在这些 potential keys 中。
如果发送同一个 random 和 keyshare 多次，多个连接可以出现同一个 AuthKey。
但即使能猜出 AuthKey 还有 HMAC 和 x25519 需要逆向。
再一次，我可能是过分担忧了（@RPRX: 先说第一点吧，REALITY 验证 Client Hello 与 target 构造 Server Hello 是同时进行的，但凡有一方出错就会导致直接双向对拷

当然最关键的还是，如果某个密码学函数在某个语言的实现本身有问题，则并非 REALITY 的设计问题

**不过我认为你提出这一点还是有价值的，可以把“对客户端公钥的预检”作为 REALITY 服务端明确的安全要求，在自己的代码中就检查**

第二点我看了下你的意思应该是攻击者想在没有客户端 password (public key) 的情况下构造出一个能被 REALITY 服务端成功解密的 session id，这成功的概率基本为 0，如果 REALITY 服务端还开了验证时间戳则可以说就是 0，即使成功了也就只是能用上 REALITY

如果你的意思是攻击者的目的是解密别人的流量，则这个攻击方向就是错的，**因为 REALITY 是 ECDHE，与验证身份的 authKey 无关**@RPRX: 还有我有印象第一点我好像看到过，忘了是看 Golang x25519 的代码看到过还是有别人说过，好像是前者@RPRX: 我又想了下 curve25519 的 Basepoint，把它放进 Client Hello key_share，此时 authKey 就是 REALITY public key，仍需知道才能连上

如果攻击者把 Basepoint 放进 Server Hello key_share，没 REALITY private key 就无法伪造服务端自签证书，和普通 MITM 的困境相同@RPRX: https://github.com/XTLS/Xray-core/issues/4458 这个奇葩 issue 咋整个都没了，也没显示是谁删的@RPRX: Audit log 也没有任何相关信息@RPRX: Anyway，我终于发现我有厌蠢症，或者说，长期以来被大量傻逼逼出来的厌蠢症

我最近都没空管 Xray，但是看到傻逼言论，高低要去喷死他，真的是受不了一点，智商不够可以安静看着，不要出来浪费别人的时间@RPRX: 我知道为什么了，https://github.com/gaungap 也 404 了，且这个 username 仍是被占状态，应该是有人举报他，然后他被封号了@RPRX: ~~如果 GitHub 能看懂我们在讨论什么，说明 GitHub 员工也用 REALITY？~~@RPRX: 我发现 @gaungap 这傻逼还给 [XHTTP: Beyond REALITY](https://github.com/XTLS/Xray-core/discussions/4113) 点了个踩，要不要这么搞笑，就是说 XHTTP 跟他的“分析”有半毛钱关系吗

画龙点睛之笔了属于是，自己给自己盖章认证的“我是傻逼”

Solution patch:
diff --git a/go.mod b/go.mod
index 62ef9bdc..6d620b9a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,6 @@ module github.com/xtls/xray-core
 go 1.24
 
 require (
-	github.com/OmarTariq612/goech v0.0.0-20240405204721-8e2e1dafd3a0
 	github.com/cloudflare/circl v1.6.1
 	github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344
 	github.com/golang/mock v1.7.0-rc.1
diff --git a/go.sum b/go.sum
index d4f9161d..181db031 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,3 @@
-github.com/OmarTariq612/goech v0.0.0-20240405204721-8e2e1dafd3a0 h1:Wo41lDOevRJSGpevP+8Pk5bANX7fJacO2w04aqLiC5I=
-github.com/OmarTariq612/goech v0.0.0-20240405204721-8e2e1dafd3a0/go.mod h1:FVGavL/QEBQDcBpr3fAojoK17xX5k9bicBphrOpP7uM=
 github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
 github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go
index 8e1ab9e3..e90a785d 100644
--- a/infra/conf/transport_internet.go
+++ b/infra/conf/transport_internet.go
@@ -412,6 +412,8 @@ type TLSConfig struct {
 	MasterKeyLog                         string           `json:"masterKeyLog"`
 	ServerNameToVerify                   string           `json:"serverNameToVerify"`
 	VerifyPeerCertInNames                []string         `json:"verifyPeerCertInNames"`
+	ECHConfigList                        string           `json:"echConfigList"`
+	ECHServerKeys                        string           `json:"echServerKeys"`
 }
 
 // Build implements Buildable.
@@ -483,6 +485,16 @@ func (c *TLSConfig) Build() (proto.Message, error) {
 	}
 	config.VerifyPeerCertInNames = c.VerifyPeerCertInNames
 
+	config.EchConfigList = c.ECHConfigList
+
+	if c.ECHServerKeys != "" {
+		EchPrivateKey, err := base64.StdEncoding.DecodeString(c.ECHServerKeys)
+		if err != nil {
+			return nil, errors.New("invalid ECH Config", c.ECHServerKeys)
+		}
+		config.EchServerKeys = EchPrivateKey
+	}
+
 	return config, nil
 }
 
@@ -613,6 +625,9 @@ func (c *REALITYConfig) Build() (proto.Message, error) {
 		config.MaxTimeDiff = c.MaxTimeDiff
 
 		if c.Mldsa65Seed != "" {
+			if c.Mldsa65Seed == c.PrivateKey {
+				return nil, errors.New(`"mldsa65Seed" and "privateKey" can not be the same value: `, c.Mldsa65Seed)
+			}
 			if config.Mldsa65Seed, err = base64.RawURLEncoding.DecodeString(c.Mldsa65Seed); err != nil || len(config.Mldsa65Seed) != 32 {
 				return nil, errors.New(`invalid "mldsa65Seed": `, c.Mldsa65Seed)
 			}
diff --git a/main/commands/all/api/api.go b/main/commands/all/api/api.go
index 14a7c564..968213b6 100644
--- a/main/commands/all/api/api.go
+++ b/main/commands/all/api/api.go
@@ -23,6 +23,8 @@ var CmdAPI = &base.Command{
 		cmdRemoveOutbounds,
 		cmdListInbounds,
 		cmdListOutbounds,
+		cmdAddInboundUsers,
+		cmdRemoveInboundUsers,
 		cmdInboundUser,
 		cmdInboundUserCount,
 		cmdAddRules,
diff --git a/main/commands/all/api/inbound_user_add.go b/main/commands/all/api/inbound_user_add.go
new file mode 100644
index 00000000..9f9acf7e
--- /dev/null
+++ b/main/commands/all/api/inbound_user_add.go
@@ -0,0 +1,144 @@
+package api
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/xtls/xray-core/common/protocol"
+
+	handlerService "github.com/xtls/xray-core/app/proxyman/command"
+	cserial "github.com/xtls/xray-core/common/serial"
+
+	"github.com/xtls/xray-core/core"
+	"github.com/xtls/xray-core/infra/conf"
+	"github.com/xtls/xray-core/infra/conf/serial"
+	"github.com/xtls/xray-core/proxy/shadowsocks"
+	"github.com/xtls/xray-core/proxy/shadowsocks_2022"
+	"github.com/xtls/xray-core/proxy/trojan"
+	vlessin "github.com/xtls/xray-core/proxy/vless/inbound"
+	vmessin "github.com/xtls/xray-core/proxy/vmess/inbound"
+
+	"github.com/xtls/xray-core/main/commands/base"
+)
+
+var cmdAddInboundUsers = &base.Command{
+	CustomFlags: true,
+	UsageLine:   "{{.Exec}} api adu [--server=127.0.0.1:8080] <c1.json> [c2.json]...",
+	Short:       "Add users to inbounds",
+	Long: `
+Add users to inbounds.
+Arguments:
+	-s, -server
+		The API server address. Default 127.0.0.1:8080
+	-t, -timeout
+		Timeout seconds to call API. Default 3
+Example:
+    {{.Exec}} {{.LongName}} --server=127.0.0.1:8080  c1.json c2.json
+`,
+	Run: executeAddInboundUsers,
+}
+
+func executeAddInboundUsers(cmd *base.Command, args []string) {
+	setSharedFlags(cmd)
+	cmd.Flag.Parse(args)
+	unnamedArgs := cmd.Flag.Args()
+	inbs := extractInboundsConfig(unnamedArgs)
+
+	conn, ctx, close := dialAPIServer()
+	defer close()
+	client := handlerService.NewHandlerServiceClient(conn)
+
+	success := 0
+	for _, inb := range inbs {
+		success += executeInboundUserAction(ctx, client, inb, addInboundUserAction)
+	}
+	fmt.Println("Added", success, "user(s) in total.")
+}
+
+func addInboundUserAction(ctx context.Context, client handlerService.HandlerServiceClient, tag string, user *protocol.User) error {
+	fmt.Println("add user:", user.Email)
+	_, err := client.AlterInbound(ctx, &handlerService.AlterInboundRequest{
+		Tag: tag,
+		Operation: cserial.ToTypedMessage(
+			&handlerService.AddUserOperation{
+				User: user,
+			}),
+	})
+	return err
+}
+
+func extractInboundUsers(inb *core.InboundHandlerConfig) []*protocol.User {
+	if inb == nil {
+		return nil
+	}
+	inst, err := inb.ProxySettings.GetInstance()
+	if err != nil || inst == nil {
+		fmt.Println("failed to get inbound instance:", err)
+		return nil
+	}
+	switch ty := inst.(type) {
+	case *vmessin.Config:
+		return ty.User
+	case *vlessin.Config:
+		return ty.Clients
+	case *trojan.ServerConfig:
+		return ty.Users
+	case *shadowsocks.ServerConfig:
+		return ty.Users
+	case *shadowsocks_2022.MultiUserServerConfig:
+		return ty.Users
+	default:
+		fmt.Println("unsupported inbound type")
+	}
+	return nil
+}
+
+func extractInboundsConfig(unnamedArgs []string) []conf.InboundDetourConfig {
+	ins := make([]conf.InboundDetourConfig, 0)
+	for _, arg := range unnamedArgs {
+		r, err := loadArg(arg)
+		if err != nil {
+			base.Fatalf("failed to load %s: %s", arg, err)
+		}
+		conf, err := serial.DecodeJSONConfig(r)
+		if err != nil {
+			base.Fatalf("failed to decode %s: %s", arg, err)
+		}
+		ins = append(ins, conf.InboundConfigs...)
+	}
+	return ins
+}
+
+func executeInboundUserAction(ctx context.Context, client handlerService.HandlerServiceClient, inb conf.InboundDetourConfig, action func(ctx context.Context, client handlerService.HandlerServiceClient, tag string, user *protocol.User) error) int {
+	success := 0
+
+	tag := inb.Tag
+	if len(tag) < 1 {
+		return success
+	}
+
+	fmt.Println("processing inbound:", tag)
+	built, err := inb.Build()
+	if err != nil {
+		fmt.Println("failed to build config:", err)
+		return success
+	}
+
+	users := extractInboundUsers(built)
+	if users == nil {
+		return success
+	}
+
+	for _, user := range users {
+		if len(user.Email) < 1 {
+			continue
+		}
+		if err := action(ctx, client, inb.Tag, user); err == nil {
+			fmt.Println("result: ok")
+			success += 1
+		} else {
+			fmt.Println(err)
+		}
+	}
+	return success
+}
diff --git a/main/commands/all/api/inbound_user_remove.go b/main/commands/all/api/inbound_user_remove.go
new file mode 100644
index 00000000..d1261700
--- /dev/null
+++ b/main/commands/all/api/inbound_user_remove.go
@@ -0,0 +1,62 @@
+package api
+
+import (
+	"fmt"
+
+	handlerService "github.com/xtls/xray-core/app/proxyman/command"
+	cserial "github.com/xtls/xray-core/common/serial"
+
+	"github.com/xtls/xray-core/main/commands/base"
+)
+
+var cmdRemoveInboundUsers = &base.Command{
+	CustomFlags: true,
+	UsageLine:   "{{.Exec}} api rmu [--server=127.0.0.1:8080] -tag=tag <email1> [email2]...",
+	Short:       "Remove users from inbounds",
+	Long: `
+Remove users from inbounds.
+Arguments:
+	-s, -server
+		The API server address. Default 127.0.0.1:8080
+	-t, -timeout
+		Timeout seconds to call API. Default 3
+	-tag
+		Inbound tag
+Example:
+    {{.Exec}} {{.LongName}} --server=127.0.0.1:8080 -tag="vless-in" "xray@love.com" ...
+`,
+	Run: executeRemoveUsers,
+}
+
+func executeRemoveUsers(cmd *base.Command, args []string) {
+	setSharedFlags(cmd)
+	var tag string
+	cmd.Flag.StringVar(&tag, "tag", "", "")
+	cmd.Flag.Parse(args)
+	emails := cmd.Flag.Args()
+	if len(tag) < 1 {
+		base.Fatalf("inbound tag not specified")
+	}
+
+	conn, ctx, close := dialAPIServer()
+	defer close()
+	client := handlerService.NewHandlerServiceClient(conn)
+
+	success := 0
+	for _, email := range emails {
+		fmt.Println("remove user:", email)
+		_, err := client.AlterInbound(ctx, &handlerService.AlterInboundRequest{
+			Tag: tag,
+			Operation: cserial.ToTypedMessage(
+				&handlerService.RemoveUserOperation{
+					Email: email,
+				}),
+		})
+		if err == nil {
+			success += 1
+		} else {
+			fmt.Println(err)
+		}
+	}
+	fmt.Println("Removed", success, "user(s) in total.")
+}
diff --git a/main/commands/all/tls/ech.go b/main/commands/all/tls/ech.go
index d4e17f9b..18ecb602 100644
--- a/main/commands/all/tls/ech.go
+++ b/main/commands/all/tls/ech.go
@@ -1,25 +1,26 @@
 package tls
 
 import (
-	"encoding/json"
+	"encoding/base64"
 	"encoding/pem"
 	"os"
-	"strings"
 
-	"github.com/OmarTariq612/goech"
-	"github.com/cloudflare/circl/hpke"
+	"github.com/xtls/reality/hpke"
 	"github.com/xtls/xray-core/common"
 	"github.com/xtls/xray-core/main/commands/base"
+	"github.com/xtls/xray-core/transport/internet/tls"
+	"golang.org/x/crypto/cryptobyte"
 )
 
 var cmdECH = &base.Command{
-	UsageLine: `{{.Exec}} tls ech [--serverName (string)] [--json]`,
+	UsageLine: `{{.Exec}} tls ech [--serverName (string)] [--pem] [-i "ECHServerKeys (base64.StdEncoding)"]`,
 	Short:     `Generate TLS-ECH certificates`,
 	Long: `
 Generate TLS-ECH certificates.
 
 Set serverName to your custom string: {{.Exec}} tls ech --serverName (string)
-Generate into json format: {{.Exec}} tls ech --json
+Generate into pem format: {{.Exec}} tls ech --pem
+Restore ECHConfigs from ECHServerKeys: {{.Exec}} tls ech -i "ECHServerKeys (base64.StdEncoding)"
 `, // Enable PQ signature schemes: {{.Exec}} tls ech --pq-signature-schemes-enabled
 }
 
@@ -27,43 +28,66 @@ func init() {
 	cmdECH.Run = executeECH
 }
 
-var input_pqSignatureSchemesEnabled = cmdECH.Flag.Bool("pqSignatureSchemesEnabled", false, "")
+var input_echServerKeys = cmdECH.Flag.String("i", "", "ECHServerKeys (base64.StdEncoding)")
+
+// var input_pqSignatureSchemesEnabled = cmdECH.Flag.Bool("pqSignatureSchemesEnabled", false, "")
 var input_serverName = cmdECH.Flag.String("serverName", "cloudflare-ech.com", "")
-var input_json = cmdECH.Flag.Bool("json", false, "True == turn on json output")
+var input_pem = cmdECH.Flag.Bool("pem", false, "True == turn on pem output")
 
 func executeECH(cmd *base.Command, args []string) {
-	var kem hpke.KEM
+	var kem uint16
 
-	if *input_pqSignatureSchemesEnabled {
-		kem = hpke.KEM_X25519_KYBER768_DRAFT00
-	} else {
-		kem = hpke.KEM_X25519_HKDF_SHA256
-	}
+	// if *input_pqSignatureSchemesEnabled {
+	// 	kem = 0x30 // hpke.KEM_X25519_KYBER768_DRAFT00
+	// } else {
+	kem = hpke.DHKEM_X25519_HKDF_SHA256
+	// }
 
-	echKeySet, err := goech.GenerateECHKeySet(0, *input_serverName, kem)
+	echConfig, priv, err := tls.GenerateECHKeySet(0, *input_serverName, kem)
 	common.Must(err)
 
-	configBuffer, _ := echKeySet.ECHConfig.MarshalBinary()
-	keyBuffer, _ := echKeySet.MarshalBinary()
-
-	configPEM := string(pem.EncodeToMemory(&pem.Block{Type: "ECH CONFIGS", Bytes: configBuffer}))
-	keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "ECH KEYS", Bytes: keyBuffer}))
-	if *input_json {
-		jECHConfigs := map[string]interface{}{
-			"configs": strings.Split(strings.TrimSpace(string(configPEM)), "\n"),
+	var configBuffer, keyBuffer []byte
+	if *input_echServerKeys == "" {
+		configBytes, _ := tls.MarshalBinary(echConfig)
+		var b cryptobyte.Builder
+		b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
+			child.AddBytes(configBytes)
+		})
+		configBuffer, _ = b.Bytes()
+		var b2 cryptobyte.Builder
+		b2.AddUint16(uint16(len(priv)))
+		b2.AddBytes(priv)
+		b2.AddUint16(uint16(len(configBytes)))
+		b2.AddBytes(configBytes)
+		keyBuffer, _ = b2.Bytes()
+	} else {
+		keySetsByte, err := base64.StdEncoding.DecodeString(*input_echServerKeys)
+		if err != nil {
+			os.Stdout.WriteString("Failed to decode ECHServerKeys: " + err.Error() + "\n")
+			return
 		}
-		jECHKey := map[string]interface{}{
-			"key": strings.Split(strings.TrimSpace(string(keyPEM)), "\n"),
+		keyBuffer = keySetsByte
+		KeySets, err := tls.ConvertToGoECHKeys(keySetsByte)
+		if err != nil {
+			os.Stdout.WriteString("Failed to decode ECHServerKeys: " + err.Error() + "\n")
+			return
 		}
-
-		for _, i := range []map[string]interface{}{jECHConfigs, jECHKey} {
-			content, err := json.MarshalIndent(i, "", "  ")
-			common.Must(err)
-			os.Stdout.Write(content)
-			os.Stdout.WriteString("\n")
+		var b cryptobyte.Builder
+		for _, keySet := range KeySets {
+			b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
+				child.AddBytes(keySet.Config)
+			})
 		}
-	} else {
+		configBuffer, _ = b.Bytes()
+	}
+
+	if *input_pem {
+		configPEM := string(pem.EncodeToMemory(&pem.Block{Type: "ECH CONFIGS", Bytes: configBuffer}))
+		keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "ECH KEYS", Bytes: keyBuffer}))
 		os.Stdout.WriteString(configPEM)
 		os.Stdout.WriteString(keyPEM)
+	} else {
+		os.Stdout.WriteString("ECH config list: \n" + base64.StdEncoding.EncodeToString(configBuffer) + "\n")
+		os.Stdout.WriteString("ECH server keys: \n" + base64.StdEncoding.EncodeToString(keyBuffer) + "\n")
 	}
 }
diff --git a/transport/internet/tls/config.go b/transport/internet/tls/config.go
index 517bf40b..cb030776 100644
--- a/transport/internet/tls/config.go
+++ b/transport/internet/tls/config.go
@@ -444,6 +444,12 @@ func (c *Config) GetTLSConfig(opts ...Option) *tls.Config {
 			config.KeyLogWriter = writer
 		}
 	}
+	if len(c.EchConfigList) > 0 || len(c.EchServerKeys) > 0 {
+		err := ApplyECH(c, config)
+		if err != nil {
+			errors.LogError(context.Background(), err)
+		}
+	}
 
 	return config
 }
diff --git a/transport/internet/tls/config.pb.go b/transport/internet/tls/config.pb.go
index bc45dc4e..fc719c7b 100644
--- a/transport/internet/tls/config.pb.go
+++ b/transport/internet/tls/config.pb.go
@@ -217,6 +217,8 @@ type Config struct {
 	// @Document After allow_insecure (automatically), if the server's cert can't be verified by any of these names, pinned_peer_certificate_chain_sha256 will be tried.
 	// @Critical
 	VerifyPeerCertInNames []string `protobuf:"bytes,17,rep,name=verify_peer_cert_in_names,json=verifyPeerCertInNames,proto3" json:"verify_peer_cert_in_names,omitempty"`
+	EchConfigList         string   `protobuf:"bytes,18,opt,name=ech_config_list,json=echConfigList,proto3" json:"ech_config_list,omitempty"`
+	EchServerKeys         []byte   `protobuf:"bytes,19,opt,name=ech_server_keys,json=echServerKeys,proto3" json:"ech_server_keys,omitempty"`
 }
 
 func (x *Config) Reset() {
@@ -361,6 +363,20 @@ func (x *Config) GetVerifyPeerCertInNames() []string {
 	return nil
 }
 
+func (x *Config) GetEchConfigList() string {
+	if x != nil {
+		return x.EchConfigList
+	}
+	return ""
+}
+
+func (x *Config) GetEchServerKeys() []byte {
+	if x != nil {
+		return x.EchServerKeys
+	}
+	return nil
+}
+
 var File_transport_internet_tls_config_proto protoreflect.FileDescriptor
 
 var file_transport_internet_tls_config_proto_rawDesc = []byte{
@@ -392,7 +408,7 @@ var file_transport_internet_tls_config_proto_rawDesc = []byte{
 	0x4e, 0x43, 0x49, 0x50, 0x48, 0x45, 0x52, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x00, 0x12, 0x14, 0x0a,
 	0x10, 0x41, 0x55, 0x54, 0x48, 0x4f, 0x52, 0x49, 0x54, 0x59, 0x5f, 0x56, 0x45, 0x52, 0x49, 0x46,
 	0x59, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x41, 0x55, 0x54, 0x48, 0x4f, 0x52, 0x49, 0x54, 0x59,
-	0x5f, 0x49, 0x53, 0x53, 0x55, 0x45, 0x10, 0x02, 0x22, 0x9a, 0x06, 0x0a, 0x06, 0x43, 0x6f, 0x6e,
+	0x5f, 0x49, 0x53, 0x53, 0x55, 0x45, 0x10, 0x02, 0x22, 0xea, 0x06, 0x0a, 0x06, 0x43, 0x6f, 0x6e,
 	0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x69, 0x6e, 0x73,
 	0x65, 0x63, 0x75, 0x72, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61, 0x6c, 0x6c,
 	0x6f, 0x77, 0x49, 0x6e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x65, 0x12, 0x4a, 0x0a, 0x0b, 0x63, 0x65,
@@ -442,7 +458,12 @@ var file_transport_internet_tls_config_proto_rawDesc = []byte{
 	0x65, 0x72, 0x69, 0x66, 0x79, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x63, 0x65, 0x72, 0x74, 0x5f,
 	0x69, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15,
 	0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x65, 0x65, 0x72, 0x43, 0x65, 0x72, 0x74, 0x49, 0x6e,
-	0x4e, 0x61, 0x6d, 0x65, 0x73, 0x42, 0x73, 0x0a, 0x1f, 0x63, 0x6f, 0x6d, 0x2e, 0x78, 0x72, 0x61,
+	0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x65, 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d,
+	0x65, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x26, 0x0a,
+	0x0f, 0x65, 0x63, 0x68, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x6b, 0x65, 0x79, 0x73,
+	0x18, 0x13, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x65, 0x63, 0x68, 0x53, 0x65, 0x72, 0x76, 0x65,
+	0x72, 0x4b, 0x65, 0x79, 0x73, 0x42, 0x73, 0x0a, 0x1f, 0x63, 0x6f, 0x6d, 0x2e, 0x78, 0x72, 0x61,
 	0x79, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x69, 0x6e, 0x74, 0x65,
 	0x72, 0x6e, 0x65, 0x74, 0x2e, 0x74, 0x6c, 0x73, 0x50, 0x01, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68,
 	0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f, 0x78, 0x72, 0x61, 0x79,
diff --git a/transport/internet/tls/config.proto b/transport/internet/tls/config.proto
index 3fac25af..0e78651f 100644
--- a/transport/internet/tls/config.proto
+++ b/transport/internet/tls/config.proto
@@ -91,4 +91,8 @@ message Config {
      @Critical
   */
   repeated string verify_peer_cert_in_names = 17;
+
+  string ech_config_list = 18;
+
+  bytes ech_server_keys = 19;
 }
diff --git a/transport/internet/tls/ech.go b/transport/internet/tls/ech.go
new file mode 100644
index 00000000..982235db
--- /dev/null
+++ b/transport/internet/tls/ech.go
@@ -0,0 +1,367 @@
+package tls
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdh"
+	"crypto/rand"
+	"crypto/tls"
+	"encoding/base64"
+	"encoding/binary"
+	"io"
+	"net/http"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/miekg/dns"
+	"github.com/xtls/reality"
+	"github.com/xtls/reality/hpke"
+	"github.com/xtls/xray-core/common/errors"
+	"github.com/xtls/xray-core/common/net"
+	"github.com/xtls/xray-core/common/utils"
+	"github.com/xtls/xray-core/transport/internet"
+	"golang.org/x/crypto/cryptobyte"
+)
+
+func ApplyECH(c *Config, config *tls.Config) error {
+	var ECHConfig []byte
+	var err error
+
+	nameToQuery := c.ServerName
+	var DNSServer string
+
+	// for client
+	if len(c.EchConfigList) != 0 {
+		// direct base64 config
+		if strings.Contains(c.EchConfigList, "://") {
+			// query config from dns
+			parts := strings.Split(c.EchConfigList, "+")
+			if len(parts) == 2 {
+				// parse ECH DNS server in format of "example.com+https://1.1.1.1/dns-query"
+				nameToQuery = parts[0]
+				DNSServer = parts[1]
+			} else if len(parts) == 1 {
+				// normal format
+				DNSServer = parts[0]
+			} else {
+				return errors.New("Invalid ECH DNS server format: ", c.EchConfigList)
+			}
+			if nameToQuery == "" {
+				return errors.New("Using DNS for ECH Config needs serverName or use Server format example.com+https://1.1.1.1/dns-query")
+			}
+			ECHConfig, err = QueryRecord(nameToQuery, DNSServer)
+			if err != nil {
+				return err
+			}
+		} else {
+			ECHConfig, err = base64.StdEncoding.DecodeString(c.EchConfigList)
+			if err != nil {
+				return errors.New("Failed to unmarshal ECHConfigList: ", err)
+			}
+		}
+
+		config.EncryptedClientHelloConfigList = ECHConfig
+	}
+
+	// for server
+	if len(c.EchServerKeys) != 0 {
+		KeySets, err := ConvertToGoECHKeys(c.EchServerKeys)
+		if err != nil {
+			return errors.New("Failed to unmarshal ECHKeySetList: ", err)
+		}
+		config.EncryptedClientHelloKeys = KeySets
+	}
+
+	return nil
+}
+
+type ECHConfigCache struct {
+	configRecord atomic.Pointer[echConfigRecord]
+	// updateLock is not for preventing concurrent read/write, but for preventing concurrent update
+	UpdateLock sync.Mutex
+}
+
+type echConfigRecord struct {
+	config []byte
+	expire time.Time
+}
+
+var (
+	GlobalECHConfigCache = utils.NewTypedSyncMap[string, *ECHConfigCache]()
+	clientForECHDOH      = utils.NewTypedSyncMap[string, *http.Client]()
+)
+
+// Update updates the ECH config for given domain and server.
+// this method is concurrent safe, only one update request will be sent, others get the cache.
+// if isLockedUpdate is true, it will not try to acquire the lock.
+func (c *ECHConfigCache) Update(domain string, server string, isLockedUpdate bool) ([]byte, error) {
+	if !isLockedUpdate {
+		c.UpdateLock.Lock()
+		defer c.UpdateLock.Unlock()
+	}
+	// Double check cache after acquiring lock
+	configRecord := c.configRecord.Load()
+	if configRecord.expire.After(time.Now()) {
+		errors.LogDebug(context.Background(), "Cache hit for domain after double check: ", domain)
+		return configRecord.config, nil
+	}
+	// Query ECH config from DNS server
+	errors.LogDebug(context.Background(), "Trying to query ECH config for domain: ", domain, " with ECH server: ", server)
+	echConfig, ttl, err := dnsQuery(server, domain)
+	if err != nil {
+		return nil, err
+	}
+	configRecord = &echConfigRecord{
+		config: echConfig,
+		expire: time.Now().Add(time.Duration(ttl) * time.Second),
+	}
+	c.configRecord.Store(configRecord)
+	return configRecord.config, nil
+}
+
+// QueryRecord returns the ECH config for given domain.
+// If the record is not in cache or expired, it will query the DNS server and update the cache.
+func QueryRecord(domain string, server string) ([]byte, error) {
+	echConfigCache, ok := GlobalECHConfigCache.Load(domain)
+	if !ok {
+		echConfigCache = &ECHConfigCache{}
+		echConfigCache.configRecord.Store(&echConfigRecord{})
+		echConfigCache, _ = GlobalECHConfigCache.LoadOrStore(domain, echConfigCache)
+	}
+	configRecord := echConfigCache.configRecord.Load()
+	if configRecord.expire.After(time.Now()) {
+		errors.LogDebug(context.Background(), "Cache hit for domain: ", domain)
+		return configRecord.config, nil
+	}
+
+	// If expire is zero value, it means we are in initial state, wait for the query to finish
+	// otherwise return old value immediately and update in a goroutine
+	// but if the cache is too old, wait for update
+	if configRecord.expire == (time.Time{}) || configRecord.expire.Add(time.Hour*6).Before(time.Now()) {
+		return echConfigCache.Update(domain, server, false)
+	} else {
+		// If someone already acquired the lock, it means it is updating, do not start another update goroutine
+		if echConfigCache.UpdateLock.TryLock() {
+			go func() {
+				defer echConfigCache.UpdateLock.Unlock()
+				echConfigCache.Update(domain, server, true)
+			}()
+		}
+		return configRecord.config, nil
+	}
+}
+
+// dnsQuery is the real func for sending type65 query for given domain to given DNS server.
+// return ECH config, TTL and error
+func dnsQuery(server string, domain string) ([]byte, uint32, error) {
+	m := new(dns.Msg)
+	var dnsResolve []byte
+	m.SetQuestion(dns.Fqdn(domain), dns.TypeHTTPS)
+	// for DOH server
+	if strings.HasPrefix(server, "https://") {
+		// always 0 in DOH
+		m.Id = 0
+		msg, err := m.Pack()
+		if err != nil {
+			return []byte{}, 0, err
+		}
+		var client *http.Client
+		if client, _ = clientForECHDOH.Load(server); client == nil {
+			// All traffic sent by core should via xray's internet.DialSystem
+			// This involves the behavior of some Android VPN GUI clients
+			tr := &http.Transport{
+				IdleConnTimeout:   90 * time.Second,
+				ForceAttemptHTTP2: true,
+				DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+					dest, err := net.ParseDestination(network + ":" + addr)
+					if err != nil {
+						return nil, err
+					}
+					conn, err := internet.DialSystem(ctx, dest, nil)
+					if err != nil {
+						return nil, err
+					}
+					return conn, nil
+				},
+			}
+			c := &http.Client{
+				Timeout:   5 * time.Second,
+				Transport: tr,
+			}
+			client, _ = clientForECHDOH.LoadOrStore(server, c)
+		}
+		req, err := http.NewRequest("POST", server, bytes.NewReader(msg))
+		if err != nil {
+			return []byte{}, 0, err
+		}
+		req.Header.Set("Content-Type", "application/dns-message")
+		resp, err := client.Do(req)
+		if err != nil {
+			return []byte{}, 0, err
+		}
+		defer resp.Body.Close()
+		respBody, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return []byte{}, 0, err
+		}
+		if resp.StatusCode != http.StatusOK {
+			return []byte{}, 0, errors.New("query failed with response code:", resp.StatusCode)
+		}
+		dnsResolve = respBody
+	} else if strings.HasPrefix(server, "udp://") { // for classic udp dns server
+		udpServerAddr := server[len("udp://"):]
+		// default port 53 if not specified
+		if !strings.Contains(udpServerAddr, ":") {
+			udpServerAddr = udpServerAddr + ":53"
+		}
+		dest, err := net.ParseDestination("udp" + ":" + udpServerAddr)
+		if err != nil {
+			return nil, 0, errors.New("failed to parse udp dns server ", udpServerAddr, " for ECH: ", err)
+		}
+		dnsTimeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		// use xray's internet.DialSystem as mentioned above
+		conn, err := internet.DialSystem(dnsTimeoutCtx, dest, nil)
+		defer func() {
+			err := conn.Close()
+			if err != nil {
+				errors.LogDebug(context.Background(), "Failed to close connection: ", err)
+			}
+		}()
+		if err != nil {
+			return []byte{}, 0, err
+		}
+		msg, err := m.Pack()
+		if err != nil {
+			return []byte{}, 0, err
+		}
+		conn.Write(msg)
+		udpResponse := make([]byte, 512)
+		_, err = conn.Read(udpResponse)
+		if err != nil {
+			return []byte{}, 0, err
+		}
+		dnsResolve = udpResponse
+	}
+	respMsg := new(dns.Msg)
+	err := respMsg.Unpack(dnsResolve)
+	if err != nil {
+		return []byte{}, 0, errors.New("failed to unpack dns response for ECH: ", err)
+	}
+	if len(respMsg.Answer) > 0 {
+		for _, answer := range respMsg.Answer {
+			if https, ok := answer.(*dns.HTTPS); ok && https.Hdr.Name == dns.Fqdn(domain) {
+				for _, v := range https.Value {
+					if echConfig, ok := v.(*dns.SVCBECHConfig); ok {
+						errors.LogDebug(context.Background(), "Get ECH config:", echConfig.String(), " TTL:", respMsg.Answer[0].Header().Ttl)
+						return echConfig.ECH, answer.Header().Ttl, nil
+					}
+				}
+			}
+		}
+	}
+	return []byte{}, 0, errors.New("no ech record found")
+}
+
+// reference github.com/OmarTariq612/goech
+func MarshalBinary(ech reality.EchConfig) ([]byte, error) {
+	var b cryptobyte.Builder
+	b.AddUint16(ech.Version)
+	b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
+		child.AddUint8(ech.ConfigID)
+		child.AddUint16(ech.KemID)
+		child.AddUint16(uint16(len(ech.PublicKey)))
+		child.AddBytes(ech.PublicKey)
+		child.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
+			for _, cipherSuite := range ech.SymmetricCipherSuite {
+				child.AddUint16(cipherSuite.KDFID)
+				child.AddUint16(cipherSuite.AEADID)
+			}
+		})
+		child.AddUint8(ech.MaxNameLength)
+		child.AddUint8(uint8(len(ech.PublicName)))
+		child.AddBytes(ech.PublicName)
+		child.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
+			for _, extention := range ech.Extensions {
+				child.AddUint16(extention.Type)
+				child.AddBytes(extention.Data)
+			}
+		})
+	})
+	return b.Bytes()
+}
+
+var ErrInvalidLen = errors.New("goech: invalid length")
+
+func ConvertToGoECHKeys(data []byte) ([]tls.EncryptedClientHelloKey, error) {
+	var keys []tls.EncryptedClientHelloKey
+	s := cryptobyte.String(data)
+	for !s.Empty() {
+		if len(s) < 2 {
+			return keys, ErrInvalidLen
+		}
+		keyLength := int(binary.BigEndian.Uint16(s[:2]))
+		if len(s) < keyLength+4 {
+			return keys, ErrInvalidLen
+		}
+		configLength := int(binary.BigEndian.Uint16(s[keyLength+2 : keyLength+4]))
+		if len(s) < 2+keyLength+2+configLength {
+			return keys, ErrInvalidLen
+		}
+		child := cryptobyte.String(s[:2+keyLength+2+configLength])
+		var (
+			sk, config cryptobyte.String
+		)
+		if !child.ReadUint16LengthPrefixed(&sk) || !child.ReadUint16LengthPrefixed(&config) || !child.Empty() {
+			return keys, ErrInvalidLen
+		}
+		if !s.Skip(2 + keyLength + 2 + configLength) {
+			return keys, ErrInvalidLen
+		}
+		keys = append(keys, tls.EncryptedClientHelloKey{
+			Config:     config,
+			PrivateKey: sk,
+		})
+	}
+	return keys, nil
+}
+
+const ExtensionEncryptedClientHello = 0xfe0d
+const KDF_HKDF_SHA384 = 0x0002
+const KDF_HKDF_SHA512 = 0x0003
+
+func GenerateECHKeySet(configID uint8, domain string, kem uint16) (reality.EchConfig, []byte, error) {
+	config := reality.EchConfig{
+		Version:    ExtensionEncryptedClientHello,
+		ConfigID:   configID,
+		PublicName: []byte(domain),
+		KemID:      kem,
+		SymmetricCipherSuite: []reality.EchCipher{
+			{KDFID: hpke.KDF_HKDF_SHA256, AEADID: hpke.AEAD_AES_128_GCM},
+			{KDFID: hpke.KDF_HKDF_SHA256, AEADID: hpke.AEAD_AES_256_GCM},
+			{KDFID: hpke.KDF_HKDF_SHA256, AEADID: hpke.AEAD_ChaCha20Poly1305},
+			{KDFID: KDF_HKDF_SHA384, AEADID: hpke.AEAD_AES_128_GCM},
+			{KDFID: KDF_HKDF_SHA384, AEADID: hpke.AEAD_AES_256_GCM},
+			{KDFID: KDF_HKDF_SHA384, AEADID: hpke.AEAD_ChaCha20Poly1305},
+			{KDFID: KDF_HKDF_SHA512, AEADID: hpke.AEAD_AES_128_GCM},
+			{KDFID: KDF_HKDF_SHA512, AEADID: hpke.AEAD_AES_256_GCM},
+			{KDFID: KDF_HKDF_SHA512, AEADID: hpke.AEAD_ChaCha20Poly1305},
+		},
+		MaxNameLength: 0,
+		Extensions:    nil,
+	}
+	// if kem == hpke.DHKEM_X25519_HKDF_SHA256 {
+	curve := ecdh.X25519()
+	priv := make([]byte, 32) //x25519
+	_, err := io.ReadFull(rand.Reader, priv)
+	if err != nil {
+		return config, nil, err
+	}
+	privKey, _ := curve.NewPrivateKey(priv)
+	config.PublicKey = privKey.PublicKey().Bytes()
+	return config, priv, nil
+	// }
+	// TODO: add mlkem768 (former kyber768 draft00). The golang mlkem private key is 64 bytes seed?
+}
diff --git a/transport/internet/tls/tls.go b/transport/internet/tls/tls.go
index 28c7bf63..20b9716d 100644
--- a/transport/internet/tls/tls.go
+++ b/transport/internet/tls/tls.go
@@ -128,12 +128,13 @@ func UClient(c net.Conn, config *tls.Config, fingerprint *utls.ClientHelloID) ne
 
 func copyConfig(c *tls.Config) *utls.Config {
 	return &utls.Config{
-		Rand:                  c.Rand,
-		RootCAs:               c.RootCAs,
-		ServerName:            c.ServerName,
-		InsecureSkipVerify:    c.InsecureSkipVerify,
-		VerifyPeerCertificate: c.VerifyPeerCertificate,
-		KeyLogWriter:          c.KeyLogWriter,
+		Rand:                           c.Rand,
+		RootCAs:                        c.RootCAs,
+		ServerName:                     c.ServerName,
+		InsecureSkipVerify:             c.InsecureSkipVerify,
+		VerifyPeerCertificate:          c.VerifyPeerCertificate,
+		KeyLogWriter:                   c.KeyLogWriter,
+		EncryptedClientHelloConfigList: c.EncryptedClientHelloConfigList,
 	}
 }
 



Test patch:
diff --git a/transport/internet/tls/ech_test.go b/transport/internet/tls/ech_test.go
new file mode 100644
index 00000000..f0bb3c56
--- /dev/null
+++ b/transport/internet/tls/ech_test.go
@@ -0,0 +1,43 @@
+package tls_test
+
+import (
+	"io"
+	"net/http"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/xtls/xray-core/common"
+	. "github.com/xtls/xray-core/transport/internet/tls"
+)
+
+func TestECHDial(t *testing.T) {
+	config := &Config{
+		ServerName:    "encryptedsni.com",
+		EchConfigList: "udp://1.1.1.1",
+	}
+	// test concurrent Dial(to test cache problem)
+	wg := sync.WaitGroup{}
+	for range 10 {
+		wg.Add(1)
+		go func() {
+			TLSConfig := config.GetTLSConfig()
+			TLSConfig.NextProtos = []string{"http/1.1"}
+			client := &http.Client{
+				Transport: &http.Transport{
+					TLSClientConfig: TLSConfig,
+				},
+			}
+			resp, err := client.Get("https://encryptedsni.com/cdn-cgi/trace")
+			common.Must(err)
+			defer resp.Body.Close()
+			body, err := io.ReadAll(resp.Body)
+			common.Must(err)
+			if !strings.Contains(string(body), "sni=encrypted") {
+				t.Error("ECH Dial success but SNI is not encrypted")
+			}
+			wg.Done()
+		}()
+	}
+	wg.Wait()
+}