pkg/controller/common/certificates/ca_reconcile.go (288 lines of code) (raw):

// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one // or more contributor license agreements. Licensed under the Elastic License 2.0; // you may not use this file except in compliance with the Elastic License 2.0. package certificates import ( "context" "crypto" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "fmt" "os" "path/filepath" "strings" "time" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/name" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/fs" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" ) // CAType is a type of CA type CAType string const ( // TransportCAType is the CA used for ES transport certificates TransportCAType CAType = "transport" // HTTPCAType is the CA used for HTTP certificates HTTPCAType CAType = "http" ) const ( caInternalSecretSuffix = "ca-internal" ) // CAInternalSecretName returns the name of the internal secret containing the CA certs and keys func CAInternalSecretName(namer name.Namer, ownerName string, caType CAType) string { return namer.Suffix(ownerName, string(caType), caInternalSecretSuffix) } // ReconcileCAForOwner ensures that a CA exists for the given owner and CAType, and returns it. // // The CA is persisted across operator restarts in the apiserver as a Secret for the CA certificate and private key: // `<clusterName>-<caType>-ca-internal` // // The CA cert and private key are rotated if they become invalid (or soon to expire). func ReconcileCAForOwner( ctx context.Context, cl k8s.Client, namer name.Namer, owner client.Object, labels map[string]string, caType CAType, rotationParams RotationParams, ) (*CA, error) { log := ulog.FromContext(ctx) // retrieve current CA secret caInternalSecret := corev1.Secret{} err := cl.Get(ctx, types.NamespacedName{ Namespace: owner.GetNamespace(), Name: CAInternalSecretName(namer, owner.GetName(), caType), }, &caInternalSecret) if err != nil && !apierrors.IsNotFound(err) { return nil, err } if apierrors.IsNotFound(err) { log.Info("No internal CA certificate Secret found, creating a new one", "owner_namespace", owner.GetNamespace(), "owner_name", owner.GetName(), "ca_type", caType) return renewCA(ctx, cl, namer, owner, labels, rotationParams.Validity, caType) } // build CA ca := BuildCAFromSecret(ctx, caInternalSecret) if ca == nil { log.Info("Cannot build CA from secret, creating a new one", "owner_namespace", owner.GetNamespace(), "owner_name", owner.GetName(), "ca_type", caType) return renewCA(ctx, cl, namer, owner, labels, rotationParams.Validity, caType) } // renew or recreate from private key if cannot reuse if !CanReuseCA(ctx, ca, rotationParams.RotateBefore) { if ca.PrivateKey != nil && certExpiring(time.Now(), *ca.Cert, rotationParams.RotateBefore) { log.Info("Existing CA is expiring, creating a new one from existing private key", "owner_namespace", owner.GetNamespace(), "owner_name", owner.GetName(), "ca_type", caType) return renewCAFromExisting(ctx, cl, namer, owner, labels, rotationParams.Validity, caType, ca.PrivateKey) } log.Info("Cannot reuse existing CA, creating a new one", "owner_namespace", owner.GetNamespace(), "owner_name", owner.GetName(), "ca_type", caType) return renewCA(ctx, cl, namer, owner, labels, rotationParams.Validity, caType) } // reuse existing CA return ca, nil } // renewCAFromExisting will attempt to renew, or rather create a new CA using the existing // private key from the existing CA, using the same options as the previous CA. There are 2 // scenarios where this will fail back to the existing behavior of creating a new CA with // a newly created private key and those are: // 1. The given CA is nil // 2. The CA's private key interface type cannot be asserted to be a *rsa.PrivateKey func renewCAFromExisting( ctx context.Context, client k8s.Client, namer name.Namer, owner client.Object, labels map[string]string, expireIn time.Duration, caType CAType, signer crypto.Signer, ) (*CA, error) { log := ulog.FromContext(ctx) privateKey, ok := signer.(*rsa.PrivateKey) if !ok { log.Error( errors.New("cannot cast ca.PrivateKey into *rsa.PrivateKey"), "Failed to cast the operator generated CA private key into a RSA private key", "namespace", owner.GetNamespace(), "name", owner.GetName(), "type", fmt.Sprintf("%T", signer), ) return renewCA(ctx, client, namer, owner, labels, expireIn, caType) } log.Info( "Attempting to renew CA certificate with existing private key", "namespace", owner.GetNamespace(), "name", owner.GetName(), ) return renewCAWithOptions(ctx, client, namer, owner, labels, caType, CABuilderOptions{ Subject: pkix.Name{ CommonName: owner.GetName() + "-" + string(caType), OrganizationalUnit: []string{owner.GetName()}, }, ExpireIn: &expireIn, PrivateKey: privateKey, }) } // renewCA creates and stores a new CA to replace one that might exist using a set of default builder options. func renewCA( ctx context.Context, client k8s.Client, namer name.Namer, owner client.Object, labels map[string]string, expireIn time.Duration, caType CAType, ) (*CA, error) { return renewCAWithOptions(ctx, client, namer, owner, labels, caType, CABuilderOptions{ Subject: pkix.Name{ CommonName: owner.GetName() + "-" + string(caType), OrganizationalUnit: []string{owner.GetName()}, }, ExpireIn: &expireIn, }) } // renewCAWithOptions will create and store a new CA to replace one that might exist using a set of given builder options // instead of accepting the defaults. func renewCAWithOptions( ctx context.Context, client k8s.Client, namer name.Namer, owner client.Object, labels map[string]string, caType CAType, options CABuilderOptions, ) (*CA, error) { ca, err := NewSelfSignedCA(options) if err != nil { return nil, err } caInternalSecret, err := internalSecretForCA(ca, namer, owner, labels, caType) if err != nil { return nil, err } // create or update internal secret if _, err := reconciler.ReconcileSecret(ctx, client, caInternalSecret, owner); err != nil { return nil, err } return ca, nil } // CanReuseCA returns true if the given CA is valid for reuse func CanReuseCA(ctx context.Context, ca *CA, expirationSafetyMargin time.Duration) bool { return PrivateMatchesPublicKey(ctx, ca.Cert.PublicKey, ca.PrivateKey) && CertIsValid(ctx, *ca.Cert, expirationSafetyMargin) } // CertIsValid returns true if the given cert is valid, // according to a safety time margin. func CertIsValid(ctx context.Context, cert x509.Certificate, expirationSafetyMargin time.Duration) bool { log := ulog.FromContext(ctx) now := time.Now() if now.Before(cert.NotBefore) { log.Info("CA cert is not valid yet", "subject", cert.Subject) return false } if certExpiring(now, cert, expirationSafetyMargin) { log.Info("CA cert expired or soon to expire", "subject", cert.Subject, "expiration", cert.NotAfter) return false } return true } // certExpiring is a simple helper function to see if a certificate is expiring relative to the given // time.Time, and a given safety margin. func certExpiring(t time.Time, cert x509.Certificate, expirationSafetyMargin time.Duration) bool { return t.After(cert.NotAfter.Add(-expirationSafetyMargin)) } // internalSecretForCA returns a new internal Secret for the given CA. func internalSecretForCA( ca *CA, namer name.Namer, owner v1.Object, labels map[string]string, caType CAType, ) (corev1.Secret, error) { privateKeyData, err := EncodePEMPrivateKey(ca.PrivateKey) if err != nil { return corev1.Secret{}, err } return corev1.Secret{ ObjectMeta: v1.ObjectMeta{ Namespace: owner.GetNamespace(), Name: CAInternalSecretName(namer, owner.GetName(), caType), Labels: labels, }, Data: map[string][]byte{ CertFileName: EncodePEMCert(ca.Cert.Raw), KeyFileName: privateKeyData, }, }, nil } func detectCAFileNames(path string) (string, string, error) { dirExists, err := fs.FileExists(path) if err != nil { return "", "", err } if !dirExists { return "", "", fmt.Errorf("global CA directory %s does not exist", path) } caFiles := []string{CAFileName, CAKeyFileName} tlsFiles := []string{CertFileName, KeyFileName} existsInDirectory := map[string]bool{} for _, f := range append(caFiles, tlsFiles...) { exists, err := fs.FileExists(filepath.Join(path, f)) if err != nil { return "", "", err } existsInDirectory[f] = exists } switch { case (existsInDirectory[CertFileName] || existsInDirectory[KeyFileName]) && existsInDirectory[CAKeyFileName]: return "", "", fmt.Errorf("both tls.* and ca.* files exist, configuration error") case existsInDirectory[CAFileName] && existsInDirectory[CAKeyFileName]: return filepath.Join(path, CAFileName), filepath.Join(path, CAKeyFileName), nil case existsInDirectory[CertFileName] && existsInDirectory[KeyFileName]: return filepath.Join(path, CertFileName), filepath.Join(path, KeyFileName), nil } return "", "", fmt.Errorf( "no CA certificate files found in %s, expecting one of the following key pair: (%s) or (%s)", path, strings.Join(caFiles, ","), strings.Join(tlsFiles, ",")) } // BuildCAFromFile reads and parses a CA and its associated private from files under path. Two naming conventions are supported: // tls.key and tls.crt or ca.key and ca.crt for private key and certificate respectively. func BuildCAFromFile(path string) (*CA, error) { certFile, privateKeyFile, err := detectCAFileNames(path) if err != nil { return nil, err } bytes, err := os.ReadFile(certFile) if err != nil { return nil, err } certs, err := ParsePEMCerts(bytes) if err != nil { return nil, errors.Wrapf(err, "cannot parse PEM cert from %s", certFile) } if len(certs) == 0 { return nil, fmt.Errorf("PEM %s file does not contain any certificates", certFile) } if len(certs) > 1 { return nil, fmt.Errorf("more than one certificate in PEM file %s", certFile) } cert := certs[0] privateKeyBytes, err := os.ReadFile(privateKeyFile) if err != nil { return nil, err } privateKey, err := ParsePEMPrivateKey(privateKeyBytes) if err != nil { return nil, errors.Wrapf(err, "cannot parse private key from PEM file %s", privateKeyFile) } return NewCA(privateKey, cert), nil } // BuildCAFromSecret parses the given secret into a CA. // It returns nil if the secrets could not be parsed into a CA. func BuildCAFromSecret(ctx context.Context, caInternalSecret corev1.Secret) *CA { if caInternalSecret.Data == nil { return nil } log := ulog.FromContext(ctx) caBytes, exists := caInternalSecret.Data[CertFileName] if !exists || len(caBytes) == 0 { return nil } certs, err := ParsePEMCerts(caBytes) if err != nil { log.Error(err, "cannot parse PEM cert from CA secret, will create a new one", "namespace", caInternalSecret.Namespace, "secret_name", caInternalSecret.Name) return nil } if len(certs) == 0 { return nil } if len(certs) > 1 { log.Info( "More than 1 certificate in the CA secret, continuing with the first one", "namespace", caInternalSecret.Namespace, "secret_name", caInternalSecret.Name, ) } cert := certs[0] privateKeyBytes, exists := caInternalSecret.Data[KeyFileName] if !exists || len(privateKeyBytes) == 0 { return nil } privateKey, err := ParsePEMPrivateKey(privateKeyBytes) if err != nil { log.Error(err, "Cannot parse PEM private key from CA secret, will create a new one", "namespace", caInternalSecret.Namespace, "secret_name", caInternalSecret.Name) return nil } return NewCA(privateKey, cert) }