An interesting challenge came up an work recently about how to do TLS Client Authentication from Kubernetes pods with short lived certificates.

The main gist was how to dynamically update the certificates on a short time frame (e.g. once a week) but without needing to manually mess about with recreating the Secrets that hold the keys and certificates

Cert-Manager

The standard tool for dealing with Certificate Authorities in Kubernetes is cert-manager, the usual use case is to issue server certificates either linked to an Ingress objects by adding annotations to these objects. These can then be provisioned from the CA, usually by using ACME. I set up a Small Step CA with ACME to issue certificates on my LAN.

This basically works by creating a Kubernetes Web Hook that looks for when Ingress are created or modified and then reading the annotation and creating a Certificate Signing Request which is then submitted to the CA for signing and the resultant Certificate and Key are stored in a Kubernetes Secret.

Cert-Manager-CSI

This allows for the creation of Certificates for things that are not exposed by Ingress e.g. where the TLS termination is being done in the Pod rather than be delegated to the HTTP reverse proxy providing the Ingress service or for Client Certificates.

The CSI driver allows virtual volumes to be created that mount the Secret that holds a x509 Certificate and Private Key to be mounted as files into a Pod.

When I first installed the CSI driver I couldn’t get it to work as it kept complaining that /var/snap was a Read Only file system, this turned out to be because I use microk8s as my Kubernetes distribution and this has a none standard kubelet directory location because it is packaged as a snap bundle. The solution is to pass the correct kubelet path to the helm chart

$ helm upgrade cert-manager-csi-driver oci://quay.io/jetstack/charts/cert-manager-csi-driver \

--install \

--namespace cert-manager \

--set app.kubeletRootDir=/var/snap/microk8s/common/var/lib/kubelet \

--wait

Small Step Certificate Issuer

As I already have a Small Step CA up and running to provide server certificates via ACME I needed to add a new provisioner that would create client certificates with the correct flags. I could probably have reused to the one I set up to do email signing but I decided to keep them separate for this test.

Details of how to install and configure the required tools can be found here.

$ step ca provisioner add "step-client-certs" --type JWK --create \

--x509-min-dur 24h --x509-max-dur 720h --x509-default-dur 720h

Keep a note of the password created as part of this process as it will need to be added to a Secret later, the kid of the provisioner is also required.

$ kubectl create secret -n step-issuer generic step-client-certs-password --from-literal=password=TopSecret

The Step Issuer is installed with

$ helm install \

step-issuer smallstep/step-issuer \

--namespace step-issuer \

--create-namespace

Then either a StepIssuer or a StepClusterIssuer I’m going to user a Cluster scoped Issuer so I don’t have to create one in every namespace I’m using.

apiVersion: certmanager.step.sm/v1beta1

kind: StepClusterIssuer

metadata:

name: step-client-issuer

namespace: step-issuer

spec:

url: https://certs.loc:9000

caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJwekNDQVU2Z0F3SUJBZ0lSQUp1MnByWWJFa09HRGRBSnpORXErYWt3Q2dZSUtvWkl6ajBFQXdJd01qRVQKTUJFR0ExVUVDaE1LU0dGeVpHbHNiQ0JEUVRFYk1Ca0dBMVVFQXhNU1NHRnlaR2xzYkNCRFFTQlNiMjkwSUVOQgpNQjRYRFRJeU1USXdOREUxTWpVME1sb1hEVE15TVRJd01URTFNalUwTWxvd01qRVRNQkVHQTFVRUNoTUtTR0Z5ClpHbHNiQ0JEUVRFYk1Ca0dBMVVFQXhNU1NHRnlaR2xzYkNCRFFTQlNiMjkwSUVOQk1Ga3dFd1lIS29aSXpqMEMKQVFZSUtvWkl6ajBEQVFjRFFnQUVPRXI2ZFdZL1JjdStHNzBUV0FVbWVSVHQycFg1cEpwTy9YNkJHSHM3NkdlaQpWcFhGeTZ1YXFFeHVCc2xIdGRiMGtUanNkbWlnM2l5ckhFQmhXRVFCd3FORk1FTXdEZ1lEVlIwUEFRSC9CQVFECkFnRUdNQklHQTFVZEV3RUIvd1FJTUFZQkFmOENBUUV3SFFZRFZSME9CQllFRkhSOG1nUndMUGN0UkZmV3B6Rk0KMk5mMDFkT2RNQW9HQ0NxR1NNNDlCQU1DQTBjQU1FUUNJSDc1bUh2cUxsRDRmYy94ZmFCZjVpc2htRWVJZW4xQQpiVEU5VTZxODRnZHdBaUJUVEZaWjB1dzJBSlpmQ1ZuWmFSQ3hmYnhzYURZNFBSQTNaMjdTczBkd2FRPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=

provisioner:

name: step-client-certs

kid: IdYupAKmyDlfKPQlDP5IAqVhzU3eC0Au0OUj1wxDeTY

passwordRef:

name: step-client-certs-password

key: password

namespace: step-issuer

Testing it all

I have a standard bare Ubuntu pod I tend to use for debugging when I need a shell in the cluster. I modified it to request a client certificate and mount the files on /tls

apiVersion: v1

kind: Pod

metadata:

name: ubuntu

labels:

app: ubuntu

spec:

containers:

- image: "ubuntu:24.04"

command:

- "tail"

- "-f"

- "/dev/null"

imagePullPolicy: IfNotPresent

name: ubuntu

volumeMounts:

- name: tls

mountPath: /tls

volumes:

- name: tls

csi:

driver: csi.cert-manager.io

readOnly: true

volumeAttributes:

csi.cert-manager.io/issuer-name: step-client-issuer

csi.cert-manager.io/issuer-group: certmanager.step.sm

csi.cert-manager.io/issuer-kind: StepClusterIssuer

csi.cert-manager.io/common-name: ${POD_NAME}-${POD_NAMESPACE}

csi.cert-manager.io/key-usages: digital signature,client auth

csi.cert-manager.io/duration: 720h

restartPolicy: Always

The important configuration parts as the volumeAttributes which set which Issuer to use and what Common Name to set.

$ kubectl exec -it ubuntu -- /bin/bash

root@ubuntu:/# cd /tls

root@ubuntu:/tls# ls

ca.crt tls.crt tls.key

root@ubuntu:/tls# cat tls.crt

-----BEGIN CERTIFICATE-----

MIIDADCCAqegAwIBAgIQScv/Sg70g2nvMynmAej51jAKBggqhkjOPQQDAjA6MRMw

EQYDVQQKEwpIYXJkaWxsIENBMSMwIQYDVQQDExpIYXJkaWxsIENBIEludGVybWVk

...