fix(ci): pin the cluster CA in the repo rather than in a secret
Build and Push Image / Unit tests (push) Successful in 43s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped

The CA fingerprint is not a secret.  It is a public value that anyone can
read straight off the certificate, so pinning it in a Gitea secret was the
wrong shape:

- A change to it would be invisible - no commit, no review.
- Nobody with secret-write access could silently repoint the CI runner at a
  different CA.

It now lives in `scripts/cluster-ca.sha256`, so changing it is a reviewable
commit.  The script reads it from there, and still accepts an override as
its first argument.

This also removes the need for a `KUBE_SERVER_CA_FINGERPRINT` secret, which
the bot token cannot create anyway - the Actions secrets API is owner-only.

The behaviour is unchanged: the CA is fetched over an unverified connection
but only USED if it matches the pinned fingerprint, so an intercepted CA is
refused.  Re-tested against the live API server - the matching fingerprint
refreshes the CA and kubectl then works; a wrong one is refused and the
stale CA is left in place.
This commit is contained in:
2026-11-09 12:13:27 +10:00
parent ce6f5b7962
commit a4b3d40e2d
3 changed files with 39 additions and 8 deletions
+3 -2
View File
@@ -126,8 +126,9 @@ jobs:
# The kubeconfig embeds the k3s server CA. If the cluster's CA has # The kubeconfig embeds the k3s server CA. If the cluster's CA has
# been rotated since the secret was created, kubectl cannot verify # been rotated since the secret was created, kubectl cannot verify
# the server. The script refreshes the CA, but only if it matches # the server. The script refreshes the CA, but only if it matches
# the fingerprint pinned in the secret - see the script for why. # the fingerprint pinned in scripts/cluster-ca.sha256 - see the
bash scripts/kube-ca-refresh.sh "${{ secrets.KUBE_SERVER_CA_FINGERPRINT }}" # script for why.
bash scripts/kube-ca-refresh.sh
- name: Install kubectl - name: Install kubectl
run: | run: |
+17
View File
@@ -0,0 +1,17 @@
# SHA-256 fingerprint of the cluster's API server CA (the k3s server-ca.crt).
#
# This is a PUBLIC value, not a secret - it can be read straight off the
# certificate. It lives in the repo rather than in a Gitea secret so that
# changing it is a reviewable commit, and so nobody with secret-write access
# can silently repoint the CI runner at a different CA.
#
# The deploy job fetches the cluster's CA over an unverified connection and
# compares its fingerprint against this value. A CA that does not match is
# refused, so an attacker able to intercept the fetch cannot substitute
# their own.
#
# To refresh it after a legitimate CA rotation:
# openssl x509 -in /var/lib/rancher/k3s/server/tls/server-ca.crt # -noout -fingerprint -sha256
# and update the line below (colons are stripped before comparison, so either
# format works).
d23a17bb9c5f7c66d15e15635d6bb928e3a34f38142705e4e6516075a5f35907
+19 -6
View File
@@ -12,13 +12,21 @@
# #
# The refresh is PINNED: the server's CA is fetched over an unverified # The refresh is PINNED: the server's CA is fetched over an unverified
# connection, but it is only used if its SHA-256 fingerprint matches the value # connection, but it is only used if its SHA-256 fingerprint matches the value
# in the KUBE_SERVER_CA_FINGERPRINT secret. An attacker able to intercept # pinned in scripts/cluster-ca.sha256. An attacker able to intercept the
# the fetch cannot use it - their CA will not hash to the pinned fingerprint. # fetch cannot use it - their CA will not hash to the pinned fingerprint.
# The runner has cluster-admin credentials, so trusting an unverified CA here # The runner has cluster-admin credentials, so trusting an unverified CA here
# would be a privilege-escalation hole. # would be a privilege-escalation hole.
# #
# The fingerprint is a PUBLIC value - it can be read straight off the
# certificate - so it lives in the repo rather than in a secret. That way a
# change to it is a reviewable commit, and nobody with secret-write access
# can silently repoint the CI runner at a different CA.
#
# The expected fingerprint is read from scripts/cluster-ca.sha256 next to
# this script. It can be overridden with the first argument.
#
# Usage: # Usage:
# kube-ca-refresh.sh <expected-fingerprint> # kube-ca-refresh.sh [expected-fingerprint]
# #
# Exit codes: # Exit codes:
# 0 the cluster is reachable # 0 the cluster is reachable
@@ -26,7 +34,12 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
EXPECTED="${1:-}" EXPECTED="${1:-}"
if [ -z "$EXPECTED" ] && [ -f "$SCRIPT_DIR/cluster-ca.sha256" ]; then
# The file may carry comments; take the last non-comment line.
EXPECTED=$(grep -vE '^[[:space:]]*(#|$)' "$SCRIPT_DIR/cluster-ca.sha256" | tail -1 | tr -d '[:space:]')
fi
if kubectl version --request-timeout=15s >/dev/null 2>&1; then if kubectl version --request-timeout=15s >/dev/null 2>&1; then
echo "cluster reachable" echo "cluster reachable"
@@ -36,8 +49,8 @@ fi
if [ -z "$EXPECTED" ]; then if [ -z "$EXPECTED" ]; then
echo "ERROR: the kubeconfig cannot verify the API server, and no expected CA" >&2 echo "ERROR: the kubeconfig cannot verify the API server, and no expected CA" >&2
echo " fingerprint was supplied, so the CA cannot be refreshed safely." >&2 echo " fingerprint was supplied, so the CA cannot be refreshed safely." >&2
echo " Set KUBE_SERVER_CA_FINGERPRINT to the SHA-256 fingerprint of the" >&2 echo " and no pinned CA fingerprint could be found." >&2
echo " cluster's CA:" >&2 echo " Set one in scripts/cluster-ca.sha256:" >&2
echo " openssl x509 -in server-ca.crt -noout -fingerprint -sha256" >&2 echo " openssl x509 -in server-ca.crt -noout -fingerprint -sha256" >&2
exit 1 exit 1
fi fi
@@ -72,7 +85,7 @@ echo "actual CA fingerprint: $ACTUAL"
if [ "$ACTUAL" != "$WANTED" ]; then if [ "$ACTUAL" != "$WANTED" ]; then
echo "ERROR: the cluster CA does not match the pinned fingerprint." >&2 echo "ERROR: the cluster CA does not match the pinned fingerprint." >&2
echo " Refusing to use it. Either the CA has been rotated and the" >&2 echo " Refusing to use it. Either the CA has been rotated and the" >&2
echo " fingerprint secret needs updating, or the connection was" >&2 echo " pinned fingerprint needs updating, or the connection was" >&2
echo " intercepted." >&2 echo " intercepted." >&2
exit 1 exit 1
fi fi