Repository: k0sproject/k0s
Pull request #5752: Support configuring the expiration duration for certificates
Author: login: iawia002
Created: 2025-04-12T07:21:02Z | Merged: 2025-05-20T11:45:17Z

## Description

Support custom expiration duration for CA/server certificates of apiserver/etcd, the default duration (87600h, 8760h) remaining the same as before.

## Type of change

<!-- check the related options -->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update

## How Has This Been Tested?

- [x] Manual test
- [ ] Auto test added

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->

## Checklist

- [x] My code follows the style [guidelines](https://github.com/k0sproject/k0s/blob/main/docs/contributors/overview.md) of this project
- [x] My commit messages are [signed-off](https://github.com/k0sproject/k0s/blob/main/docs/contributors/github_workflow.md)
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings



Diff:
diff --git a/cmd/controller/certificates.go b/cmd/controller/certificates.go
index 5c57cc1c6691..4b788390d33a 100644
--- a/cmd/controller/certificates.go
+++ b/cmd/controller/certificates.go
@@ -54,7 +54,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 	caCertPath := filepath.Join(c.K0sVars.CertRootDir, "ca.crt")
 	caCertKey := filepath.Join(c.K0sVars.CertRootDir, "ca.key")
 
-	if err := c.CertManager.EnsureCA("ca", "kubernetes-ca"); err != nil {
+	if err := c.CertManager.EnsureCA("ca", "kubernetes-ca", c.ClusterSpec.API.CAExpiry.Duration); err != nil {
 		return err
 	}
 
@@ -76,7 +76,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 	}
 	eg.Go(func() error {
 		// Front proxy CA
-		if err := c.CertManager.EnsureCA("front-proxy-ca", "kubernetes-front-proxy-ca"); err != nil {
+		if err := c.CertManager.EnsureCA("front-proxy-ca", "kubernetes-front-proxy-ca", c.ClusterSpec.API.CAExpiry.Duration); err != nil {
 			return err
 		}
 
@@ -89,7 +89,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 			CACert: proxyCertPath,
 			CAKey:  proxyCertKey,
 		}
-		_, err := c.CertManager.EnsureCertificate(proxyClientReq, apiServerUID)
+		_, err := c.CertManager.EnsureCertificate(proxyClientReq, apiServerUID, c.ClusterSpec.API.CertExpiry.Duration)
 
 		return err
 	})
@@ -103,7 +103,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 			CACert: caCertPath,
 			CAKey:  caCertKey,
 		}
-		adminCert, err := c.CertManager.EnsureCertificate(adminReq, users.RootUID)
+		adminCert, err := c.CertManager.EnsureCertificate(adminReq, users.RootUID, c.ClusterSpec.API.CertExpiry.Duration)
 		if err != nil {
 			return err
 		}
@@ -132,7 +132,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 			logrus.WithError(err).Warn("Files with key material for konnectivity-server user will be owned by root")
 		}
 
-		konnectivityCert, err := c.CertManager.EnsureCertificate(konnectivityReq, uid)
+		konnectivityCert, err := c.CertManager.EnsureCertificate(konnectivityReq, uid, c.ClusterSpec.API.CertExpiry.Duration)
 		if err != nil {
 			return err
 		}
@@ -148,7 +148,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 			CACert: caCertPath,
 			CAKey:  caCertKey,
 		}
-		ccmCert, err := c.CertManager.EnsureCertificate(ccmReq, apiServerUID)
+		ccmCert, err := c.CertManager.EnsureCertificate(ccmReq, apiServerUID, c.ClusterSpec.API.CertExpiry.Duration)
 		if err != nil {
 			return err
 		}
@@ -172,7 +172,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 			logrus.WithError(err).Warn("Files with key material for kube-scheduler user will be owned by root")
 		}
 
-		schedulerCert, err := c.CertManager.EnsureCertificate(schedulerReq, uid)
+		schedulerCert, err := c.CertManager.EnsureCertificate(schedulerReq, uid, c.ClusterSpec.API.CertExpiry.Duration)
 		if err != nil {
 			return err
 		}
@@ -188,7 +188,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 			CACert: caCertPath,
 			CAKey:  caCertKey,
 		}
-		_, err := c.CertManager.EnsureCertificate(kubeletClientReq, apiServerUID)
+		_, err := c.CertManager.EnsureCertificate(kubeletClientReq, apiServerUID, c.ClusterSpec.API.CertExpiry.Duration)
 		return err
 	})
 
@@ -238,7 +238,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 			CAKey:     caCertKey,
 			Hostnames: hostnames,
 		}
-		_, err = c.CertManager.EnsureCertificate(serverReq, apiServerUID)
+		_, err = c.CertManager.EnsureCertificate(serverReq, apiServerUID, c.ClusterSpec.API.CertExpiry.Duration)
 		return err
 	})
 
@@ -252,7 +252,7 @@ func (c *Certificates) Init(ctx context.Context) error {
 			Hostnames: hostnames,
 		}
 		// TODO Not sure about the user...
-		_, err := c.CertManager.EnsureCertificate(apiReq, apiServerUID)
+		_, err := c.CertManager.EnsureCertificate(apiReq, apiServerUID, c.ClusterSpec.API.CertExpiry.Duration)
 		return err
 	})
 
diff --git a/cmd/kubeconfig/create.go b/cmd/kubeconfig/create.go
index bf7df752daa6..85740c55a263 100644
--- a/cmd/kubeconfig/create.go
+++ b/cmd/kubeconfig/create.go
@@ -20,6 +20,7 @@ import (
 	"errors"
 	"fmt"
 	"path/filepath"
+	"time"
 
 	"github.com/k0sproject/k0s/internal/pkg/users"
 	"github.com/k0sproject/k0s/pkg/certificate"
@@ -91,7 +92,7 @@ func createUserKubeconfig(k0sVars *config.CfgVars, clusterAPIURL, username, grou
 	certManager := certificate.Manager{
 		K0sVars: k0sVars,
 	}
-	userCert, err := certManager.EnsureCertificate(userReq, users.RootUID)
+	userCert, err := certManager.EnsureCertificate(userReq, users.RootUID, 8760*time.Hour)
 	if err != nil {
 		return nil, fmt.Errorf("failed generate user certificate: %w, check if the control plane is initialized on this node", err)
 	}
diff --git a/cmd/kubeconfig/create_test.go b/cmd/kubeconfig/create_test.go
index 9fd26feabfad..9f5c153d8a57 100644
--- a/cmd/kubeconfig/create_test.go
+++ b/cmd/kubeconfig/create_test.go
@@ -21,6 +21,7 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
+	"time"
 
 	"github.com/k0sproject/k0s/cmd"
 	"github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1"
@@ -46,7 +47,7 @@ func TestKubeconfigCreate(t *testing.T) {
 	// Create the CA
 	require.NoError(t, os.MkdirAll(k0sVars.CertRootDir, 0755))
 	certManager := certificate.Manager{K0sVars: k0sVars}
-	require.NoError(t, certManager.EnsureCA("ca", t.Name()))
+	require.NoError(t, certManager.EnsureCA("ca", t.Name(), 87600*time.Hour))
 
 	// Setup the kubeconfig command
 	configData, err := yaml.Marshal(cfg)
diff --git a/docs/configuration.md b/docs/configuration.md
index bcd90e68af3e..ca9ae19e956d 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -56,6 +56,8 @@ spec:
     port: 6443
     sans:
     - 192.168.68.104
+    caExpiry: 87600h
+    certExpiry: 8760h
   controllerManager: {}
   extensions:
     helm:
@@ -103,6 +105,8 @@ spec:
   storage:
     etcd:
       peerAddress: 192.168.68.104
+      caExpiry: 87600h
+      certExpiry: 8760h
     type: etcd
   telemetry:
     enabled: true
@@ -118,6 +122,8 @@ spec:
 | `onlyBindToAddress` | The API server binds too all interfaces by default. With this option set to `true`, the API server will only listen on the IP address configured by the `address` option (first non-local address by default). This can be necessary with multi-homed control plane nodes. |
 | `externalAddress`   | The loadbalancer address (for k0s controllers running behind a loadbalancer). Configures all cluster components to connect to this address and also configures this address for use when joining new nodes to the cluster.                                                 |
 | `sans`              | List of additional addresses to push to API servers serving the certificate.                                                                                                                                                                                               |
+| `caExpiry`          | The expiration duration of the CA certificate.                                                                                                                                                                                                                             |
+| `certExpiry`        | The expiration duration of the server certificate.                                                                                                                                                                                                                         |
 | `extraArgs`         | Map of key-values (strings) for any extra arguments to pass down to Kubernetes api-server process. Any behavior triggered by these parameters is outside k0s support.                                                                                                      |
 | `port`¹             | Custom port for kube-api server to listen on (default: 6443)                                                                                                                                                                                                               |
 | `k0sApiPort`¹       | Custom port for k0s-api server to listen on (default: 9443)                                                                                                                                                                                                                |
@@ -131,6 +137,8 @@ spec:
 | `type`                 | Type of the data store (valid values:`etcd` or `kine`). **Note**: Type `etcd` will cause k0s to create and manage an elastic etcd cluster within the controller nodes. |
 | `etcd.peerAddress`     | Node address used for etcd cluster peering.                                                                                                                            |
 | `etcd.extraArgs`       | Map of key-values (strings) for any extra arguments to pass down to etcd process. Any behavior triggered by these parameters is outside k0s support.                   |
+| `etcd.caExpiry`        | The expiration duration of the CA certificate.                                                                                                                         |
+| `etcd.certExpiry`      | The expiration duration of the server certificate.                                                                                                                     |
 | `kine.dataSource`      | [kine](https://github.com/k3s-io/kine) datasource URL.                                                                                                                 |
 | `etcd.externalCluster` | Configuration when etcd is externally managed, i.e. running on dedicated nodes. See [`spec.storage.etcd.externalCluster`](#specstorageetcdexternalcluster)             |
 
diff --git a/pkg/apis/k0s/v1beta1/api.go b/pkg/apis/k0s/v1beta1/api.go
index d968a2b736a4..1c85ec249846 100644
--- a/pkg/apis/k0s/v1beta1/api.go
+++ b/pkg/apis/k0s/v1beta1/api.go
@@ -22,10 +22,12 @@ import (
 	"net"
 	"net/url"
 	"strconv"
+	"time"
 
 	"github.com/k0sproject/k0s/internal/pkg/iface"
 	"github.com/k0sproject/k0s/internal/pkg/stringslice"
 
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/util/validation"
 	"k8s.io/apimachinery/pkg/util/validation/field"
 
@@ -64,6 +66,14 @@ type APISpec struct {
 	// List of additional addresses to push to API servers serving the certificate
 	// +listType=set
 	SANs []string `json:"sans,omitempty"`
+
+	// The expiration duration of the CA certificate
+	// +kubebuilder:default="87600h"
+	CAExpiry metav1.Duration `json:"caExpiry,omitempty"`
+
+	// The expiration duration of the server certificate
+	// +kubebuilder:default="8760h"
+	CertExpiry metav1.Duration `json:"certExpiry,omitempty"`
 }
 
 // DefaultAPISpec default settings for api
@@ -195,4 +205,14 @@ func (a *APISpec) setDefaults() {
 	if a.Port == 0 {
 		a.Port = 6443
 	}
+	if a.CAExpiry.Duration == 0 {
+		a.CAExpiry = metav1.Duration{
+			Duration: 87600 * time.Hour,
+		}
+	}
+	if a.CertExpiry.Duration == 0 {
+		a.CertExpiry = metav1.Duration{
+			Duration: 8760 * time.Hour,
+		}
+	}
 }
diff --git a/pkg/apis/k0s/v1beta1/storage.go b/pkg/apis/k0s/v1beta1/storage.go
index 827c149b3275..9fd9b1e163ca 100644
--- a/pkg/apis/k0s/v1beta1/storage.go
+++ b/pkg/apis/k0s/v1beta1/storage.go
@@ -26,11 +26,13 @@ import (
 	"path/filepath"
 	"slices"
 	"strings"
+	"time"
 
 	"github.com/k0sproject/k0s/internal/pkg/iface"
 	"github.com/k0sproject/k0s/pkg/config/kine"
 	"github.com/k0sproject/k0s/pkg/constant"
 
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/util/validation/field"
 
 	"github.com/sirupsen/logrus"
@@ -140,6 +142,14 @@ type EtcdConfig struct {
 
 	// Map of key-values (strings) for any extra arguments you want to pass down to the etcd process
 	ExtraArgs map[string]string `json:"extraArgs,omitempty"`
+
+	// The expiration duration of the CA certificate
+	// +kubebuilder:default="87600h"
+	CAExpiry metav1.Duration `json:"caExpiry,omitempty"`
+
+	// The expiration duration of the server certificate
+	// +kubebuilder:default="8760h"
+	CertExpiry metav1.Duration `json:"certExpiry,omitempty"`
 }
 
 // ExternalCluster defines external etcd cluster related config options
@@ -172,6 +182,12 @@ func DefaultEtcdConfig() *EtcdConfig {
 		ExternalCluster: nil,
 		PeerAddress:     addr,
 		ExtraArgs:       make(map[string]string),
+		CAExpiry: metav1.Duration{
+			Duration: 87600 * time.Hour,
+		},
+		CertExpiry: metav1.Duration{
+			Duration: 8760 * time.Hour,
+		},
 	}
 }
 
diff --git a/pkg/apis/k0s/v1beta1/zz_generated.deepcopy.go b/pkg/apis/k0s/v1beta1/zz_generated.deepcopy.go
index c5d277ff46d7..9fe65acf92bf 100644
--- a/pkg/apis/k0s/v1beta1/zz_generated.deepcopy.go
+++ b/pkg/apis/k0s/v1beta1/zz_generated.deepcopy.go
@@ -39,6 +39,8 @@ func (in *APISpec) DeepCopyInto(out *APISpec) {
 		*out = make([]string, len(*in))
 		copy(*out, *in)
 	}
+	out.CAExpiry = in.CAExpiry
+	out.CertExpiry = in.CertExpiry
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APISpec.
@@ -553,6 +555,8 @@ func (in *EtcdConfig) DeepCopyInto(out *EtcdConfig) {
 			(*out)[key] = val
 		}
 	}
+	out.CAExpiry = in.CAExpiry
+	out.CertExpiry = in.CertExpiry
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EtcdConfig.
diff --git a/pkg/certificate/manager.go b/pkg/certificate/manager.go
index f2fedb6d8a3c..f64f8048b4f8 100644
--- a/pkg/certificate/manager.go
+++ b/pkg/certificate/manager.go
@@ -26,11 +26,13 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"time"
 
 	"github.com/cloudflare/cfssl/certinfo"
 	"github.com/cloudflare/cfssl/cli"
 	"github.com/cloudflare/cfssl/cli/genkey"
 	"github.com/cloudflare/cfssl/cli/sign"
+	cfsslconfig "github.com/cloudflare/cfssl/config"
 	"github.com/cloudflare/cfssl/csr"
 	"github.com/cloudflare/cfssl/initca"
 	"github.com/cloudflare/cfssl/signer"
@@ -64,7 +66,7 @@ type Manager struct {
 }
 
 // EnsureCA makes sure the given CA certs and key is created.
-func (m *Manager) EnsureCA(name, cn string) error {
+func (m *Manager) EnsureCA(name, cn string, expiry time.Duration) error {
 	keyFile := filepath.Join(m.K0sVars.CertRootDir, name+".key")
 	certFile := filepath.Join(m.K0sVars.CertRootDir, name+".crt")
 
@@ -78,7 +80,7 @@ func (m *Manager) EnsureCA(name, cn string) error {
 	req.KeyRequest.S = 2048
 	req.CN = cn
 	req.CA = &csr.CAConfig{
-		Expiry: "87600h",
+		Expiry: expiry.String(),
 	}
 	cert, _, key, err := initca.New(req)
 	if err != nil {
@@ -99,7 +101,7 @@ func (m *Manager) EnsureCA(name, cn string) error {
 }
 
 // EnsureCertificate creates the specified certificate if it does not already exist
-func (m *Manager) EnsureCertificate(certReq Request, ownerID int) (Certificate, error) {
+func (m *Manager) EnsureCertificate(certReq Request, ownerID int, expiry time.Duration) (Certificate, error) {
 
 	keyFile := filepath.Join(m.K0sVars.CertRootDir, certReq.Name+".key")
 	certFile := filepath.Join(m.K0sVars.CertRootDir, certReq.Name+".crt")
@@ -128,6 +130,16 @@ func (m *Manager) EnsureCertificate(certReq Request, ownerID int) (Certificate,
 		config := cli.Config{
 			CAFile:    "file:" + certReq.CACert,
 			CAKeyFile: "file:" + certReq.CAKey,
+			CFG: &cfsslconfig.Config{
+				Signing: &cfsslconfig.Signing{
+					Profiles: map[string]*cfsslconfig.SigningProfile{},
+					Default: &cfsslconfig.SigningProfile{
+						Usage:        []string{"signing", "key encipherment", "server auth", "client auth"},
+						Expiry:       expiry,
+						ExpiryString: expiry.String(),
+					},
+				},
+			},
 		}
 		s, err := sign.SignerFromConfig(config)
 		if err != nil {
diff --git a/pkg/certificate/manager_test.go b/pkg/certificate/manager_test.go
new file mode 100644
index 000000000000..9cc52c9bd2a3
--- /dev/null
+++ b/pkg/certificate/manager_test.go
@@ -0,0 +1,91 @@
+/*
+Copyright 2025 k0s authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package certificate
+
+import (
+	"crypto/x509"
+	"encoding/pem"
+	"os"
+	"testing"
+	"time"
+
+	cfsslconfig "github.com/cloudflare/cfssl/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/k0sproject/k0s/pkg/config"
+)
+
+func TestEnsureCA(t *testing.T) {
+	// Create some k0sVars
+	k0sVars, err := config.NewCfgVars(nil, t.TempDir())
+	require.NoError(t, err)
+
+	// Create the CA
+	require.NoError(t, os.MkdirAll(k0sVars.CertRootDir, 0755))
+	certManager := Manager{K0sVars: k0sVars}
+	require.NoError(t, certManager.EnsureCA("ca", t.Name(), 100000*time.Hour))
+
+	pemBytes, _ := os.ReadFile(k0sVars.CertRootDir + "/ca.crt")
+	cert, err := parseCert(pemBytes)
+	require.NoError(t, err)
+	// check the expiration date of the cert
+	assert.Equal(t, cert.NotBefore.Add(100000*time.Hour), cert.NotAfter)
+}
+
+func TestEnsureCertificate(t *testing.T) {
+	// Create some k0sVars
+	k0sVars, err := config.NewCfgVars(nil, t.TempDir())
+	require.NoError(t, err)
+
+	// Create the CA
+	require.NoError(t, os.MkdirAll(k0sVars.CertRootDir, 0755))
+	certManager := Manager{K0sVars: k0sVars}
+	require.NoError(t, certManager.EnsureCA("ca", t.Name(), 100000*time.Hour))
+
+	req := Request{
+		Name:   "test",
+		CN:     "kubernetes-test",
+		O:      "system:masters",
+		CACert: k0sVars.CertRootDir + "/ca.crt",
+		CAKey:  k0sVars.CertRootDir + "/ca.key",
+	}
+	certData, err := certManager.EnsureCertificate(req, 1, 10000*time.Hour)
+	require.NoError(t, err)
+	cert, err := parseCert([]byte(certData.Cert))
+	require.NoError(t, err)
+
+	// check the expiration date of the cert
+	assert.Equal(t, cert.NotBefore.Add(10000*time.Hour), cert.NotAfter)
+	// check if the cert has the `signing` usage
+	assert.NotEqual(t, 0, cert.KeyUsage&x509.KeyUsageDigitalSignature)
+	// check if the cert has the `key encipherment` usage
+	assert.NotEqual(t, 0, cert.KeyUsage&x509.KeyUsageKeyEncipherment)
+	assert.Equal(t,
+		[]x509.ExtKeyUsage{cfsslconfig.ExtKeyUsage["server auth"], cfsslconfig.ExtKeyUsage["client auth"]},
+		cert.ExtKeyUsage,
+	)
+}
+
+func parseCert(pemBytes []byte) (*x509.Certificate, error) {
+	block, _ := pem.Decode(pemBytes)
+	cert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		return nil, err
+	}
+	return cert, nil
+}
diff --git a/pkg/component/controller/etcd.go b/pkg/component/controller/etcd.go
index 81ba881a250e..def41741a801 100644
--- a/pkg/component/controller/etcd.go
+++ b/pkg/component/controller/etcd.go
@@ -268,7 +268,7 @@ func (e *Etcd) setupCerts(ctx context.Context) error {
 	etcdCaCert := filepath.Join(e.K0sVars.EtcdCertDir, "ca.crt")
 	etcdCaCertKey := filepath.Join(e.K0sVars.EtcdCertDir, "ca.key")
 
-	if err := e.CertManager.EnsureCA("etcd/ca", "etcd-ca"); err != nil {
+	if err := e.CertManager.EnsureCA("etcd/ca", "etcd-ca", e.Config.CAExpiry.Duration); err != nil {
 		return fmt.Errorf("failed to create etcd ca: %w", err)
 	}
 
@@ -295,7 +295,7 @@ func (e *Etcd) setupCerts(ctx context.Context) error {
 			logrus.WithError(err).Warn("Files with key material for kube-apiserver user will be owned by root")
 		}
 
-		_, err = e.CertManager.EnsureCertificate(etcdCertReq, uid)
+		_, err = e.CertManager.EnsureCertificate(etcdCertReq, uid, e.Config.CertExpiry.Duration)
 		return err
 	})
 
@@ -313,7 +313,7 @@ func (e *Etcd) setupCerts(ctx context.Context) error {
 			},
 		}
 
-		_, err := e.CertManager.EnsureCertificate(etcdCertReq, e.uid)
+		_, err := e.CertManager.EnsureCertificate(etcdCertReq, e.uid, e.Config.CertExpiry.Duration)
 		return err
 	})
 
@@ -328,7 +328,7 @@ func (e *Etcd) setupCerts(ctx context.Context) error {
 				e.Config.PeerAddress,
 			},
 		}
-		_, err := e.CertManager.EnsureCertificate(etcdPeerCertReq, e.uid)
+		_, err := e.CertManager.EnsureCertificate(etcdPeerCertReq, e.uid, e.Config.CertExpiry.Duration)
 		return err
 	})
 
diff --git a/static/_crds/k0s/k0s.k0sproject.io_clusterconfigs.yaml b/static/_crds/k0s/k0s.k0sproject.io_clusterconfigs.yaml
index 23537a6159f1..f6d6640fff8d 100644
--- a/static/_crds/k0s/k0s.k0sproject.io_clusterconfigs.yaml
+++ b/static/_crds/k0s/k0s.k0sproject.io_clusterconfigs.yaml
@@ -45,6 +45,14 @@ spec:
                   address:
                     description: Address on which to connect to the API server.
                     type: string
+                  caExpiry:
+                    default: 87600h
+                    description: The expiration duration of the CA certificate
+                    type: string
+                  certExpiry:
+                    default: 8760h
+                    description: The expiration duration of the server certificate
+                    type: string
                   externalAddress:
                     description: The loadbalancer address (for k0s controllers running
                       behind a loadbalancer)
@@ -914,6 +922,14 @@ spec:
                   etcd:
                     description: EtcdConfig defines etcd related config options
                     properties:
+                      caExpiry:
+                        default: 87600h
+                        description: The expiration duration of the CA certificate
+                        type: string
+                      certExpiry:
+                        default: 8760h
+                        description: The expiration duration of the server certificate
+                        type: string
                       externalCluster:
                         description: ExternalCluster defines external etcd cluster
                           related config options


Reviews:
user: login: twz123
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-20T11:10:04Z
user: login: iawia002
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T04:53:51Z
user: login: twz123
body: Okay implementation-wise from my side. Although I'd like to bikeshed a bit about the config format. I was thinking about something like this maybe?

```yaml
spec:
  api:
    ca:
      expiresAfter: 87600h
      certificatesExpireAfter: 8760h
  storage:
    etcd:
      ca:
        expiresAfter: 87600h
        certificatesExpireAfter: 8760h
```

Does that make sense?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T12:40:52Z
user: login: iawia002
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T14:24:57Z
user: login: twz123
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T14:10:41Z
user: login: twz123
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T14:11:57Z
user: login: iawia002
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T05:41:38Z
user: login: iawia002
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T07:26:31Z
user: login: juanluisvaladas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T07:50:53Z
user: login: juanluisvaladas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T07:51:59Z
user: login: jnummelin
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-20T11:08:22Z

Review comments:
diff_hunk: @@ -64,6 +65,14 @@ type APISpec struct {
 	// List of additional addresses to push to API servers serving the certificate
 	// +listType=set
 	SANs []string `json:"sans,omitempty"`
+
+	// The expiration duration of the CA certificate
+	// +kubebuilder:default="87600h"
+	CAExpiry string `json:"caExpiry,omitempty"`
path: pkg/apis/k0s/v1beta1/api.go
user: login: twz123
body: Wouldn't it be better to use `metav1.Duration` for these fields instead of `string`?
created_at: 2025-04-20T11:09:30Z
author_association: MEMBER
diff_hunk: @@ -64,6 +65,14 @@ type APISpec struct {
 	// List of additional addresses to push to API servers serving the certificate
 	// +listType=set
 	SANs []string `json:"sans,omitempty"`
+
+	// The expiration duration of the CA certificate
+	// +kubebuilder:default="87600h"
+	CAExpiry string `json:"caExpiry,omitempty"`
path: pkg/apis/k0s/v1beta1/api.go
user: login: iawia002
body: Updated
created_at: 2025-04-21T04:53:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -91,7 +92,7 @@ func createUserKubeconfig(k0sVars *config.CfgVars, clusterAPIURL, username, grou
 	certManager := certificate.Manager{
 		K0sVars: k0sVars,
 	}
-	userCert, err := certManager.EnsureCertificate(userReq, users.RootUID)
+	userCert, err := certManager.EnsureCertificate(userReq, users.RootUID, 8760*time.Hour)
path: cmd/kubeconfig/create.go
user: login: twz123
body: Would be cool if we'd get a flag for this maybe? E.g. `--certificate-expires-after` or sth. along those lines?
created_at: 2025-05-02T12:36:08Z
author_association: MEMBER
diff_hunk: @@ -91,7 +92,7 @@ func createUserKubeconfig(k0sVars *config.CfgVars, clusterAPIURL, username, grou
 	certManager := certificate.Manager{
 		K0sVars: k0sVars,
 	}
-	userCert, err := certManager.EnsureCertificate(userReq, users.RootUID)
+	userCert, err := certManager.EnsureCertificate(userReq, users.RootUID, 8760*time.Hour)
path: cmd/kubeconfig/create.go
user: login: iawia002
body: All comments addressed, PTAL
created_at: 2025-05-02T14:24:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -128,6 +130,16 @@ func (m *Manager) EnsureCertificate(certReq Request, ownerID int) (Certificate,
 		config := cli.Config{
 			CAFile:    "file:" + certReq.CACert,
 			CAKeyFile: "file:" + certReq.CAKey,
+			CFG: &cfsslconfig.Config{
+				Signing: &cfsslconfig.Signing{
+					Profiles: map[string]*cfsslconfig.SigningProfile{},
+					Default: &cfsslconfig.SigningProfile{
+						Usage:        []string{"signing", "key encipherment", "server auth", "client auth"},
path: pkg/certificate/manager.go
user: login: twz123
body: What happens if we leave the signing profile and the usages unspecified, here?
created_at: 2025-05-08T14:10:41Z
author_association: MEMBER
diff_hunk: @@ -99,7 +101,7 @@ func (m *Manager) EnsureCA(name, cn string) error {
 }
 
 // EnsureCertificate creates the specified certificate if it does not already exist
-func (m *Manager) EnsureCertificate(certReq Request, ownerID int) (Certificate, error) {
+func (m *Manager) EnsureCertificate(certReq Request, ownerID int, expiry time.Duration) (Certificate, error) {
path: pkg/certificate/manager.go
user: login: twz123
body: Maybe a simple unit test would be good, à la create CA, create cert, read in CA and cert and verify the right expiration days and key usages and so on.
created_at: 2025-05-08T14:11:57Z
author_association: MEMBER
diff_hunk: @@ -128,6 +130,16 @@ func (m *Manager) EnsureCertificate(certReq Request, ownerID int) (Certificate,
 		config := cli.Config{
 			CAFile:    "file:" + certReq.CACert,
 			CAKeyFile: "file:" + certReq.CAKey,
+			CFG: &cfsslconfig.Config{
+				Signing: &cfsslconfig.Signing{
+					Profiles: map[string]*cfsslconfig.SigningProfile{},
+					Default: &cfsslconfig.SigningProfile{
+						Usage:        []string{"signing", "key encipherment", "server auth", "client auth"},
path: pkg/certificate/manager.go
user: login: iawia002
body: The certificate issuance will fail, or the certificate will lose certain functionalities. This is using the same default configuration as before, with the only modification to the `Expiry` value:

```go
// DefaultConfig returns a default configuration specifying basic key
// usage and a 1 year expiration time. The key usages chosen are
// signing, key encipherment, client auth and server auth.
func DefaultConfig() *SigningProfile {
	d := helpers.OneYear
	return &SigningProfile{
		Usage:        []string{"signing", "key encipherment", "server auth", "client auth"},
		Expiry:       d,
		ExpiryString: "8760h",
	}
}
```

https://github.com/cloudflare/cfssl/blob/ed8df49712657524e8647cb7a9576422baa2faa1/config/config.go#L675-L685
created_at: 2025-05-09T05:41:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,7 +101,7 @@ func (m *Manager) EnsureCA(name, cn string) error {
 }
 
 // EnsureCertificate creates the specified certificate if it does not already exist
-func (m *Manager) EnsureCertificate(certReq Request, ownerID int) (Certificate, error) {
+func (m *Manager) EnsureCertificate(certReq Request, ownerID int, expiry time.Duration) (Certificate, error) {
path: pkg/certificate/manager.go
user: login: iawia002
body: Done
created_at: 2025-05-09T07:26:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,91 @@
+/*
+Copyright 2025 k0s authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package certificate
+
+import (
+	"crypto/x509"
+	"encoding/pem"
+	"os"
+	"testing"
+	"time"
+
+	cfsslconfig "github.com/cloudflare/cfssl/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/k0sproject/k0s/pkg/config"
+)
+
+func TestEnsureCA(t *testing.T) {
+	// Create some k0sVars
+	k0sVars, err := config.NewCfgVars(nil, t.TempDir())
+	require.NoError(t, err)
+
+	// Create the CA
+	require.NoError(t, os.MkdirAll(k0sVars.CertRootDir, 0755))
+	certManager := Manager{K0sVars: k0sVars}
+	require.NoError(t, certManager.EnsureCA("ca", t.Name(), 100000*time.Hour))
+
+	pemBytes, _ := os.ReadFile(k0sVars.CertRootDir + "/ca.crt")
+	cert, err := parseCert(pemBytes)
+	require.NoError(t, err)
+	// check the expiration date of the cert
+	assert.Equal(t, cert.NotBefore.Add(100000*time.Hour), cert.NotAfter)
+}
+
+func TestEnsureCertificate(t *testing.T) {
+	// Create some k0sVars
+	k0sVars, err := config.NewCfgVars(nil, t.TempDir())
+	require.NoError(t, err)
+
+	// Create the CA
+	require.NoError(t, os.MkdirAll(k0sVars.CertRootDir, 0755))
+	certManager := Manager{K0sVars: k0sVars}
+	require.NoError(t, certManager.EnsureCA("ca", t.Name(), 100000*time.Hour))
+
+	req := Request{
+		Name:   "test",
+		CN:     "kubernetes-test",
+		O:      "system:masters",
+		CACert: k0sVars.CertRootDir + "/ca.crt",
+		CAKey:  k0sVars.CertRootDir + "/ca.key",
+	}
+	certData, err := certManager.EnsureCertificate(req, 1, 10000*time.Hour)
+	require.NoError(t, err)
+	cert, err := parseCert([]byte(certData.Cert))
+	require.NoError(t, err)
+
+	// check the expiration date of the cert
+	assert.Equal(t, cert.NotBefore.Add(10000*time.Hour), cert.NotAfter)
+	// check if the cert has the `signing` usage
+	assert.NotEqual(t, cert.KeyUsage&x509.KeyUsageDigitalSignature, 0)
path: pkg/certificate/manager_test.go
user: login: juanluisvaladas
body: ```suggestion
	assert.NotEqual(t,0, cert.KeyUsage&x509.KeyUsageDigitalSignature)
```
created_at: 2025-05-20T07:50:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,91 @@
+/*
+Copyright 2025 k0s authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package certificate
+
+import (
+	"crypto/x509"
+	"encoding/pem"
+	"os"
+	"testing"
+	"time"
+
+	cfsslconfig "github.com/cloudflare/cfssl/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/k0sproject/k0s/pkg/config"
+)
+
+func TestEnsureCA(t *testing.T) {
+	// Create some k0sVars
+	k0sVars, err := config.NewCfgVars(nil, t.TempDir())
+	require.NoError(t, err)
+
+	// Create the CA
+	require.NoError(t, os.MkdirAll(k0sVars.CertRootDir, 0755))
+	certManager := Manager{K0sVars: k0sVars}
+	require.NoError(t, certManager.EnsureCA("ca", t.Name(), 100000*time.Hour))
+
+	pemBytes, _ := os.ReadFile(k0sVars.CertRootDir + "/ca.crt")
+	cert, err := parseCert(pemBytes)
+	require.NoError(t, err)
+	// check the expiration date of the cert
+	assert.Equal(t, cert.NotBefore.Add(100000*time.Hour), cert.NotAfter)
+}
+
+func TestEnsureCertificate(t *testing.T) {
+	// Create some k0sVars
+	k0sVars, err := config.NewCfgVars(nil, t.TempDir())
+	require.NoError(t, err)
+
+	// Create the CA
+	require.NoError(t, os.MkdirAll(k0sVars.CertRootDir, 0755))
+	certManager := Manager{K0sVars: k0sVars}
+	require.NoError(t, certManager.EnsureCA("ca", t.Name(), 100000*time.Hour))
+
+	req := Request{
+		Name:   "test",
+		CN:     "kubernetes-test",
+		O:      "system:masters",
+		CACert: k0sVars.CertRootDir + "/ca.crt",
+		CAKey:  k0sVars.CertRootDir + "/ca.key",
+	}
+	certData, err := certManager.EnsureCertificate(req, 1, 10000*time.Hour)
+	require.NoError(t, err)
+	cert, err := parseCert([]byte(certData.Cert))
+	require.NoError(t, err)
+
+	// check the expiration date of the cert
+	assert.Equal(t, cert.NotBefore.Add(10000*time.Hour), cert.NotAfter)
+	// check if the cert has the `signing` usage
+	assert.NotEqual(t, cert.KeyUsage&x509.KeyUsageDigitalSignature, 0)
+	// check if the cert has the `key encipherment` usage
+	assert.NotEqual(t, cert.KeyUsage&x509.KeyUsageKeyEncipherment, 0)
path: pkg/certificate/manager_test.go
user: login: juanluisvaladas
body: ```suggestion
	assert.NotEqual(t, 0, cert.KeyUsage&x509.KeyUsageKeyEncipherment)
```
created_at: 2025-05-20T07:51:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: iawia002
created_at: 2025-04-18T02:56:04Z
body: cc @twz123, can you help me review this PR?
author_association: CONTRIBUTOR
user: login: jnummelin
created_at: 2025-04-30T12:22:56Z
body: @iawia002 What's the use case for this, i.e. where and why you'd want to configure these?
author_association: MEMBER
user: login: iawia002
created_at: 2025-04-30T14:11:32Z
body: > @iawia002 What's the use case for this, i.e. where and why you'd want to configure these?

This requirement is very common for users in offline environments, as such clusters are usually in an intranet environment. We configure a longer certificate expiration duration to reduce the cluster's frequency of certificate maintenance (rotation). Typically, we extend the expiration duration of the CA certificate to 50 years and the server certificate to 10 years.
author_association: CONTRIBUTOR
user: login: twz123
created_at: 2025-05-14T09:29:47Z
body: It seems that the changes from 79a5010648fe2dd0a44ef2022a430828f30e9baf got lost after the last merge. (Also, the GitHub Actions runs are hanging for unknown reasons :thinking:)
author_association: MEMBER
user: login: juanluisvaladas
created_at: 2025-05-20T10:15:36Z
body: The two suggestions fix the lint issue
author_association: CONTRIBUTOR
user: login: juanluisvaladas
created_at: 2025-05-20T11:48:45Z
body: Thanks for the contribution @iawia002 ! It will be included in 1.33
author_association: CONTRIBUTOR
user: login: twz123
created_at: 2025-05-21T06:17:36Z
body: > It seems that the changes from [79a5010](https://github.com/k0sproject/k0s/commit/79a5010648fe2dd0a44ef2022a430828f30e9baf) got lost after the last merge.

I've created the follow-up PR #5863 for this.
author_association: MEMBER