From 3ad4370fa59acb859208aa7fd461e0d5dbc1ee8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 9 Sep 2022 18:19:50 +0800 Subject: [PATCH] Add ECH TLS client --- common/tls/client.go | 6 +- common/tls/ech_client.go | 188 ++++++++++++++++++++ go.mod | 1 + go.sum | 6 + option/tls.go | 28 +-- transport/cloudflaretls/common.go | 2 + transport/cloudflaretls/ech.go | 27 ++- transport/cloudflaretls/handshake_client.go | 2 +- 8 files changed, 242 insertions(+), 18 deletions(-) create mode 100644 common/tls/ech_client.go diff --git a/common/tls/client.go b/common/tls/client.go index c5dc4cb1..d24f1d6f 100644 --- a/common/tls/client.go +++ b/common/tls/client.go @@ -21,7 +21,11 @@ func NewDialerFromOptions(router adapter.Router, dialer N.Dialer, serverAddress } func NewClient(router adapter.Router, serverAddress string, options option.OutboundTLSOptions) (Config, error) { - return newStdClient(serverAddress, options) + if options.ECH != nil && options.ECH.Enabled { + return newECHClient(router, serverAddress, options) + } else { + return newStdClient(serverAddress, options) + } } func ClientHandshake(ctx context.Context, conn net.Conn, config Config) (net.Conn, error) { diff --git a/common/tls/ech_client.go b/common/tls/ech_client.go new file mode 100644 index 00000000..07320287 --- /dev/null +++ b/common/tls/ech_client.go @@ -0,0 +1,188 @@ +//go:build with_ech + +package tls + +import ( + "context" + "crypto/x509" + "encoding/base64" + "net" + "net/netip" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/cloudflaretls" + "github.com/sagernet/sing-dns" + E "github.com/sagernet/sing/common/exceptions" + + mDNS "github.com/miekg/dns" + "golang.org/x/net/dns/dnsmessage" +) + +type echClientConfig struct { + config *tls.Config +} + +func (e *echClientConfig) Config() (*STDConfig, error) { + return nil, E.New("unsupported usage for ECH") +} + +func (e *echClientConfig) Client(conn net.Conn) Conn { + return tls.Client(conn, e.config) +} + +func newECHClient(router adapter.Router, serverAddress string, options option.OutboundTLSOptions) (Config, error) { + var serverName string + if options.ServerName != "" { + serverName = options.ServerName + } else if serverAddress != "" { + if _, err := netip.ParseAddr(serverName); err != nil { + serverName = serverAddress + } + } + if serverName == "" && !options.Insecure { + return nil, E.New("missing server_name or insecure=true") + } + + var tlsConfig tls.Config + if options.DisableSNI { + tlsConfig.ServerName = "127.0.0.1" + } else { + tlsConfig.ServerName = serverName + } + if options.Insecure { + tlsConfig.InsecureSkipVerify = options.Insecure + } else if options.DisableSNI { + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyConnection = func(state tls.ConnectionState) error { + verifyOptions := x509.VerifyOptions{ + DNSName: serverName, + Intermediates: x509.NewCertPool(), + } + for _, cert := range state.PeerCertificates[1:] { + verifyOptions.Intermediates.AddCert(cert) + } + _, err := state.PeerCertificates[0].Verify(verifyOptions) + return err + } + } + if len(options.ALPN) > 0 { + tlsConfig.NextProtos = options.ALPN + } + if options.MinVersion != "" { + minVersion, err := ParseTLSVersion(options.MinVersion) + if err != nil { + return nil, E.Cause(err, "parse min_version") + } + tlsConfig.MinVersion = minVersion + } + if options.MaxVersion != "" { + maxVersion, err := ParseTLSVersion(options.MaxVersion) + if err != nil { + return nil, E.Cause(err, "parse max_version") + } + tlsConfig.MaxVersion = maxVersion + } + if options.CipherSuites != nil { + find: + for _, cipherSuite := range options.CipherSuites { + for _, tlsCipherSuite := range tls.CipherSuites() { + if cipherSuite == tlsCipherSuite.Name { + tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, tlsCipherSuite.ID) + continue find + } + } + return nil, E.New("unknown cipher_suite: ", cipherSuite) + } + } + var certificate []byte + if options.Certificate != "" { + certificate = []byte(options.Certificate) + } else if options.CertificatePath != "" { + content, err := os.ReadFile(options.CertificatePath) + if err != nil { + return nil, E.Cause(err, "read certificate") + } + certificate = content + } + if len(certificate) > 0 { + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(certificate) { + return nil, E.New("failed to parse certificate:\n\n", certificate) + } + tlsConfig.RootCAs = certPool + } + + // ECH Config + + tlsConfig.ECHEnabled = true + tlsConfig.PQSignatureSchemesEnabled = options.ECH.PQSignatureSchemesEnabled + tlsConfig.DynamicRecordSizingDisabled = options.ECH.DynamicRecordSizingDisabled + if options.ECH.Config != "" { + clientConfigContent, err := base64.StdEncoding.DecodeString(options.ECH.Config) + if err != nil { + return nil, err + } + clientConfig, err := tls.UnmarshalECHConfigs(clientConfigContent) + if err != nil { + return nil, err + } + tlsConfig.ClientECHConfigs = clientConfig + } else { + tlsConfig.GetClientECHConfigs = fetchECHClientConfig(router) + } + return &echClientConfig{&tlsConfig}, nil +} + +const typeHTTPS = 65 + +func fetchECHClientConfig(router adapter.Router) func(ctx context.Context, serverName string) ([]tls.ECHConfig, error) { + return func(ctx context.Context, serverName string) ([]tls.ECHConfig, error) { + message := &dnsmessage.Message{ + Header: dnsmessage.Header{ + RecursionDesired: true, + }, + Questions: []dnsmessage.Question{ + { + Name: dnsmessage.MustNewName(serverName + "."), + Type: typeHTTPS, + Class: dnsmessage.ClassINET, + }, + }, + } + response, err := router.Exchange(ctx, message) + if err != nil { + return nil, err + } + if response.RCode != dnsmessage.RCodeSuccess { + return nil, dns.RCodeError(response.RCode) + } + content, err := response.Pack() + if err != nil { + return nil, err + } + var mMsg mDNS.Msg + err = mMsg.Unpack(content) + if err != nil { + return nil, err + } + for _, rr := range mMsg.Answer { + switch resource := rr.(type) { + case *mDNS.HTTPS: + for _, value := range resource.Value { + if value.Key().String() == "ech" { + echConfig, err := base64.StdEncoding.DecodeString(value.String()) + if err != nil { + return nil, E.Cause(err, "decode ECH config") + } + return tls.UnmarshalECHConfigs(echConfig) + } + } + default: + return nil, E.New("unknown resource record type: ", resource.Header().Rrtype) + } + } + return nil, E.New("no ECH config found") + } +} diff --git a/go.mod b/go.mod index e95d1267..4299506d 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/hashicorp/yamux v0.1.1 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mholt/acmez v1.0.4 + github.com/miekg/dns v1.1.50 github.com/oschwald/maxminddb-golang v1.10.0 github.com/pires/go-proxyproto v0.6.2 github.com/sagernet/certmagic v0.0.0-20220819042630-4a57f8b6853a diff --git a/go.sum b/go.sum index a8b9e7f8..9d3c0f9a 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/marten-seemann/qtls-go1-19 v0.1.0 h1:rLFKD/9mp/uq1SYGYuVZhm83wkmU95pK github.com/marten-seemann/qtls-go1-19 v0.1.0/go.mod h1:5HTDWtVudo/WFsHKRNuOhWlbdjrfs5JHrYb0wIJqGpI= github.com/mholt/acmez v1.0.4 h1:N3cE4Pek+dSolbsofIkAYz6H1d3pE+2G0os7QHslf80= github.com/mholt/acmez v1.0.4/go.mod h1:qFGLZ4u+ehWINeJZjzPlsnjJBCPAADWTcIqE/7DAYQY= +github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= +github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -215,6 +217,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI= golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= @@ -225,6 +228,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -245,6 +249,7 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -273,6 +278,7 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.11-0.20220513221640-090b14e8501f h1:OKYpQQVE3DKSc3r3zHVzq46vq5YH7x8xpR3/k9ixmUg= golang.org/x/tools v0.1.11-0.20220513221640-090b14e8501f/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/option/tls.go b/option/tls.go index d5b9d46e..105dead0 100644 --- a/option/tls.go +++ b/option/tls.go @@ -15,14 +15,22 @@ type InboundTLSOptions struct { } type OutboundTLSOptions struct { - Enabled bool `json:"enabled,omitempty"` - DisableSNI bool `json:"disable_sni,omitempty"` - ServerName string `json:"server_name,omitempty"` - Insecure bool `json:"insecure,omitempty"` - ALPN Listable[string] `json:"alpn,omitempty"` - MinVersion string `json:"min_version,omitempty"` - MaxVersion string `json:"max_version,omitempty"` - CipherSuites Listable[string] `json:"cipher_suites,omitempty"` - Certificate string `json:"certificate,omitempty"` - CertificatePath string `json:"certificate_path,omitempty"` + Enabled bool `json:"enabled,omitempty"` + DisableSNI bool `json:"disable_sni,omitempty"` + ServerName string `json:"server_name,omitempty"` + Insecure bool `json:"insecure,omitempty"` + ALPN Listable[string] `json:"alpn,omitempty"` + MinVersion string `json:"min_version,omitempty"` + MaxVersion string `json:"max_version,omitempty"` + CipherSuites Listable[string] `json:"cipher_suites,omitempty"` + Certificate string `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + ECH *OutboundECHOptions `json:"ech,omitempty"` +} + +type OutboundECHOptions struct { + Enabled bool `json:"enabled,omitempty"` + PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty"` + DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty"` + Config string `json:"config,omitempty"` } diff --git a/transport/cloudflaretls/common.go b/transport/cloudflaretls/common.go index e8229c25..09d023f5 100644 --- a/transport/cloudflaretls/common.go +++ b/transport/cloudflaretls/common.go @@ -834,6 +834,8 @@ type Config struct { // Otherwise, if ECH is enabled, it will send a dummy ECH extension. ClientECHConfigs []ECHConfig + GetClientECHConfigs func(ctx context.Context, serverName string) ([]ECHConfig, error) + // ServerECHProvider is the ECH provider used by the client-facing server // for the ECH extension. If the client offers ECH and TLS 1.3 is // negotiated, then the provider is used to compute the HPKE context diff --git a/transport/cloudflaretls/ech.go b/transport/cloudflaretls/ech.go index 131089ac..42c5f53c 100644 --- a/transport/cloudflaretls/ech.go +++ b/transport/cloudflaretls/ech.go @@ -4,6 +4,7 @@ package tls import ( + "context" "errors" "fmt" "io" @@ -37,7 +38,7 @@ var zeros = [8]byte{} // // TODO(cjpatton): "[When offering ECH, the client] MUST NOT offer to resume any // session for TLS 1.2 and below [in ClientHelloInner]." -func (c *Conn) echOfferOrGrease(helloBase *clientHelloMsg) (hello, helloInner *clientHelloMsg, err error) { +func (c *Conn) echOfferOrGrease(ctx context.Context, helloBase *clientHelloMsg) (hello, helloInner *clientHelloMsg, err error) { config := c.config if !config.ECHEnabled || testingECHTriggerBypassBeforeHRR { @@ -47,7 +48,10 @@ func (c *Conn) echOfferOrGrease(helloBase *clientHelloMsg) (hello, helloInner *c // Choose the ECHConfig to use for this connection. If none is available, or // if we're not offering TLS 1.3 or above, then GREASE. - echConfig := config.echSelectConfig() + echConfig, err := config.echSelectConfig(ctx, helloBase.serverName) + if err != nil { + return nil, nil, fmt.Errorf("tls: ech: fetch ech config: %s", err) + } if echConfig == nil || config.maxSupportedVersion(roleClient) < VersionTLS13 { var err error @@ -1008,14 +1012,26 @@ func splitClientHelloExtensions(data []byte) ([]byte, []byte) { // // TODO(cjpatton): Implement ECH config extensions as described in // draft-ietf-tls-esni-13, Section 4.1. -func (c *Config) echSelectConfig() *ECHConfig { +func (c *Config) echSelectConfig(ctx context.Context, serverName string) (*ECHConfig, error) { for _, echConfig := range c.ClientECHConfigs { if _, err := echConfig.selectSuite(); err == nil && echConfig.version == extensionECH { - return &echConfig + return &echConfig, nil } } - return nil + if c.GetClientECHConfigs != nil { + echConfigs, err := c.GetClientECHConfigs(ctx, serverName) + if err != nil { + return nil, err + } + for _, echConfig := range echConfigs { + if _, err = echConfig.selectSuite(); err == nil && + echConfig.version == extensionECH { + return &echConfig, nil + } + } + } + return nil, nil } func (c *Config) echCanOffer() bool { @@ -1023,7 +1039,6 @@ func (c *Config) echCanOffer() bool { return false } return c.ECHEnabled && - c.echSelectConfig() != nil && c.maxSupportedVersion(roleClient) >= VersionTLS13 } diff --git a/transport/cloudflaretls/handshake_client.go b/transport/cloudflaretls/handshake_client.go index 77940291..d79824d2 100644 --- a/transport/cloudflaretls/handshake_client.go +++ b/transport/cloudflaretls/handshake_client.go @@ -187,7 +187,7 @@ func (c *Conn) clientHandshake(ctx context.Context) (err error) { return err } - hello, helloInner, err := c.echOfferOrGrease(helloBase) + hello, helloInner, err := c.echOfferOrGrease(ctx, helloBase) if err != nil { return err }