diff --git a/client/pkg/tlsutil/versions.go b/client/pkg/tlsutil/versions.go new file mode 100644 index 000000000000..e18bb69d8c74 --- /dev/null +++ b/client/pkg/tlsutil/versions.go @@ -0,0 +1,36 @@ +// Copyright 2023 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tlsutil + +import ( + "crypto/tls" + "fmt" +) + +// tlsVersions is a map of TLS version string to the value of tls.Config.Min/MaxVersion. +var tlsVersions = map[string]uint16{ + "": 0, // If version was not given use 0 (uninitialized version) to let Go decide. + "TLS12": tls.VersionTLS12, + "TLS13": tls.VersionTLS13, +} + +// GetTLSVersion returns the corresponding TLS version. +func GetTLSVersion(version string) (uint16, error) { + v, ok := tlsVersions[version] + if !ok { + return 0, fmt.Errorf("unexpected TLS version %q", version) + } + return v, nil +} diff --git a/client/pkg/tlsutil/versions_test.go b/client/pkg/tlsutil/versions_test.go new file mode 100644 index 000000000000..dffbe9e226ff --- /dev/null +++ b/client/pkg/tlsutil/versions_test.go @@ -0,0 +1,46 @@ +// Copyright 2023 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tlsutil + +import ( + "crypto/tls" + "testing" +) + +func TestGetVersion_success(t *testing.T) { + versions := map[string]uint16{ + "": 0, + "TLS12": tls.VersionTLS12, + "TLS13": tls.VersionTLS13, + } + + // Iterate versions + for version, expected := range versions { + got, err := GetTLSVersion(version) + if err != nil { + t.Fatal(err) + } + if got != expected { + t.Fatalf("Got unexpected TLS version expected=%v got=%v", expected, got) + } + } +} + +func TestGetVersion_not_existing(t *testing.T) { + _, err := GetTLSVersion("not_existing") + if err == nil { + t.Fatal("Expected error") + } +} diff --git a/client/pkg/transport/listener.go b/client/pkg/transport/listener.go index 398cbc5596e9..5e0e13e25a73 100644 --- a/client/pkg/transport/listener.go +++ b/client/pkg/transport/listener.go @@ -166,6 +166,14 @@ type TLSInfo struct { // Note that cipher suites are prioritized in the given order. CipherSuites []uint16 + // MinVersion is the minimum TLS version that is acceptable. + // If not set, the minimum version is TLS 1.2. + MinVersion uint16 + + // MaxVersion is the maximum TLS version that is acceptable. + // If not set, the default used by Go is selected (see tls.Config.MaxVersion). + MaxVersion uint16 + selfCert bool // parseFunc exists to simplify testing. Typically, parseFunc @@ -380,8 +388,17 @@ func (info TLSInfo) baseConfig() (*tls.Config, error) { } } + var minVersion uint16 + if info.MinVersion != 0 { + minVersion = info.MinVersion + } else { + // Default minimum version is TLS 1.2, previous versions are insecure and deprecated. + minVersion = tls.VersionTLS12 + } + cfg := &tls.Config{ - MinVersion: tls.VersionTLS12, + MinVersion: minVersion, + MaxVersion: info.MaxVersion, ServerName: info.ServerName, } @@ -512,11 +529,6 @@ func (info TLSInfo) ServerConfig() (*tls.Config, error) { // "h2" NextProtos is necessary for enabling HTTP2 for go's HTTP server cfg.NextProtos = []string{"h2"} - // go1.13 enables TLS 1.3 by default - // and in TLS 1.3, cipher suites are not configurable - // setting Max TLS version to TLS 1.2 for go 1.13 - cfg.MaxVersion = tls.VersionTLS12 - return cfg, nil } @@ -571,11 +583,6 @@ func (info TLSInfo) ClientConfig() (*tls.Config, error) { } } - // go1.13 enables TLS 1.3 by default - // and in TLS 1.3, cipher suites are not configurable - // setting Max TLS version to TLS 1.2 for go 1.13 - cfg.MaxVersion = tls.VersionTLS12 - return cfg, nil } diff --git a/server/embed/config.go b/server/embed/config.go index 2c5637742929..0b6ad665c3d2 100644 --- a/server/embed/config.go +++ b/server/embed/config.go @@ -226,6 +226,11 @@ type Config struct { // Note that cipher suites are prioritized in the given order. CipherSuites []string `json:"cipher-suites"` + // TlsMinVersion is the minimum accepted TLS version between client/server and peers. + TlsMinVersion string `json:"tls-min-version"` + // TlsMaxVersion is the maximum accepted TLS version between client/server and peers. + TlsMaxVersion string `json:"tls-max-version"` + ClusterState string `json:"initial-cluster-state"` DNSCluster string `json:"discovery-srv"` DNSClusterServiceName string `json:"discovery-srv-name"` @@ -660,6 +665,27 @@ func updateCipherSuites(tls *transport.TLSInfo, ss []string) error { return nil } +func updateMinMaxVersions(info *transport.TLSInfo, min, max string) error { + minVersion, err := tlsutil.GetTLSVersion(min) + if err != nil { + return err + } + + maxVersion, err := tlsutil.GetTLSVersion(max) + if err != nil { + return err + } + + // Check if both min and max were provided and min is greater than max + if minVersion != 0 && maxVersion != 0 && minVersion > maxVersion { + return fmt.Errorf("min version (%s) is greater than max version (%s)", min, max) + } + + info.MinVersion = minVersion + info.MaxVersion = maxVersion + return nil +} + // Validate ensures that '*embed.Config' fields are properly configured. func (cfg *Config) Validate() error { if err := cfg.setupLogging(); err != nil { diff --git a/server/embed/config_test.go b/server/embed/config_test.go index 06e465c5fb94..55c497953d70 100644 --- a/server/embed/config_test.go +++ b/server/embed/config_test.go @@ -15,6 +15,7 @@ package embed import ( + "crypto/tls" "errors" "fmt" "net" @@ -429,3 +430,80 @@ func TestLogRotation(t *testing.T) { }) } } + +func TestTLSVersionMinMax(t *testing.T) { + tests := []struct { + name string + config Config + expectError bool + expectedMinTLSVersion uint16 + expectedMaxTLSVersion uint16 + }{ + { + name: "Minimum TLS version is set", + config: Config{ + TlsMinVersion: "TLS13", + }, + expectedMinTLSVersion: tls.VersionTLS13, + expectedMaxTLSVersion: 0, + }, + { + name: "Maximum TLS version is set", + config: Config{ + TlsMaxVersion: "TLS12", + }, + expectedMinTLSVersion: 0, + expectedMaxTLSVersion: tls.VersionTLS12, + }, + { + name: "Minimum and Maximum TLS versions are set", + config: Config{ + TlsMinVersion: "TLS13", + TlsMaxVersion: "TLS13", + }, + expectedMinTLSVersion: tls.VersionTLS13, + expectedMaxTLSVersion: tls.VersionTLS13, + }, + { + name: "Minimum and Maximum TLS versions are set in reverse order", + config: Config{ + TlsMinVersion: "TLS13", + TlsMaxVersion: "TLS12", + }, + expectError: true, + }, + { + name: "Invalid minimum TLS version", + config: Config{ + TlsMinVersion: "invalid version", + }, + expectError: true, + }, + { + name: "Invalid maximum TLS version", + config: Config{ + TlsMaxVersion: "invalid version", + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := updateMinMaxVersions(&tt.config.PeerTLSInfo, tt.config.TlsMinVersion, tt.config.TlsMaxVersion) + if (err != nil) != tt.expectError { + t.Errorf("updateMinMaxVersions() = %q, expected error: %v", err, tt.expectError) + } + + err = updateMinMaxVersions(&tt.config.ClientTLSInfo, tt.config.TlsMinVersion, tt.config.TlsMaxVersion) + if (err != nil) != tt.expectError { + t.Errorf("updateMinMaxVersions() = %q, expected error: %v", err, tt.expectError) + } + + assert.Equal(t, tt.expectedMinTLSVersion, tt.config.PeerTLSInfo.MinVersion) + assert.Equal(t, tt.expectedMaxTLSVersion, tt.config.PeerTLSInfo.MaxVersion) + assert.Equal(t, tt.expectedMinTLSVersion, tt.config.ClientTLSInfo.MinVersion) + assert.Equal(t, tt.expectedMaxTLSVersion, tt.config.ClientTLSInfo.MaxVersion) + }) + } +} diff --git a/server/embed/etcd.go b/server/embed/etcd.go index e3863ee62bc9..0932f07a6532 100644 --- a/server/embed/etcd.go +++ b/server/embed/etcd.go @@ -499,6 +499,9 @@ func configurePeerListeners(cfg *Config) (peers []*peerListener, err error) { if err = cfg.PeerSelfCert(); err != nil { cfg.logger.Fatal("failed to get peer self-signed certs", zap.Error(err)) } + if err = updateMinMaxVersions(&cfg.PeerTLSInfo, cfg.TlsMinVersion, cfg.TlsMaxVersion); err != nil { + return nil, err + } if !cfg.PeerTLSInfo.Empty() { cfg.logger.Info( "starting with peer TLS", @@ -611,6 +614,9 @@ func configureClientListeners(cfg *Config) (sctxs map[string]*serveCtx, err erro if err = cfg.ClientSelfCert(); err != nil { cfg.logger.Fatal("failed to get client self-signed certs", zap.Error(err)) } + if err = updateMinMaxVersions(&cfg.ClientTLSInfo, cfg.TlsMinVersion, cfg.TlsMaxVersion); err != nil { + return nil, err + } if cfg.EnablePprof { cfg.logger.Info("pprof is enabled", zap.String("path", debugutil.HTTPPrefixPProf)) } diff --git a/server/etcdmain/config.go b/server/etcdmain/config.go index 9ad7b1b23beb..e2897e352693 100644 --- a/server/etcdmain/config.go +++ b/server/etcdmain/config.go @@ -215,6 +215,8 @@ func newConfig() *config { fs.StringVar(&cfg.ec.PeerTLSInfo.AllowedHostname, "peer-cert-allowed-hostname", "", "Allowed TLS hostname for inter peer authentication.") fs.Var(flags.NewStringsValue(""), "cipher-suites", "Comma-separated list of supported TLS cipher suites between client/server and peers (empty will be auto-populated by Go).") fs.BoolVar(&cfg.ec.PeerTLSInfo.SkipClientSANVerify, "experimental-peer-skip-client-san-verification", false, "Skip verification of SAN field in client certificate for peer connections.") + fs.StringVar(&cfg.ec.TlsMinVersion, "tls-min-version", "TLS12", "Minimum TLS version supported by etcd. Possible values: TLS12, TLS13.") + fs.StringVar(&cfg.ec.TlsMaxVersion, "tls-max-version", "", "Maximum TLS version supported by etcd. Possible values: TLS12, TLS13 (empty will be auto-populated by Go).") fs.Var( flags.NewUniqueURLsWithExceptions("*", "*"), diff --git a/server/etcdmain/help.go b/server/etcdmain/help.go index 61e8d278c5b8..edcc0001672c 100644 --- a/server/etcdmain/help.go +++ b/server/etcdmain/help.go @@ -199,6 +199,10 @@ Security: Comma-separated whitelist of origins for CORS, or cross-origin resource sharing, (empty or * means allow all). --host-whitelist '*' Acceptable hostnames from HTTP client requests, if server is not secure (empty or * means allow all). + --tls-min-version 'TLS12' + Minimum TLS version supported for client and peer connections. Possible values: TLS12, TLS13. + --tls-max-version '' + Maximum TLS version supported for client ane peer connections. Possible values: TLS12, TLS13 (empty will be auto-populated by Go). Auth: --auth-token 'simple' diff --git a/tests/integration/v3_tls_test.go b/tests/integration/v3_tls_test.go index 318466d0c150..89429ed25a1a 100644 --- a/tests/integration/v3_tls_test.go +++ b/tests/integration/v3_tls_test.go @@ -49,6 +49,12 @@ func testTLSCipherSuites(t *testing.T, valid bool) { srvTLS.CipherSuites, cliTLS.CipherSuites = cipherSuites[:2], cipherSuites[2:] } + // go1.13 enables TLS 1.3 by default + // and in TLS 1.3, cipher suites are not configurable, + // so setting Max TLS version to TLS 1.2 to test cipher config. + srvTLS.MaxVersion = tls.VersionTLS12 + cliTLS.MaxVersion = tls.VersionTLS12 + clus := integration.NewCluster(t, &integration.ClusterConfig{Size: 1, ClientTLS: &srvTLS}) defer clus.Terminate(t) @@ -72,3 +78,69 @@ func testTLSCipherSuites(t *testing.T, valid bool) { t.Fatalf("expected TLS handshake success, got %v", cerr) } } + +func TestTLSMinMaxVersion(t *testing.T) { + integration.BeforeTest(t) + + tests := []struct { + name string + minVersion uint16 + maxVersion uint16 + expectError bool + }{ + { + name: "Connect with default TLS version should succeed", + minVersion: 0, + maxVersion: 0, + }, + { + name: "Connect with TLS 1.2 only should fail", + minVersion: tls.VersionTLS12, + maxVersion: tls.VersionTLS12, + expectError: true, + }, + { + name: "Connect with TLS 1.2 and 1.3 should succeed", + minVersion: tls.VersionTLS12, + maxVersion: tls.VersionTLS13, + }, + { + name: "Connect with TLS 1.3 only should succeed", + minVersion: tls.VersionTLS13, + maxVersion: tls.VersionTLS13, + }, + } + + // Configure server to support TLS 1.3 only. + srvTLS := integration.TestTLSInfo + srvTLS.MinVersion = tls.VersionTLS13 + srvTLS.MaxVersion = tls.VersionTLS13 + clus := integration.NewCluster(t, &integration.ClusterConfig{Size: 1, ClientTLS: &srvTLS}) + defer clus.Terminate(t) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cc, err := integration.TestTLSInfo.ClientConfig() + if err != nil { + t.Fatal(err) + } + cc.MinVersion = tt.minVersion + cc.MaxVersion = tt.maxVersion + cli, cerr := integration.NewClient(t, clientv3.Config{ + Endpoints: []string{clus.Members[0].GRPCURL()}, + DialTimeout: time.Second, + DialOptions: []grpc.DialOption{grpc.WithBlock()}, + TLS: cc, + }) + if cli != nil { + cli.Close() + } + if tt.expectError && cerr != context.DeadlineExceeded { + t.Fatalf("expected %v with TLS handshake failure, got %v", context.DeadlineExceeded, cerr) + } + if (cerr != nil) != tt.expectError { + t.Fatalf("expected TLS handshake success, got %v", cerr) + } + }) + } +}