diff --git a/go.mod b/go.mod index c7047ed0..f3076379 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/xtls/xray-core go 1.24 require ( - github.com/OmarTariq612/goech v0.0.0-20240405204721-8e2e1dafd3a0 + github.com/OmarTariq612/goech v0.0.1 github.com/cloudflare/circl v1.6.0 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 d4df8932..eb12f731 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -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/OmarTariq612/goech v0.0.1 h1:/0c918Bk1ik65GXDj2k7SOK78DyZr30Jmq9euy1/HXg= +github.com/OmarTariq612/goech v0.0.1/go.mod h1:FVGavL/QEBQDcBpr3fAojoK17xX5k9bicBphrOpP7uM= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index e32be326..ab788755 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"` + EchKeySets string `json:"echKeySets"` } // Build implements Buildable. @@ -483,6 +485,16 @@ func (c *TLSConfig) Build() (proto.Message, error) { } config.VerifyPeerCertInNames = c.VerifyPeerCertInNames + config.EchConfigList = c.ECHConfigList + + if c.EchKeySets != "" { + EchPrivateKey, err := base64.StdEncoding.DecodeString(c.EchKeySets) + if err != nil { + return nil, errors.New("invalid ECH Config", c.EchKeySets) + } + config.EchKeySets = EchPrivateKey + } + return config, nil } diff --git a/main/commands/all/tls/ech.go b/main/commands/all/tls/ech.go index d4e17f9b..701cf937 100644 --- a/main/commands/all/tls/ech.go +++ b/main/commands/all/tls/ech.go @@ -1,10 +1,8 @@ package tls import ( - "encoding/json" "encoding/pem" "os" - "strings" "github.com/OmarTariq612/goech" "github.com/cloudflare/circl/hpke" @@ -13,13 +11,13 @@ import ( ) var cmdECH = &base.Command{ - UsageLine: `{{.Exec}} tls ech [--serverName (string)] [--json]`, + UsageLine: `{{.Exec}} tls ech [--serverName (string)] [--pem]`, 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 `, // Enable PQ signature schemes: {{.Exec}} tls ech --pq-signature-schemes-enabled } @@ -29,7 +27,7 @@ func init() { 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 @@ -40,30 +38,26 @@ func executeECH(cmd *base.Command, args []string) { kem = hpke.KEM_X25519_HKDF_SHA256 } - echKeySet, err := goech.GenerateECHKeySet(0, *input_serverName, kem) + echKeySet, err := goech.GenerateECHKeySet(0, *input_serverName, kem, nil) common.Must(err) - configBuffer, _ := echKeySet.ECHConfig.MarshalBinary() - keyBuffer, _ := echKeySet.MarshalBinary() + // Make single key set to a list with only one element + ECHConfigList := make(goech.ECHConfigList, 1) + ECHConfigList[0] = echKeySet.ECHConfig + ECHKeySetList := make(goech.ECHKeySetList, 1) + ECHKeySetList[0] = echKeySet + configBuffer, _ := ECHConfigList.MarshalBinary() + keyBuffer, _ := ECHKeySetList.MarshalBinary() + configStr, _ := ECHConfigList.ToBase64() + keySetStr, _ := ECHKeySetList.ToBase64() 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") - } - } else { + if *input_pem { os.Stdout.WriteString(configPEM) os.Stdout.WriteString(keyPEM) + } else { + os.Stdout.WriteString("ECH config list: \n" + configStr + "\n") + os.Stdout.WriteString("ECH Key sets: \n" + keySetStr + "\n") } } diff --git a/transport/internet/tls/config.go b/transport/internet/tls/config.go index d6701a7d..501f29b5 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.EchKeySets) > 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..064c795a 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"` + EchKeySets []byte `protobuf:"bytes,19,opt,name=ech_key_sets,json=echKeySets,proto3" json:"ech_key_sets,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) GetEchKeySets() []byte { + if x != nil { + return x.EchKeySets + } + 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, 0xe4, 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,15 +458,19 @@ 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, - 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, - 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2f, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x65, 0x74, 0x2f, 0x74, 0x6c, 0x73, 0xaa, 0x02, 0x1b, 0x58, - 0x72, 0x61, 0x79, 0x2e, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x49, 0x6e, - 0x74, 0x65, 0x72, 0x6e, 0x65, 0x74, 0x2e, 0x54, 0x6c, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 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, 0x20, 0x0a, + 0x0c, 0x65, 0x63, 0x68, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x73, 0x65, 0x74, 0x73, 0x18, 0x13, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x65, 0x63, 0x68, 0x4b, 0x65, 0x79, 0x53, 0x65, 0x74, 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, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, + 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, + 0x65, 0x74, 0x2f, 0x74, 0x6c, 0x73, 0xaa, 0x02, 0x1b, 0x58, 0x72, 0x61, 0x79, 0x2e, 0x54, 0x72, + 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x65, 0x74, + 0x2e, 0x54, 0x6c, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/transport/internet/tls/config.proto b/transport/internet/tls/config.proto index 3fac25af..3e032183 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_key_sets = 19; } diff --git a/transport/internet/tls/ech.go b/transport/internet/tls/ech.go new file mode 100644 index 00000000..498199ff --- /dev/null +++ b/transport/internet/tls/ech.go @@ -0,0 +1,239 @@ +package tls + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/OmarTariq612/goech" + "github.com/miekg/dns" + "github.com/xtls/xray-core/common/errors" + "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/transport/internet" +) + +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.HasPrefix(c.EchConfigList, "base64") { + Base64ECHConfigList := c.EchConfigList[len("base64://"):] + ECHConfigList, err := goech.ECHConfigListFromBase64(Base64ECHConfigList) + if err != nil { + return errors.New("Failed to unmarshal ECHConfigList: ", err) + } + ECHConfig, _ = ECHConfigList.MarshalBinary() + } else { // 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 + } + } + + config.EncryptedClientHelloConfigList = ECHConfig + } + + // for server + if len(c.EchKeySets) != 0 { + var keys []tls.EncryptedClientHelloKey + KeySets, err := goech.UnmarshalECHKeySetList(c.EchKeySets) + if err != nil { + return errors.New("Failed to unmarshal ECHKeySetList: ", err) + } + for idx, keySet := range KeySets { + ECHConfig, err := keySet.ECHConfig.MarshalBinary() + ECHPrivateKey, err := keySet.PrivateKey.MarshalBinary() + if err != nil { + return errors.New("Failed to marshal ECHKey in index: ", idx, "with err: ", err) + } + keys = append(keys, tls.EncryptedClientHelloKey{ + Config: ECHConfig, + PrivateKey: ECHPrivateKey}) + } + config.EncryptedClientHelloKeys = keys + } + + return nil +} + +type record struct { + echConfig []byte + expire time.Time +} + +var ( + dnsCache sync.Map + // global Lock? I'm not sure if this needs finer grained locks. + // If we do this, we will need to nest another layer of struct + updating sync.Mutex +) + +// 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) { + val, found := dnsCache.Load(domain) + rec, _ := val.(record) + if found && rec.expire.After(time.Now()) { + errors.LogDebug(context.Background(), "Cache hit for domain: ", domain) + return rec.echConfig, nil + } + + updating.Lock() + defer updating.Unlock() + // Try to get cache again after lock, in case another goroutine has updated it + // This might happen when the core tring is just stared and multiple goroutines are trying to query the same domain + val, found = dnsCache.Load(domain) + rec, _ = val.(record) + if found && rec.expire.After(time.Now()) { + errors.LogDebug(context.Background(), "ECH Config cache hit for domain: ", domain, " after trying to get update lock") + return rec.echConfig, 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 []byte{}, err + } + + // Set minimum TTL to 600 seconds + if ttl < 600 { + ttl = 600 + } + + // Update cache + newRecored := record{ + echConfig: echConfig, + expire: time.Now().Add(time.Second * time.Duration(ttl)), + } + dnsCache.Store(domain, newRecored) + return echConfig, 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 + } + // 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 + }, + } + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: tr, + } + 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 conn.Close() + 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") +}