From fb7a9d8d616bf5cec722ea092dfc1d43433519fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Sat, 26 Jul 2025 16:47:27 +0800 Subject: [PATCH] TLS client & server: Support Encrypted Client Hello (ECH) (#3813) https://github.com/XTLS/Xray-docs-next/commit/b9a72a4a26490cda4c915a857a78ce08ec273fe7 --------- Co-authored-by: yuhan6665 <1588741+yuhan6665@users.noreply.github.com> --- go.mod | 1 - go.sum | 2 - infra/conf/transport_internet.go | 12 + main/commands/all/tls/ech.go | 92 ++++--- transport/internet/tls/config.go | 6 + transport/internet/tls/config.pb.go | 25 +- transport/internet/tls/config.proto | 4 + transport/internet/tls/ech.go | 367 ++++++++++++++++++++++++++++ transport/internet/tls/ech_test.go | 43 ++++ transport/internet/tls/tls.go | 13 +- 10 files changed, 520 insertions(+), 45 deletions(-) create mode 100644 transport/internet/tls/ech.go create mode 100644 transport/internet/tls/ech_test.go 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 d72abd11..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 } 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"), - } - jECHKey := map[string]interface{}{ - "key": strings.Split(strings.TrimSpace(string(keyPEM)), "\n"), - } - - 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 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 + } + keyBuffer = keySetsByte + KeySets, err := tls.ConvertToGoECHKeys(keySetsByte) + if err != nil { + os.Stdout.WriteString("Failed to decode ECHServerKeys: " + err.Error() + "\n") + return + } + var b cryptobyte.Builder + for _, keySet := range KeySets { + b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { + child.AddBytes(keySet.Config) + }) + } + 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/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() +} 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, } }