diff --git a/echo/server.go b/echo/server.go index 5281d0c..b526739 100644 --- a/echo/server.go +++ b/echo/server.go @@ -100,6 +100,8 @@ var ( tlsMaxVersion string // TLS cipher suites (comma-separated list of cipher suite names) tlsCipherSuites string + // CA certificates used to verify the client certificates (mTLS) + clientCAFile string ) func InitFlag() { @@ -110,6 +112,7 @@ func InitFlag() { flag.StringVar(&tlsMinVersion, "web.tls-min-version", "", "Minimum TLS version (e.g., \"1.2\", \"1.3\")") flag.StringVar(&tlsMaxVersion, "web.tls-max-version", "", "Maximum TLS version (e.g., \"1.2\", \"1.3\")") flag.StringVar(&tlsCipherSuites, "web.tls-cipher-suites", "", "Comma-separated list of TLS cipher suite names") + flag.StringVar(&clientCAFile, "web.tls-client-ca-file", "", "The path to the CA certificates (PEM) used to verify the client certificates. When set, every client must present a certificate signed by one of them (mTLS), including the health probes. Only these CAs are trusted, not the system ones. It is reloaded when the file changes, but the established connections are not verified again.") } // parseTLSVersion converts a version string (e.g., "1.2", "1.3") to a tls.Version constant. @@ -345,6 +348,8 @@ func (b *Builder) build() (*server, error) { return nil, errors.New("--web.tls-key-file must be set when --web.tls-cert-file is set") case cert == "" && key != "": return nil, errors.New("--web.tls-cert-file must be set when --web.tls-key-file is set") + case cert == "" && clientCAFile != "": + return nil, errors.New("--web.tls-cert-file and --web.tls-key-file must be set when --web.tls-client-ca-file is set") } if len(b.addr) == 0 { b.addr = addr @@ -434,6 +439,7 @@ func (b *Builder) build() (*server, error) { tlsMinVersion: parsedMinVersion, tlsMaxVersion: parsedMaxVersion, tlsCipherSuites: parsedCipherSuites, + clientCAFile: clientCAFile, mdws: mdws, preMDWs: b.preMDWs, shutdownTimeout: 30 * time.Second, @@ -479,6 +485,7 @@ type server struct { tlsMinVersion uint16 tlsMaxVersion uint16 tlsCipherSuites []uint16 + clientCAFile string mdws []echo.MiddlewareFunc preMDWs []echo.MiddlewareFunc shutdownTimeout time.Duration @@ -568,23 +575,43 @@ func (s *server) configureHTTPServer(httpServer *http.Server) { } // tlsConfig builds the TLS configuration of the HTTPS server. -// The certificate is provided by a reloader, so a rotated certificate is used without restarting the server. +// The certificate and the client CA are provided by reloaders, so they are rotated without restarting the server. func (s *server) tlsConfig() (*tls.Config, error) { - reloader, err := newCertificateReloader(s.cert, s.key) + certReloader, err := newCertificateReloader(s.cert, s.key) if err != nil { return nil, err } tlsConfig := &tls.Config{ - MinVersion: s.tlsMinVersion, - MaxVersion: s.tlsMaxVersion, - CipherSuites: s.tlsCipherSuites, - GetCertificate: reloader.GetCertificate, + MinVersion: s.tlsMinVersion, + MaxVersion: s.tlsMaxVersion, + CipherSuites: s.tlsCipherSuites, + GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + return certReloader.get(), nil + }, } if isHTTP2Compatible(s.tlsMinVersion, s.tlsCipherSuites) { tlsConfig.NextProtos = []string{"h2"} } else { logrus.Warn("HTTP/2 is disabled because --web.tls-cipher-suites contains neither TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 nor TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, one of which HTTP/2 requires with TLS 1.2 (RFC 7540, section 9.2.2)") } + if s.clientCAFile != "" { + clientCAReloader, caErr := newClientCAReloader(s.clientCAFile) + if caErr != nil { + return nil, caErr + } + tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + // Without ClientCAs, the client certificates would be verified with the system CAs, so the base configuration + // has them as well in case it is used without GetConfigForClient. + tlsConfig.ClientCAs = clientCAReloader.get() + // tls.Config.ClientCAs cannot be changed once the server started, so each handshake uses a copy of the + // configuration with the current client CA. The resumed sessions are verified again with it as well. + tlsConfig.GetConfigForClient = func(_ *tls.ClientHelloInfo) (*tls.Config, error) { + config := tlsConfig.Clone() + config.GetConfigForClient = nil + config.ClientCAs = clientCAReloader.get() + return config, nil + } + } return tlsConfig, nil } diff --git a/echo/server_test.go b/echo/server_test.go index 83b44c3..568297b 100644 --- a/echo/server_test.go +++ b/echo/server_test.go @@ -18,6 +18,7 @@ import ( "crypto/tls" "net" "net/http" + "path/filepath" "strings" "testing" "time" @@ -524,9 +525,15 @@ func startTLSServer(t *testing.T, certFile, keyFile, minVersion, cipherSuites st require.NoError(t, task.Finalize()) }) + // echo wraps the listener with TLS before serving, so the server is ready once the port is open. A TLS handshake + // would not work when a client CA is set with TLS 1.2, as the server rejects a client without certificate. require.Eventually(t, func() bool { - _, dialErr := dialTLSServer(address, &tls.Config{}) - return dialErr == nil + conn, dialErr := net.Dial("tcp", address) + if dialErr != nil { + return false + } + _ = conn.Close() + return true }, 5*time.Second, 10*time.Millisecond) return address } @@ -585,6 +592,112 @@ func TestServer_TLSCipherSuitesWithoutHTTP2(t *testing.T) { require.Error(t, err) } +func TestServer_TLSClientCA(t *testing.T) { + dir := t.TempDir() + certFile, keyFile := writeCertificate(t, dir, 1) + // The client certificates are self-signed, so they are their own CA. + clientPEM, clientKeyPEM := generateCertificate(t, 2) + clientCertificate, err := tls.X509KeyPair(clientPEM, clientKeyPEM) + require.NoError(t, err) + otherClientPEM, otherClientKeyPEM := generateCertificate(t, 3) + otherClientCertificate, err := tls.X509KeyPair(otherClientPEM, otherClientKeyPEM) + require.NoError(t, err) + caFile := filepath.Join(dir, "ca.crt") + replaceFile(t, caFile, clientPEM) + + origClientCAFile := clientCAFile + clientCAFile = caFile + defer func() { + clientCAFile = origClientCAFile + }() + address := startTLSServer(t, certFile, keyFile, "", "") + + // An HTTP request is sent, as with TLS 1.3, the client completes the handshake before the server verifies its + // certificate: a rejected certificate is only reported when the client reads the response. + get := func(certificates ...tls.Certificate) error { + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true, Certificates: certificates}, + DisableKeepAlives: true, + }, + } + resp, reqErr := client.Get("https://" + address) + if reqErr != nil { + return reqErr + } + return resp.Body.Close() + } + + // Only the clients presenting a certificate signed by the CA are accepted. + require.NoError(t, get(clientCertificate)) + assert.ErrorContains(t, get(), "certificate required") + assert.ErrorContains(t, get(otherClientCertificate), "unknown certificate authority") + + // The CA is rotated: the new connections are verified with the new one. + replaceFile(t, caFile, otherClientPEM) + require.NoError(t, get(otherClientCertificate)) + assert.ErrorContains(t, get(clientCertificate), "unknown certificate authority") + + // The new file is invalid: the previous CA is kept. + replaceFile(t, caFile, []byte("not a certificate")) + require.NoError(t, get(otherClientCertificate)) + assert.ErrorContains(t, get(clientCertificate), "unknown certificate authority") +} + +// A resumed session must not bypass the rotation of the client CA. +func TestServer_TLSClientCAResumption(t *testing.T) { + for _, version := range []uint16{tls.VersionTLS12, tls.VersionTLS13} { + t.Run(tls.VersionName(version), func(t *testing.T) { + dir := t.TempDir() + certFile, keyFile := writeCertificate(t, dir, 1) + clientPEM, clientKeyPEM := generateCertificate(t, 2) + clientCertificate, err := tls.X509KeyPair(clientPEM, clientKeyPEM) + require.NoError(t, err) + otherClientPEM, _ := generateCertificate(t, 3) + caFile := filepath.Join(dir, "ca.crt") + replaceFile(t, caFile, clientPEM) + + origClientCAFile := clientCAFile + clientCAFile = caFile + defer func() { + clientCAFile = origClientCAFile + }() + address := startTLSServer(t, certFile, keyFile, "", "") + + // Every connection uses the same TLS configuration, so the same session cache. + clientConfig := &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{clientCertificate}, + ClientSessionCache: tls.NewLRUClientSessionCache(1), + MaxVersion: version, + } + get := func() (*tls.ConnectionState, error) { + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{TLSClientConfig: clientConfig, DisableKeepAlives: true}, + } + resp, reqErr := client.Get("https://" + address) + if reqErr != nil { + return nil, reqErr + } + return resp.TLS, resp.Body.Close() + } + + _, err = get() + require.NoError(t, err) + state, err := get() + require.NoError(t, err) + require.True(t, state.DidResume) + + // The CA is rotated: the session is not resumed, and the full handshake fails. + replaceFile(t, caFile, otherClientPEM) + _, err = get() + assert.ErrorContains(t, err, "unknown certificate authority") + }) + } +} + func TestBuild_PartialTLSConfiguration(t *testing.T) { origHooks := logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) t.Cleanup(func() { @@ -596,6 +709,7 @@ func TestBuild_PartialTLSConfiguration(t *testing.T) { name string certFile string keyFile string + clientCAFile string minVersion string expectedError string expectedWarning bool @@ -618,6 +732,11 @@ func TestBuild_PartialTLSConfiguration(t *testing.T) { keyFile: "tls.key", expectedError: "--web.tls-cert-file must be set when --web.tls-key-file is set", }, + { + name: "only the client CA is set", + clientCAFile: "ca.crt", + expectedError: "--web.tls-cert-file and --web.tls-key-file must be set when --web.tls-client-ca-file is set", + }, { name: "TLS option without HTTPS", minVersion: "1.2", @@ -634,13 +753,16 @@ func TestBuild_PartialTLSConfiguration(t *testing.T) { t.Run(tt.name, func(t *testing.T) { origCert := cert origKey := key + origClientCAFile := clientCAFile origMinVersion := tlsMinVersion cert = tt.certFile key = tt.keyFile + clientCAFile = tt.clientCAFile tlsMinVersion = tt.minVersion defer func() { cert = origCert key = origKey + clientCAFile = origClientCAFile tlsMinVersion = origMinVersion }() hook.Reset() diff --git a/echo/tls.go b/echo/tls.go index 151fb30..6b24333 100644 --- a/echo/tls.go +++ b/echo/tls.go @@ -15,94 +15,113 @@ package echo import ( "crypto/tls" + "crypto/x509" + "fmt" "os" + "slices" "sync" "github.com/sirupsen/logrus" ) -// certificateReloader provides the certificate used by the HTTPS server. -// On every TLS handshake, it checks if the certificate or the key file changed and loads them again if it is the case. +// fileReloader provides a value loaded from files, like the certificate used by the HTTPS server. +// On every TLS handshake, it checks if the files changed and loads them again if it is the case. // Like that, a rotated certificate is used for the new connections without restarting the server. -type certificateReloader struct { - certFile string - keyFile string - mutex sync.Mutex - certificate *tls.Certificate - certInfo os.FileInfo - keyInfo os.FileInfo +type fileReloader[T any] struct { + // name describes the value in the logs (e.g., "TLS certificate"). + name string + files []string + load func() (T, error) + mutex sync.Mutex + value T + infos []os.FileInfo // statFailed is true when the files could not be checked during the previous handshake. // It is used to log the error only once instead of on every handshake. statFailed bool } -// newCertificateReloader loads the certificate and the key a first time. -// It returns an error if they cannot be loaded, so the server doesn't start with an invalid certificate. -func newCertificateReloader(certFile, keyFile string) (*certificateReloader, error) { - r := &certificateReloader{ - certFile: certFile, - keyFile: keyFile, - } - certInfo, keyInfo, err := r.stat() +// newFileReloader loads the value a first time. +// It returns an error if it cannot be loaded, so the server doesn't start with an invalid configuration. +func newFileReloader[T any](name string, load func() (T, error), files ...string) (*fileReloader[T], error) { + r := &fileReloader[T]{name: name, files: files, load: load} + infos, err := r.stat() if err != nil { - return nil, err + return nil, fmt.Errorf("unable to load the %s: %w", name, err) } - r.certInfo = certInfo - r.keyInfo = keyInfo - if err = r.load(); err != nil { - return nil, err + r.infos = infos + if r.value, err = load(); err != nil { + return nil, fmt.Errorf("unable to load the %s: %w", name, err) } return r, nil } -// GetCertificate is used as tls.Config.GetCertificate. -// When the certificate or the key cannot be loaded again, the error is logged and the previous certificate is kept. -func (r *certificateReloader) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { +// get returns the value, loaded again if the files changed. +// When the files cannot be loaded again, the error is logged and the previous value is kept. +func (r *fileReloader[T]) get() T { r.mutex.Lock() defer r.mutex.Unlock() - certInfo, keyInfo, err := r.stat() + infos, err := r.stat() if err != nil { if !r.statFailed { - logrus.WithError(err).Error("unable to check if the TLS certificate changed, the previous one is used") + logrus.WithError(err).Errorf("unable to check if the %s changed, the previous one is used", r.name) r.statFailed = true } - return r.certificate, nil + return r.value } r.statFailed = false - if isSameFile(r.certInfo, certInfo) && isSameFile(r.keyInfo, keyInfo) { - return r.certificate, nil + if slices.EqualFunc(r.infos, infos, isSameFile) { + return r.value } // The files are tracked even if they are invalid, so they are loaded again only once they change. // For example, it happens when the certificate has been replaced but not yet the key. - r.certInfo = certInfo - r.keyInfo = keyInfo - if err = r.load(); err != nil { - logrus.WithError(err).Error("unable to reload the TLS certificate, the previous one is used") - return r.certificate, nil + r.infos = infos + value, err := r.load() + if err != nil { + logrus.WithError(err).Errorf("unable to reload the %s, the previous one is used", r.name) + return r.value } - logrus.Info("TLS certificate reloaded") - return r.certificate, nil + r.value = value + logrus.Infof("%s reloaded", r.name) + return r.value } -func (r *certificateReloader) stat() (os.FileInfo, os.FileInfo, error) { - certInfo, err := os.Stat(r.certFile) - if err != nil { - return nil, nil, err - } - keyInfo, err := os.Stat(r.keyFile) - if err != nil { - return nil, nil, err +func (r *fileReloader[T]) stat() ([]os.FileInfo, error) { + infos := make([]os.FileInfo, 0, len(r.files)) + for _, file := range r.files { + info, err := os.Stat(file) + if err != nil { + return nil, err + } + infos = append(infos, info) } - return certInfo, keyInfo, nil + return infos, nil } -func (r *certificateReloader) load() error { - certificate, err := tls.LoadX509KeyPair(r.certFile, r.keyFile) - if err != nil { - return err - } - r.certificate = &certificate - return nil +// newCertificateReloader provides the certificate used by the HTTPS server, reloaded when the files change. +func newCertificateReloader(certFile, keyFile string) (*fileReloader[*tls.Certificate], error) { + return newFileReloader("TLS certificate", func() (*tls.Certificate, error) { + certificate, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + return &certificate, nil + }, certFile, keyFile) +} + +// newClientCAReloader provides the CA certificates used to verify the client certificates (mTLS), reloaded when the +// file changes. +func newClientCAReloader(caFile string) (*fileReloader[*x509.CertPool], error) { + return newFileReloader("TLS client CA", func() (*x509.CertPool, error) { + caPEM, err := os.ReadFile(caFile) + if err != nil { + return nil, err + } + clientCAs := x509.NewCertPool() + if !clientCAs.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("no certificate found in %q", caFile) + } + return clientCAs, nil + }, caFile) } // isSameFile tells if the file described by newInfo is the one described by oldInfo and if it was not modified since. diff --git a/echo/tls_test.go b/echo/tls_test.go index 126d7ec..8949f06 100644 --- a/echo/tls_test.go +++ b/echo/tls_test.go @@ -20,6 +20,8 @@ import ( "crypto/tls" "crypto/x509" "encoding/pem" + "fmt" + "io/fs" "math/big" "net" "os" @@ -35,6 +37,7 @@ import ( // generateCertificate returns a self-signed certificate for 127.0.0.1 and its key, both PEM encoded. // The serial number is used to identify the certificate served. +// It can be used by a server or by a client (mTLS), and trusted directly as the client CA, as it is self-signed. func generateCertificate(t *testing.T, serialNumber int64) ([]byte, []byte) { t.Helper() privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -44,7 +47,7 @@ func generateCertificate(t *testing.T, serialNumber int64) ([]byte, []byte) { NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, } der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) @@ -80,11 +83,9 @@ func writeCertificate(t *testing.T, dir string, serialNumber int64) (string, str return certFile, keyFile } -func servedSerialNumber(t *testing.T, r *certificateReloader) int64 { +func servedSerialNumber(t *testing.T, r *fileReloader[*tls.Certificate]) int64 { t.Helper() - certificate, err := r.GetCertificate(&tls.ClientHelloInfo{}) - require.NoError(t, err) - leaf, err := x509.ParseCertificate(certificate.Certificate[0]) + leaf, err := x509.ParseCertificate(r.get().Certificate[0]) require.NoError(t, err) return leaf.SerialNumber.Int64() } @@ -131,7 +132,7 @@ func TestNewCertificateReloader(t *testing.T) { } } -func TestCertificateReloader_GetCertificate(t *testing.T) { +func TestCertificateReloader_Get(t *testing.T) { dir := t.TempDir() certFile, keyFile := writeCertificate(t, dir, 1) r, err := newCertificateReloader(certFile, keyFile) @@ -171,3 +172,23 @@ func TestCertificateReloader_GetCertificate(t *testing.T) { writeCertificate(t, dir, 5) assert.Equal(t, int64(5), servedSerialNumber(t, r)) } + +func TestNewClientCAReloader(t *testing.T) { + dir := t.TempDir() + notPEMFile := filepath.Join(dir, "ca.txt") + replaceFile(t, notPEMFile, []byte("not a certificate")) + + _, err := newClientCAReloader(filepath.Join(dir, "missing.crt")) + require.ErrorIs(t, err, fs.ErrNotExist) + _, err = newClientCAReloader(notPEMFile) + assert.ErrorContains(t, err, fmt.Sprintf("no certificate found in %q", notPEMFile)) + + caPEM, _ := generateCertificate(t, 1) + caFile := filepath.Join(dir, "ca.crt") + replaceFile(t, caFile, caPEM) + r, err := newClientCAReloader(caFile) + require.NoError(t, err) + expected := x509.NewCertPool() + require.True(t, expected.AppendCertsFromPEM(caPEM)) + assert.True(t, expected.Equal(r.get())) +}