From ffb5201d95b25861168df8dec26138878e5bcad0 Mon Sep 17 00:00:00 2001 From: JoshVanL Date: Sun, 5 Apr 2020 20:32:46 +0100 Subject: [PATCH] Adds extensible issuing controller Signed-off-by: JoshVanL --- pkg/api/util/conditions.go | 24 ++ pkg/apis/certmanager/v1alpha2/types.go | 3 + pkg/apis/certmanager/v1alpha3/types.go | 2 + pkg/controller/expcertificates/BUILD.bazel | 1 + .../expcertificates/issuing/BUILD.bazel | 65 ++++ .../issuing/issuing_controller.go | 368 ++++++++++++++++++ .../expcertificates/issuing/keystore.go | 136 +++++++ .../expcertificates/issuing/keystore_test.go | 225 +++++++++++ .../expcertificates/issuing/secret.go | 190 +++++++++ .../trigger/trigger_controller.go | 7 +- pkg/controller/expcertificates/util.go | 31 +- pkg/internal/apis/certmanager/types.go | 3 + 12 files changed, 1041 insertions(+), 14 deletions(-) create mode 100644 pkg/controller/expcertificates/issuing/BUILD.bazel create mode 100644 pkg/controller/expcertificates/issuing/issuing_controller.go create mode 100644 pkg/controller/expcertificates/issuing/keystore.go create mode 100644 pkg/controller/expcertificates/issuing/keystore_test.go create mode 100644 pkg/controller/expcertificates/issuing/secret.go diff --git a/pkg/api/util/conditions.go b/pkg/api/util/conditions.go index d00c18718..97981770b 100644 --- a/pkg/api/util/conditions.go +++ b/pkg/api/util/conditions.go @@ -119,6 +119,15 @@ func GetCertificateCondition(crt *cmapi.Certificate, conditionType cmapi.Certifi return nil } +func GetCertificateRequestCondition(req *cmapi.CertificateRequest, conditionType cmapi.CertificateRequestConditionType) *cmapi.CertificateRequestCondition { + for _, cond := range req.Status.Conditions { + if cond.Type == conditionType { + return &cond + } + } + return nil +} + // SetCertificateCondition will set a 'condition' on the given Certificate. // - If no condition of the same type already exists, the condition will be // inserted with the LastTransitionTime set to the current time. @@ -164,6 +173,21 @@ func SetCertificateCondition(crt *cmapi.Certificate, conditionType cmapi.Certifi klog.Infof("Setting lastTransitionTime for Certificate %q condition %q to %v", crt.Name, conditionType, nowTime.Time) } +// RemoteCertificateCondition will remove any condition with this condition type +func RemoveCertificateCondition(crt *cmapi.Certificate, conditionType cmapi.CertificateConditionType) { + var updatedConditions []cmapi.CertificateCondition + + // Search through existing conditions + for _, cond := range crt.Status.Conditions { + // Only add unrelated conditions + if cond.Type != conditionType { + updatedConditions = append(updatedConditions, cond) + } + } + + crt.Status.Conditions = updatedConditions +} + // SetCertificateRequestCondition will set a 'condition' on the given CertificateRequest. // - If no condition of the same type already exists, the condition will be // inserted with the LastTransitionTime set to the current time. diff --git a/pkg/apis/certmanager/v1alpha2/types.go b/pkg/apis/certmanager/v1alpha2/types.go index 9e6febcde..17eae05e4 100644 --- a/pkg/apis/certmanager/v1alpha2/types.go +++ b/pkg/apis/certmanager/v1alpha2/types.go @@ -55,6 +55,9 @@ const ( // Annotation names for CertificateRequests const ( CRPrivateKeyAnnotationKey = "cert-manager.io/private-key-secret-name" + + // Annotation to declare the CertificateRequest "revision", beloning to a Certificate Resource + CertificateRequestRevisionAnnotationKey = "cert-manager.io/certificate-revision" ) const ( diff --git a/pkg/apis/certmanager/v1alpha3/types.go b/pkg/apis/certmanager/v1alpha3/types.go index 507c6598d..a57712ef0 100644 --- a/pkg/apis/certmanager/v1alpha3/types.go +++ b/pkg/apis/certmanager/v1alpha3/types.go @@ -55,6 +55,8 @@ const ( // Annotation names for CertificateRequests const ( CRPrivateKeyAnnotationKey = "cert-manager.io/private-key-secret-name" + // Annotation to declare the CertificateRequest "revision", beloning to a Certificate Resource + CertificateRequestRevisionAnnotationKey = "cert-manager.io/certificate-revision" ) const ( diff --git a/pkg/controller/expcertificates/BUILD.bazel b/pkg/controller/expcertificates/BUILD.bazel index e2e0b7d1c..facdb7f82 100644 --- a/pkg/controller/expcertificates/BUILD.bazel +++ b/pkg/controller/expcertificates/BUILD.bazel @@ -30,6 +30,7 @@ filegroup( name = "all-srcs", srcs = [ ":package-srcs", + "//pkg/controller/expcertificates/issuing:all-srcs", "//pkg/controller/expcertificates/trigger:all-srcs", ], tags = ["automanaged"], diff --git a/pkg/controller/expcertificates/issuing/BUILD.bazel b/pkg/controller/expcertificates/issuing/BUILD.bazel new file mode 100644 index 000000000..6d8aeefb0 --- /dev/null +++ b/pkg/controller/expcertificates/issuing/BUILD.bazel @@ -0,0 +1,65 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "issuing_controller.go", + "keystore.go", + "secret.go", + ], + importpath = "github.com/jetstack/cert-manager/pkg/controller/expcertificates/issuing", + visibility = ["//visibility:public"], + deps = [ + "//pkg/api/util:go_default_library", + "//pkg/apis/certmanager/v1alpha2:go_default_library", + "//pkg/apis/meta/v1:go_default_library", + "//pkg/client/clientset/versioned:go_default_library", + "//pkg/client/informers/externalversions:go_default_library", + "//pkg/client/listers/certmanager/v1alpha2:go_default_library", + "//pkg/controller:go_default_library", + "//pkg/controller/expcertificates:go_default_library", + "//pkg/logs:go_default_library", + "//pkg/util/kube:go_default_library", + "//pkg/util/pki:go_default_library", + "@com_github_go_logr_logr//:go_default_library", + "@com_github_pavel_v_chernykh_keystore_go//:go_default_library", + "@com_sslmate_software_src_go_pkcs12//:go_default_library", + "@io_k8s_api//core/v1:go_default_library", + "@io_k8s_apimachinery//pkg/api/errors:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/labels:go_default_library", + "@io_k8s_client_go//informers:go_default_library", + "@io_k8s_client_go//kubernetes:go_default_library", + "@io_k8s_client_go//listers/core/v1:go_default_library", + "@io_k8s_client_go//tools/cache:go_default_library", + "@io_k8s_client_go//tools/record:go_default_library", + "@io_k8s_client_go//util/workqueue:go_default_library", + "@io_k8s_utils//clock:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) + +go_test( + name = "go_default_test", + srcs = ["keystore_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/apis/certmanager/v1alpha2:go_default_library", + "//pkg/util/pki:go_default_library", + "@com_github_pavel_v_chernykh_keystore_go//:go_default_library", + "@com_sslmate_software_src_go_pkcs12//:go_default_library", + ], +) diff --git a/pkg/controller/expcertificates/issuing/issuing_controller.go b/pkg/controller/expcertificates/issuing/issuing_controller.go new file mode 100644 index 000000000..0bffdfdc4 --- /dev/null +++ b/pkg/controller/expcertificates/issuing/issuing_controller.go @@ -0,0 +1,368 @@ +/* +Copyright 2020 The Jetstack cert-manager contributors. + +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 issuing + +import ( + "context" + "fmt" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + corelisters "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" + "k8s.io/utils/clock" + + apiutil "github.com/jetstack/cert-manager/pkg/api/util" + cmapi "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha2" + cmmeta "github.com/jetstack/cert-manager/pkg/apis/meta/v1" + cmclient "github.com/jetstack/cert-manager/pkg/client/clientset/versioned" + cminformers "github.com/jetstack/cert-manager/pkg/client/informers/externalversions" + cmlisters "github.com/jetstack/cert-manager/pkg/client/listers/certmanager/v1alpha2" + controllerpkg "github.com/jetstack/cert-manager/pkg/controller" + certificates "github.com/jetstack/cert-manager/pkg/controller/expcertificates" + logf "github.com/jetstack/cert-manager/pkg/logs" + utilkube "github.com/jetstack/cert-manager/pkg/util/kube" + utilpki "github.com/jetstack/cert-manager/pkg/util/pki" +) + +const ( + ControllerName = "CertificateIssuing" + + ctxTimeout = time.Second * 10 +) + +var ( + certificateGvk = cmapi.SchemeGroupVersion.WithKind("Certificate") +) + +// This controller observes the state of the certificate's 'Issuing' condition, +// which will then copy the singed certificates and private key to the target +// Secret resource. +type controller struct { + certificateLister cmlisters.CertificateLister + certificateRequestLister cmlisters.CertificateRequestLister + secretLister corelisters.SecretLister + recorder record.EventRecorder + clock clock.Clock + + client cmclient.Interface + kubeClient kubernetes.Interface + + // if true, Secret resources created by the controller will have an + // 'owner reference' set, meaning when the Certificate is deleted, the + // Secret resource will be automatically deleted. + // This option is disabled by default. + enableSecretOwnerReferences bool + + // experimentalIssuePKCS12, if true, will make the certificates controller + // create a `keystore.p12` in the Secret resource for each Certificate. + // This can only be toggled globally, and the keystore will be encrypted + // with the supplied ExperimentalPKCS12KeystorePassword. + // This flag is likely to be removed in future in favour of native PKCS12 + // keystore bundle support. + experimentalIssuePKCS12 bool + // ExperimentalPKCS12KeystorePassword is the password used to encrypt and + // decrypt PKCS#12 bundles stored in Secret resources. + // This option only has any affect is ExperimentalIssuePKCS12 is true. + experimentalPKCS12KeystorePassword string + // experimentalIssueJKS, if true, will make the certificates controller + // create a `keystore.jks` in the Secret resource for each Certificate. + // This can only be toggled globally, and the keystore will be encrypted + // with the supplied ExperimentalJKSPassword. + // This flag is likely to be removed in future in favour of native JKS + // keystore bundle support. + experimentalIssueJKS bool + // experimentalJKSPassword is the password used to encrypt and + // decrypt JKS files stored in Secret resources. + // This option only has any affect is experimentalIssueJKS is true. + experimentalJKSPassword string +} + +func NewController( + log logr.Logger, + kubeClient kubernetes.Interface, + client cmclient.Interface, + factory informers.SharedInformerFactory, + cmFactory cminformers.SharedInformerFactory, + recorder record.EventRecorder, + clock clock.Clock, + certificateControllerOptions controllerpkg.CertificateOptions, +) (*controller, workqueue.RateLimitingInterface, []cache.InformerSynced) { + + // create a queue used to queue up items to be processed + queue := workqueue.NewNamedRateLimitingQueue(workqueue.NewItemExponentialFailureRateLimiter(time.Second*1, time.Second*30), ControllerName) + + // obtain references to all the informers used by this controller + certificateInformer := cmFactory.Certmanager().V1alpha2().Certificates() + certificateRequestInformer := cmFactory.Certmanager().V1alpha2().CertificateRequests() + secretsInformer := factory.Core().V1().Secrets() + + certificateInformer.Informer().AddEventHandler(&controllerpkg.QueuingEventHandler{Queue: queue}) + certificateRequestInformer.Informer().AddEventHandler(&controllerpkg.BlockingEventHandler{ + WorkFunc: controllerpkg.HandleOwnedResourceNamespacedFunc(log, queue, certificateGvk, certificates.CertificateGetFunc(certificateInformer.Lister())), + }) + secretsInformer.Informer().AddEventHandler(&controllerpkg.BlockingEventHandler{ + // Issuer reconciles on changes to the Secret named `spec.nextPrivateKeySecretName` + WorkFunc: certificates.EnqueueCertificatesForSecretNameFunc(log, certificateInformer.Lister(), labels.Everything(), + certificates.WithNextPrivateKeySecretNamePredicateFunc, queue), + }) + + // build a list of InformerSynced functions that will be returned by the Register method. + // the controller will only begin processing items once all of these informers have synced. + mustSync := []cache.InformerSynced{ + certificateRequestInformer.Informer().HasSynced, + secretsInformer.Informer().HasSynced, + certificateInformer.Informer().HasSynced, + } + + return &controller{ + certificateLister: certificateInformer.Lister(), + certificateRequestLister: certificateRequestInformer.Lister(), + secretLister: secretsInformer.Lister(), + kubeClient: kubeClient, + client: client, + recorder: recorder, + clock: clock, + + enableSecretOwnerReferences: certificateControllerOptions.EnableOwnerRef, + experimentalIssuePKCS12: certificateControllerOptions.ExperimentalIssuePKCS12, + experimentalPKCS12KeystorePassword: certificateControllerOptions.ExperimentalPKCS12KeystorePassword, + experimentalIssueJKS: certificateControllerOptions.ExperimentalIssueJKS, + experimentalJKSPassword: certificateControllerOptions.ExperimentalJKSPassword, + }, queue, mustSync +} + +func (c *controller) ProcessItem(ctx context.Context, key string) error { + ctx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + + log := logf.FromContext(ctx).WithValues("key", key) + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + return nil + } + + crt, err := c.certificateLister.Certificates(namespace).Get(name) + if apierrors.IsNotFound(err) { + log.Error(err, "certificate not found for key") + return nil + } + if err != nil { + return err + } + + if !apiutil.CertificateHasCondition(crt, cmapi.CertificateCondition{ + Type: cmapi.CertificateConditionIssuing, + Status: cmmeta.ConditionTrue, + }) { + // Do nothing if an issuance is not in progress. + return nil + } + + if crt.Status.NextPrivateKeySecretName == nil || + len(*crt.Status.NextPrivateKeySecretName) == 0 { + // Do nothing if the next private key secret name is not set + return nil + } + + nextPrivateKeySecretName := *crt.Status.NextPrivateKeySecretName + + // CertificateRequest revisions begin from 1. If no revision is set on the + // status then assume no revision yet set. + nextRevision := 1 + if crt.Status.Revision != nil { + nextRevision = *crt.Status.Revision + 1 + } + + reqs, err := certificates.ListCertificateRequestsMatchingPredicates(c.certificateRequestLister.CertificateRequests(crt.Namespace), + labels.Everything(), + certificates.WithCertificateRevisionPredicateFunc(nextRevision), + certificates.WithCertificateRequestOwnerPredicateFunc(crt), + ) + if err != nil || len(reqs) != 1 { + // If error return. + // if no error but none exist do nothing. + // If no error but multiple exist, then leave to requestmanager controller + // to clean up. + return err + } + + req := reqs[0] + + reqReason := apiutil.GetCertificateRequestCondition(req, cmapi.CertificateRequestConditionReady).Reason + switch reqReason { + + // If the certificate request has failed, set the last failure time to now, + // and set the Issuing status condition to False with reason. + case cmapi.CertificateRequestReasonFailed: + nowTime := metav1.NewTime(c.clock.Now()) + crt.Status.LastFailureTime = &nowTime + + var reason, message string + condition := apiutil.GetCertificateRequestCondition(req, cmapi.CertificateRequestConditionReady) + + reason = condition.Reason + message = fmt.Sprintf("The certificate request has failed to complete and will be retried: %s", + condition.Message) + + crt = crt.DeepCopy() + apiutil.SetCertificateCondition(crt, cmapi.CertificateConditionIssuing, cmmeta.ConditionFalse, reason, message) + + _, err := c.client.CertmanagerV1alpha2().Certificates(crt.Namespace).UpdateStatus(ctx, crt, metav1.UpdateOptions{}) + if err != nil { + return err + } + + c.recorder.Event(crt, corev1.EventTypeWarning, reason, message) + + return nil + + // If the CertificateRequest is valid, verify its status and update + // accordingly. + case cmapi.CertificateRequestReasonIssued: + return c.issueCertificate(ctx, namespace, nextPrivateKeySecretName, nextRevision, crt, req) + + // CertificateRequest is not in a final state so do nothing. + default: + return nil + } +} + +// issueCertificate will ensure the public key of the CSR matches the signed +// certificate, and then store the certificate, CA and private key into the +// Secret in the appropriate format type. +func (c *controller) issueCertificate(ctx context.Context, namespace, nextPrivateKeySecretName string, nextRevision int, crt *cmapi.Certificate, req *cmapi.CertificateRequest) error { + csr, err := utilpki.DecodeX509CertificateRequestBytes(req.Spec.CSRPEM) + if err != nil { + return err + } + + key, err := utilkube.SecretTLSKeyRef(ctx, c.secretLister, namespace, nextPrivateKeySecretName, corev1.TLSPrivateKeyKey) + if apierrors.IsNotFound(err) { + // If secret does not exist, then the public key does not match. Do + // nothing (requestmanager will handle this). + return nil + } + if err != nil { + return err + } + + publicKeyMatches, err := utilpki.PublicKeyMatchesCSR(key.Public, csr) + if err != nil { + return err + } + + // If public key does not match, do nothing (requestmanager will handle this). + if !publicKeyMatches { + return nil + } + + // Verify the CSR options match what is requested in certificate.spec. + violations, err := certificates.RequestMatchesSpec(req, crt.Spec) + if err != nil { + return err + } + + // If there are violations in the spec, then the requestmanager will handle this. + if len(violations) > 0 { + return nil + } + + //Encode and issue the key-pair (store it in the Secret) + nextPrivateKeySecret, err := c.secretLister.Secrets(namespace).Get(*crt.Status.NextPrivateKeySecretName) + if err != nil { + // Even if the secret does not exist, we should error here as something + // potentially has gone very wrong. We should back off to be safe and try + // again once the next Secret has been created with the private key. + return err + } + + keyData, ok := nextPrivateKeySecret.Data[corev1.TLSPrivateKeyKey] + if !ok { + return fmt.Errorf("failed to find private key in Secret %s/%s at key %q", + namespace, *crt.Status.NextPrivateKeySecretName, corev1.TLSPrivateKeyKey) + } + + signedCertificate := req.Status.Certificate + ca := req.Status.CA + + err = c.updateSecretData(ctx, namespace, crt, secretData{sk: keyData, cert: signedCertificate, ca: ca}) + if err != nil { + return err + } + + //Set status.revision to revision of the CertificateRequest + crt.Status.Revision = &nextRevision + + crt = crt.DeepCopy() + + // Remove Issuing status condition + apiutil.RemoveCertificateCondition(crt, cmapi.CertificateConditionIssuing) + + //Clear status.lastFailureTime (if set) + crt.Status.LastFailureTime = nil + + _, err = c.client.CertmanagerV1alpha2().Certificates(crt.Namespace).UpdateStatus(ctx, crt, metav1.UpdateOptions{}) + if err != nil { + return err + } + + message := "The certificate has been successfully issued" + c.recorder.Event(crt, corev1.EventTypeNormal, "Issuing", message) + + return nil +} + +// controllerWrapper wraps the `controller` structure to make it implement +// the controllerpkg.queueingController interface +type controllerWrapper struct { + *controller +} + +func (c *controllerWrapper) Register(ctx *controllerpkg.Context) (workqueue.RateLimitingInterface, []cache.InformerSynced, error) { + // construct a new named logger to be reused throughout the controller + log := logf.FromContext(ctx.RootContext, ControllerName) + + ctrl, queue, mustSync := NewController(log, + ctx.Client, + ctx.CMClient, + ctx.KubeSharedInformerFactory, + ctx.SharedInformerFactory, + ctx.Recorder, + ctx.Clock, + ctx.CertificateOptions, + ) + c.controller = ctrl + + return queue, mustSync, nil +} + +func init() { + controllerpkg.Register(ControllerName, func(ctx *controllerpkg.Context) (controllerpkg.Interface, error) { + return controllerpkg.NewBuilder(ctx, ControllerName). + For(&controllerWrapper{}). + Complete() + }) +} diff --git a/pkg/controller/expcertificates/issuing/keystore.go b/pkg/controller/expcertificates/issuing/keystore.go new file mode 100644 index 000000000..598351231 --- /dev/null +++ b/pkg/controller/expcertificates/issuing/keystore.go @@ -0,0 +1,136 @@ +/* +Copyright 2020 The Jetstack cert-manager contributors. + +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. +*/ + +// This file defines methods used for PKCS#12 support. +// This is an experimental feature and the contents of this file are intended +// to be absorbed into a more fully fledged implementing ahead of the v0.15 +// release. +// This should hopefully not exist by the next time you come to read this :) + +package issuing + +import ( + "bytes" + "crypto/rand" + "crypto/x509" + "time" + + jks "github.com/pavel-v-chernykh/keystore-go" + "software.sslmate.com/src/go-pkcs12" + + "github.com/jetstack/cert-manager/pkg/util/pki" +) + +const ( + // pkcs12SecretKey is the name of the data entry in the Secret resource + // used to store the p12 file. + pkcs12SecretKey = "keystore.p12" + + // jksSecretKey is the name of the data entry in the Secret resource + // used to store the jks file. + jksSecretKey = "keystore.jks" +) + +// encodePKCS12Keystore will encode a PKCS12 keystore using the password provided. +// The key, certificate and CA data must be provided in PKCS1 or PKCS8 PEM format. +// If the certificate data contains multiple certificates, the first will be used +// as the keystores 'certificate' and the remaining certificates will be prepended +// to the list of CAs in the resulting keystore. +func encodePKCS12Keystore(password string, rawKey []byte, certPem []byte, caPem []byte) ([]byte, error) { + key, err := pki.DecodePrivateKeyBytes(rawKey) + if err != nil { + return nil, err + } + certs, err := pki.DecodeX509CertificateChainBytes(certPem) + if err != nil { + return nil, err + } + var cas []*x509.Certificate + if len(caPem) > 0 { + cas, err = pki.DecodeX509CertificateChainBytes(caPem) + if err != nil { + return nil, err + } + // prepend the certificate chain to the list of certificates as the PKCS12 + // library only allows setting a single certificate. + if len(certs) > 1 { + cas = append(certs[1:], cas...) + } + } + keystoreData, err := pkcs12.Encode(rand.Reader, key, certs[0], cas, password) + if err != nil { + return nil, err + } + return keystoreData, nil +} + +func encodeJKSKeystore(password string, rawKey []byte, certPem []byte, caPem []byte) ([]byte, error) { + // encode the private key to PKCS8 + key, err := pki.DecodePrivateKeyBytes(rawKey) + if err != nil { + return nil, err + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + + // encode the certificate chain + chain, err := pki.DecodeX509CertificateChainBytes(certPem) + if err != nil { + return nil, err + } + certs := make([]jks.Certificate, len(chain)) + for i, cert := range chain { + certs[i] = jks.Certificate{ + Type: "X509", + Content: cert.Raw, + } + } + + ks := jks.KeyStore{ + "certificate": &jks.PrivateKeyEntry{ + Entry: jks.Entry{ + CreationDate: time.Now(), + }, + PrivKey: keyDER, + CertChain: certs, + }, + } + // add the CA certificate, if set + if len(caPem) > 0 { + ca, err := pki.DecodeX509CertificateBytes(caPem) + if err != nil { + return nil, err + } + + ks["ca"] = &jks.TrustedCertificateEntry{ + Entry: jks.Entry{ + CreationDate: time.Now(), + }, + Certificate: jks.Certificate{ + Type: "X509", + Content: ca.Raw, + }, + } + } + + buf := &bytes.Buffer{} + if err := jks.Encode(buf, ks, []byte(password)); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/pkg/controller/expcertificates/issuing/keystore_test.go b/pkg/controller/expcertificates/issuing/keystore_test.go new file mode 100644 index 000000000..179c6297a --- /dev/null +++ b/pkg/controller/expcertificates/issuing/keystore_test.go @@ -0,0 +1,225 @@ +/* +Copyright 2020 The Jetstack cert-manager contributors. + +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 issuing + +import ( + "bytes" + "testing" + + jks "github.com/pavel-v-chernykh/keystore-go" + "software.sslmate.com/src/go-pkcs12" + + cmapi "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha2" + "github.com/jetstack/cert-manager/pkg/util/pki" +) + +func mustGeneratePrivateKey(t *testing.T, encoding cmapi.KeyEncoding) []byte { + pk, err := pki.GenerateRSAPrivateKey(2048) + if err != nil { + t.Fatal(err) + } + pkBytes, err := pki.EncodePrivateKey(pk, encoding) + if err != nil { + t.Fatal(err) + } + return pkBytes +} + +func mustSelfSignCertificate(t *testing.T, pkBytes []byte) []byte { + if pkBytes == nil { + pkBytes = mustGeneratePrivateKey(t, cmapi.PKCS8) + } + pk, err := pki.DecodePrivateKeyBytes(pkBytes) + if err != nil { + t.Fatal(err) + } + x509Crt, err := pki.GenerateTemplate(&cmapi.Certificate{ + Spec: cmapi.CertificateSpec{ + DNSNames: []string{"example.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + certBytes, _, err := pki.SignCertificate(x509Crt, x509Crt, pk.Public(), pk) + if err != nil { + t.Fatal(err) + } + return certBytes +} + +func TestEncodeJKSKeystore(t *testing.T) { + tests := map[string]struct { + password string + rawKey, certPEM, caPEM []byte + verify func(t *testing.T, out []byte, err error) + }{ + "encode a JKS bundle for a PKCS1 key and certificate only": { + password: "password", + rawKey: mustGeneratePrivateKey(t, cmapi.PKCS1), + certPEM: mustSelfSignCertificate(t, nil), + verify: func(t *testing.T, out []byte, err error) { + if err != nil { + t.Errorf("expected no error but got: %v", err) + return + } + buf := bytes.NewBuffer(out) + ks, err := jks.Decode(buf, []byte("password")) + if err != nil { + t.Errorf("error decoding keystore: %v", err) + return + } + if ks["certificate"] == nil { + t.Errorf("no certificate data found in keystore") + } + if ks["ca"] != nil { + t.Errorf("unexpected ca data found in keystore") + } + }, + }, + "encode a JKS bundle for a PKCS8 key and certificate only": { + password: "password", + rawKey: mustGeneratePrivateKey(t, cmapi.PKCS8), + certPEM: mustSelfSignCertificate(t, nil), + verify: func(t *testing.T, out []byte, err error) { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + buf := bytes.NewBuffer(out) + ks, err := jks.Decode(buf, []byte("password")) + if err != nil { + t.Errorf("error decoding keystore: %v", err) + return + } + if ks["certificate"] == nil { + t.Errorf("no certificate data found in keystore") + } + if ks["ca"] != nil { + t.Errorf("unexpected ca data found in keystore") + } + }, + }, + "encode a JKS bundle for a key, certificate and ca": { + password: "password", + rawKey: mustGeneratePrivateKey(t, cmapi.PKCS8), + certPEM: mustSelfSignCertificate(t, nil), + caPEM: mustSelfSignCertificate(t, nil), + verify: func(t *testing.T, out []byte, err error) { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + buf := bytes.NewBuffer(out) + ks, err := jks.Decode(buf, []byte("password")) + if err != nil { + t.Errorf("error decoding keystore: %v", err) + return + } + if ks["certificate"] == nil { + t.Errorf("no certificate data found in keystore") + } + if ks["ca"] == nil { + t.Errorf("no ca data found in keystore") + } + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + out, err := encodeJKSKeystore(test.password, test.rawKey, test.certPEM, test.caPEM) + test.verify(t, out, err) + }) + } +} + +func TestEncodePKCS12Keystore(t *testing.T) { + tests := map[string]struct { + password string + rawKey, certPEM, caPEM []byte + verify func(t *testing.T, out []byte, err error) + }{ + "encode a JKS bundle for a PKCS1 key and certificate only": { + password: "password", + rawKey: mustGeneratePrivateKey(t, cmapi.PKCS1), + certPEM: mustSelfSignCertificate(t, nil), + verify: func(t *testing.T, out []byte, err error) { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + pk, cert, err := pkcs12.Decode(out, "password") + if err != nil { + t.Errorf("error decoding keystore: %v", err) + return + } + if cert == nil { + t.Errorf("no certificate data found in keystore") + } + if pk == nil { + t.Errorf("no ca data found in keystore") + } + }, + }, + "encode a JKS bundle for a PKCS8 key and certificate only": { + password: "password", + rawKey: mustGeneratePrivateKey(t, cmapi.PKCS8), + certPEM: mustSelfSignCertificate(t, nil), + verify: func(t *testing.T, out []byte, err error) { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + pk, cert, err := pkcs12.Decode(out, "password") + if err != nil { + t.Errorf("error decoding keystore: %v", err) + return + } + if cert == nil { + t.Errorf("no certificate data found in keystore") + } + if pk == nil { + t.Errorf("no ca data found in keystore") + } + }, + }, + "encode a JKS bundle for a key, certificate and ca": { + password: "password", + rawKey: mustGeneratePrivateKey(t, cmapi.PKCS8), + certPEM: mustSelfSignCertificate(t, nil), + caPEM: mustSelfSignCertificate(t, nil), + verify: func(t *testing.T, out []byte, err error) { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + // The pkcs12 package does not expose a way to decode the CA + // data that has been written. + // It will return an error when attempting to decode a file + // with more than one 'certbag', so we just ensure the error + // returned is the expected error and don't inspect the keystore + // contents. + _, _, err = pkcs12.Decode(out, "password") + if err == nil || err.Error() != "pkcs12: expected exactly two safe bags in the PFX PDU" { + t.Errorf("unexpected error string, exp=%q, got=%v", "pkcs12: expected exactly two safe bags in the PFX PDU", err) + return + } + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + out, err := encodePKCS12Keystore(test.password, test.rawKey, test.certPEM, test.caPEM) + test.verify(t, out, err) + }) + } +} diff --git a/pkg/controller/expcertificates/issuing/secret.go b/pkg/controller/expcertificates/issuing/secret.go new file mode 100644 index 000000000..b34ebd7eb --- /dev/null +++ b/pkg/controller/expcertificates/issuing/secret.go @@ -0,0 +1,190 @@ +/* +Copyright 2020 The Jetstack cert-manager contributors. + +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. +*/ + +// This file defines methods used for PKCS#12 support. +// This is an experimental feature and the contents of this file are intended +// to be absorbed into a more fully fledged implementing ahead of the v0.15 +// release. +// This should hopefully not exist by the next time you come to read this :) +package issuing + +import ( + "bytes" + "context" + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiutil "github.com/jetstack/cert-manager/pkg/api/util" + cmapi "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha2" + cmmeta "github.com/jetstack/cert-manager/pkg/apis/meta/v1" + utilpki "github.com/jetstack/cert-manager/pkg/util/pki" +) + +// secretData is a structure wrapping private key, certificate and CA data +type secretData struct { + sk, cert, ca []byte +} + +// updateSecretData will ensure the Secret resource contains the given secret +// data as well as appropriate metadata. +// If the Secret resource does not exist, it will be created. +// Otherwise, the existing resource will be updated. +// The first return argument will be true if the resource was updated/created +// without error. +// updateSecretData will also update deprecated annotations if they exist. +func (c *controller) updateSecretData(ctx context.Context, namespace string, crt *cmapi.Certificate, data secretData) error { + // Fetch a copy of the existing Secret resource + secret, err := c.secretLister.Secrets(crt.Namespace).Get(crt.Spec.SecretName) + if !apierrors.IsNotFound(err) && err != nil { + // If secret doesn't exist yet, then don't error + return err + } + + secretExists := (secret != nil) + + // If the seret does not exist yet, then we need to create one + if !secretExists { + secret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: crt.Spec.SecretName, + Namespace: namespace, + }, + Type: corev1.SecretTypeTLS, + } + } + + // secret will be overwritten by 'existingSecret' if existingSecret is non-nil + if c.enableSecretOwnerReferences { + secret.OwnerReferences = []metav1.OwnerReference{*metav1.NewControllerRef(crt, certificateGvk)} + } + + //newSecret := secret.DeepCopy() + + err = c.setSecretValues(crt, secret, data) + if err != nil { + return err + } + + // TODO: P12/JKS values use a random parameter so it's values will always + // change. Devise a better solution for checking change. + //if reflect.DeepEqual(secret, newSecret) { + // return nil + //} + + // If secret does not exist then create it + if !secretExists { + _, err = c.kubeClient.CoreV1().Secrets(secret.Namespace).Create(ctx, secret, metav1.CreateOptions{}) + return err + } + + _, err = c.kubeClient.CoreV1().Secrets(secret.Namespace).Update(ctx, secret, metav1.UpdateOptions{}) + return err +} + +// setSecretValues will update the Secret resource 's' with the data contained +// in the given secretData. +// It will update labels and annotations on the Secret resource appropriately. +// The Secret resource 's' must be non-nil, although may be a resource that does +// not exist in the Kubernetes apiserver yet. +// setSecretValues will NOT actually update the resource in the apiserver. +// If updating an existing Secret resource returned by an api client 'lister', +// make sure to DeepCopy the object first to avoid modifying data in-cache. +// It will also update depreciated issuer name and kind annotations if they exist. +func (c *controller) setSecretValues(crt *cmapi.Certificate, s *corev1.Secret, data secretData) error { + // initialize the `Data` field if it is nil + if s.Data == nil { + s.Data = make(map[string][]byte) + } + + // Handle the experimental PKCS12 support + if c.experimentalIssuePKCS12 { + // Only write a new PKCS12 file if any of the private key/certificate/CA data has + // actually changed. + if data.sk != nil && data.cert != nil && + (!bytes.Equal(s.Data[corev1.TLSPrivateKeyKey], data.sk) || + !bytes.Equal(s.Data[corev1.TLSCertKey], data.cert) || + !bytes.Equal(s.Data[cmmeta.TLSCAKey], data.ca)) { + keystoreData, err := encodePKCS12Keystore(c.experimentalPKCS12KeystorePassword, data.sk, data.cert, data.ca) + if err != nil { + return fmt.Errorf("error encoding PKCS12 bundle: %w", err) + } + // always overwrite the keystore entry for now + s.Data[pkcs12SecretKey] = keystoreData + } + } + // Handle the experimental JKS support + if c.experimentalIssueJKS { + // Only write a new JKS file if any of the private key/certificate/CA data has + // actually changed. + if data.sk != nil && data.cert != nil && + (!bytes.Equal(s.Data[corev1.TLSPrivateKeyKey], data.sk) || + !bytes.Equal(s.Data[corev1.TLSCertKey], data.cert) || + !bytes.Equal(s.Data[cmmeta.TLSCAKey], data.ca)) { + keystoreData, err := encodeJKSKeystore(c.experimentalJKSPassword, data.sk, data.cert, data.ca) + if err != nil { + return fmt.Errorf("error encoding JKS bundle: %w", err) + } + // always overwrite the keystore entry for now + s.Data[jksSecretKey] = keystoreData + } + } + + s.Data[corev1.TLSPrivateKeyKey] = data.sk + s.Data[corev1.TLSCertKey] = data.cert + s.Data[cmmeta.TLSCAKey] = data.ca + + if s.Annotations == nil { + s.Annotations = make(map[string]string) + } + + s.Annotations[cmapi.CertificateNameKey] = crt.Name + s.Annotations[cmapi.IssuerNameAnnotationKey] = crt.Spec.IssuerRef.Name + s.Annotations[cmapi.IssuerKindAnnotationKey] = apiutil.IssuerKind(crt.Spec.IssuerRef) + + // If deprecated annotations exist with any value, then they too shall be + // updated + if _, ok := s.Annotations[cmapi.DeprecatedIssuerNameAnnotationKey]; ok { + s.Annotations[cmapi.DeprecatedIssuerNameAnnotationKey] = crt.Spec.IssuerRef.Name + } + if _, ok := s.Annotations[cmapi.DeprecatedIssuerKindAnnotationKey]; ok { + s.Annotations[cmapi.DeprecatedIssuerKindAnnotationKey] = apiutil.IssuerKind(crt.Spec.IssuerRef) + } + + // if the certificate data is empty, clear the subject related annotations + if len(data.cert) == 0 { + delete(s.Annotations, cmapi.CommonNameAnnotationKey) + delete(s.Annotations, cmapi.AltNamesAnnotationKey) + delete(s.Annotations, cmapi.IPSANAnnotationKey) + delete(s.Annotations, cmapi.URISANAnnotationKey) + } else { + x509Cert, err := utilpki.DecodeX509CertificateBytes(data.cert) + // TODO: handle InvalidData here? + if err != nil { + return err + } + + s.Annotations[cmapi.CommonNameAnnotationKey] = x509Cert.Subject.CommonName + s.Annotations[cmapi.AltNamesAnnotationKey] = strings.Join(x509Cert.DNSNames, ",") + s.Annotations[cmapi.IPSANAnnotationKey] = strings.Join(utilpki.IPAddressesToString(x509Cert.IPAddresses), ",") + s.Annotations[cmapi.URISANAnnotationKey] = strings.Join(utilpki.URLsToString(x509Cert.URIs), ",") + } + + return nil +} diff --git a/pkg/controller/expcertificates/trigger/trigger_controller.go b/pkg/controller/expcertificates/trigger/trigger_controller.go index 45577308c..b8891f798 100644 --- a/pkg/controller/expcertificates/trigger/trigger_controller.go +++ b/pkg/controller/expcertificates/trigger/trigger_controller.go @@ -91,7 +91,8 @@ func NewController( }) secretsInformer.Informer().AddEventHandler(&controllerpkg.BlockingEventHandler{ // Trigger reconciles on changes to the Secret named `spec.secretName` - WorkFunc: certificates.EnqueueCertificatesForSecretNameFunc(log, certificateInformer.Lister(), labels.Everything(), queue), + WorkFunc: certificates.EnqueueCertificatesForSecretNameFunc(log, certificateInformer.Lister(), labels.Everything(), + certificates.WithSecretNamePredicateFunc, queue), }) // build a list of InformerSynced functions that will be returned by the Register method. @@ -181,9 +182,9 @@ func (c *controller) buildPolicyInputForCertificate(ctx context.Context, crt *cm // Attempt to fetch the CertificateRequest resource for the current 'status.revision'. var req *cmapi.CertificateRequest if crt.Status.Revision != nil { - reqs, err := certificates.ListCertificateRequestsMatchingPredicate(c.certificateRequestLister.CertificateRequests(crt.Namespace), + reqs, err := certificates.ListCertificateRequestsMatchingPredicates(c.certificateRequestLister.CertificateRequests(crt.Namespace), labels.Everything(), - certificates.WithOwnerPredicateFunc(crt), + certificates.WithCertificateRequestOwnerPredicateFunc(crt), certificates.WithCertificateRevisionPredicateFunc(*crt.Status.Revision), ) if err != nil { diff --git a/pkg/controller/expcertificates/util.go b/pkg/controller/expcertificates/util.go index b072d1536..e62045016 100644 --- a/pkg/controller/expcertificates/util.go +++ b/pkg/controller/expcertificates/util.go @@ -42,11 +42,12 @@ func CertificateGetFunc(lister cmlisters.CertificateLister) GetFunc { } // EnqueueCertificatesForSecretNameFunc will enqueue Certificate resources that -// specify a `spec.secretName` equal to the name of the Secret resource being -// processed. +// satisfy a CertificatePredicateFunc based upon the name of the Secret resource +// being processed. // This is used to trigger Certificates to reconcile for changes to the Secret // being managed. -func EnqueueCertificatesForSecretNameFunc(log logr.Logger, lister cmlisters.CertificateLister, selector labels.Selector, queue workqueue.Interface) func(obj interface{}) { +func EnqueueCertificatesForSecretNameFunc(log logr.Logger, lister cmlisters.CertificateLister, selector labels.Selector, + secretNamePredicate WithCertificatePredicateFunc, queue workqueue.Interface) func(obj interface{}) { return func(obj interface{}) { s, ok := obj.(*corev1.Secret) if !ok { @@ -54,7 +55,7 @@ func EnqueueCertificatesForSecretNameFunc(log logr.Logger, lister cmlisters.Cert return } - certs, err := ListCertificatesMatchingPredicate(lister.Certificates(s.Namespace), selector, WithSecretNamePredicateFunc(s.Name)) + certs, err := ListCertificatesMatchingPredicate(lister.Certificates(s.Namespace), selector, secretNamePredicate(s.Name)) if err != nil { log.Error(err, "Failed listing Certificate resources") return @@ -71,6 +72,8 @@ func EnqueueCertificatesForSecretNameFunc(log logr.Logger, lister cmlisters.Cert } } +type WithCertificatePredicateFunc func(string) CertificatePredicateFunc + type CertificatePredicateFunc func(*cmapi.Certificate) bool func WithSecretNamePredicateFunc(name string) CertificatePredicateFunc { @@ -79,6 +82,15 @@ func WithSecretNamePredicateFunc(name string) CertificatePredicateFunc { } } +func WithNextPrivateKeySecretNamePredicateFunc(name string) CertificatePredicateFunc { + return func(crt *cmapi.Certificate) bool { + if crt.Status.NextPrivateKeySecretName == nil { + return false + } + return *crt.Status.NextPrivateKeySecretName == name + } +} + func ListCertificatesMatchingPredicate(lister cmlisters.CertificateNamespaceLister, selector labels.Selector, predicate CertificatePredicateFunc) ([]*cmapi.Certificate, error) { crts, err := lister.List(selector) if err != nil { @@ -93,10 +105,6 @@ func ListCertificatesMatchingPredicate(lister cmlisters.CertificateNamespaceList return out, nil } -const ( - CertificateRevisionAnnotationKey = "cert-manager.io/certificate-revision" -) - type CertificateRequestPredicateFunc func(*cmapi.CertificateRequest) bool func WithCertificateRevisionPredicateFunc(revision int) CertificateRequestPredicateFunc { @@ -104,17 +112,17 @@ func WithCertificateRevisionPredicateFunc(revision int) CertificateRequestPredic if req.Annotations == nil { return false } - return req.Annotations[CertificateRevisionAnnotationKey] == fmt.Sprintf("%d", revision) + return req.Annotations[cmapi.CertificateRequestRevisionAnnotationKey] == fmt.Sprintf("%d", revision) } } -func WithOwnerPredicateFunc(owner metav1.Object) CertificateRequestPredicateFunc { +func WithCertificateRequestOwnerPredicateFunc(owner metav1.Object) CertificateRequestPredicateFunc { return func(req *cmapi.CertificateRequest) bool { return metav1.IsControlledBy(req, owner) } } -func ListCertificateRequestsMatchingPredicate(lister cmlisters.CertificateRequestNamespaceLister, selector labels.Selector, predicates ...CertificateRequestPredicateFunc) ([]*cmapi.CertificateRequest, error) { +func ListCertificateRequestsMatchingPredicates(lister cmlisters.CertificateRequestNamespaceLister, selector labels.Selector, predicates ...CertificateRequestPredicateFunc) ([]*cmapi.CertificateRequest, error) { reqs, err := lister.List(selector) if err != nil { return nil, err @@ -132,6 +140,7 @@ func ListCertificateRequestsMatchingPredicate(lister cmlisters.CertificateReques out = append(out, req) } } + return out, nil } diff --git a/pkg/internal/apis/certmanager/types.go b/pkg/internal/apis/certmanager/types.go index 3bc601435..8bf7600c2 100644 --- a/pkg/internal/apis/certmanager/types.go +++ b/pkg/internal/apis/certmanager/types.go @@ -30,6 +30,9 @@ const ( // Annotation names for CertificateRequests const ( CRPrivateKeyAnnotationKey = "cert-manager.io/private-key-secret-name" + + // Annotation to declare the CertificateRequest "revision", beloning to a Certificate Resource + CertificateRequestRevisionAnnotationKey = "cert-manager.io/certificate-revision" ) const (