فهرست منبع

TLS client & server: Support Encrypted Client Hello (ECH) (#3813)

https://github.com/XTLS/Xray-docs-next/commit/b9a72a4a26490cda4c915a857a78ce08ec273fe7

---------

Co-authored-by: yuhan6665 <[email protected]>
风扇滑翔翼 6 ماه پیش
والد
کامیت
fb7a9d8d61

+ 0 - 1
go.mod

@@ -3,7 +3,6 @@ module github.com/xtls/xray-core
 go 1.24
 go 1.24
 
 
 require (
 require (
-	github.com/OmarTariq612/goech v0.0.0-20240405204721-8e2e1dafd3a0
 	github.com/cloudflare/circl v1.6.1
 	github.com/cloudflare/circl v1.6.1
 	github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344
 	github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344
 	github.com/golang/mock v1.7.0-rc.1
 	github.com/golang/mock v1.7.0-rc.1

+ 0 - 2
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 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
 github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
 github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=

+ 12 - 0
infra/conf/transport_internet.go

@@ -412,6 +412,8 @@ type TLSConfig struct {
 	MasterKeyLog                         string           `json:"masterKeyLog"`
 	MasterKeyLog                         string           `json:"masterKeyLog"`
 	ServerNameToVerify                   string           `json:"serverNameToVerify"`
 	ServerNameToVerify                   string           `json:"serverNameToVerify"`
 	VerifyPeerCertInNames                []string         `json:"verifyPeerCertInNames"`
 	VerifyPeerCertInNames                []string         `json:"verifyPeerCertInNames"`
+	ECHConfigList                        string           `json:"echConfigList"`
+	ECHServerKeys                        string           `json:"echServerKeys"`
 }
 }
 
 
 // Build implements Buildable.
 // Build implements Buildable.
@@ -483,6 +485,16 @@ func (c *TLSConfig) Build() (proto.Message, error) {
 	}
 	}
 	config.VerifyPeerCertInNames = c.VerifyPeerCertInNames
 	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
 	return config, nil
 }
 }
 
 

+ 56 - 32
main/commands/all/tls/ech.go

@@ -1,25 +1,26 @@
 package tls
 package tls
 
 
 import (
 import (
-	"encoding/json"
+	"encoding/base64"
 	"encoding/pem"
 	"encoding/pem"
 	"os"
 	"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/common"
 	"github.com/xtls/xray-core/main/commands/base"
 	"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{
 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`,
 	Short:     `Generate TLS-ECH certificates`,
 	Long: `
 	Long: `
 Generate TLS-ECH certificates.
 Generate TLS-ECH certificates.
 
 
 Set serverName to your custom string: {{.Exec}} tls ech --serverName (string)
 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
 `, // Enable PQ signature schemes: {{.Exec}} tls ech --pq-signature-schemes-enabled
 }
 }
 
 
@@ -27,43 +28,66 @@ func init() {
 	cmdECH.Run = executeECH
 	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_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) {
 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)
 	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(configPEM)
 		os.Stdout.WriteString(keyPEM)
 		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")
 	}
 	}
 }
 }

+ 6 - 0
transport/internet/tls/config.go

@@ -444,6 +444,12 @@ func (c *Config) GetTLSConfig(opts ...Option) *tls.Config {
 			config.KeyLogWriter = writer
 			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
 	return config
 }
 }

+ 23 - 2
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.
 	// @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
 	// @Critical
 	VerifyPeerCertInNames []string `protobuf:"bytes,17,rep,name=verify_peer_cert_in_names,json=verifyPeerCertInNames,proto3" json:"verify_peer_cert_in_names,omitempty"`
 	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() {
 func (x *Config) Reset() {
@@ -361,6 +363,20 @@ func (x *Config) GetVerifyPeerCertInNames() []string {
 	return nil
 	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 protoreflect.FileDescriptor
 
 
 var file_transport_internet_tls_config_proto_rawDesc = []byte{
 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,
 	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,
 	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,
 	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,
 	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,
 	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,
 	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,
 	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,
 	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,
 	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,
 	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,
 	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,
 	0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f, 0x78, 0x72, 0x61, 0x79,

+ 4 - 0
transport/internet/tls/config.proto

@@ -91,4 +91,8 @@ message Config {
      @Critical
      @Critical
   */
   */
   repeated string verify_peer_cert_in_names = 17;
   repeated string verify_peer_cert_in_names = 17;
+
+  string ech_config_list = 18;
+
+  bytes ech_server_keys = 19;
 }
 }

+ 367 - 0
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?
+}

+ 43 - 0
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()
+}

+ 7 - 6
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 {
 func copyConfig(c *tls.Config) *utls.Config {
 	return &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,
 	}
 	}
 }
 }