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())))
+		})
 	})
 })