diff --git a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go index 5b85fbdce490..fb3947fd4a32 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go +++ b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go @@ -2152,9 +2152,14 @@ func (r *HostedControlPlaneReconciler) reconcilePKI(ctx context.Context, hcp *hy observedDefaultIngressCert = nil } } + namedCertCAs, err := r.getNamedCertificateCAs(ctx, hcp) + if err != nil { + return fmt.Errorf("failed to get named certificate CAs: %w", err) + } + rootCAConfigMap := manifests.RootCAConfigMap(hcp.Namespace) if _, err := createOrUpdate(ctx, r, rootCAConfigMap, func() error { - return pki.ReconcileRootCAConfigMap(rootCAConfigMap, p.OwnerRef, rootCASecret, observedDefaultIngressCert) + return pki.ReconcileRootCAConfigMap(rootCAConfigMap, p.OwnerRef, rootCASecret, observedDefaultIngressCert, namedCertCAs...) }); err != nil { return fmt.Errorf("failed to reconcile root CA configmap: %w", err) } @@ -2250,6 +2255,57 @@ func (r *HostedControlPlaneReconciler) reconcilePlatformSpecificCerts(ctx contex return nil } +// getNamedCertificateCAs extracts CA certificates from named certificate Secrets +// so they can be included in the root-ca ConfigMap and ultimately propagated to +// the guest cluster's kube-root-ca.crt trust bundle. +// +// CA certificates are collected from both the tls.crt chain (non-leaf CA +// certs) and the ca.crt key (if present), merging both sources. This covers +// Secrets where the chain and the root CA are split across the two keys. +// If neither source provides a valid CA, a warning is logged. +func (r *HostedControlPlaneReconciler) getNamedCertificateCAs(ctx context.Context, hcp *hyperv1.HostedControlPlane) ([]*corev1.Secret, error) { + if hcp.Spec.Configuration == nil { + return nil, nil + } + var namedCertCAs []*corev1.Secret + for _, namedCert := range hcp.Spec.Configuration.GetNamedCertificates() { + if namedCert.ServingCertificate.Name == "" { + continue + } + certSecret := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{ + Namespace: hcp.Namespace, + Name: namedCert.ServingCertificate.Name, + }, certSecret); err != nil { + // Return an error for all failures including NotFound so we + // never publish a root-ca ConfigMap with an incomplete CA + // bundle. The controller will requeue and the existing + // ConfigMap stays intact until all configured Secrets are + // available. + return nil, fmt.Errorf("failed to get named certificate secret %s: %w", namedCert.ServingCertificate.Name, err) + } + // Extract CAs from the tls.crt chain (non-leaf certificates) + // and also from ca.crt if present, merging both sources so that + // a Secret with leaf+intermediate in tls.crt and the root in + // ca.crt contributes the full trust chain. + caCerts := pki.ExtractCACertificatesFromChain(certSecret.Data[corev1.TLSCertKey]) + if caData, ok := certSecret.Data[certs.CASignerCertMapKey]; ok && len(caData) > 0 { + caCerts = append(caCerts, pki.FilterCACertificates(caData)...) + } + if len(caCerts) > 0 { + namedCertCAs = append(namedCertCAs, &corev1.Secret{ + Data: map[string][]byte{ + certs.CASignerCertMapKey: caCerts, + }, + }) + } else { + r.Log.Info("Named certificate secret has no valid CA certificate in tls.crt chain or ca.crt; its signer will not be included in the root-ca trust bundle", + "secret", namedCert.ServingCertificate.Name) + } + } + return namedCertCAs, nil +} + func (r *HostedControlPlaneReconciler) reconcileUnmanagedEtcd(ctx context.Context, hcp *hyperv1.HostedControlPlane, createOrUpdate upsert.CreateOrUpdateFN) error { // reconcile client secret over if hcp.Spec.Etcd.Unmanaged == nil || len(hcp.Spec.Etcd.Unmanaged.TLS.ClientSecret.Name) == 0 || len(hcp.Spec.Etcd.Unmanaged.Endpoint) == 0 { diff --git a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller_test.go b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller_test.go index fa607cd997ef..5d7acff7c4d3 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller_test.go +++ b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller_test.go @@ -6934,3 +6934,237 @@ func TestValidateAWSKMSConfig(t *testing.T) { }) } } + +//go:embed testdata/named-cert-cas/root-ca.pem +var testNamedCertRootCAPEM []byte + +//go:embed testdata/named-cert-cas/leaf.pem +var testNamedCertLeafPEM []byte + +//go:embed testdata/named-cert-cas/chain.pem +var testNamedCertChainPEM []byte + +//go:embed testdata/named-cert-cas/leaf-intermediate-chain.pem +var testNamedCertLeafIntermediateChainPEM []byte + +func TestGetNamedCertificateCAs(t *testing.T) { + rootCAPEM := testNamedCertRootCAPEM + leafPEM := testNamedCertLeafPEM + chainPEM := testNamedCertChainPEM + + const namespace = "test-ns" + + tests := []struct { + name string + hcp *hyperv1.HostedControlPlane + objects []client.Object + expectErr bool + expectCount int + }{ + { + name: "When configuration is nil, it should return nil", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"}, + }, + expectCount: 0, + }, + { + name: "When named cert has empty ServingCertificate name, it should skip it", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"}, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + ServingCerts: configv1.APIServerServingCerts{ + NamedCertificates: []configv1.APIServerNamedServingCert{ + {ServingCertificate: configv1.SecretNameReference{Name: ""}}, + }, + }, + }, + }, + }, + }, + expectCount: 0, + }, + { + name: "When named cert Secret does not exist, it should return an error", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"}, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + ServingCerts: configv1.APIServerServingCerts{ + NamedCertificates: []configv1.APIServerNamedServingCert{ + {ServingCertificate: configv1.SecretNameReference{Name: "missing-secret"}}, + }, + }, + }, + }, + }, + }, + expectErr: true, + }, + { + name: "When tls.crt contains a CA chain, it should extract the CA certificates", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"}, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + ServingCerts: configv1.APIServerServingCerts{ + NamedCertificates: []configv1.APIServerNamedServingCert{ + {ServingCertificate: configv1.SecretNameReference{Name: "chain-cert"}}, + }, + }, + }, + }, + }, + }, + objects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "chain-cert"}, + Data: map[string][]byte{ + corev1.TLSCertKey: chainPEM, + }, + }, + }, + expectCount: 1, + }, + { + name: "When tls.crt has only a leaf and ca.crt is present, it should fall back to ca.crt", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"}, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + ServingCerts: configv1.APIServerServingCerts{ + NamedCertificates: []configv1.APIServerNamedServingCert{ + {ServingCertificate: configv1.SecretNameReference{Name: "leaf-only-cert"}}, + }, + }, + }, + }, + }, + }, + objects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "leaf-only-cert"}, + Data: map[string][]byte{ + corev1.TLSCertKey: leafPEM, + certs.CASignerCertMapKey: rootCAPEM, + }, + }, + }, + expectCount: 1, + }, + { + name: "When tls.crt has only a leaf and no ca.crt, it should return empty and log a warning", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"}, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + ServingCerts: configv1.APIServerServingCerts{ + NamedCertificates: []configv1.APIServerNamedServingCert{ + {ServingCertificate: configv1.SecretNameReference{Name: "no-ca-cert"}}, + }, + }, + }, + }, + }, + }, + objects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "no-ca-cert"}, + Data: map[string][]byte{ + corev1.TLSCertKey: leafPEM, + }, + }, + }, + expectCount: 0, + }, + { + name: "When tls.crt has leaf and intermediate and ca.crt has the root, it should include both CAs", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"}, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + ServingCerts: configv1.APIServerServingCerts{ + NamedCertificates: []configv1.APIServerNamedServingCert{ + {ServingCertificate: configv1.SecretNameReference{Name: "split-chain-cert"}}, + }, + }, + }, + }, + }, + }, + objects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "split-chain-cert"}, + Data: map[string][]byte{ + corev1.TLSCertKey: testNamedCertLeafIntermediateChainPEM, + certs.CASignerCertMapKey: rootCAPEM, + }, + }, + }, + expectCount: 1, + }, + { + name: "When multiple named certs are configured, it should extract CAs from all of them", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"}, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + ServingCerts: configv1.APIServerServingCerts{ + NamedCertificates: []configv1.APIServerNamedServingCert{ + {ServingCertificate: configv1.SecretNameReference{Name: "cert-a"}}, + {ServingCertificate: configv1.SecretNameReference{Name: "cert-b"}}, + }, + }, + }, + }, + }, + }, + objects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "cert-a"}, + Data: map[string][]byte{ + corev1.TLSCertKey: chainPEM, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "cert-b"}, + Data: map[string][]byte{ + corev1.TLSCertKey: leafPEM, + certs.CASignerCertMapKey: rootCAPEM, + }, + }, + }, + expectCount: 2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewGomegaWithT(t) + fakeClient := fake.NewClientBuilder().WithObjects(tc.objects...).Build() + r := &HostedControlPlaneReconciler{ + Client: fakeClient, + Log: ctrl.LoggerFrom(t.Context()), + } + + result, err := r.getNamedCertificateCAs(t.Context(), tc.hcp) + if tc.expectErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(result).To(HaveLen(tc.expectCount)) + for _, s := range result { + g.Expect(s.Data).To(HaveKey(certs.CASignerCertMapKey)) + g.Expect(s.Data[certs.CASignerCertMapKey]).NotTo(BeEmpty()) + } + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/pki/ca.go b/control-plane-operator/controllers/hostedcontrolplane/pki/ca.go index a2dc5d668ce1..50f31569d448 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/pki/ca.go +++ b/control-plane-operator/controllers/hostedcontrolplane/pki/ca.go @@ -176,7 +176,7 @@ func ReconcileEtcdMetricsSignerConfigMap(cm *corev1.ConfigMap, ownerRef config.O return reconcileAggregateCA(cm, ownerRef, etcdMetricsSigner) } -func ReconcileRootCAConfigMap(cm *corev1.ConfigMap, ownerRef config.OwnerRef, rootCA *corev1.Secret, observedDefaultIngressCert *corev1.ConfigMap) error { +func ReconcileRootCAConfigMap(cm *corev1.ConfigMap, ownerRef config.OwnerRef, rootCA *corev1.Secret, observedDefaultIngressCert *corev1.ConfigMap, namedCertCAs ...*corev1.Secret) error { sources := []*corev1.Secret{rootCA} if observedDefaultIngressCert != nil { sources = append(sources, &corev1.Secret{ @@ -185,9 +185,58 @@ func ReconcileRootCAConfigMap(cm *corev1.ConfigMap, ownerRef config.OwnerRef, ro }, }) } + sources = append(sources, namedCertCAs...) return reconcileAggregateCA(cm, ownerRef, sources...) } +// FilterCACertificates parses a PEM bundle and returns only the certificates +// that are valid CAs (IsCA=true). Malformed or non-CA certificates are +// skipped. +func FilterCACertificates(certPEM []byte) []byte { + var caCerts []byte + rest := certPEM + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + continue + } + if !cert.IsCA { + continue + } + caCerts = append(caCerts, pem.EncodeToMemory(block)...) + } + return caCerts +} + +// ExtractCACertificatesFromChain parses a PEM-encoded certificate chain and +// returns a PEM bundle containing only the non-leaf CA certificates. +// The first CERTIFICATE block (the leaf/server cert) is always skipped. +// Subsequent certificates that fail to parse or are not CAs are also skipped. +// If the chain contains only a single certificate, nil is returned. +func ExtractCACertificatesFromChain(certPEM []byte) []byte { + // Skip the first CERTIFICATE PEM block (leaf), then filter the rest. + rest := certPEM + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + return nil + } + if block.Type == "CERTIFICATE" { + break + } + } + return FilterCACertificates(rest) +} + func ReconcileKonnectivityConfigMap(cm *corev1.ConfigMap, ownerRef config.OwnerRef, signers ...*corev1.Secret) error { return reconcileAggregateCA(cm, ownerRef, signers...) } diff --git a/control-plane-operator/controllers/hostedcontrolplane/pki/ca_test.go b/control-plane-operator/controllers/hostedcontrolplane/pki/ca_test.go index 3d6dea23187e..8e59f152b9fc 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/pki/ca_test.go +++ b/control-plane-operator/controllers/hostedcontrolplane/pki/ca_test.go @@ -77,6 +77,296 @@ func newOwnerRef() config.OwnerRef { } } +// generateTestCert creates a PEM-encoded certificate signed by the given parent. +// If parent and parentKey are nil, the certificate is self-signed. +func generateTestCert(t *testing.T, cn string, isCA bool, parent *x509.Certificate, parentKey *ecdsa.PrivateKey) (certPEM []byte, key *ecdsa.PrivateKey, cert *x509.Certificate) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: isCA, + } + if isCA { + template.KeyUsage = x509.KeyUsageCertSign + template.BasicConstraintsValid = true + } + signer := key + signerCert := template + if parent != nil { + signerCert = parent + signer = parentKey + } + certDER, err := x509.CreateCertificate(rand.Reader, template, signerCert, &key.PublicKey, signer) + if err != nil { + t.Fatalf("failed to create certificate: %v", err) + } + cert, err = x509.ParseCertificate(certDER) + if err != nil { + t.Fatalf("failed to parse certificate: %v", err) + } + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + return certPEM, key, cert +} + +// buildChain concatenates PEM blocks into a single chain. +func buildChain(parts ...[]byte) []byte { + var chain []byte + for _, p := range parts { + chain = append(chain, p...) + } + return chain +} + +func TestFilterCACertificates(t *testing.T) { + rootCAPEM, rootKey, rootCert := generateTestCert(t, "root-ca", true, nil, nil) + intermediatePEM, _, _ := generateTestCert(t, "intermediate-ca", true, rootCert, rootKey) + leafPEM, _, _ := generateTestCert(t, "api.example.com", false, nil, nil) + + tests := []struct { + name string + certPEM []byte + expectNonEmpty bool + expectContains []string + }{ + { + name: "When input is nil, it should return nil", + certPEM: nil, + expectNonEmpty: false, + }, + { + name: "When input contains a single CA, it should return it", + certPEM: rootCAPEM, + expectNonEmpty: true, + expectContains: []string{"root-ca"}, + }, + { + name: "When input contains multiple CAs, it should return all of them", + certPEM: buildChain(rootCAPEM, intermediatePEM), + expectNonEmpty: true, + expectContains: []string{"root-ca", "intermediate-ca"}, + }, + { + name: "When input contains only a non-CA certificate, it should return nil", + certPEM: leafPEM, + expectNonEmpty: false, + }, + { + name: "When input mixes CA and non-CA certificates, it should return only CAs", + certPEM: buildChain(leafPEM, rootCAPEM), + expectNonEmpty: true, + expectContains: []string{"root-ca"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := FilterCACertificates(tc.certPEM) + if tc.expectNonEmpty && len(result) == 0 { + t.Fatal("expected non-empty CA bundle, got empty") + } + if !tc.expectNonEmpty && len(result) != 0 { + t.Fatalf("expected empty CA bundle, got %d bytes", len(result)) + } + for _, cn := range tc.expectContains { + rest := result + found := false + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate: %v", err) + } + if cert.Subject.CommonName == cn { + found = true + break + } + } + if !found { + t.Errorf("expected CA with CN=%q in result, not found", cn) + } + } + }) + } +} + +func TestExtractCACertificatesFromChain(t *testing.T) { + rootCAPEM, rootKey, rootCert := generateTestCert(t, "root-ca", true, nil, nil) + intermediatePEM, intermediateKey, intermediateCert := generateTestCert(t, "intermediate-ca", true, rootCert, rootKey) + leafPEM, _, _ := generateTestCert(t, "api.example.com", false, intermediateCert, intermediateKey) + nonCAPEM, _, _ := generateTestCert(t, "not-a-ca", false, rootCert, rootKey) + + tests := []struct { + name string + certPEM []byte + expectNonEmpty bool + expectContains []string + }{ + { + name: "When input is nil, it should return nil", + certPEM: nil, + expectNonEmpty: false, + }, + { + name: "When input is empty, it should return nil", + certPEM: []byte{}, + expectNonEmpty: false, + }, + { + name: "When chain has only a single self-signed cert, it should return nil", + certPEM: leafPEM, + expectNonEmpty: false, + }, + { + name: "When chain has a leaf and intermediate CA, it should return the intermediate CA", + certPEM: buildChain(leafPEM, intermediatePEM), + expectNonEmpty: true, + expectContains: []string{"intermediate-ca"}, + }, + { + name: "When chain has leaf, intermediate, and root CA, it should return both CAs", + certPEM: buildChain(leafPEM, intermediatePEM, rootCAPEM), + expectNonEmpty: true, + expectContains: []string{"intermediate-ca", "root-ca"}, + }, + { + name: "When chain contains non-CERTIFICATE PEM blocks, it should skip them", + certPEM: buildChain(leafPEM, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: []byte("fake")}), intermediatePEM), + expectNonEmpty: true, + expectContains: []string{"intermediate-ca"}, + }, + { + name: "When chain contains a non-CA certificate after the leaf, it should skip it", + certPEM: buildChain(leafPEM, nonCAPEM, intermediatePEM), + expectNonEmpty: true, + expectContains: []string{"intermediate-ca"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := ExtractCACertificatesFromChain(tc.certPEM) + if tc.expectNonEmpty && len(result) == 0 { + t.Fatal("expected non-empty CA bundle, got empty") + } + if !tc.expectNonEmpty && len(result) != 0 { + t.Fatalf("expected empty CA bundle, got %d bytes", len(result)) + } + for _, cn := range tc.expectContains { + rest := result + found := false + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate: %v", err) + } + if cert.Subject.CommonName == cn { + found = true + break + } + } + if !found { + t.Errorf("expected CA with CN=%q in result, not found", cn) + } + } + }) + } +} + +func TestReconcileRootCAConfigMap(t *testing.T) { + tests := []struct { + name string + rootCA *corev1.Secret + ingressCert *corev1.ConfigMap + namedCAs []*corev1.Secret + expected string + }{ + { + name: "When no named certs are configured, it should contain only root CA and ingress cert", + rootCA: &corev1.Secret{ + Data: map[string][]byte{ + certs.CASignerCertMapKey: []byte("root-ca-cert-pem"), + }, + }, + ingressCert: &corev1.ConfigMap{ + Data: map[string]string{ + certs.CASignerCertMapKey: "ingress-cert-pem", + }, + }, + namedCAs: nil, + expected: "root-ca-cert-pemingress-cert-pem", + }, + { + name: "When ingress cert is nil, it should contain only root CA", + rootCA: &corev1.Secret{ + Data: map[string][]byte{ + certs.CASignerCertMapKey: []byte("root-ca-cert-pem"), + }, + }, + ingressCert: nil, + namedCAs: nil, + expected: "root-ca-cert-pem", + }, + { + name: "When named cert CAs are provided, it should include them after root CA and ingress cert", + rootCA: &corev1.Secret{ + Data: map[string][]byte{ + certs.CASignerCertMapKey: []byte("root-ca-cert-pem"), + }, + }, + ingressCert: &corev1.ConfigMap{ + Data: map[string]string{ + certs.CASignerCertMapKey: "ingress-cert-pem", + }, + }, + namedCAs: []*corev1.Secret{ + {Data: map[string][]byte{certs.CASignerCertMapKey: []byte("external-ca-1-pem")}}, + {Data: map[string][]byte{certs.CASignerCertMapKey: []byte("external-ca-2-pem")}}, + }, + expected: "root-ca-cert-pemingress-cert-pemexternal-ca-1-pemexternal-ca-2-pem", + }, + { + name: "When ingress cert is nil and named cert CAs are provided, it should include root CA and named CAs", + rootCA: &corev1.Secret{ + Data: map[string][]byte{ + certs.CASignerCertMapKey: []byte("root-ca-cert-pem"), + }, + }, + ingressCert: nil, + namedCAs: []*corev1.Secret{ + {Data: map[string][]byte{certs.CASignerCertMapKey: []byte("external-ca-pem")}}, + }, + expected: "root-ca-cert-pemexternal-ca-pem", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "root-ca", Namespace: "test"}} + ownerRef := config.OwnerRef{} + + if err := ReconcileRootCAConfigMap(cm, ownerRef, tc.rootCA, tc.ingressCert, tc.namedCAs...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := cm.Data[certs.CASignerCertMapKey] + if got != tc.expected { + t.Errorf("unexpected configmap data:\ngot: %q\nwant: %q", got, tc.expected) + } + }) + } +} + func TestReconcileTotalClientCA_PreservesPreviousCAsOnRotation(t *testing.T) { t.Parallel() now := time.Now() diff --git a/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/chain.pem b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/chain.pem new file mode 100644 index 000000000000..1201de7f6572 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/chain.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAVygAwIBAgIBAzAKBggqhkjOPQQDAjAzMRIwEAYDVQQKEwlvcGVuc2hp +ZnQxHTAbBgNVBAMTFHRlc3QtaW50ZXJtZWRpYXRlLWNhMCAXDTI0MDEwMTAwMDAw +MFoYDzIxMjQwMTAxMDAwMDAwWjAuMRIwEAYDVQQKEwlvcGVuc2hpZnQxGDAWBgNV +BAMTD2FwaS5leGFtcGxlLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA3/ +LiyFQQTRmnkb0oiN0uldK6D1pnmHBn89HvLwK4PB3THYHGeeu2DOwfVH6sxlhVss +Y28NePgAYMtO3p1pP+WjZDBiMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr +BgEFBQcDATAfBgNVHSMEGDAWgBQ2IZvTS9VtyWF6HJ0pLpO9De8o4DAaBgNVHREE +EzARgg9hcGkuZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhAPta1hXjNU0W +/JC+PnnGkfHXrS7oNXubp1rd9m6vs4rCAiEA1s690Br11kbVIpGvnsSWUI87Z02r +c7JPlCzIRjrPKC0= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIBiTCCAS+gAwIBAgIBATAKBggqhkjOPQQDAjArMRIwEAYDVQQKEwlvcGVuc2hp +ZnQxFTATBgNVBAMTDHRlc3Qtcm9vdC1jYTAgFw0yNDAxMDEwMDAwMDBaGA8yMTI0 +MDEwMTAwMDAwMFowKzESMBAGA1UEChMJb3BlbnNoaWZ0MRUwEwYDVQQDEwx0ZXN0 +LXJvb3QtY2EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATv2nScgBxg/GsNh7wB +J/MOYvvAgt109r7Q34zdYTAi2TsQotbcVZvWhUQmWpHFBYouK9T8uDKsj+mGtFjs +PBCmo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQU+rhjjz3YEjNtXRmitp/qSM4qkrEwCgYIKoZIzj0EAwIDSAAwRQIgAXITCVXb +CvT9kgO6Hsbaoi6NWldWzTXCQajuVOEiTt8CIQDYbWlvJUCTHTUvhWeT1gttyzOp +xBInjiJ4KiHvIV5FCw== +-----END CERTIFICATE----- diff --git a/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/intermediate-ca.pem b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/intermediate-ca.pem new file mode 100644 index 000000000000..a62c13c620cf --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/intermediate-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBsjCCAVigAwIBAgIBAjAKBggqhkjOPQQDAjArMRIwEAYDVQQKEwlvcGVuc2hp +ZnQxFTATBgNVBAMTDHRlc3Qtcm9vdC1jYTAgFw0yNDAxMDEwMDAwMDBaGA8yMTI0 +MDEwMTAwMDAwMFowMzESMBAGA1UEChMJb3BlbnNoaWZ0MR0wGwYDVQQDExR0ZXN0 +LWludGVybWVkaWF0ZS1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABAb6hrI1 +VdwHLUXSA7x6SW1bVzeNjfCOIgKI52jCpMVma8du5X/1Upmais9jAlx4HkbN/tdB +eIbUC/3ZES5mTxCjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBQ2IZvTS9VtyWF6HJ0pLpO9De8o4DAfBgNVHSMEGDAWgBT6uGOP +PdgSM21dGaK2n+pIziqSsTAKBggqhkjOPQQDAgNIADBFAiA8zp1CcJbazV5/k4Tw +/YYNfiYV1dNWZtwDmcJS4IXegAIhAJIe68faivHqG3Ug7u0onbO3bZaAt1MApu/+ +iZXkQPbp +-----END CERTIFICATE----- diff --git a/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/leaf-intermediate-chain.pem b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/leaf-intermediate-chain.pem new file mode 100644 index 000000000000..4e990b3c0765 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/leaf-intermediate-chain.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAVygAwIBAgIBAzAKBggqhkjOPQQDAjAzMRIwEAYDVQQKEwlvcGVuc2hp +ZnQxHTAbBgNVBAMTFHRlc3QtaW50ZXJtZWRpYXRlLWNhMCAXDTI0MDEwMTAwMDAw +MFoYDzIxMjQwMTAxMDAwMDAwWjAuMRIwEAYDVQQKEwlvcGVuc2hpZnQxGDAWBgNV +BAMTD2FwaS5leGFtcGxlLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA3/ +LiyFQQTRmnkb0oiN0uldK6D1pnmHBn89HvLwK4PB3THYHGeeu2DOwfVH6sxlhVss +Y28NePgAYMtO3p1pP+WjZDBiMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr +BgEFBQcDATAfBgNVHSMEGDAWgBQ2IZvTS9VtyWF6HJ0pLpO9De8o4DAaBgNVHREE +EzARgg9hcGkuZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhAPta1hXjNU0W +/JC+PnnGkfHXrS7oNXubp1rd9m6vs4rCAiEA1s690Br11kbVIpGvnsSWUI87Z02r +c7JPlCzIRjrPKC0= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIBsjCCAVigAwIBAgIBAjAKBggqhkjOPQQDAjArMRIwEAYDVQQKEwlvcGVuc2hp +ZnQxFTATBgNVBAMTDHRlc3Qtcm9vdC1jYTAgFw0yNDAxMDEwMDAwMDBaGA8yMTI0 +MDEwMTAwMDAwMFowMzESMBAGA1UEChMJb3BlbnNoaWZ0MR0wGwYDVQQDExR0ZXN0 +LWludGVybWVkaWF0ZS1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABAb6hrI1 +VdwHLUXSA7x6SW1bVzeNjfCOIgKI52jCpMVma8du5X/1Upmais9jAlx4HkbN/tdB +eIbUC/3ZES5mTxCjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBQ2IZvTS9VtyWF6HJ0pLpO9De8o4DAfBgNVHSMEGDAWgBT6uGOP +PdgSM21dGaK2n+pIziqSsTAKBggqhkjOPQQDAgNIADBFAiA8zp1CcJbazV5/k4Tw +/YYNfiYV1dNWZtwDmcJS4IXegAIhAJIe68faivHqG3Ug7u0onbO3bZaAt1MApu/+ +iZXkQPbp +-----END CERTIFICATE----- diff --git a/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/leaf.pem b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/leaf.pem new file mode 100644 index 000000000000..a57f90687916 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/leaf.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAVygAwIBAgIBAzAKBggqhkjOPQQDAjAzMRIwEAYDVQQKEwlvcGVuc2hp +ZnQxHTAbBgNVBAMTFHRlc3QtaW50ZXJtZWRpYXRlLWNhMCAXDTI0MDEwMTAwMDAw +MFoYDzIxMjQwMTAxMDAwMDAwWjAuMRIwEAYDVQQKEwlvcGVuc2hpZnQxGDAWBgNV +BAMTD2FwaS5leGFtcGxlLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA3/ +LiyFQQTRmnkb0oiN0uldK6D1pnmHBn89HvLwK4PB3THYHGeeu2DOwfVH6sxlhVss +Y28NePgAYMtO3p1pP+WjZDBiMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr +BgEFBQcDATAfBgNVHSMEGDAWgBQ2IZvTS9VtyWF6HJ0pLpO9De8o4DAaBgNVHREE +EzARgg9hcGkuZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhAPta1hXjNU0W +/JC+PnnGkfHXrS7oNXubp1rd9m6vs4rCAiEA1s690Br11kbVIpGvnsSWUI87Z02r +c7JPlCzIRjrPKC0= +-----END CERTIFICATE----- diff --git a/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/root-ca.pem b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/root-ca.pem new file mode 100644 index 000000000000..825577a44dfc --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/testdata/named-cert-cas/root-ca.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBiTCCAS+gAwIBAgIBATAKBggqhkjOPQQDAjArMRIwEAYDVQQKEwlvcGVuc2hp +ZnQxFTATBgNVBAMTDHRlc3Qtcm9vdC1jYTAgFw0yNDAxMDEwMDAwMDBaGA8yMTI0 +MDEwMTAwMDAwMFowKzESMBAGA1UEChMJb3BlbnNoaWZ0MRUwEwYDVQQDEwx0ZXN0 +LXJvb3QtY2EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATv2nScgBxg/GsNh7wB +J/MOYvvAgt109r7Q34zdYTAi2TsQotbcVZvWhUQmWpHFBYouK9T8uDKsj+mGtFjs +PBCmo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQU+rhjjz3YEjNtXRmitp/qSM4qkrEwCgYIKoZIzj0EAwIDSAAwRQIgAXITCVXb +CvT9kgO6Hsbaoi6NWldWzTXCQajuVOEiTt8CIQDYbWlvJUCTHTUvhWeT1gttyzOp +xBInjiJ4KiHvIV5FCw== +-----END CERTIFICATE----- diff --git a/test/e2e/v2/tests/hosted_cluster_named_cert_trust_test.go b/test/e2e/v2/tests/hosted_cluster_named_cert_trust_test.go new file mode 100644 index 000000000000..65bc9f5c6325 --- /dev/null +++ b/test/e2e/v2/tests/hosted_cluster_named_cert_trust_test.go @@ -0,0 +1,191 @@ +//go:build e2ev2 + +/* +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 tests + +import ( + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/support/certs" + v2util "github.com/openshift/hypershift/test/e2e/v2/internal" + testutil "github.com/openshift/hypershift/test/e2e/v2/util" + + configv1 "github.com/openshift/api/config/v1" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + + crclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const namedCertSecretName = "e2e-named-cert-trust" + +// RegisterNamedCertTrustTests registers tests that validate named certificate +// CA propagation into the root-ca trust bundle. +func RegisterNamedCertTrustTests(getTestCtx v2util.TestContextGetter) { + EnsureNamedCertCATrustBundleTest(getTestCtx) +} + +// EnsureNamedCertCATrustBundleTest verifies that when a named certificate +// signed by an external CA is configured, the CA appears in the root-ca +// ConfigMap in the control plane namespace and in kube-root-ca.crt in the +// hosted cluster. +func EnsureNamedCertCATrustBundleTest(getTestCtx v2util.TestContextGetter) { + When("a named certificate signed by an external CA is configured", func() { + It("should include the external CA in the root-ca and kube-root-ca.crt trust bundles", Label(v2util.InformingLabel), func() { + tc := getTestCtx() + hc, err := tc.GetHostedCluster() + Expect(err).NotTo(HaveOccurred()) + + // Generate a CA + leaf cert signed by that CA. + kasHost := hc.Status.ControlPlaneEndpoint.Host + Expect(kasHost).NotTo(BeEmpty(), "HostedCluster should have a control plane endpoint host") + + caPEM, chainPEM, keyPEM, err := testutil.GenerateCASignedCertificate( + []string{kasHost}, 24*time.Hour, + ) + Expect(err).NotTo(HaveOccurred(), "failed to generate CA-signed certificate") + + // Create the TLS Secret with the chain in tls.crt. + certSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: namedCertSecretName, + Namespace: hc.Namespace, + }, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: chainPEM, + corev1.TLSPrivateKeyKey: keyPEM, + }, + } + Expect(tc.MgmtClient.Create(tc.Context, certSecret)).To(Succeed(), + "failed to create named certificate secret") + DeferCleanup(func() { + err := tc.MgmtClient.Delete(tc.Context, certSecret) + if err != nil && !apierrors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred(), "cleanup: failed to delete named cert secret") + } + }) + + // Patch the HostedCluster to add the named certificate. + originalNamedCerts := getNamedCerts(hc) + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := &hyperv1.HostedCluster{} + if err := tc.MgmtClient.Get(tc.Context, crclient.ObjectKeyFromObject(hc), latest); err != nil { + return err + } + if latest.Spec.Configuration == nil { + latest.Spec.Configuration = &hyperv1.ClusterConfiguration{} + } + if latest.Spec.Configuration.APIServer == nil { + latest.Spec.Configuration.APIServer = &configv1.APIServerSpec{} + } + latest.Spec.Configuration.APIServer.ServingCerts.NamedCertificates = append( + latest.Spec.Configuration.APIServer.ServingCerts.NamedCertificates, + configv1.APIServerNamedServingCert{ + Names: []string{kasHost}, + ServingCertificate: configv1.SecretNameReference{Name: namedCertSecretName}, + }, + ) + return tc.MgmtClient.Update(tc.Context, latest) + }) + Expect(err).NotTo(HaveOccurred(), "failed to add named certificate to HostedCluster") + DeferCleanup(func() { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := &hyperv1.HostedCluster{} + if err := tc.MgmtClient.Get(tc.Context, crclient.ObjectKeyFromObject(hc), latest); err != nil { + return err + } + if latest.Spec.Configuration != nil && latest.Spec.Configuration.APIServer != nil { + latest.Spec.Configuration.APIServer.ServingCerts.NamedCertificates = originalNamedCerts + } + return tc.MgmtClient.Update(tc.Context, latest) + }) + if err != nil && !apierrors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred(), "cleanup: failed to restore named certificates") + } + }) + + caString := string(caPEM) + + // Verify the root-ca ConfigMap in the control plane namespace + // contains the external CA certificate. + Eventually(func(g Gomega) { + rootCA := &corev1.ConfigMap{} + g.Expect(tc.MgmtClient.Get(tc.Context, crclient.ObjectKey{ + Namespace: tc.ControlPlaneNamespace, + Name: "root-ca", + }, rootCA)).To(Succeed()) + g.Expect(rootCA.Data).To(HaveKey(certs.CASignerCertMapKey)) + g.Expect(rootCA.Data[certs.CASignerCertMapKey]).To(ContainSubstring(caString), + "root-ca ConfigMap should contain the external CA certificate") + }).WithContext(tc.Context).WithTimeout(2 * time.Minute).WithPolling(5 * time.Second).Should(Succeed()) + + // Verify the kube-root-ca.crt ConfigMap in the hosted cluster + // contains the external CA certificate. + hcClient, err := tc.GetHostedClusterClient(hc) + Expect(err).NotTo(HaveOccurred(), "failed to get hosted cluster client") + + Eventually(func(g Gomega) { + kubeRootCA := &corev1.ConfigMap{} + g.Expect(hcClient.Get(tc.Context, crclient.ObjectKey{ + Namespace: "default", + Name: "kube-root-ca.crt", + }, kubeRootCA)).To(Succeed()) + g.Expect(kubeRootCA.Data).To(HaveKey("ca.crt")) + g.Expect(kubeRootCA.Data["ca.crt"]).To( + satisfyCAPresent(caString), + "kube-root-ca.crt in hosted cluster should contain the external CA certificate", + ) + }).WithContext(tc.Context).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(Succeed()) + }) + }) +} + +// satisfyCAPresent returns a Gomega matcher that checks if a PEM bundle +// contains the given CA certificate string. +func satisfyCAPresent(caPEM string) OmegaMatcher { + return ContainSubstring(strings.TrimSpace(caPEM)) +} + +// getNamedCerts returns a copy of the current named certificates from an HC. +func getNamedCerts(hc *hyperv1.HostedCluster) []configv1.APIServerNamedServingCert { + if hc.Spec.Configuration == nil || hc.Spec.Configuration.APIServer == nil { + return nil + } + orig := hc.Spec.Configuration.APIServer.ServingCerts.NamedCertificates + result := make([]configv1.APIServerNamedServingCert, len(orig)) + copy(result, orig) + return result +} + +var _ = Describe("[sig-hypershift][Jira:Hypershift][Feature:NamedCertTrust] Hosted Cluster Named Certificate Trust", Label("hosted-cluster-named-cert-trust"), func() { + var testCtx *v2util.TestContext + + BeforeEach(func() { + testCtx = v2util.GetTestContext() + Expect(testCtx).NotTo(BeNil(), "test context should be set up in BeforeSuite") + }) + + RegisterNamedCertTrustTests(func() *v2util.TestContext { return testCtx }) +}) diff --git a/test/e2e/v2/util/certs.go b/test/e2e/v2/util/certs.go index afaa66549f05..5ba7e145fd20 100644 --- a/test/e2e/v2/util/certs.go +++ b/test/e2e/v2/util/certs.go @@ -9,6 +9,45 @@ import ( "github.com/openshift/hypershift/support/certs" ) +// GenerateCASignedCertificate generates a CA key/cert pair and a leaf certificate +// signed by that CA for the given DNS names. Returns (caPEM, leafChainPEM, leafKeyPEM, error) +// where leafChainPEM contains the leaf followed by the CA certificate. +func GenerateCASignedCertificate(dnsNames []string, validity time.Duration) (caPEM, leafChainPEM, leafKeyPEM []byte, err error) { + if len(dnsNames) == 0 { + return nil, nil, nil, fmt.Errorf("no DNS names provided") + } + + caCfg := &certs.CertCfg{ + Subject: pkix.Name{CommonName: "e2e-test-ca", Organization: []string{"hypershift-e2e"}}, + KeyUsages: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + Validity: validity, + IsCA: true, + } + caKey, caCert, err := certs.GenerateSelfSignedCertificate(caCfg) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to generate CA: %w", err) + } + + leafCfg := &certs.CertCfg{ + Subject: pkix.Name{CommonName: dnsNames[0], Organization: []string{"hypershift-e2e"}}, + KeyUsages: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + Validity: validity, + DNSNames: dnsNames, + IsCA: false, + } + leafKey, leafCert, err := certs.GenerateSignedCertificate(caKey, caCert, leafCfg) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to generate leaf certificate: %w", err) + } + + caPEM = certs.CertToPem(caCert) + leafPEM := certs.CertToPem(leafCert) + leafChainPEM = append(leafPEM, caPEM...) + leafKeyPEM = certs.PrivateKeyToPem(leafKey) + return caPEM, leafChainPEM, leafKeyPEM, nil +} + // GenerateCustomCertificate generates a self-signed certificate for the given DNS names. func GenerateCustomCertificate(dnsNames []string, validity time.Duration) ([]byte, []byte, error) { if len(dnsNames) == 0 {