diff --git a/embed/config.go b/embed/config.go index 7135274ec655..0b7c2f49b0ed 100644 --- a/embed/config.go +++ b/embed/config.go @@ -195,6 +195,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"` @@ -575,6 +580,17 @@ func updateCipherSuites(tls *transport.TLSInfo, ss []string) error { return nil } +func updateMinMaxVersions(info *transport.TLSInfo, min, max string) { + // Validate() has been called to check the user input, so it should never fail. + var err error + if info.MinVersion, err = tlsutil.GetTLSVersion(min); err != nil { + panic(err) + } + if info.MaxVersion, err = tlsutil.GetTLSVersion(max); err != nil { + panic(err) + } +} + // Validate ensures that '*embed.Config' fields are properly configured. func (cfg *Config) Validate() error { if err := cfg.setupLogging(); err != nil { @@ -646,6 +662,25 @@ func (cfg *Config) Validate() error { return fmt.Errorf("setting experimental-enable-lease-checkpoint-persist requires experimental-enable-lease-checkpoint") } + minVersion, err := tlsutil.GetTLSVersion(cfg.TlsMinVersion) + if err != nil { + return err + } + maxVersion, err := tlsutil.GetTLSVersion(cfg.TlsMaxVersion) + if err != nil { + return err + } + + // maxVersion == 0 means that Go selects the highest available version. + if maxVersion != 0 && minVersion > maxVersion { + return fmt.Errorf("min version (%s) is greater than max version (%s)", cfg.TlsMinVersion, cfg.TlsMaxVersion) + } + + // Check if user attempted to configure ciphers for TLS1.3 only: Go does not support that currently. + if minVersion == tls.VersionTLS13 && len(cfg.CipherSuites) > 0 { + return fmt.Errorf("cipher suites cannot be configured when only TLS1.3 is enabled") + } + return nil } diff --git a/embed/config_test.go b/embed/config_test.go index bb94fec0aee6..8a06521b0bcf 100644 --- a/embed/config_test.go +++ b/embed/config_test.go @@ -15,6 +15,7 @@ package embed import ( + "crypto/tls" "fmt" "io/ioutil" "net/url" @@ -22,6 +23,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "go.etcd.io/etcd/pkg/transport" "sigs.k8s.io/yaml" @@ -202,3 +204,80 @@ func TestAutoCompactionModeParse(t *testing.T) { } } } + +func TestTLSVersionMinMax(t *testing.T) { + tests := []struct { + name string + givenTLSMinVersion string + givenTLSMaxVersion string + givenCipherSuites []string + expectError bool + expectedMinTLSVersion uint16 + expectedMaxTLSVersion uint16 + }{ + { + name: "Minimum TLS version is set", + givenTLSMinVersion: "TLS1.3", + expectedMinTLSVersion: tls.VersionTLS13, + expectedMaxTLSVersion: 0, + }, + { + name: "Maximum TLS version is set", + givenTLSMaxVersion: "TLS1.2", + expectedMinTLSVersion: 0, + expectedMaxTLSVersion: tls.VersionTLS12, + }, + { + name: "Minimum and Maximum TLS versions are set", + givenTLSMinVersion: "TLS1.3", + givenTLSMaxVersion: "TLS1.3", + expectedMinTLSVersion: tls.VersionTLS13, + expectedMaxTLSVersion: tls.VersionTLS13, + }, + { + name: "Minimum and Maximum TLS versions are set in reverse order", + givenTLSMinVersion: "TLS1.3", + givenTLSMaxVersion: "TLS1.2", + expectError: true, + }, + { + name: "Invalid minimum TLS version", + givenTLSMinVersion: "invalid version", + expectError: true, + }, + { + name: "Invalid maximum TLS version", + givenTLSMaxVersion: "invalid version", + expectError: true, + }, + { + name: "Cipher suites configured for TLS 1.3", + givenTLSMinVersion: "TLS1.3", + givenCipherSuites: []string{"TLS_AES_128_GCM_SHA256"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewConfig() + cfg.TlsMinVersion = tt.givenTLSMinVersion + cfg.TlsMaxVersion = tt.givenTLSMaxVersion + cfg.CipherSuites = tt.givenCipherSuites + + err := cfg.Validate() + if err != nil { + assert.True(t, tt.expectError, "Validate() returned error while expecting success: %v", err) + return + } + + updateMinMaxVersions(&cfg.PeerTLSInfo, cfg.TlsMinVersion, cfg.TlsMaxVersion) + updateMinMaxVersions(&cfg.ClientTLSInfo, cfg.TlsMinVersion, cfg.TlsMaxVersion) + + assert.Equal(t, tt.expectedMinTLSVersion, cfg.PeerTLSInfo.MinVersion) + assert.Equal(t, tt.expectedMaxTLSVersion, cfg.PeerTLSInfo.MaxVersion) + assert.Equal(t, tt.expectedMinTLSVersion, cfg.ClientTLSInfo.MinVersion) + assert.Equal(t, tt.expectedMaxTLSVersion, cfg.ClientTLSInfo.MaxVersion) + }) + } +} diff --git a/embed/etcd.go b/embed/etcd.go index 03830ebe7977..5a863866761a 100644 --- a/embed/etcd.go +++ b/embed/etcd.go @@ -471,6 +471,9 @@ func configurePeerListeners(cfg *Config) (peers []*peerListener, err error) { plog.Fatalf("could not get certs (%v)", err) } } + + updateMinMaxVersions(&cfg.PeerTLSInfo, cfg.TlsMinVersion, cfg.TlsMaxVersion) + if !cfg.PeerTLSInfo.Empty() { if cfg.logger != nil { cfg.logger.Info( @@ -608,6 +611,9 @@ func configureClientListeners(cfg *Config) (sctxs map[string]*serveCtx, err erro plog.Fatalf("could not get certs (%v)", err) } } + + updateMinMaxVersions(&cfg.ClientTLSInfo, cfg.TlsMinVersion, cfg.TlsMaxVersion) + if cfg.EnablePprof { if cfg.logger != nil { cfg.logger.Info("pprof is enabled", zap.String("path", debugutil.HTTPPrefixPProf)) diff --git a/etcdmain/config.go b/etcdmain/config.go index 209ec89663db..d6f0e18ecc86 100644 --- a/etcdmain/config.go +++ b/etcdmain/config.go @@ -29,6 +29,7 @@ import ( "go.etcd.io/etcd/embed" "go.etcd.io/etcd/pkg/flags" "go.etcd.io/etcd/pkg/logutil" + "go.etcd.io/etcd/pkg/tlsutil" "go.etcd.io/etcd/pkg/types" "go.etcd.io/etcd/version" @@ -216,6 +217,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", string(tlsutil.TLSVersion12), "Minimum TLS version supported by etcd. Possible values: TLS1.2, TLS1.3.") + fs.StringVar(&cfg.ec.TlsMaxVersion, "tls-max-version", string(tlsutil.TLSVersionDefault), "Maximum TLS version supported by etcd. Possible values: TLS1.2, TLS1.3 (empty defers to Go).") fs.Var( flags.NewUniqueURLsWithExceptions("*", "*"), diff --git a/etcdmain/help.go b/etcdmain/help.go index 4e0069123b21..d3bab31e0a3d 100644 --- a/etcdmain/help.go +++ b/etcdmain/help.go @@ -158,6 +158,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 'TLS1.2' + Minimum TLS version supported by etcd. Possible values: TLS1.2, TLS1.3. + --tls-max-version '' + Maximum TLS version supported by etcd. Possible values: TLS1.2, TLS1.3 (empty will be auto-populated by Go). Auth: --auth-token 'simple' diff --git a/integration/v3_tls_test.go b/integration/v3_tls_test.go index 7699d44ea33c..e5301e19323d 100644 --- a/integration/v3_tls_test.go +++ b/integration/v3_tls_test.go @@ -20,6 +20,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "go.etcd.io/etcd/clientv3" "go.etcd.io/etcd/pkg/testutil" @@ -49,6 +50,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 := NewClusterV3(t, &ClusterConfig{Size: 1, ClientTLS: &srvTLS}) defer clus.Terminate(t) @@ -75,3 +82,65 @@ func testTLSCipherSuites(t *testing.T, valid bool) { t.Fatalf("expected TLS handshake success, got %v", cerr) } } + +func TestTLSMinMaxVersion(t *testing.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 := testTLSInfo + srvTLS.MinVersion = tls.VersionTLS13 + srvTLS.MaxVersion = tls.VersionTLS13 + clus := NewClusterV3(t, &ClusterConfig{Size: 1, ClientTLS: &srvTLS}) + defer clus.Terminate(t) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cc, err := testTLSInfo.ClientConfig() + assert.NoError(t, err) + + cc.MinVersion = tt.minVersion + cc.MaxVersion = tt.maxVersion + cli, cerr := clientv3.New(clientv3.Config{ + Endpoints: []string{clus.Members[0].GRPCAddr()}, + DialTimeout: time.Second, + DialOptions: []grpc.DialOption{grpc.WithBlock()}, + TLS: cc, + }) + if cerr != nil { + assert.True(t, tt.expectError, "got TLS handshake error while expecting success: %v", cerr) + assert.Equal(t, context.DeadlineExceeded, cerr, "expected %v with TLS handshake failure, got %v", context.DeadlineExceeded, cerr) + return + } + + cli.Close() + }) + } +} diff --git a/pkg/tlsutil/versions.go b/pkg/tlsutil/versions.go new file mode 100644 index 000000000000..ffcecd8c670f --- /dev/null +++ b/pkg/tlsutil/versions.go @@ -0,0 +1,47 @@ +// 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" +) + +type TLSVersion string + +// Constants for TLS versions. +const ( + TLSVersionDefault TLSVersion = "" + TLSVersion12 TLSVersion = "TLS1.2" + TLSVersion13 TLSVersion = "TLS1.3" +) + +// GetTLSVersion returns the corresponding tls.Version or error. +func GetTLSVersion(version string) (uint16, error) { + var v uint16 + + switch version { + case string(TLSVersionDefault): + v = 0 // 0 means let Go decide. + case string(TLSVersion12): + v = tls.VersionTLS12 + case string(TLSVersion13): + v = tls.VersionTLS13 + default: + return 0, fmt.Errorf("unexpected TLS version %q (must be one of: TLS1.2, TLS1.3)", version) + } + + return v, nil +} diff --git a/pkg/tlsutil/versions_test.go b/pkg/tlsutil/versions_test.go new file mode 100644 index 000000000000..89c7c3f64b7d --- /dev/null +++ b/pkg/tlsutil/versions_test.go @@ -0,0 +1,63 @@ +// 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" + + "github.com/stretchr/testify/assert" +) + +func TestGetVersion(t *testing.T) { + tests := []struct { + name string + version string + want uint16 + expectError bool + }{ + { + name: "TLS1.2", + version: "TLS1.2", + want: tls.VersionTLS12, + }, + { + name: "TLS1.3", + version: "TLS1.3", + want: tls.VersionTLS13, + }, + { + name: "Empty version", + version: "", + want: 0, + }, + { + name: "Converting invalid version string to TLS version", + version: "not_existing", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetTLSVersion(tt.version) + if err != nil { + assert.True(t, tt.expectError, "GetTLSVersion() returned error while expecting success: %v", err) + return + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/transport/listener.go b/pkg/transport/listener.go index ec6cd9e8580e..7bc6b5ad0739 100644 --- a/pkg/transport/listener.go +++ b/pkg/transport/listener.go @@ -84,6 +84,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 @@ -263,8 +271,17 @@ func (info TLSInfo) baseConfig() (*tls.Config, error) { return nil, err } + 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, } diff --git a/tests/e2e/etcd_config_test.go b/tests/e2e/etcd_config_test.go index 1d57e6067870..82e32f4f9a3b 100644 --- a/tests/e2e/etcd_config_test.go +++ b/tests/e2e/etcd_config_test.go @@ -21,6 +21,7 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" "go.etcd.io/etcd/pkg/expect" ) @@ -336,3 +337,32 @@ func TestGrpcproxyAndListenCipherSuite(t *testing.T) { }) } } + +func TestEtcdTLSVersion(t *testing.T) { + skipInShortMode(t) + + d := t.TempDir() + proc, err := spawnCmd( + []string{ + binDir + "/etcd", + "--data-dir", d, + "--name", "e1", + "--listen-client-urls", "https://0.0.0.0:0", + "--advertise-client-urls", "https://0.0.0.0:0", + "--listen-peer-urls", fmt.Sprintf("https://127.0.0.1:%d", etcdProcessBasePort), + "--initial-advertise-peer-urls", fmt.Sprintf("https://127.0.0.1:%d", etcdProcessBasePort), + "--initial-cluster", fmt.Sprintf("e1=https://127.0.0.1:%d", etcdProcessBasePort), + "--peer-cert-file", certPath, + "--peer-key-file", privateKeyPath, + "--cert-file", certPath2, + "--key-file", privateKeyPath2, + + "--tls-min-version", "TLS1.2", + "--tls-max-version", "TLS1.3", + }, nil, + ) + assert.NoError(t, err) + assert.NoError(t, waitReadyExpectProc(proc, etcdServerReadyLines), "did not receive expected output from etcd process") + assert.NoError(t, proc.Stop()) + +}