From e9b73379d817bf5a67c117cff9f66f4daa440d7d Mon Sep 17 00:00:00 2001 From: Geoffrey Wossum Date: Tue, 6 Jan 2026 13:02:28 -0600 Subject: [PATCH] feat: add client certificate support to TLSCertLoader Add support for client certificates to tlsconfig.TLSCertLoader. Also add TLSCertLoader.SetupTLSConfig to simplify using a TLSCertLoader with a tls.Config object. Clean cherry-pick from master-1.x. (cherry picked from commit 7050b3dd66230680516db3c21925588240ad4f8f) Closes: #27089 --- pkg/testing/selfsigned/selfsigned.go | 25 ++++- pkg/tlsconfig/certconfig.go | 38 +++++++- pkg/tlsconfig/certconfig_test.go | 138 +++++++++++++++++++++++++++ services/httpd/service.go | 2 +- services/opentsdb/service.go | 2 +- 5 files changed, 197 insertions(+), 8 deletions(-) diff --git a/pkg/testing/selfsigned/selfsigned.go b/pkg/testing/selfsigned/selfsigned.go index 7f5ec5fc98f..6f1517aadd7 100644 --- a/pkg/testing/selfsigned/selfsigned.go +++ b/pkg/testing/selfsigned/selfsigned.go @@ -42,6 +42,12 @@ type CertOptions struct { // CombinedFile indicates if the certificate and key should be combined into a single file CombinedFile bool + + // CAOrganization sets the CA certificate's Subject.Organization field + CAOrganization string + + // CACommonName sets the CA certificate's Subject.CommonName field + CACommonName string } type CertOpt func(*CertOptions) @@ -82,6 +88,13 @@ func WithCombinedFile() CertOpt { } } +func WithCASubject(organization, commonName string) CertOpt { + return func(o *CertOptions) { + o.CAOrganization = organization + o.CACommonName = commonName + } +} + func NewSelfSignedCert(t *testing.T, opts ...CertOpt) *Cert { t.Helper() tmpdir := t.TempDir() @@ -108,6 +121,14 @@ func NewSelfSignedCert(t *testing.T, opts ...CertOpt) *Cert { options.NotAfter = time.Now().Add(7 * 24 * time.Hour) } + if options.CAOrganization == "" { + options.CAOrganization = "my_test_ca" + } + + if options.CACommonName == "" { + options.CACommonName = "My Test CA" + } + // Sanity check options. require.NotEmpty(t, options.DNSNames) @@ -129,8 +150,8 @@ func NewSelfSignedCert(t *testing.T, opts ...CertOpt) *Cert { BasicConstraintsValid: true, Subject: pkix.Name{ - Organization: []string{"my_test_ca"}, - CommonName: "My Test CA", + Organization: []string{options.CAOrganization}, + CommonName: options.CACommonName, }, IsCA: true, diff --git a/pkg/tlsconfig/certconfig.go b/pkg/tlsconfig/certconfig.go index be0f7a644e8..685a998ad79 100644 --- a/pkg/tlsconfig/certconfig.go +++ b/pkg/tlsconfig/certconfig.go @@ -30,10 +30,11 @@ const ( ) var ( - ErrCertificateNil = errors.New("TLS certificate is nil") - ErrCertificateEmpty = errors.New("TLS certificate is empty") - ErrLoadedCertificateInvalid = errors.New("LoadedCertificate is invalid") - ErrPathEmpty = errors.New("empty path") + ErrCertificateNil = errors.New("TLS certificate is nil") + ErrCertificateEmpty = errors.New("TLS certificate is empty") + ErrCertificateRequestInfoNil = errors.New("CertificateRequestInfo is nil") + ErrLoadedCertificateInvalid = errors.New("LoadedCertificate is invalid") + ErrPathEmpty = errors.New("empty path") ) // LoadedCertificate encapsulates information about a loaded certificate. @@ -275,6 +276,18 @@ func (cl *TLSCertLoader) Certificate() *tls.Certificate { return cl.cert } +// SetupTLSConfig modifies tlsConfig to use cl for server and client certificates. +// tlsConfig may be nil. If other fields like tlsConfig.Certificates or +// tlsConfig.NameToCertificate have been set, then cl's certificate may not be used +// as expected. +func (cl *TLSCertLoader) SetupTLSConfig(tlsConfig *tls.Config) { + if tlsConfig == nil { + return + } + tlsConfig.GetCertificate = cl.GetCertificate + tlsConfig.GetClientCertificate = cl.GetClientCertificate +} + // GetCertificate is for use with a tls.Config's GetCertificate member. This allows a // tls.Config to dynamically update its certificate when Load changes the active // certificate. @@ -290,6 +303,23 @@ func (cl *TLSCertLoader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, } } +// GetClientCertificate is for use with a tls.Config's GetClientCertificate member. This allows a +// tls.Config to dynamically update its client certificates when Load changes the active +// certificate. +func (cl *TLSCertLoader) GetClientCertificate(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) { + if cri == nil { + return new(tls.Certificate), ErrCertificateRequestInfoNil + } + cert := cl.Certificate() + if cert == nil { + return new(tls.Certificate), ErrCertificateNil + } + if err := cri.SupportsCertificate(cert); err != nil { + return new(tls.Certificate), err + } + return cert, nil +} + // Leaf returns the parsed x509 certificate of the currently loaded certificate. // If no certificate is loaded then nil is returned. func (cl *TLSCertLoader) Leaf() *x509.Certificate { diff --git a/pkg/tlsconfig/certconfig_test.go b/pkg/tlsconfig/certconfig_test.go index 18d945c308a..4a78051a77f 100644 --- a/pkg/tlsconfig/certconfig_test.go +++ b/pkg/tlsconfig/certconfig_test.go @@ -1,7 +1,9 @@ package tlsconfig import ( + "crypto/tls" "crypto/x509" + "encoding/pem" "fmt" "math/big" "os" @@ -488,3 +490,139 @@ func TestTLSCertLoader_VerifyLoad(t *testing.T) { require.Equal(t, sn2, cl.Leaf().SerialNumber.String()) } } + +func TestTLSCertLoader_GetClientCertificate(t *testing.T) { + ss := selfsigned.NewSelfSignedCert(t, selfsigned.WithDNSName("client.influxdata.edge")) + + cl, err := NewTLSCertLoader(ss.CertPath, ss.KeyPath) + require.NoError(t, err) + require.NotNil(t, cl) + defer func() { + require.NoError(t, cl.Close()) + }() + + // Test happy path: certificate supports the request. + // The selfsigned package creates RSA certificates, so we use RSA signature schemes. + t.Run("supported certificate", func(t *testing.T) { + cri := &tls.CertificateRequestInfo{ + SignatureSchemes: []tls.SignatureScheme{ + tls.PKCS1WithSHA256, + tls.PKCS1WithSHA384, + tls.PKCS1WithSHA512, + }, + } + + cert, err := cl.GetClientCertificate(cri) + require.NoError(t, err) + require.NotNil(t, cert) + require.Equal(t, cl.Certificate(), cert) + }) + + t.Run("nil CertificateRequestInfo", func(t *testing.T) { + cert, err := cl.GetClientCertificate(nil) + require.ErrorIs(t, err, ErrCertificateRequestInfoNil) + require.NotNil(t, cert) + require.Empty(t, cert.Certificate) + }) + + // Test unsupported certificate: CertificateRequestInfo only accepts Ed25519, + // but our certificate uses RSA. + t.Run("unsupported certificate", func(t *testing.T) { + cri := &tls.CertificateRequestInfo{ + SignatureSchemes: []tls.SignatureScheme{ + tls.Ed25519, // Our RSA cert doesn't support this + }, + } + + cert, err := cl.GetClientCertificate(cri) + require.ErrorContains(t, err, "doesn't support any of the certificate's signature algorithms") + // GetClientCertificate must return a non-nil certificate even on error + // (per the tls.Config.GetClientCertificate contract). + require.NotNil(t, cert) + // The returned certificate should be an empty certificate, not the loaded one. + require.NotEqual(t, cl.Certificate(), cert) + require.Empty(t, cert.Certificate) + }) + + // Test with AcceptableCAs that include our CA. + t.Run("acceptable CA", func(t *testing.T) { + // Verify that if we change cri to ss's CA subject then we do get cert. + caCert, err := os.ReadFile(ss.CACertPath) + require.NoError(t, err) + + // Parse the CA cert to get its RawSubject for AcceptableCAs. + block, _ := pem.Decode(caCert) + require.NotNil(t, block) + parsedCA, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + + cri := &tls.CertificateRequestInfo{ + SignatureSchemes: []tls.SignatureScheme{ + tls.PKCS1WithSHA256, + }, + AcceptableCAs: [][]byte{parsedCA.RawSubject}, + } + + cert, err := cl.GetClientCertificate(cri) + require.NoError(t, err) + require.NotNil(t, cert) + require.Equal(t, cl.Certificate(), cert) + }) + + // Test with AcceptableCAs that don't include our CA. + t.Run("unacceptable CA", func(t *testing.T) { + // Create a certificate with a different CA subject. + ss2 := selfsigned.NewSelfSignedCert(t, + selfsigned.WithCASubject("different_org", "Different CA"), + ) + caCert2, err := os.ReadFile(ss2.CACertPath) + require.NoError(t, err) + + // Parse the CA cert to get its RawSubject for AcceptableCAs. + block2, _ := pem.Decode(caCert2) + require.NotNil(t, block2) + parsedCA2, err := x509.ParseCertificate(block2.Bytes) + require.NoError(t, err) + + cri := &tls.CertificateRequestInfo{ + SignatureSchemes: []tls.SignatureScheme{ + tls.PKCS1WithSHA256, + }, + AcceptableCAs: [][]byte{parsedCA2.RawSubject}, + } + + cert, err := cl.GetClientCertificate(cri) + require.ErrorContains(t, err, "not signed by an acceptable CA") + require.NotNil(t, cert) + require.Empty(t, cert.Certificate) + }) +} + +func TestTLSCertLoader_SetupTLSConfig(t *testing.T) { + ss := selfsigned.NewSelfSignedCert(t) + + cl, err := NewTLSCertLoader(ss.CertPath, ss.KeyPath) + require.NoError(t, err) + require.NotNil(t, cl) + defer func() { + require.NoError(t, cl.Close()) + }() + + t.Run("nil config", func(t *testing.T) { + require.NotPanics(t, func() { + cl.SetupTLSConfig(nil) + }) + }) + + t.Run("sets callbacks", func(t *testing.T) { + tlsConfig := &tls.Config{} + + require.Nil(t, tlsConfig.GetCertificate) + require.Nil(t, tlsConfig.GetClientCertificate) + + cl.SetupTLSConfig(tlsConfig) + + require.NotNil(t, tlsConfig.GetCertificate) + require.NotNil(t, tlsConfig.GetClientCertificate) + }) +} diff --git a/services/httpd/service.go b/services/httpd/service.go index 883e0d3c33a..2f2e7c111d2 100644 --- a/services/httpd/service.go +++ b/services/httpd/service.go @@ -158,7 +158,7 @@ func (s *Service) Open() error { } tlsConfig := s.tlsConfig.Clone() - tlsConfig.GetCertificate = s.certLoader.GetCertificate + s.certLoader.SetupTLSConfig(tlsConfig) listener, err := tls.Listen("tcp", s.addr, tlsConfig) if err != nil { diff --git a/services/opentsdb/service.go b/services/opentsdb/service.go index 54ccb215b67..0de3867376e 100644 --- a/services/opentsdb/service.go +++ b/services/opentsdb/service.go @@ -141,7 +141,7 @@ func (s *Service) Open() error { s.certLoader = certLoader tlsConfig := s.tlsConfig.Clone() - tlsConfig.GetCertificate = s.certLoader.GetCertificate + s.certLoader.SetupTLSConfig(tlsConfig) listener, err := tls.Listen("tcp", s.BindAddress, tlsConfig) if err != nil {