diff --git a/cmd/manager/main.go b/cmd/manager/main.go index a7d4bcda7..2905eb3cf 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -20,6 +20,7 @@ import ( "context" "flag" "fmt" + "net/http" "os" "path/filepath" @@ -176,7 +177,7 @@ func main() { os.Exit(1) } - certPool, err := httputil.NewCertPool(caCertDir) + certPoolWatcher, err := httputil.NewCertPoolWatcher(caCertDir, ctrl.Log.WithName("cert-pool")) if err != nil { setupLog.Error(err, "unable to create CA certificate pool") os.Exit(1) @@ -184,8 +185,8 @@ func main() { unpacker := &source.ImageRegistry{ BaseCachePath: filepath.Join(cachePath, "unpack"), // TODO: This needs to be derived per extension via ext.Spec.InstallNamespace - AuthNamespace: systemNamespace, - CaCertPool: certPool, + AuthNamespace: systemNamespace, + CertPoolWatcher: certPoolWatcher, } clusterExtensionFinalizers := crfinalizer.NewFinalizers() @@ -210,18 +211,15 @@ func main() { } cl := mgr.GetClient() - httpClient, err := httputil.BuildHTTPClient(certPool) - if err != nil { - setupLog.Error(err, "unable to create catalogd http client") - os.Exit(1) - } catalogsCachePath := filepath.Join(cachePath, "catalogs") if err := os.MkdirAll(catalogsCachePath, 0700); err != nil { setupLog.Error(err, "unable to create catalogs cache directory") os.Exit(1) } - catalogClient := catalogclient.New(cache.NewFilesystemCache(catalogsCachePath, httpClient)) + catalogClient := catalogclient.New(cache.NewFilesystemCache(catalogsCachePath, func() (*http.Client, error) { + return httputil.BuildHTTPClient(certPoolWatcher) + })) resolver := &resolve.CatalogResolver{ WalkCatalogsFunc: resolve.CatalogWalker( @@ -243,7 +241,6 @@ func main() { Unpacker: unpacker, InstalledBundleGetter: &controllers.DefaultInstalledBundleGetter{ActionClientGetter: acg}, Finalizers: clusterExtensionFinalizers, - CaCertPool: certPool, Preflights: preflights, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClusterExtension") diff --git a/config/components/tls/patches/manager_deployment_cert.yaml b/config/components/tls/patches/manager_deployment_cert.yaml index 9a1cf1b7a..747979321 100644 --- a/config/components/tls/patches/manager_deployment_cert.yaml +++ b/config/components/tls/patches/manager_deployment_cert.yaml @@ -3,7 +3,7 @@ value: {"name":"olmv1-certificate", "secret":{"secretName":"olmv1-cert", "optional": false, "items": [{"key": "ca.crt", "path": "olm-ca.crt"}]}} - op: add path: /spec/template/spec/containers/0/volumeMounts/- - value: {"name":"olmv1-certificate", "readOnly": true, "mountPath":"/var/certs/olm-ca.crt", "subPath":"olm-ca.crt"} + value: {"name":"olmv1-certificate", "readOnly": true, "mountPath":"/var/certs/"} - op: add path: /spec/template/spec/containers/0/args/- value: "--ca-certs-dir=/var/certs" diff --git a/go.mod b/go.mod index f47c51bb1..cad5ebec9 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/Masterminds/semver/v3 v3.2.1 github.com/blang/semver/v4 v4.0.0 github.com/containerd/containerd v1.7.20 + github.com/fsnotify/fsnotify v1.7.0 github.com/go-logr/logr v1.4.2 github.com/google/go-cmp v0.6.0 github.com/google/go-containerregistry v0.20.1 @@ -112,7 +113,6 @@ require ( github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.15.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect diff --git a/internal/catalogmetadata/cache/cache.go b/internal/catalogmetadata/cache/cache.go index acb0c89a8..419eef096 100644 --- a/internal/catalogmetadata/cache/cache.go +++ b/internal/catalogmetadata/cache/cache.go @@ -25,11 +25,11 @@ var _ client.Fetcher = &filesystemCache{} // - IF cached it will verify the cache is up to date. If it is up to date it will return // the cached contents, if not it will fetch the new contents from the catalogd HTTP // server and update the cached contents. -func NewFilesystemCache(cachePath string, client *http.Client) client.Fetcher { +func NewFilesystemCache(cachePath string, clientFunc func() (*http.Client, error)) client.Fetcher { return &filesystemCache{ cachePath: cachePath, mutex: sync.RWMutex{}, - client: client, + getClient: clientFunc, cacheDataByCatalogName: map[string]cacheData{}, } } @@ -50,7 +50,7 @@ type cacheData struct { type filesystemCache struct { mutex sync.RWMutex cachePath string - client *http.Client + getClient func() (*http.Client, error) cacheDataByCatalogName map[string]cacheData } @@ -95,7 +95,11 @@ func (fsc *filesystemCache) FetchCatalogContents(ctx context.Context, catalog *c return nil, fmt.Errorf("error forming request: %v", err) } - resp, err := fsc.client.Do(req) + client, err := fsc.getClient() + if err != nil { + return nil, fmt.Errorf("error getting HTTP client: %w", err) + } + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error performing request: %v", err) } diff --git a/internal/catalogmetadata/cache/cache_test.go b/internal/catalogmetadata/cache/cache_test.go index 168b32581..05ea28ec5 100644 --- a/internal/catalogmetadata/cache/cache_test.go +++ b/internal/catalogmetadata/cache/cache_test.go @@ -214,7 +214,9 @@ func TestFilesystemCache(t *testing.T) { maps.Copy(tt.tripper.content, tt.contents) httpClient := http.DefaultClient httpClient.Transport = tt.tripper - c := cache.NewFilesystemCache(cacheDir, httpClient) + c := cache.NewFilesystemCache(cacheDir, func() (*http.Client, error) { + return httpClient, nil + }) actualFS, err := c.FetchCatalogContents(ctx, tt.catalog) if !tt.wantErr { diff --git a/internal/controllers/clusterextension_controller.go b/internal/controllers/clusterextension_controller.go index 1fb63037b..49dcea832 100644 --- a/internal/controllers/clusterextension_controller.go +++ b/internal/controllers/clusterextension_controller.go @@ -19,7 +19,6 @@ package controllers import ( "bytes" "context" - "crypto/x509" "errors" "fmt" "io" @@ -89,7 +88,6 @@ type ClusterExtensionReconciler struct { cache cache.Cache InstalledBundleGetter InstalledBundleGetter Finalizers crfinalizer.Finalizers - CaCertPool *x509.CertPool Preflights []Preflight } diff --git a/internal/httputil/certpoolwatcher.go b/internal/httputil/certpoolwatcher.go new file mode 100644 index 000000000..2a250d069 --- /dev/null +++ b/internal/httputil/certpoolwatcher.go @@ -0,0 +1,108 @@ +package httputil + +import ( + "crypto/x509" + "fmt" + "os" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/go-logr/logr" +) + +type CertPoolWatcher struct { + generation int + dir string + mx sync.RWMutex + pool *x509.CertPool + log logr.Logger + watcher *fsnotify.Watcher + done chan bool +} + +// Returns the current CertPool and the generation number +func (cpw *CertPoolWatcher) Get() (*x509.CertPool, int, error) { + cpw.mx.RLock() + defer cpw.mx.RUnlock() + if cpw.pool == nil { + return nil, 0, fmt.Errorf("no certificate pool available") + } + return cpw.pool.Clone(), cpw.generation, nil +} + +func (cpw *CertPoolWatcher) Done() { + cpw.done <- true +} + +func NewCertPoolWatcher(caDir string, log logr.Logger) (*CertPoolWatcher, error) { + pool, err := NewCertPool(caDir, log) + if err != nil { + return nil, err + } + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + if err = watcher.Add(caDir); err != nil { + return nil, err + } + + cpw := &CertPoolWatcher{ + generation: 1, + dir: caDir, + pool: pool, + log: log, + watcher: watcher, + done: make(chan bool), + } + go func() { + for { + select { + case <-watcher.Events: + cpw.drainEvents() + cpw.update() + case err := <-watcher.Errors: + log.Error(err, "error watching certificate dir") + os.Exit(1) + case <-cpw.done: + err := watcher.Close() + if err != nil { + log.Error(err, "error closing watcher") + } + return + } + } + }() + return cpw, nil +} + +func (cpw *CertPoolWatcher) update() { + cpw.log.Info("updating certificate pool") + pool, err := NewCertPool(cpw.dir, cpw.log) + if err != nil { + cpw.log.Error(err, "error updating certificate pool") + os.Exit(1) + } + cpw.mx.Lock() + defer cpw.mx.Unlock() + cpw.pool = pool + cpw.generation++ +} + +// Drain as many events as possible before doing anything +// Otherwise, we will be hit with an event for _every_ entry in the +// directory, and end up doing an update for each one +func (cpw *CertPoolWatcher) drainEvents() { + for { + drainTimer := time.NewTimer(time.Millisecond * 50) + select { + case <-drainTimer.C: + return + case <-cpw.watcher.Events: + } + if !drainTimer.Stop() { + <-drainTimer.C + } + } +} diff --git a/internal/httputil/certpoolwatcher_test.go b/internal/httputil/certpoolwatcher_test.go new file mode 100644 index 000000000..bfebebd28 --- /dev/null +++ b/internal/httputil/certpoolwatcher_test.go @@ -0,0 +1,97 @@ +package httputil_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/operator-framework/operator-controller/internal/httputil" +) + +func createCert(t *testing.T, name string) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + notBefore := time.Now() + notAfter := notBefore.Add(time.Hour) + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{name}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + IsCA: true, + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + + BasicConstraintsValid: true, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + require.NoError(t, err) + + certOut, err := os.Create(name) + require.NoError(t, err) + + err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + require.NoError(t, err) + + err = certOut.Close() + require.NoError(t, err) + + // ignore the key +} + +func TestCertPoolWatcher(t *testing.T) { + // create a temporary directory + tmpDir, err := os.MkdirTemp("", "cert-pool") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // create the first cert + certName := filepath.Join(tmpDir, "test1.pem") + t.Logf("Create cert file at %q\n", certName) + createCert(t, certName) + + // Create the cert pool watcher + cpw, err := httputil.NewCertPoolWatcher(tmpDir, log.FromContext(context.Background())) + require.NoError(t, err) + defer cpw.Done() + + // Get the original pool + firstPool, firstGen, err := cpw.Get() + require.NoError(t, err) + require.NotNil(t, firstPool) + + // Create a second cert + certName = filepath.Join(tmpDir, "test2.pem") + t.Logf("Create cert file at %q\n", certName) + createCert(t, certName) + + require.Eventually(t, func() bool { + secondPool, secondGen, err := cpw.Get() + if err != nil { + return false + } + return secondGen != firstGen && !firstPool.Equal(secondPool) + }, 30*time.Second, time.Second) +} diff --git a/internal/httputil/certutil.go b/internal/httputil/certutil.go index 23e132164..767fd57a6 100644 --- a/internal/httputil/certutil.go +++ b/internal/httputil/certutil.go @@ -8,6 +8,9 @@ import ( "fmt" "os" "path/filepath" + "time" + + "github.com/go-logr/logr" ) var pemStart = []byte("\n-----BEGIN ") @@ -157,7 +160,7 @@ func pemDecode(data []byte) (*pem.Block, []byte) { } // This version of (*x509.CertPool).AppendCertsFromPEM() will error out if parsing fails -func appendCertsFromPEM(s *x509.CertPool, pemCerts []byte) error { +func appendCertsFromPEM(s *x509.CertPool, pemCerts []byte, firstExpiration *time.Time) error { n := 1 for len(pemCerts) > 0 { var block *pem.Block @@ -179,6 +182,15 @@ func appendCertsFromPEM(s *x509.CertPool, pemCerts []byte) error { if err != nil { return fmt.Errorf("unable to parse cert %d: %w", n, err) } + if firstExpiration.IsZero() || firstExpiration.After(cert.NotAfter) { + *firstExpiration = cert.NotAfter + } + now := time.Now() + if now.Before(cert.NotBefore) { + return fmt.Errorf("not yet valid cert %d: %q", n, cert.NotBefore.Format(time.RFC3339)) + } else if now.After(cert.NotAfter) { + return fmt.Errorf("expired cert %d: %q", n, cert.NotAfter.Format(time.RFC3339)) + } // no return values - panics or always succeeds s.AddCert(cert) n++ @@ -187,7 +199,7 @@ func appendCertsFromPEM(s *x509.CertPool, pemCerts []byte) error { return nil } -func NewCertPool(caDir string) (*x509.CertPool, error) { +func NewCertPool(caDir string, log logr.Logger) (*x509.CertPool, error) { caCertPool, err := x509.SystemCertPool() if err != nil { return nil, err @@ -200,20 +212,37 @@ func NewCertPool(caDir string) (*x509.CertPool, error) { if err != nil { return nil, err } + count := 0 + firstExpiration := time.Time{} + for _, e := range dirEntries { - if e.IsDir() { + file := filepath.Join(caDir, e.Name()) + // These might be symlinks pointing to directories, so use Stat() to resolve + fi, err := os.Stat(file) + if err != nil { + return nil, err + } + if fi.IsDir() { + log.Info("skip directory", "name", e.Name()) continue } - file := filepath.Join(caDir, e.Name()) + log.Info("load certificate", "name", e.Name()) data, err := os.ReadFile(file) if err != nil { return nil, fmt.Errorf("error reading cert file %q: %w", file, err) } - err = appendCertsFromPEM(caCertPool, data) + err = appendCertsFromPEM(caCertPool, data, &firstExpiration) if err != nil { return nil, fmt.Errorf("error adding cert file %q: %w", file, err) } + count++ + } + + // Found no certs! + if count == 0 { + return nil, fmt.Errorf("no certificates found in %q", caDir) } + log.Info("first expiration", "time", firstExpiration.Format(time.RFC3339)) return caCertPool, nil } diff --git a/internal/httputil/certutil_test.go b/internal/httputil/certutil_test.go index 9f72b51a8..a8a158ff3 100644 --- a/internal/httputil/certutil_test.go +++ b/internal/httputil/certutil_test.go @@ -1,8 +1,10 @@ package httputil_test import ( + "context" "testing" + "github.com/go-logr/logr" "github.com/stretchr/testify/require" "github.com/operator-framework/operator-controller/internal/httputil" @@ -21,17 +23,20 @@ func TestNewCertPool(t *testing.T) { dir string msg string }{ + {"../../testdata/certs/", `no certificates found in "../../testdata/certs/"`}, {"../../testdata/certs/good", ""}, {"../../testdata/certs/bad", `error adding cert file "../../testdata/certs/bad/Amazon_Root_CA_2.pem": unable to PEM decode cert 1`}, {"../../testdata/certs/ugly", `error adding cert file "../../testdata/certs/ugly/Amazon_Root_CA.pem": unable to PEM decode cert 2`}, {"../../testdata/certs/ugly2", `error adding cert file "../../testdata/certs/ugly2/Amazon_Root_CA_1.pem": unable to PEM decode cert 1`}, {"../../testdata/certs/ugly3", `error adding cert file "../../testdata/certs/ugly3/not_a_cert.pem": unable to PEM decode cert 1`}, {"../../testdata/certs/empty", `error adding cert file "../../testdata/certs/empty/empty.pem": unable to parse cert 1: x509: malformed certificate`}, + {"../../testdata/certs/expired", `error adding cert file "../../testdata/certs/expired/expired.pem": expired cert 1: "2024-01-02T15:00:00Z"`}, } + log, _ := logr.FromContext(context.Background()) for _, caDir := range caDirs { t.Logf("Loading certs from %q", caDir.dir) - pool, err := httputil.NewCertPool(caDir.dir) + pool, err := httputil.NewCertPool(caDir.dir, log) if caDir.msg == "" { require.NoError(t, err) require.NotNil(t, pool) diff --git a/internal/httputil/httputil.go b/internal/httputil/httputil.go index 2f15bfaf1..d620866e4 100644 --- a/internal/httputil/httputil.go +++ b/internal/httputil/httputil.go @@ -2,16 +2,20 @@ package httputil import ( "crypto/tls" - "crypto/x509" "net/http" "time" ) -func BuildHTTPClient(caCertPool *x509.CertPool) (*http.Client, error) { +func BuildHTTPClient(cpw *CertPoolWatcher) (*http.Client, error) { httpClient := &http.Client{Timeout: 10 * time.Second} + pool, _, err := cpw.Get() + if err != nil { + return nil, err + } + tlsConfig := &tls.Config{ - RootCAs: caCertPool, + RootCAs: pool, MinVersion: tls.VersionTLS12, } tlsTransport := &http.Transport{ diff --git a/internal/rukpak/source/image_registry.go b/internal/rukpak/source/image_registry.go index 775d7d47e..a6d6640d4 100644 --- a/internal/rukpak/source/image_registry.go +++ b/internal/rukpak/source/image_registry.go @@ -4,7 +4,6 @@ import ( "archive/tar" "context" "crypto/tls" - "crypto/x509" "errors" "fmt" "io/fs" @@ -20,6 +19,8 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" apimacherrors "k8s.io/apimachinery/pkg/util/errors" "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/operator-framework/operator-controller/internal/httputil" ) // SourceTypeImage is the identifier for image-type bundle sources @@ -51,9 +52,9 @@ func NewUnrecoverable(err error) *Unrecoverable { // TODO: Make asynchronous type ImageRegistry struct { - BaseCachePath string - AuthNamespace string - CaCertPool *x509.CertPool + BaseCachePath string + AuthNamespace string + CertPoolWatcher *httputil.CertPoolWatcher } func (i *ImageRegistry) Unpack(ctx context.Context, bundle *BundleSource) (*Result, error) { @@ -99,8 +100,12 @@ func (i *ImageRegistry) Unpack(ctx context.Context, bundle *BundleSource) (*Resu if bundle.Image.InsecureSkipTLSVerify { transport.TLSClientConfig.InsecureSkipVerify = true // nolint:gosec } - if i.CaCertPool != nil { - transport.TLSClientConfig.RootCAs = i.CaCertPool + if i.CertPoolWatcher != nil { + pool, _, err := i.CertPoolWatcher.Get() + if err != nil { + return nil, err + } + transport.TLSClientConfig.RootCAs = pool } remoteOpts = append(remoteOpts, remote.WithTransport(transport)) diff --git a/testdata/certs/expired/expired.pem b/testdata/certs/expired/expired.pem new file mode 100644 index 000000000..e8912ba61 --- /dev/null +++ b/testdata/certs/expired/expired.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFXzCCA0egAwIBAgIUN5r8l1RrpH53+9e6pfj6CXoyqP0wDQYJKoZIhvcNAQEL +BQAwPzELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB1JlZCBIYXQxDDAKBgNVBAsMA09M +TTEQMA4GA1UEAwwHZXhwaXJlZDAeFw0yNDAxMDExNTAwMDBaFw0yNDAxMDIxNTAw +MDBaMD8xCzAJBgNVBAYTAlVTMRAwDgYDVQQKDAdSZWQgSGF0MQwwCgYDVQQLDANP +TE0xEDAOBgNVBAMMB2V4cGlyZWQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQDFalyjEXz0cMGs3pt360Cz0uD0CDnnAqQFHxXPchfCMZnW/VRGrJQq29rZ +UgU5PnxPgqadrw20BodfR2RS9xIacMP+092GY7Ep96xWokwXcsGPj2e5VMlEYVM1 +0MGqIbEv52ZnoEaZDHl4yprYeTs+b/7NGvdG1+N/YNAjkpk8cCBKUXo4ZhkgAZoW +jbv3DkAdkpQHipUYkQZNRws1ebyfTbKaEPxw7abEh9TJrHD1EI9hbmYOGJWLfe1e +zeBQjFioQA31FcQR3/v+aNEDX390+qi3p0LXe7GMabgcoFYcGXO7XvX0DdUBvdZZ +dyHA7cJvyfWfcbucI7xQ9xvAnu/4Ih4D8mHnJXjZK5ReQn06FPM/ZCgZ5LrHAKcZ +0mrOts/8noY9dMmBreSJmLCP8EqzY7yKJFFHVCeKo+bU6/KOyNhJGGSCHVJ/pZGK +ZpOQcNwVvHciLH+MfpW12xJXPEs8Wv24KufDdBCDliSFnVTYH3kZaq4Ozb7+3A5j +wUQ2aDg8nrq4oNORMSCafvia8MYH3NXbpUq1SAyD5DTKtMcWY3gcVnJgrBai1hPn +TPhrMb2NMDFnMnj7/l8jdu9xHrsgOmOrv7Zj0ytmpT6ITJgWNGXsiq7Dp+HH1c6N +ggG6g0zqoyoaxcPVN7PMrWTvfKUD3LHfIsesPc4+lT+TSlBQYwIDAQABo1MwUTAd +BgNVHQ4EFgQU8mBHR/00anEl8Io/A2c0LQlGF5MwHwYDVR0jBBgwFoAU8mBHR/00 +anEl8Io/A2c0LQlGF5MwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AgEABZYGEeJY2dgyi4W0LNVgN8mKuZuapIcisQ66foe46WuWGAjVONIHlb0Ciy75 +aaClLC8fiiIh+FUFZ5aIZkfhKH97QvehFO5O7mqCjM7ipvtEm+Vs1IVtXWDONUxo +SfgbjEPBV8+eflgvKQ6jJqiSqs8EnqdbGAfhxVG/3RN1b5xSFtKz6kzHQE+Gy6QT +DGCVhYvDq8j6G2LCePsqE8piOnSaXuRwD4/YEOaYhx4jjgOnaM0m/dM/Cx9wy2xg +LMRBjBwxFf6palgiFUvyqvturIPONQICkM/lZkpmHbeM4FCat/CD5VW+JgpYiEtW +2oFslTEbawUjmEYnzdo9iw9KPLJQqtasFEWzkWWnJrfm7AVGxcgAHVqGZhUMgq0k +MccM2zYZN2fCSZUUueDB7VCxFq5jK2oLzE14ngXdR7ZbxT3qai/zvGg1kl9y1bIF +WVTK0WZnHqZwVnHQVBH0Duv0uyRUzb6yRRziuLN5aBGQpy/Jm7MS0jLidCbqoCXC +dYqGMFlImzU+6CwPyTJo+X+v6L+FATIxZRpBBeEhHqEU6wz51ms68Sjx4bpW33b+ +WFt0JKEmIxB1puJK1qQvKu/MxJyy52GNqiRg7HXkJH9MMYWoAkF2jKMLFoerUPun +7GaV8SIUTFO/5pbnpxZ97a2FuB2RvDKs7GSdspEmC3wbAPU= +-----END CERTIFICATE-----