From a4b3d40e2d6686c5762c68a4ae53d46d16aab378 Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 9 Nov 2026 12:13:27 +1000 Subject: [PATCH] fix(ci): pin the cluster CA in the repo rather than in a secret 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. --- .gitea/workflows/build_push.yml | 5 +++-- scripts/cluster-ca.sha256 | 17 +++++++++++++++++ scripts/kube-ca-refresh.sh | 25 +++++++++++++++++++------ 3 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 scripts/cluster-ca.sha256 diff --git a/.gitea/workflows/build_push.yml b/.gitea/workflows/build_push.yml index 4f46187..d013b01 100644 --- a/.gitea/workflows/build_push.yml +++ b/.gitea/workflows/build_push.yml @@ -126,8 +126,9 @@ jobs: # The kubeconfig embeds the k3s server CA. If the cluster's CA has # been rotated since the secret was created, kubectl cannot verify # the server. The script refreshes the CA, but only if it matches - # the fingerprint pinned in the secret - see the script for why. - bash scripts/kube-ca-refresh.sh "${{ secrets.KUBE_SERVER_CA_FINGERPRINT }}" + # the fingerprint pinned in scripts/cluster-ca.sha256 - see the + # script for why. + bash scripts/kube-ca-refresh.sh - name: Install kubectl run: | diff --git a/scripts/cluster-ca.sha256 b/scripts/cluster-ca.sha256 new file mode 100644 index 0000000..3f6664b --- /dev/null +++ b/scripts/cluster-ca.sha256 @@ -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 diff --git a/scripts/kube-ca-refresh.sh b/scripts/kube-ca-refresh.sh index f513919..73a6b09 100755 --- a/scripts/kube-ca-refresh.sh +++ b/scripts/kube-ca-refresh.sh @@ -12,13 +12,21 @@ # # 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 -# in the KUBE_SERVER_CA_FINGERPRINT secret. An attacker able to intercept -# the fetch cannot use it - their CA will not hash to the pinned fingerprint. +# pinned in scripts/cluster-ca.sha256. An attacker able to intercept the +# 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 # 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: -# kube-ca-refresh.sh +# kube-ca-refresh.sh [expected-fingerprint] # # Exit codes: # 0 the cluster is reachable @@ -26,7 +34,12 @@ set -euo pipefail +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 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 echo "cluster reachable" @@ -36,8 +49,8 @@ fi if [ -z "$EXPECTED" ]; then 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 " Set KUBE_SERVER_CA_FINGERPRINT to the SHA-256 fingerprint of the" >&2 - echo " cluster's CA:" >&2 + echo " and no pinned CA fingerprint could be found." >&2 + echo " Set one in scripts/cluster-ca.sha256:" >&2 echo " openssl x509 -in server-ca.crt -noout -fingerprint -sha256" >&2 exit 1 fi @@ -72,7 +85,7 @@ echo "actual CA fingerprint: $ACTUAL" if [ "$ACTUAL" != "$WANTED" ]; then 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 " fingerprint secret needs updating, or the connection was" >&2 + echo " pinned fingerprint needs updating, or the connection was" >&2 echo " intercepted." >&2 exit 1 fi