From e224d15eec4ada84ad86f5e15f85502eefb70662 Mon Sep 17 00:00:00 2001 From: Raphael Vogel <raphael.vogel@sap.com> Date: Tue, 26 Nov 2024 08:50:59 +0100 Subject: [PATCH] Self signed issuer (#228) * implement selfSigned issuer * enable cert duration for ca issuer * enable cert duration for acme issuer * checks for isCA and duration * set requests per day to MaxInt for CA and self-signed issuer * Update pkg/controller/issuer/ca/handler.go Co-authored-by: Martin Weindel <martin.weindel@sap.com> * chore: Remove obsolete script format.sh * style: Run `make format` * fix: Resolve mishaps from rebase * style: Add trailing newline * fix: Dereference duration pointer * refactor: Rename multipleIssuerTypes to hasMultipleIssuerTypes * chore: Fix typo in comment * fix: Check against nil Duration pointer * fix: Check against nil Duration pointer * chore: Fix typo in comment * test: Add self-signed controller unit tests * test: Add certificate controller unit tests * test: Add issuer info unit test * test: Add PKI unit tests * test: Add certificate unit tests * chore: Use proper assertion HaveCap() * style: Format Go imports * chore: Add missing license headers * test: Wrap ACME issuer test in context * test: Integration test for self-signed certificates * test: Structure self-signed certificate unit test * style: Add period after comment (PR review) * style: Add period after comment (PR review) * style: Add period after comment (PR review) * test: Fix test name (PR review) * test: Assert private key size properly (PR review) * test: Assert private key size (PR review) --------- Co-authored-by: Martin Weindel <martin.weindel@sap.com> Co-authored-by: Marc Vornetran <marc.vornetran@sap.com> --- README.md | 68 +++++++- .../cert.gardener.cloud_certificates.yaml | 6 + .../cert.gardener.cloud_issuers.yaml | 7 +- examples/20-issuer-selfsigned.yaml | 7 + examples/30-cert-selfsigned.yaml | 23 +++ .../cert.gardener.cloud_certificates.yaml | 6 + .../crds/cert.gardener.cloud_issuers.yaml | 7 +- pkg/apis/cert/crds/zz_generated_crds.go | 13 +- pkg/apis/cert/v1alpha1/types.go | 14 +- .../cert/v1alpha1/zz_generated.deepcopy.go | 26 +++ pkg/cert/legobridge/certificate.go | 89 +++++++++- pkg/cert/legobridge/certificate_test.go | 162 ++++++++++++++---- pkg/cert/legobridge/pki.go | 38 ++++ pkg/cert/legobridge/pki_test.go | 81 +++++++++ pkg/cert/utils/issuerinfo.go | 7 + pkg/cert/utils/issuerinfo_test.go | 23 +++ pkg/cert/utils/utils_suite_test.go | 17 ++ pkg/controller/issuer/ca/handler.go | 4 + .../certificate/certificate_suite_test.go | 17 ++ .../issuer/certificate/reconciler.go | 84 ++++++++- pkg/controller/issuer/certificate/utils.go | 17 +- .../issuer/certificate/utils_test.go | 37 ++++ pkg/controller/issuer/core/const.go | 2 + pkg/controller/issuer/core/support.go | 7 + pkg/controller/issuer/reconciler.go | 3 +- pkg/controller/issuer/selfSigned/handler.go | 53 ++++++ .../issuer/selfSigned/handler_test.go | 38 ++++ .../selfSigned/selfSigned_suite_test.go | 17 ++ .../controller/issuer/issuer_test.go | 101 ++++++++--- 29 files changed, 901 insertions(+), 73 deletions(-) create mode 100644 examples/20-issuer-selfsigned.yaml create mode 100644 examples/30-cert-selfsigned.yaml create mode 100644 pkg/cert/legobridge/pki_test.go create mode 100644 pkg/cert/utils/issuerinfo_test.go create mode 100644 pkg/cert/utils/utils_suite_test.go create mode 100644 pkg/controller/issuer/certificate/certificate_suite_test.go create mode 100644 pkg/controller/issuer/certificate/utils_test.go create mode 100644 pkg/controller/issuer/selfSigned/handler.go create mode 100644 pkg/controller/issuer/selfSigned/handler_test.go create mode 100644 pkg/controller/issuer/selfSigned/selfSigned_suite_test.go diff --git a/README.md b/README.md index 5b4ba5a2..8f17d02c 100644 --- a/README.md +++ b/README.md @@ -143,11 +143,13 @@ is already in place. The operator must request/provide by its own means a CA or an intermediate CA. This is mainly used for **on-premises** and **airgapped** environements. -It can also be used for **developement** or **testing** purproses. In this case -a Self-signed Certificate Authority can be created by following the section below. +To create a self-signed certificate a dedicated issuer of type [selfSigned](#selfsigned) should be used. -_Create a Self-signed Certificate Authority (optional)_ +It is also possible to manually create a self-signed certificate using the CA issuer +<details> + <summary>Manual steps</summary> +Create a Self-signed Certificate Authority ```bash ▶ openssl genrsa -out CA-key.pem 4096 ▶ export CONFIG=" @@ -244,6 +246,66 @@ Some details about the CA can be found in the status of the issuer. "type": "ca" } ``` +</details> + +### SelfSigned +This issuer is meant to be used when you want to create a fully managed self-signed certificate. + +Configure your shoot to allow custom issuers in the shoot cluster. By default, issuers are created in the control plane of your cluster. +```yaml +kind: Shoot +... +spec: + extensions: + - type: shoot-cert-service + providerConfig: + apiVersion: service.cert.extensions.gardener.cloud/v1alpha1 + kind: CertConfig + shootIssuers: + enabled: true # if true, allows to specify issuers in the shoot cluster +... +``` + +Create and deploy a self-signed issuer in your shoot cluster ([examples/20-issuer-selfsigned.yaml](./examples/20-issuer-selfsigned.yaml)) +```yaml +apiVersion: cert.gardener.cloud/v1alpha1 +kind: Issuer +metadata: + name: issuer-selfsigned + namespace: default +spec: + selfSigned: {} + +``` + +Create a certificate ([examples/30-cert-selfsigned.yaml](./examples/30-cert-selfsigned.yaml)). +Please note that `spec.isCA` must be set to `true` to create a self-signed certificate. The duration (life-time) of the certificate +as well as the private key algorithm and key size may be specified. Duration value must be in units accepted by Go `time.ParseDuration` +([see here](https://golang.org/pkg/time/#ParseDurationThe)), and it must be greater than 720h (30 days). +```yaml +apiVersion: cert.gardener.cloud/v1alpha1 +kind: Certificate +metadata: + name: cert-selfsigned + namespace: default +spec: + commonName: cert1.mydomain.com + isCA: true + # optional: default is 90 days (2160h). Must be greater 30 days (720h) + # duration: 720h1m + # optional defaults to RSA 2048 + #privateKey: + # algorithm: ECDSA + # size: 384 + issuerRef: + name: issuer-selfsigned + namespace: default # must be specified when issuer runs in shoot! + # optional: secret where the certificate should be stored + #secretRef: + # name: cert-selfsigned-foo + # namespace: default +``` + ## Requesting a Certificate diff --git a/charts/cert-management/templates/cert.gardener.cloud_certificates.yaml b/charts/cert-management/templates/cert.gardener.cloud_certificates.yaml index 76266f0e..ffa0e3b2 100644 --- a/charts/cert-management/templates/cert.gardener.cloud_certificates.yaml +++ b/charts/cert-management/templates/cert.gardener.cloud_certificates.yaml @@ -108,6 +108,12 @@ spec: is used if CNAME record for DNS01 challange domain `_acme-challenge.<domain>` is set. type: boolean + isCA: + description: |- + IsCA value is used to set the `isCA` field on the certificate request. + Note that the issuer may choose to ignore the requested isCA value, just + like any other requested attribute. + type: boolean issuerRef: description: IssuerRef is the reference of the issuer to use. properties: diff --git a/charts/cert-management/templates/cert.gardener.cloud_issuers.yaml b/charts/cert-management/templates/cert.gardener.cloud_issuers.yaml index 354d830d..e3eb8eaf 100644 --- a/charts/cert-management/templates/cert.gardener.cloud_issuers.yaml +++ b/charts/cert-management/templates/cert.gardener.cloud_issuers.yaml @@ -181,6 +181,9 @@ spec: description: RequestsPerDayQuota is the maximum number of certificate requests per days allowed for this issuer type: integer + selfSigned: + description: SelfSigned is the self signed specific spec. + type: object type: object status: description: IssuerStatus is the status of the issuer. @@ -209,8 +212,8 @@ spec: description: State is either empty, 'Pending', 'Error', or 'Ready'. type: string type: - description: Type is the issuer type. Currently only 'acme' and 'ca' - are supported. + description: Type is the issuer type. Currently only 'acme', 'ca' + and 'selfSigned' are supported. type: string required: - state diff --git a/examples/20-issuer-selfsigned.yaml b/examples/20-issuer-selfsigned.yaml new file mode 100644 index 00000000..5c73b1dd --- /dev/null +++ b/examples/20-issuer-selfsigned.yaml @@ -0,0 +1,7 @@ +apiVersion: cert.gardener.cloud/v1alpha1 +kind: Issuer +metadata: + name: issuer-selfsigned + namespace: default +spec: + selfSigned: {} diff --git a/examples/30-cert-selfsigned.yaml b/examples/30-cert-selfsigned.yaml new file mode 100644 index 00000000..b31a56d4 --- /dev/null +++ b/examples/30-cert-selfsigned.yaml @@ -0,0 +1,23 @@ +apiVersion: cert.gardener.cloud/v1alpha1 +kind: Certificate +metadata: + name: cert-selfsigned + namespace: default +spec: + commonName: ca1.mydomain.com + isCA: true + # optional: default is 90 days (2160h). Must be greater 2*30 days (1440h) + # duration: 1441h + # optional defaults to RSA 2048 + # privateKey: + # algorithm: ECDSA + # size: 384 + # CSR can also be specified + # csr: ... + issuerRef: + name: issuer-selfsigned + namespace: default # must be specified when issuer runs in shoot! + # optional: secret where the certificate should be stored + #secretRef: + # name: cert-selfsigned-foo + # namespace: default diff --git a/pkg/apis/cert/crds/cert.gardener.cloud_certificates.yaml b/pkg/apis/cert/crds/cert.gardener.cloud_certificates.yaml index b327fec0..1c994ef5 100644 --- a/pkg/apis/cert/crds/cert.gardener.cloud_certificates.yaml +++ b/pkg/apis/cert/crds/cert.gardener.cloud_certificates.yaml @@ -103,6 +103,12 @@ spec: is used if CNAME record for DNS01 challange domain `_acme-challenge.<domain>` is set. type: boolean + isCA: + description: |- + IsCA value is used to set the `isCA` field on the certificate request. + Note that the issuer may choose to ignore the requested isCA value, just + like any other requested attribute. + type: boolean issuerRef: description: IssuerRef is the reference of the issuer to use. properties: diff --git a/pkg/apis/cert/crds/cert.gardener.cloud_issuers.yaml b/pkg/apis/cert/crds/cert.gardener.cloud_issuers.yaml index 4e2e0de0..a3fab0ab 100644 --- a/pkg/apis/cert/crds/cert.gardener.cloud_issuers.yaml +++ b/pkg/apis/cert/crds/cert.gardener.cloud_issuers.yaml @@ -176,6 +176,9 @@ spec: description: RequestsPerDayQuota is the maximum number of certificate requests per days allowed for this issuer type: integer + selfSigned: + description: SelfSigned is the self signed specific spec. + type: object type: object status: description: IssuerStatus is the status of the issuer. @@ -204,8 +207,8 @@ spec: description: State is either empty, 'Pending', 'Error', or 'Ready'. type: string type: - description: Type is the issuer type. Currently only 'acme' and 'ca' - are supported. + description: Type is the issuer type. Currently only 'acme', 'ca' + and 'selfSigned' are supported. type: string required: - state diff --git a/pkg/apis/cert/crds/zz_generated_crds.go b/pkg/apis/cert/crds/zz_generated_crds.go index abc43836..0ace1a19 100644 --- a/pkg/apis/cert/crds/zz_generated_crds.go +++ b/pkg/apis/cert/crds/zz_generated_crds.go @@ -407,6 +407,12 @@ spec: is used if CNAME record for DNS01 challange domain ` + "`" + `_acme-challenge.<domain>` + "`" + ` is set. type: boolean + isCA: + description: |- + IsCA value is used to set the ` + "`" + `isCA` + "`" + ` field on the certificate request. + Note that the issuer may choose to ignore the requested isCA value, just + like any other requested attribute. + type: boolean issuerRef: description: IssuerRef is the reference of the issuer to use. properties: @@ -875,6 +881,9 @@ spec: description: RequestsPerDayQuota is the maximum number of certificate requests per days allowed for this issuer type: integer + selfSigned: + description: SelfSigned is the self signed specific spec. + type: object type: object status: description: IssuerStatus is the status of the issuer. @@ -903,8 +912,8 @@ spec: description: State is either empty, 'Pending', 'Error', or 'Ready'. type: string type: - description: Type is the issuer type. Currently only 'acme' and 'ca' - are supported. + description: Type is the issuer type. Currently only 'acme', 'ca' + and 'selfSigned' are supported. type: string required: - state diff --git a/pkg/apis/cert/v1alpha1/types.go b/pkg/apis/cert/v1alpha1/types.go index 9a17918d..7f8ad0e2 100644 --- a/pkg/apis/cert/v1alpha1/types.go +++ b/pkg/apis/cert/v1alpha1/types.go @@ -85,6 +85,11 @@ type CertificateSpec struct { // Private key options. These include the key algorithm and size. // +optional PrivateKey *CertificatePrivateKey `json:"privateKey,omitempty"` + // IsCA value is used to set the `isCA` field on the certificate request. + // Note that the issuer may choose to ignore the requested isCA value, just + // like any other requested attribute. + // +optional + IsCA *bool `json:"isCA,omitempty"` // Requested 'duration' (i.e. lifetime) of the Certificate. Note that the // ACME issuer may choose to ignore the requested duration, just like any other // requested attribute. @@ -406,6 +411,9 @@ type IssuerSpec struct { // CA is the CA specific spec. // +optional CA *CASpec `json:"ca,omitempty"` + // SelfSigned is the self signed specific spec. + // +optional + SelfSigned *SelfSignedSpec `json:"selfSigned,omitempty"` // RequestsPerDayQuota is the maximum number of certificate requests per days allowed for this issuer // +optional RequestsPerDayQuota *int `json:"requestsPerDayQuota,omitempty"` @@ -475,6 +483,10 @@ type CASpec struct { PrivateKeySecretRef *corev1.SecretReference `json:"privateKeySecretRef,omitempty"` } +// SelfSignedSpec is the self signed specific spec. +type SelfSignedSpec struct { +} + // IssuerStatus is the status of the issuer. type IssuerStatus struct { // ObservedGeneration is the observed generation of the spec. @@ -484,7 +496,7 @@ type IssuerStatus struct { // Message is the status or error message. // +optional Message *string `json:"message,omitempty"` - // Type is the issuer type. Currently only 'acme' and 'ca' are supported. + // Type is the issuer type. Currently only 'acme', 'ca' and 'selfSigned' are supported. // +optional Type *string `json:"type"` // ACME is the ACME specific status. diff --git a/pkg/apis/cert/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/cert/v1alpha1/zz_generated.deepcopy.go index 121734aa..618b3df6 100644 --- a/pkg/apis/cert/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/cert/v1alpha1/zz_generated.deepcopy.go @@ -453,6 +453,11 @@ func (in *CertificateSpec) DeepCopyInto(out *CertificateSpec) { *out = new(CertificatePrivateKey) (*in).DeepCopyInto(*out) } + if in.IsCA != nil { + in, out := &in.IsCA, &out.IsCA + *out = new(bool) + **out = **in + } if in.Duration != nil { in, out := &in.Duration, &out.Duration *out = new(metav1.Duration) @@ -644,6 +649,11 @@ func (in *IssuerSpec) DeepCopyInto(out *IssuerSpec) { *out = new(CASpec) (*in).DeepCopyInto(*out) } + if in.SelfSigned != nil { + in, out := &in.SelfSigned, &out.SelfSigned + *out = new(SelfSignedSpec) + **out = **in + } if in.RequestsPerDayQuota != nil { in, out := &in.RequestsPerDayQuota, &out.RequestsPerDayQuota *out = new(int) @@ -830,3 +840,19 @@ func (in *SecretStatuses) DeepCopy() *SecretStatuses { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SelfSignedSpec) DeepCopyInto(out *SelfSignedSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SelfSignedSpec. +func (in *SelfSignedSpec) DeepCopy() *SelfSignedSpec { + if in == nil { + return nil + } + out := new(SelfSignedSpec) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/cert/legobridge/certificate.go b/pkg/cert/legobridge/certificate.go index 942b739b..1cf858d2 100644 --- a/pkg/cert/legobridge/certificate.go +++ b/pkg/cert/legobridge/certificate.go @@ -63,6 +63,8 @@ type ObtainInput struct { PreferredChain string // KeyType represents the algo and size to use for the private key (only used if CSR is not set). KeyType certcrypto.KeyType + // IsCA is used to request a self-signed certificate + IsCA bool // Duration is the lifetime of the certificate Duration *time.Duration } @@ -111,6 +113,8 @@ type ObtainOutput struct { CSR []byte // KeyType is the copy from the input. KeyType certcrypto.KeyType + // IsCA is used to request a self-signed certificate + IsCA bool // Err contains the obtain request error. Err error } @@ -317,8 +321,10 @@ func (o *obtainer) Obtain(input ObtainInput) error { return o.ObtainACME(input) case input.CAKeyPair != nil: return o.ObtainFromCA(input) + case input.IsCA: + return o.ObtainFromSelfSigned(input) default: - return fmt.Errorf("Certificate obtention not valid, neither ACME or CA values were provided") + return fmt.Errorf("Certificate obtention not valid, neither ACME, CA or selfSigned values were provided") } } @@ -467,6 +473,85 @@ func (o *obtainer) collectDomainNames(input ObtainInput) ([]string, error) { return san, nil } +// ObtainFromSelfSigned starts the creation of a selfsigned certificate +func (o *obtainer) ObtainFromSelfSigned(input ObtainInput) error { + go func() { + certificates, err := newSelfSignedCertFromInput(input) + output := &ObtainOutput{ + Certificates: certificates, + IssuerInfo: utils.NewSelfSignedIssuerInfo(input.IssuerKey), + CommonName: input.CommonName, + DNSNames: input.DNSNames, + KeyType: input.KeyType, + IsCA: input.IsCA, + CSR: input.CSR, + Err: err, + } + input.Callback(output) + }() + return nil +} + +func newSelfSignedCertFromInput(input ObtainInput) (certificates *certificate.Resource, err error) { + var certPEM, privKeyPEM []byte + if input.CSR != nil { + certPEM, privKeyPEM, err = newSelfSignedCertFromCSRinPEMFormat(input) + } else { + certPrivateKey := FromKeyType(input.KeyType) + if certPrivateKey == nil { + return nil, fmt.Errorf("invalid key type: '%s'", input.KeyType) + } + var algo x509.PublicKeyAlgorithm + switch *certPrivateKey.Algorithm { + case api.RSAKeyAlgorithm: + algo = x509.RSA + case api.ECDSAKeyAlgorithm: + algo = x509.ECDSA + } + certPEM, privKeyPEM, err = newSelfSignedCertInPEMFormat(input, algo, int(*certPrivateKey.Size)) + } + if err != nil { + return nil, err + } + + return &certificate.Resource{ + PrivateKey: privKeyPEM, + Certificate: certPEM, + IssuerCertificate: certPEM, + }, nil +} + +func newSelfSignedCertFromCSRinPEMFormat(input ObtainInput) ([]byte, []byte, error) { + csr, err := extractCertificateRequest(input.CSR) + if err != nil { + return nil, nil, err + } + pubKeySize := pubKeySize(csr.PublicKey) + if pubKeySize == 0 { + pubKeySize = defaultKeySize(csr.PublicKeyAlgorithm) + } + certPrivateKey, certPrivateKeyPEM, err := generateKey(csr.PublicKeyAlgorithm, pubKeySize) + if err != nil { + return nil, nil, err + } + csrPEM, err := generateCSRPEM(csr, certPrivateKey) + if err != nil { + return nil, nil, err + } + if input.Duration == nil { + return nil, nil, fmt.Errorf("duration must be set") + } + crt, err := generateCertFromCSR(csrPEM, *input.Duration, true) + if err != nil { + return nil, nil, err + } + crtPEM, err := signCert(crt, crt, certPrivateKey.Public(), certPrivateKey) + if err != nil { + return nil, nil, err + } + return crtPEM, certPrivateKeyPEM, nil +} + // CertificatesToSecretData converts a certificate resource to secret data. func CertificatesToSecretData(certificates *certificate.Resource) map[string][]byte { data := map[string][]byte{} @@ -478,7 +563,7 @@ func CertificatesToSecretData(certificates *certificate.Resource) map[string][]b return data } -// SecretDataToCertificates converts secret data to a certicate resource. +// SecretDataToCertificates converts secret data to a certificate resource. func SecretDataToCertificates(data map[string][]byte) *certificate.Resource { certificates := &certificate.Resource{} certificates.Certificate = data[corev1.TLSCertKey] diff --git a/pkg/cert/legobridge/certificate_test.go b/pkg/cert/legobridge/certificate_test.go index d05e8961..9f728c88 100644 --- a/pkg/cert/legobridge/certificate_test.go +++ b/pkg/cert/legobridge/certificate_test.go @@ -7,6 +7,12 @@ package legobridge import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "time" + "github.com/go-acme/lego/v4/certcrypto" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,40 +21,124 @@ import ( api "github.com/gardener/cert-management/pkg/apis/cert/v1alpha1" ) -var _ = DescribeTable("KeyType conversion", - func(keyType certcrypto.KeyType, algorithm api.PrivateKeyAlgorithm, size int) { - defaults, err := NewCertificatePrivateKeyDefaults(api.RSAKeyAlgorithm, 2048, 256) - Expect(err).ToNot(HaveOccurred()) - - var key *api.CertificatePrivateKey - if len(algorithm) > 0 { - key = &api.CertificatePrivateKey{Algorithm: ptr.To(algorithm)} - } - if size > 0 { - if key == nil { - key = &api.CertificatePrivateKey{} - } - key.Size = ptr.To(api.PrivateKeySize(size)) - } - actualKeyType, err := defaults.ToKeyType(key) - if keyType == "" { - Expect(err).To(HaveOccurred()) - } else { - Expect(err).ToNot(HaveOccurred()) - Expect(actualKeyType).To(Equal(keyType)) - actualKeyType, err = defaults.ToKeyType(FromKeyType(keyType)) +var _ = Describe("Certificate", func() { + DescribeTable("KeyType conversion", + func(keyType certcrypto.KeyType, algorithm api.PrivateKeyAlgorithm, size int) { + defaults, err := NewCertificatePrivateKeyDefaults(api.RSAKeyAlgorithm, 2048, 256) Expect(err).ToNot(HaveOccurred()) - Expect(actualKeyType).To(Equal(keyType)) - } - }, - Entry("default", certcrypto.RSA2048, api.PrivateKeyAlgorithm(""), 0), - Entry("RSA from empty config", certcrypto.RSA2048, api.RSAKeyAlgorithm, 0), - Entry("RSA2048", certcrypto.RSA2048, api.RSAKeyAlgorithm, 2048), - Entry("RSA3072", certcrypto.RSA3072, api.RSAKeyAlgorithm, 3072), - Entry("RSA4096", certcrypto.RSA4096, api.RSAKeyAlgorithm, 4096), - Entry("ECDSA with default size", certcrypto.EC256, api.ECDSAKeyAlgorithm, 0), - Entry("EC256", certcrypto.EC256, api.ECDSAKeyAlgorithm, 256), - Entry("EC384", certcrypto.EC384, api.ECDSAKeyAlgorithm, 384), - Entry("RSA with wrong size", certcrypto.KeyType(""), api.RSAKeyAlgorithm, 8192), - Entry("ECDSA with wrong size", certcrypto.KeyType(""), api.ECDSAKeyAlgorithm, 511), -) + + var key *api.CertificatePrivateKey + if len(algorithm) > 0 { + key = &api.CertificatePrivateKey{Algorithm: ptr.To(algorithm)} + } + if size > 0 { + if key == nil { + key = &api.CertificatePrivateKey{} + } + key.Size = ptr.To(api.PrivateKeySize(size)) + } + actualKeyType, err := defaults.ToKeyType(key) + if keyType == "" { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + Expect(actualKeyType).To(Equal(keyType)) + actualKeyType, err = defaults.ToKeyType(FromKeyType(keyType)) + Expect(err).ToNot(HaveOccurred()) + Expect(actualKeyType).To(Equal(keyType)) + } + }, + Entry("default", certcrypto.RSA2048, api.PrivateKeyAlgorithm(""), 0), + Entry("RSA from empty config", certcrypto.RSA2048, api.RSAKeyAlgorithm, 0), + Entry("RSA2048", certcrypto.RSA2048, api.RSAKeyAlgorithm, 2048), + Entry("RSA3072", certcrypto.RSA3072, api.RSAKeyAlgorithm, 3072), + Entry("RSA4096", certcrypto.RSA4096, api.RSAKeyAlgorithm, 4096), + Entry("ECDSA with default size", certcrypto.EC256, api.ECDSAKeyAlgorithm, 0), + Entry("EC256", certcrypto.EC256, api.ECDSAKeyAlgorithm, 256), + Entry("EC384", certcrypto.EC384, api.ECDSAKeyAlgorithm, 384), + Entry("RSA with wrong size", certcrypto.KeyType(""), api.RSAKeyAlgorithm, 8192), + Entry("ECDSA with wrong size", certcrypto.KeyType(""), api.ECDSAKeyAlgorithm, 511), + ) + + Context("#newSelfSignedCertFromCSRinPEMFormat", func() { + It("should fail decoding the CSR with empty input", func() { + _, _, err := newSelfSignedCertFromCSRinPEMFormat(ObtainInput{}) + Expect(err).To(MatchError("decoding CSR failed")) + }) + + It("should fail decoding an invalid CSR", func() { + _, _, err := newSelfSignedCertFromCSRinPEMFormat(ObtainInput{CSR: []byte("invalid")}) + Expect(err).To(MatchError("decoding CSR failed")) + }) + + Context("with a valid CSR", func() { + var input ObtainInput + + BeforeEach(func() { + input = ObtainInput{CSR: _createCSR()} + }) + + It("should fail when no duration is set", func() { + _, _, err := newSelfSignedCertFromCSRinPEMFormat(input) + Expect(err).To(MatchError("duration must be set")) + }) + + It("should succeed when the duration is set", func() { + input.Duration = ptr.To(time.Hour) + cert, key, err := newSelfSignedCertFromCSRinPEMFormat(input) + Expect(err).NotTo(HaveOccurred()) + Expect(cert).NotTo(BeNil()) + Expect(key).NotTo(BeNil()) + }) + }) + }) + + Context("#newSelfSignedCertFromInput", func() { + It("should fail with empty input", func() { + _, err := newSelfSignedCertFromInput(ObtainInput{}) + Expect(err).To(MatchError("invalid key type: ''")) + }) + + It("should create a self-signed certificate from the input", func() { + input := ObtainInput{KeyType: certcrypto.RSA2048, Duration: ptr.To(time.Hour), CommonName: ptr.To("test-common-name")} + cert, err := newSelfSignedCertFromInput(input) + Expect(err).NotTo(HaveOccurred()) + Expect(cert).NotTo(BeNil()) + assertRSAPrivateKeySize(cert.PrivateKey, 2048) + }) + + It("should create a self-signed certificate from a CSR", func() { + input := ObtainInput{CSR: _createCSR(), Duration: ptr.To(time.Hour)} + cert, err := newSelfSignedCertFromInput(input) + Expect(err).NotTo(HaveOccurred()) + Expect(cert).NotTo(BeNil()) + assertRSAPrivateKeySize(cert.PrivateKey, 2048) + }) + + It("should prioritize a CSR over the input key type", func() { + input := ObtainInput{CSR: _createCSR(), KeyType: certcrypto.EC256, Duration: ptr.To(time.Hour)} + cert, err := newSelfSignedCertFromInput(input) + Expect(err).NotTo(HaveOccurred()) + Expect(cert).NotTo(BeNil()) + assertRSAPrivateKeySize(cert.PrivateKey, 2048) + }) + }) +}) + +func assertRSAPrivateKeySize(keyMaterial []byte, expectedBits int) { + block, rest := pem.Decode(keyMaterial) + ExpectWithOffset(1, rest).To(BeEmpty()) + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + ExpectWithOffset(1, privateKey.Size()).To(Equal(expectedBits / 8)) +} + +func _createCSR() []byte { + key, _ := rsa.GenerateKey(rand.Reader, 2048) + csr, _ := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key) + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csr, + }) +} diff --git a/pkg/cert/legobridge/pki.go b/pkg/cert/legobridge/pki.go index 998f43b6..107e1365 100644 --- a/pkg/cert/legobridge/pki.go +++ b/pkg/cert/legobridge/pki.go @@ -225,6 +225,44 @@ func generateCertFromCSR(csrPEM []byte, duration time.Duration, isCA bool) (*x50 }, nil } +// newSelfSignedCertInPEMFormat returns a self-signed certificate and the private key in PEM format. +func newSelfSignedCertInPEMFormat( + input ObtainInput, algo x509.PublicKeyAlgorithm, algoSize int) ([]byte, []byte, error) { + if input.CommonName == nil { + return nil, nil, fmt.Errorf("common name must be set") + } + if input.Duration == nil { + return nil, nil, fmt.Errorf("duration must be set") + } + certPrivateKey, certPrivateKeyPEM, err := generateKey(algo, algoSize) + if err != nil { + return nil, nil, err + } + keyUsage := DefaultCertKeyUsage | CAKeyUsage + if algo == x509.RSA { + keyUsage |= RSAKeyUsage + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: *input.CommonName, + }, + DNSNames: input.DNSNames, + NotBefore: time.Now(), + NotAfter: time.Now().Add(*input.Duration), + KeyUsage: keyUsage, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IsCA: true, + BasicConstraintsValid: true, + MaxPathLen: 0, + } + + certDerBytes, _ := x509.CreateCertificate(rand.Reader, &template, &template, certPrivateKey.Public(), certPrivateKey) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDerBytes}) + return certPEM, certPrivateKeyPEM, nil +} + // signCert creates a PEM encoded signed certificate. func signCert(cert, issuerCert *x509.Certificate, publicKey crypto.PublicKey, signerKey crypto.PrivateKey) ([]byte, error) { derBytes, err := x509.CreateCertificate(rand.Reader, cert, issuerCert, publicKey, signerKey) diff --git a/pkg/cert/legobridge/pki_test.go b/pkg/cert/legobridge/pki_test.go new file mode 100644 index 00000000..e73a2a70 --- /dev/null +++ b/pkg/cert/legobridge/pki_test.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package legobridge + +import ( + "crypto/x509" + "encoding/pem" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/utils/ptr" +) + +var _ = Describe("PKI", func() { + Context("#newSelfSignedCertInPEMFormat", func() { + It("returns an error with empty input", func() { + _, _, err := newSelfSignedCertInPEMFormat(ObtainInput{}, x509.RSA, 2048) + Expect(err).To(HaveOccurred()) + }) + + It("returns an error when no common name is set", func() { + input := ObtainInput{Duration: ptr.To(time.Hour)} + _, _, err := newSelfSignedCertInPEMFormat(input, x509.RSA, 2048) + Expect(err).To(MatchError("common name must be set")) + }) + + It("returns an error when no duration is set", func() { + input := ObtainInput{CommonName: ptr.To("test-common-name")} + _, _, err := newSelfSignedCertInPEMFormat(input, x509.RSA, 2048) + Expect(err).To(MatchError("duration must be set")) + }) + + It("should be able to create a self-signed certificate", func() { + By("Creating a self-signed certificate") + keySize := 2048 + duration := ptr.To(90 * 24 * time.Hour) + expectedNotBefore := time.Now() + expectedNotAfter := expectedNotBefore.Add(*duration) + input := ObtainInput{ + CommonName: ptr.To("test-common-name"), + DNSNames: []string{"test-dns-name"}, + Duration: duration, + } + certPEM, certPrivateKeyPEM, err := newSelfSignedCertInPEMFormat(input, x509.RSA, keySize) + Expect(err).NotTo(HaveOccurred()) + Expect(certPEM).NotTo(BeNil()) + Expect(certPEM).NotTo(BeEmpty()) + Expect(certPrivateKeyPEM).NotTo(BeNil()) + Expect(certPrivateKeyPEM).NotTo(BeEmpty()) + + By("Decoding the certificate") + p, _ := pem.Decode(certPEM) + Expect(p).NotTo(BeNil()) + Expect(p.Bytes).NotTo(BeEmpty()) + + By("Parsing the certificate") + cert, err := x509.ParseCertificate(p.Bytes) + Expect(err).NotTo(HaveOccurred()) + Expect(cert).NotTo(BeNil()) + Expect(cert.Subject.CommonName).To(Equal(*input.CommonName)) + Expect(cert.DNSNames).To(ContainElement(input.DNSNames[0])) + Expect(cert.IsCA).To(BeTrue()) + Expect(cert.NotBefore).To(BeTemporally("~", expectedNotBefore, time.Second)) + Expect(cert.NotAfter).To(BeTemporally("~", expectedNotAfter, time.Second)) + + By("Decoding the certificate private key") + p, _ = pem.Decode(certPrivateKeyPEM) + Expect(p).NotTo(BeNil()) + Expect(p.Bytes).NotTo(BeEmpty()) + + By("Parsing the certificate private key") + privateKey, err := x509.ParsePKCS1PrivateKey(p.Bytes) + Expect(err).NotTo(HaveOccurred()) + Expect(privateKey).NotTo(BeNil()) + Expect(privateKey.Size()).To(Equal(keySize / 8)) + }) + }) +}) diff --git a/pkg/cert/utils/issuerinfo.go b/pkg/cert/utils/issuerinfo.go index 3496e980..29cbfeb7 100644 --- a/pkg/cert/utils/issuerinfo.go +++ b/pkg/cert/utils/issuerinfo.go @@ -11,6 +11,8 @@ const ( IssuerTypeACME = "acme" // IssuerTypeCA is the issuer type CA IssuerTypeCA = "ca" + // IssuerTypeSelfSigned is the issuer type selfsigned + IssuerTypeSelfSigned = "selfSigned" ) // IssuerInfo provides name and type of an issuer @@ -29,6 +31,11 @@ func NewCAIssuerInfo(key IssuerKeyItf) IssuerInfo { return IssuerInfo{key: key, issuertype: IssuerTypeCA} } +// NewSelfSignedIssuerInfo creates info for a selfSigned issuer. +func NewSelfSignedIssuerInfo(key IssuerKeyItf) IssuerInfo { + return IssuerInfo{key: key, issuertype: IssuerTypeSelfSigned} +} + // Key returns the issuer key func (i *IssuerInfo) Key() IssuerKeyItf { return i.key diff --git a/pkg/cert/utils/issuerinfo_test.go b/pkg/cert/utils/issuerinfo_test.go new file mode 100644 index 00000000..b3a7dab9 --- /dev/null +++ b/pkg/cert/utils/issuerinfo_test.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package utils_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/gardener/cert-management/pkg/cert/utils" +) + +var _ = Describe("IssuerInfo", func() { + Context("#NewSelfSignedIssuerInfo", func() { + It("should return a new self-signed issuer info", func() { + issuerKey := utils.NewIssuerKey(utils.ClusterDefault, "test-namespace", "test-name") + issuerInfo := utils.NewSelfSignedIssuerInfo(issuerKey) + Expect(issuerInfo.Key()).To(Equal(issuerKey)) + Expect(issuerInfo.IssuerType()).To(Equal("selfSigned")) + }) + }) +}) diff --git a/pkg/cert/utils/utils_suite_test.go b/pkg/cert/utils/utils_suite_test.go new file mode 100644 index 00000000..5069f2da --- /dev/null +++ b/pkg/cert/utils/utils_suite_test.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package utils_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Utils Suite") +} diff --git a/pkg/controller/issuer/ca/handler.go b/pkg/controller/issuer/ca/handler.go index 5e03f897..0351c2d7 100644 --- a/pkg/controller/issuer/ca/handler.go +++ b/pkg/controller/issuer/ca/handler.go @@ -8,11 +8,13 @@ package ca import ( "fmt" + "math" "github.com/gardener/controller-manager-library/pkg/controllermanager/controller/reconcile" "github.com/gardener/controller-manager-library/pkg/logger" "github.com/gardener/controller-manager-library/pkg/resources" corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" api "github.com/gardener/cert-management/pkg/apis/cert/v1alpha1" "github.com/gardener/cert-management/pkg/cert/legobridge" @@ -48,6 +50,8 @@ func (r *caIssuerHandler) Reconcile(logger logger.LogContext, obj resources.Obje return r.failedCA(logger, obj, api.StateError, fmt.Errorf("missing CA spec")) } + issuer.Spec.RequestsPerDayQuota = ptr.To(math.MaxInt64) + r.support.RememberIssuerSecret(obj.ClusterKey(), ca.PrivateKeySecretRef, "") var secret *corev1.Secret diff --git a/pkg/controller/issuer/certificate/certificate_suite_test.go b/pkg/controller/issuer/certificate/certificate_suite_test.go new file mode 100644 index 00000000..a349d843 --- /dev/null +++ b/pkg/controller/issuer/certificate/certificate_suite_test.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package certificate_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCertificate(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Certificate Suite") +} diff --git a/pkg/controller/issuer/certificate/reconciler.go b/pkg/controller/issuer/certificate/reconciler.go index 0b0dfb33..835293eb 100644 --- a/pkg/controller/issuer/certificate/reconciler.go +++ b/pkg/controller/issuer/certificate/reconciler.go @@ -243,7 +243,11 @@ func (r *certReconciler) reconcileCert(logctx logger.LogContext, obj resources.O } issuerKey := r.support.IssuerClusterObjectKey(cert.Namespace, &cert.Spec) - if r.support.GetIssuerSecretHash(issuerKey) == "" { + issuer, err := r.support.LoadIssuer(issuerKey) + if err != nil { + return r.failed(logctx, obj, api.StateError, err) + } + if r.support.GetIssuerSecretHash(issuerKey) == "" && issuer.Spec.SelfSigned == nil { // issuer not reconciled yet logctx.Infof("waiting for reconciliation of issuer %s", issuerKey) return reconcile.Delay(logctx, nil) @@ -385,8 +389,8 @@ func (r *certReconciler) obtainCertificateAndPending(logctx logger.LogContext, o return r.failed(logctx, obj, api.StateError, err) } - if issuer.Spec.ACME != nil && issuer.Spec.CA != nil { - return r.failed(logctx, obj, api.StateError, fmt.Errorf("invalid issuer spec: only ACME or CA can be set, but not both")) + if hasMultipleIssuerTypes(issuer) { + return r.failed(logctx, obj, api.StateError, fmt.Errorf("invalid issuer spec: either ACME, CA or selfSigned can be set")) } if issuer.Spec.ACME != nil { return r.obtainCertificateAndPendingACME(logctx, obj, renew, cert, issuerKey, issuer) @@ -394,6 +398,9 @@ func (r *certReconciler) obtainCertificateAndPending(logctx logger.LogContext, o if issuer.Spec.CA != nil { return r.obtainCertificateCA(logctx, obj, renew, cert, issuerKey, issuer) } + if issuer.Spec.SelfSigned != nil { + return r.obtainCertificateSelfSigned(logctx, obj, renew, cert, issuerKey) + } return r.failed(logctx, obj, api.StateError, fmt.Errorf("incomplete issuer spec (ACME or CA section must be provided)")) } @@ -406,6 +413,9 @@ func (r *certReconciler) obtainCertificateAndPendingACME(logctx logger.LogContex if cert.Spec.Duration != nil { return r.failedStop(logctx, obj, api.StateError, fmt.Errorf("duration cannot be set for ACME certificate")) } + if cert.Spec.IsCA != nil { + return r.failedStop(logctx, obj, api.StateError, fmt.Errorf("isCA cannot be set for ACME certificate")) + } err = r.validateDomainsAndCsr(&cert.Spec, issuer.Spec.ACME.Domains, issuerKey) if err != nil { return r.failedStop(logctx, obj, api.StateError, err) @@ -551,8 +561,76 @@ func (r *certReconciler) restoreCA(issuerKey utils.IssuerKey, issuer *api.Issuer return CAKeyPair, nil } +func (r *certReconciler) obtainCertificateSelfSigned(logctx logger.LogContext, obj resources.Object, + renew bool, cert *api.Certificate, issuerKey utils.IssuerKey) reconcile.Status { + if cert.Spec.IsCA == nil || !*cert.Spec.IsCA { + return r.failedStop(logctx, obj, api.StateError, fmt.Errorf("self signed certificates must set 'spec.isCA: true'")) + } + duration, err := r.getDuration(cert) + if err != nil { + return r.failedStop(logctx, obj, api.StateError, err) + } + if duration == nil { + duration = ptr.To(legobridge.DefaultCertDuration) + } + err = r.validateDomainsAndCsr(&cert.Spec, nil, issuerKey) + if err != nil { + return r.failedStop(logctx, obj, api.StateError, err) + } + + if secretRef, specHash, notAfter := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secretRef != nil { + // reuse found certificate + issuerInfo := utils.NewSelfSignedIssuerInfo(issuerKey) + secretRef, err := r.copySecretIfNeeded(logctx, issuerInfo, cert.ObjectMeta, secretRef, specHash, &cert.Spec) + if err != nil { + return r.failed(logctx, obj, api.StateError, err) + } + return r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notAfter) + } + + objectName := obj.ObjectName() + sublogctx := logctx.NewContext("callback", cert.Name) + callback := func(output *legobridge.ObtainOutput) { + r.pendingResults.Add(objectName, output) + r.pendingRequests.Remove(objectName) + key := resources.NewClusterKey(r.targetCluster.GetId(), api.Kind(api.CertificateKind), objectName.Namespace(), objectName.Name()) + err := r.support.EnqueueKey(key) + if err != nil { + sublogctx.Warnf("Enqueue %s failed with %s", objectName, err.Error()) + } + } + + keyType, err := r.certificatePrivateKeyDefaults.ToKeyType(cert.Spec.PrivateKey) + if err != nil { + return r.failedStop(logctx, obj, api.StateError, err) + } + input := legobridge.ObtainInput{ + IssuerKey: issuerKey, + CommonName: cert.Spec.CommonName, + DNSNames: cert.Spec.DNSNames, + Callback: callback, + Renew: renew, + KeyType: keyType, + IsCA: true, + Duration: duration, + CSR: cert.Spec.CSR, + } + + err = r.obtainer.Obtain(input) + if err != nil { + return r.failed(logctx, obj, api.StateError, fmt.Errorf("obtaining self signed certificate failed: %w", err)) + } + + r.pendingRequests.Add(objectName) + msg := "self signed certificate requested, waiting for creation" + return r.pending(logctx, obj, msg) +} + func (r *certReconciler) obtainCertificateCA(logctx logger.LogContext, obj resources.Object, renew bool, cert *api.Certificate, issuerKey utils.IssuerKey, issuer *api.Issuer) reconcile.Status { + if cert.Spec.IsCA != nil { + return r.failedStop(logctx, obj, api.StateError, fmt.Errorf("isCA cannot be set for a certificate with issuer of type 'ca'")) + } CAKeyPair, err := r.restoreCA(issuerKey, issuer) if err != nil { return r.failed(logctx, obj, api.StateError, err) diff --git a/pkg/controller/issuer/certificate/utils.go b/pkg/controller/issuer/certificate/utils.go index a2cce632..ec58dddc 100644 --- a/pkg/controller/issuer/certificate/utils.go +++ b/pkg/controller/issuer/certificate/utils.go @@ -15,10 +15,11 @@ import ( "github.com/gardener/controller-manager-library/pkg/resources" corev1 "k8s.io/api/core/v1" + api "github.com/gardener/cert-management/pkg/apis/cert/v1alpha1" "github.com/gardener/cert-management/pkg/cert/legobridge" ) -// ExtractRequestedAtFromAnnotation extracts the requestedAt timestamp from the annotation cert.gardener.cloud/requesteAt +// ExtractRequestedAtFromAnnotation extracts the requestedAt timestamp from the annotation cert.gardener.cloud/requestedAt func ExtractRequestedAtFromAnnotation(obj resources.ObjectData) *time.Time { if value, ok := resources.GetAnnotation(obj, AnnotationRequestedAt); ok { t, err := time.Parse(time.RFC3339, value) @@ -84,3 +85,17 @@ func LookupSerialNumber(res resources.Interface, ref *corev1.SecretReference) (s } return SerialNumberToString(cert.SerialNumber, false), nil } + +func hasMultipleIssuerTypes(issuer *api.Issuer) bool { + count := 0 + if issuer.Spec.SelfSigned != nil { + count++ + } + if issuer.Spec.ACME != nil { + count++ + } + if issuer.Spec.CA != nil { + count++ + } + return count > 1 +} diff --git a/pkg/controller/issuer/certificate/utils_test.go b/pkg/controller/issuer/certificate/utils_test.go new file mode 100644 index 00000000..65c38fe1 --- /dev/null +++ b/pkg/controller/issuer/certificate/utils_test.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package certificate + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + api "github.com/gardener/cert-management/pkg/apis/cert/v1alpha1" +) + +var _ = Describe("Utils", func() { + Context("#hasMultipleIssuerTypes", func() { + var issuer *api.Issuer + + BeforeEach(func() { + issuer = &api.Issuer{} + }) + + It("should return false if no issuer type is specified", func() { + Expect(hasMultipleIssuerTypes(issuer)).To(BeFalse()) + }) + + It("should return false if only one issuer type is specified", func() { + issuer.Spec.ACME = &api.ACMESpec{} + Expect(hasMultipleIssuerTypes(issuer)).To(BeFalse()) + }) + + It("should return true if multiple issuer types are specified", func() { + issuer.Spec.ACME = &api.ACMESpec{} + issuer.Spec.SelfSigned = &api.SelfSignedSpec{} + Expect(hasMultipleIssuerTypes(issuer)).To(BeTrue()) + }) + }) +}) diff --git a/pkg/controller/issuer/core/const.go b/pkg/controller/issuer/core/const.go index e9842fd4..8154cb9b 100644 --- a/pkg/controller/issuer/core/const.go +++ b/pkg/controller/issuer/core/const.go @@ -11,6 +11,8 @@ const ( ACMEType = "acme" // CAType is the type name for CA. CAType = "ca" + // SelfSignedType is the type name for SelfSigned + SelfSignedType = "selfSigned" ) const ( diff --git a/pkg/controller/issuer/core/support.go b/pkg/controller/issuer/core/support.go index 92bed9f4..b28d97ef 100644 --- a/pkg/controller/issuer/core/support.go +++ b/pkg/controller/issuer/core/support.go @@ -370,6 +370,13 @@ func (s *Support) SucceededAndTriggerCertificates(logger logger.LogContext, obj return reconcile.Succeeded(logger) } +// SucceedSelfSignedIssuer handles succeeded self-signed issuers. +func (s *Support) SucceedSelfSignedIssuer(logger logger.LogContext, obj resources.Object, itype *string) reconcile.Status { + modificationState, _ := s.prepareUpdateStatus(obj, api.StateReady, itype, nil) + s.updateStatus(modificationState) + return reconcile.Succeeded(logger) +} + func updateTypeStatus(mod *resources.ModificationState, status **runtime.RawExtension, regRaw []byte) { changedRegistration := false if *status == nil || (*status).Raw == nil { diff --git a/pkg/controller/issuer/reconciler.go b/pkg/controller/issuer/reconciler.go index 2bbd066c..075b20cb 100644 --- a/pkg/controller/issuer/reconciler.go +++ b/pkg/controller/issuer/reconciler.go @@ -24,10 +24,11 @@ import ( "github.com/gardener/cert-management/pkg/controller/issuer/certificate" "github.com/gardener/cert-management/pkg/controller/issuer/core" "github.com/gardener/cert-management/pkg/controller/issuer/revocation" + "github.com/gardener/cert-management/pkg/controller/issuer/selfSigned" ) func newCompoundReconciler(c controller.Interface) (reconcile.Interface, error) { - handler, err := core.NewCompoundHandler(c, acme.NewACMEIssuerHandler, ca.NewCAIssuerHandler) + handler, err := core.NewCompoundHandler(c, acme.NewACMEIssuerHandler, ca.NewCAIssuerHandler, selfSigned.NewSelfSignedIssuerHandler) if err != nil { return nil, err } diff --git a/pkg/controller/issuer/selfSigned/handler.go b/pkg/controller/issuer/selfSigned/handler.go new file mode 100644 index 00000000..36fa4f39 --- /dev/null +++ b/pkg/controller/issuer/selfSigned/handler.go @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package selfSigned + +import ( + "fmt" + "math" + + "github.com/gardener/controller-manager-library/pkg/controllermanager/controller/reconcile" + "github.com/gardener/controller-manager-library/pkg/logger" + "github.com/gardener/controller-manager-library/pkg/resources" + "k8s.io/utils/ptr" + + api "github.com/gardener/cert-management/pkg/apis/cert/v1alpha1" + "github.com/gardener/cert-management/pkg/controller/issuer/core" +) + +var selfSignedType = core.SelfSignedType + +// NewSelfSignedIssuerHandler creates an SelfSigned IssuerHandler. +func NewSelfSignedIssuerHandler(support *core.Support) (core.IssuerHandler, error) { + return &selfSignedIssuerHandler{ + support: support, + }, nil +} + +type selfSignedIssuerHandler struct { + support *core.Support +} + +func (h *selfSignedIssuerHandler) Type() string { + return core.SelfSignedType +} + +func (h *selfSignedIssuerHandler) CanReconcile(issuer *api.Issuer) bool { + return issuer != nil && issuer.Spec.SelfSigned != nil +} + +func (h *selfSignedIssuerHandler) Reconcile(logger logger.LogContext, obj resources.Object, issuer *api.Issuer) reconcile.Status { + logger.Infof("reconciling") + + selfSigned := issuer.Spec.SelfSigned + if selfSigned == nil { + return h.support.Failed(logger, obj, api.StateError, &selfSignedType, fmt.Errorf("missing selfSigned spec"), false) + } + issuer.Spec.RequestsPerDayQuota = ptr.To(math.MaxInt64) + + return h.support.SucceedSelfSignedIssuer(logger, obj, &selfSignedType) +} diff --git a/pkg/controller/issuer/selfSigned/handler_test.go b/pkg/controller/issuer/selfSigned/handler_test.go new file mode 100644 index 00000000..3e032668 --- /dev/null +++ b/pkg/controller/issuer/selfSigned/handler_test.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package selfSigned_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + api "github.com/gardener/cert-management/pkg/apis/cert/v1alpha1" + "github.com/gardener/cert-management/pkg/controller/issuer/selfSigned" +) + +var _ = Describe("Handler", func() { + h, _ := selfSigned.NewSelfSignedIssuerHandler(nil) + + Context("#CanReconcile", func() { + It("should return false if issuer is nil", func() { + Expect(h.CanReconcile(nil)).To(BeFalse()) + }) + + It("should return false if issuer type is unset", func() { + issuer := &api.Issuer{} + Expect(h.CanReconcile(issuer)).To(BeFalse()) + }) + + It("should return false if issuer type is not self-signed", func() { + issuer := &api.Issuer{Spec: api.IssuerSpec{ACME: &api.ACMESpec{}}} + Expect(h.CanReconcile(issuer)).To(BeFalse()) + }) + + It("should return true if issuer type is self-signed", func() { + issuer := &api.Issuer{Spec: api.IssuerSpec{SelfSigned: &api.SelfSignedSpec{}}} + Expect(h.CanReconcile(issuer)).To(BeTrue()) + }) + }) +}) diff --git a/pkg/controller/issuer/selfSigned/selfSigned_suite_test.go b/pkg/controller/issuer/selfSigned/selfSigned_suite_test.go new file mode 100644 index 00000000..edde9152 --- /dev/null +++ b/pkg/controller/issuer/selfSigned/selfSigned_suite_test.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package selfSigned_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSelfSigned(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "SelfSigned Suite") +} diff --git a/test/integration/controller/issuer/issuer_test.go b/test/integration/controller/issuer/issuer_test.go index 21217111..f33dd4ef 100644 --- a/test/integration/controller/issuer/issuer_test.go +++ b/test/integration/controller/issuer/issuer_test.go @@ -16,6 +16,7 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/gardener/cert-management/pkg/apis/cert/v1alpha1" @@ -71,28 +72,88 @@ var _ = Describe("Issuer controller tests", func() { }) }) - It("should create an ACME issuer", func() { - issuer := &v1alpha1.Issuer{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: testRunID, - Name: "acme1", - }, - Spec: v1alpha1.IssuerSpec{ - ACME: &v1alpha1.ACMESpec{ - Email: "foo@somewhere-foo-123456.com", - Server: acmeDirectoryAddress, - AutoRegistration: true, + Context("ACME issuer", func() { + It("should create an ACME issuer", func() { + issuer := &v1alpha1.Issuer{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testRunID, + Name: "acme1", }, - }, - } - Expect(testClient.Create(ctx, issuer)).To(Succeed()) - DeferCleanup(func() { - Expect(testClient.Delete(ctx, issuer)).To(Succeed()) + Spec: v1alpha1.IssuerSpec{ + ACME: &v1alpha1.ACMESpec{ + Email: "foo@somewhere-foo-123456.com", + Server: acmeDirectoryAddress, + AutoRegistration: true, + }, + }, + } + Expect(testClient.Create(ctx, issuer)).To(Succeed()) + DeferCleanup(func() { + Expect(testClient.Delete(ctx, issuer)).To(Succeed()) + }) + + Eventually(func(g Gomega) { + Expect(testClient.Get(ctx, client.ObjectKeyFromObject(issuer), issuer)).To(Succeed()) + g.Expect(issuer.Status.State).To(Equal("Ready")) + }).Should(Succeed()) }) + }) + + Context("Self-signed issuer", func() { + It("should be able to create self-signed certificates", func() { + By("Create self-signed issuer") + issuer := &v1alpha1.Issuer{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testRunID, + Name: "self-signed-issuer", + }, + Spec: v1alpha1.IssuerSpec{ + SelfSigned: &v1alpha1.SelfSignedSpec{}, + }, + } + Expect(testClient.Create(ctx, issuer)).To(Succeed()) + DeferCleanup(func() { + Expect(testClient.Delete(ctx, issuer)).To(Succeed()) + }) + + Eventually(func(g Gomega) { + Expect(testClient.Get(ctx, client.ObjectKeyFromObject(issuer), issuer)).To(Succeed()) + g.Expect(issuer.Status.State).To(Equal("Ready")) + }).Should(Succeed()) + + By("Create self-signed certificate") + certificate := &v1alpha1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testRunID, + Name: "self-signed-certificate", + }, + Spec: v1alpha1.CertificateSpec{ + CommonName: ptr.To("ca1.mydomain.com"), + IsCA: ptr.To(true), + IssuerRef: &v1alpha1.IssuerRef{ + Name: issuer.Name, + Namespace: issuer.Namespace, + }, + }, + } + Expect(testClient.Create(ctx, certificate)).To(Succeed()) + DeferCleanup(func() { + Expect(testClient.Delete(ctx, certificate)).To(Succeed()) + }) - Eventually(func(g Gomega) { - Expect(testClient.Get(ctx, client.ObjectKeyFromObject(issuer), issuer)).To(Succeed()) - g.Expect(issuer.Status.State).To(Equal("Ready")) - }).Should(Succeed()) + Eventually(func(g Gomega) { + Expect(testClient.Get(ctx, client.ObjectKeyFromObject(certificate), certificate)).To(Succeed()) + g.Expect(certificate.Status.State).To(Equal("Ready")) + }).Should(Succeed()) + + By("Resolve certificate secret reference") + secretReference := certificate.Spec.SecretRef + secretKey := client.ObjectKey{Name: secretReference.Name, Namespace: secretReference.Namespace} + secret := &corev1.Secret{} + Expect(testClient.Get(ctx, secretKey, secret)).To(Succeed()) + Expect(secret.Data).To(HaveKeyWithValue("ca.crt", Not(BeEmpty()))) + Expect(secret.Data).To(HaveKeyWithValue("tls.crt", Not(BeEmpty()))) + Expect(secret.Data).To(HaveKeyWithValue("tls.key", Not(BeEmpty()))) + }) }) })