K3s part 7: Mutual TLS authentication with Traefik
In part 3, the Traefik IngressRoute for the whoami
service was configured with TLS certificates from Let’s Encrypt. This type of
certificate only authenticates the server, not the client, leaving client
authentication as a requirement on the application layer. (For example,
requiring to submit a username/password via HTTP, before access is granted.)
This is enough, and normal, for secure public facing web services, where your
clients might be connecting from anywhere, especially using web-browsers.
However, TLS certificates (X.509) can be used on the client too. This is rare for web-browsers, but is very common place for business and subscription API services. This forms bi-directional authentication: client authenticates server and server authenticates client: Mutual TLS. This authentication happens at the session layer, meaning that you will probably still want to use usernames and passwords at the application layer, as an extra barrier to entry before authorization, but you gain extra confidence to know that the only clients that can approach your application, are already holding a signed certificate allowing them to do so.
If you’re familiar with SSH keys, Mutual TLS is kind of like configuring sshd
with PubkeyAuthentication yes
and PasswordAuthentication no
, thus requiring
a key from each client, and denying those without keys. However, with TLS you
don’t need to list each and every key that you wish to allow (there is no
equivalent to the SSH authorized_keys
file), but rather TLS verifies that the
key is signed by your (self-hosted) Certificate Authority. In the case of
Traefik, you can enforce this on a per-route (sub-domain matching) basis, with
separate Certificate Authorities for each route.
Certificate Authority
In order to generate and sign client certificates, a new Certificate Authority must be created. Let’s Encrypt cannot be used for generating client certificates, but will continue to be used for the server certificates.
To generate the client certificates, you will use a program called Step CLI using the provided docker image. Its advisable to keep your root CA offline, so you will create this only on your workstation, via podman, not on the cluster.
## Same git repo for infrastructure as in prior posts:
FLUX_INFRA_DIR=${HOME}/git/flux-infra
CLUSTER=k3s.example.com
## Name of root CA file:
ROOT_CA=my_org_root_ca
## Name on root CA certificate:
ROOT_CA_NAME="Example Organization Root CA"
## Podman volume to store keys and certs:
CA_VOLUME=RootCertificateAuthority
Create a podman volume to store the root CA key and all generated certificates:
podman volume create ${CA_VOLUME}
Create a temporary alias to run things in the container:
alias step_run="podman run --rm -it -v \
${CA_VOLUME}:/home/step smallstep/step-ca"
Generate Root CA
You will create one Root CA that will be used to sign all intermediate CAs:
step_run step certificate create \
--profile root-ca "${ROOT_CA_NAME}" ${ROOT_CA}.crt ${ROOT_CA}.key
Choose a strong passphrase, when asked to encrypt the root CA.
Generate Intermediate CA for a single service
You will create Intermediate CAs for smaller organizational grouping. You could
create one per cluster, one per namespace, or one per service. This is the
example for creating an Intermediate CA just for the whoami
service running in
a specific namespace (In part 3 you created a
different whoami
service in the default namespace, this will be another
whoami
service in a new namespace for testing):
SERVICE=whoami-mtls
NAMESPACE=whoami-mtls
INTERMEDIATE_CA=${SERVICE}.${CLUSTER}
step_run step certificate create "${INTERMEDIATE_CA} Intermediate" \
${INTERMEDIATE_CA}-intermediate_ca.crt ${INTERMEDIATE_CA}-intermediate_ca.key \
--profile intermediate-ca --ca ./${ROOT_CA}.crt --ca-key ./${ROOT_CA}.key
You must again enter the passphrase for the ROOT CA, and choose a new passphrase for the Intermediate CA.
Export the public CA certificates
CA_CERT=$(mktemp)
step_run cat ${INTERMEDIATE_CA}-intermediate_ca.crt > ${CA_CERT}
step_run cat ${ROOT_CA}.crt >> ${CA_CERT}
echo "--------------------------"
echo Certificate chain exported: ${CA_CERT}
Create the namespace
Create a new namespace for testing Mutual TLS:
mkdir -p ${FLUX_INFRA_DIR}/${CLUSTER}/${NAMESPACE}
cat <<EOF > ${FLUX_INFRA_DIR}/${CLUSTER}/${NAMESPACE}/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- ${SERVICE}.tls.sealed_secret.yaml
- ${SERVICE}.tls.yaml
- ${SERVICE}.yaml
- ${SERVICE}.ingressroute.yaml
EOF
cat <<EOF > ${FLUX_INFRA_DIR}/${CLUSTER}/${NAMESPACE}/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: ${NAMESPACE}
EOF
Create the Sealed Secret containing CA certificates
kubectl create secret generic ${SERVICE}-certificate-authority \
--namespace ${NAMESPACE} --dry-run=client -o json \
--from-file=tls.ca=${CA_CERT} | kubeseal -o yaml > \
${FLUX_INFRA_DIR}/${CLUSTER}/${NAMESPACE}/${SERVICE}.tls.sealed_secret.yaml
Create the TLSOption
The TLSOption that will require valid signed certificates from the whoami Intermediate CA:
cat <<EOF > ${FLUX_INFRA_DIR}/${CLUSTER}/${NAMESPACE}/${SERVICE}.tls.yaml
apiVersion: traefik.containo.us/v1alpha1
kind: TLSOption
metadata:
name: ${SERVICE}
namespace: ${NAMESPACE}
spec:
clientAuth:
# the CA certificate is extracted from key 'tls.ca' of the given secrets.
secretNames:
- ${SERVICE}-certificate-authority
clientAuthType: RequireAndVerifyClientCert
EOF
Create the whoami service
cat <<EOF > ${FLUX_INFRA_DIR}/${CLUSTER}/${NAMESPACE}/${SERVICE}.yaml
apiVersion: v1
kind: Service
metadata:
name: ${SERVICE}
namespace: ${NAMESPACE}
spec:
ports:
- name: web
port: 80
protocol: TCP
selector:
app: ${SERVICE}
---
apiVersion: traefik.containo.us/v1alpha1
kind: TraefikService
metadata:
name: ${SERVICE}
namespace: ${NAMESPACE}
spec:
weighted:
services:
- name: ${SERVICE}
weight: 1
port: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: ${SERVICE}
name: ${SERVICE}
namespace: ${NAMESPACE}
spec:
replicas: 1
selector:
matchLabels:
app: ${SERVICE}
template:
metadata:
labels:
app: ${SERVICE}
spec:
containers:
- image: containous/whoami
name: whoami
ports:
- containerPort: 80
name: web
EOF
Create the IngressRoute
The IngressRoute binds this route with a specific TLSOption, which requires our signed certificate:
cat <<EOF | sed 's/@@@/`/g' > \
${FLUX_INFRA_DIR}/${CLUSTER}/${NAMESPACE}/${SERVICE}.ingressroute.yaml
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: ${SERVICE}
namespace: ${NAMESPACE}
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(@@@${SERVICE}.${CLUSTER}@@@)
services:
- name: ${SERVICE}
port: 80
tls:
certResolver: default
## Bind this route to a specific TLSOption object:
options:
name: ${SERVICE}
namespace: ${NAMESPACE}
EOF
Commit the changes
git -C ${FLUX_INFRA_DIR} add ${CLUSTER}
git -C ${FLUX_INFRA_DIR} commit -m "${CLUSTER} ${SERVICE} TLSOptions"
git -C ${FLUX_INFRA_DIR} push
Test the result
Wait a minute for flux to apply the changes to the cluster, then check in your
web-browser, load https://whoami.k3s.example.com, you should expect to see an
error telling you that the client certificate was not provided.
(ERR_BAD_SSL_CLIENT_AUTH_CERT
)
Now test with curl:
curl https://${SERVICE}.${CLUSTER}
You should again expect an error, bad certificate, errno 0
.
No one can access the whoami service without a valid client certificate!
Generate client certificates
## Certificate expiration time (1 year):
EXPIRATION=8760h
step_run step certificate create ${SERVICE}-client \
${SERVICE}-client.crt ${SERVICE}-client.key \
--profile leaf --not-after=${EXPIRATION} \
--ca ${INTERMEDIATE_CA}-intermediate_ca.crt \
--ca-key ${INTERMEDIATE_CA}-intermediate_ca.key \
--insecure --no-password --bundle
You will need to enter the password for the Intermediate CA.
Now export the client certificate and key:
CLIENT_CERT=$(mktemp)
CLIENT_KEY=$(mktemp)
step_run cat ${SERVICE}-client.crt >> ${CLIENT_CERT}
step_run cat ${SERVICE}-client.key >> ${CLIENT_KEY}
echo "--------------------------"
echo Client cert exported: ${CLIENT_CERT}
echo Client key exported: ${CLIENT_KEY}
Test curl with certificates
Now you should have access to the whoami service using the certificate and key:
curl --cert ${CLIENT_CERT} --key ${CLIENT_KEY} https://${SERVICE}.${CLUSTER}
This uses your client certificate and client key, to authenticate with the
server. curl verifies the signature of the advertised server certificate (from
Let’s Encrypt) with the local TLS trust store (ca-certificates
package). If
instead, you wanted to verify the exact CA, you can specify the file explicitly:
# Download the known Let's Encrypt Intermediate CA certificate:
# NOTE: This URL might change in the future, look it up:
# https://letsencrypt.org/certificates/
LETSENCRYPT_CA=$(mktemp)
curl -L https://letsencrypt.org/certs/lets-encrypt-r3.pem > ${LETSENCRYPT_CA}
curl --cert ${CLIENT_CERT} --key ${CLIENT_KEY} \
--cacert ${LETSENCRYPT_CA} https://${SERVICE}.${CLUSTER}
Using client certificates in programs
Here is a big list of examples from smallstep, including Python, Node.js, Go etc.
Backup podman volume
You can export a tarball of the docker volume, this will include all of the certificates and keys for your CA and clients. Keep it safe!!
podman run --rm -v ${CA_VOLUME}:/home/step smallstep/step-ca \
tar cz /home/step > ${ROOT_CA}.tar.gz
echo "Saved ROOT CA backup: $(pwd)/${ROOT_CA}.tar.gz"
You can discuss this blog on Matrix (Element): #blog-rymcg-tech:enigmacurry.com
This blog is copyright EnigmaCurry and dual-licensed CC-BY-SA and MIT. The source is on github: enigmacurry/blog.rymcg.tech and PRs are welcome. ❤️