Lange hat
Vault
in meinem Lab die internen Zertifikate ausgestellt — eine PKI-Engine, ein cert-manager-ClusterIssuer namens vault, fertig. Bequem. Und ein Single Point of Failure mit Ansage: Ist Vault sealed, abgelaufen oder beim Bootstrap, stellt niemand mehr ein Cert aus. Genau dieses Henne-Ei hat mich schon einmal in eine Break-Glass-Recovery gezwungen. Also habe ich die PKI von Vault gelöst und auf das gestellt, wofür X.509 eigentlich gedacht ist: eine Offline-Hierarchie, deren teuerste Schlüssel nie online sind.
Das Problem mit Vault als CA
Vault als PKI hat einen strukturellen Makel im Self-Hosting: Der Aussteller läuft — als Workload, der hochfahren, entsiegelt werden und ein gültiges Listener-Cert haben muss, das er sich, Achtung, am liebsten selbst ausstellen würde. Diese Schleife habe ich im Vault-Setup damals mühsam durchbrochen (das Pod-Listener-Cert kam von einer anderen, self-signed CA). Aber der grundsätzliche Punkt blieb: Eine CA, die selbst ein laufender, zustandsbehafteter Dienst ist, ist fragiler als eine CA, die nur ein paar Schlüssel auf Offline-Medien ist.
Dazu kommt mein Föderationsmodell: Mehrere Cluster, jeder soll möglichst autark sein. Eine zentrale Vault-PKI macht jeden Cluster von einem anderen abhängig. Eine Offline-CA, deren Sub-CA-Material in jedem Cluster liegt, lässt jeden Cluster seine eigenen Blätter ausstellen — keine Cross-Cluster-Abhängigkeit.
Zwei CAs, zwei Zwecke
Ich fahre zwei getrennte ClusterIssuer, und die Trennung ist bewusst:
| ClusterIssuer | Scope | Backing | Trust-Bundle |
|---|---|---|---|
internal | Public-facing ${INTERNAL_DOMAIN} + ${TAILNET_DOMAIN} (Gateway-Certs) | Offline Root→Int→SubCA | internal-ca-bundle (Offline Root) |
local | Backend cluster.local (Pod-/Service-TLS) | self-signed CA | local-ca-bundle |
Der local-Issuer ist eine simple self-signed CA für rein cluster-interne Hops — Vaults eigenes Pod-Listener-Cert, der RGW-Endpoint von Rook-Ceph, Syncthings Web-Listener. Diese Certs sieht nie ein Mensch, ihre Vertrauenskette endet an der Service-Grenze. Hier braucht es keine Offline-Zeremonie.
Der internal-Issuer ist der Vault-Ersatz: Er signiert die Certs, die Menschen und Geräte sehen — die Listener der Envoy-Gateways für ${INTERNAL_DOMAIN} und ${TAILNET_DOMAIN}. Und genau dieser bekommt die volle Hierarchie.
Namensgebung: Der frühere self-signed
internal-Issuer hieß so, obwohl er nurcluster.localbediente — er wurde zulocalumbenannt.internalbenennt jetzt die Offline-CA, die Vault ersetzt.
Die Hierarchie
Drei Tiers, jeder mit absteigender Lebensdauer und enger werdendem Mandat:
Root CA EC P-521 25 y offline media ◄── Key NIE online
│
└─ Intermediate EC P-384 15 y offline ◄── Key NIE online
│
└─ Sub/Issuing CA EC P-256 3 y im Cluster (Secret internal-ca)
│ Name Constraints: nur die 2 Domains
└─ Leaf (cert-manager) EC P-256 ≤ 1 y automatisch erneuert
| Tier | Key | Gültigkeit | Path len | Name Constraints |
|---|---|---|---|---|
| Root CA | EC P-521 | 25 y | — | — |
| Intermediate CA | EC P-384 | 15 y | 1 | — |
| Issuing/Sub CA | EC P-256 | 3 y | 0 | ${INTERNAL_DOMAIN}, ${TAILNET_DOMAIN} |
| Leaf (cert-manager) | EC P-256 | ≤ 1 y | — | erzwungen durch Sub CA |
Der wichtigste Mechanismus steht in der letzten Spalte: Name Constraints
sind erzwungen. Die Sub-CA trägt eine permittedSubtrees-Beschränkung auf exakt meine zwei Domains. Ein Blatt, das jemand außerhalb signieren wollte — sagen wir für google.com —, wird beim Verifizieren mit permitted subtree violation abgelehnt. Selbst wenn die Sub-CA kompromittiert würde, kann sie keine Certs für fremde Domains ausstellen, die ein Browser akzeptiert (sofern er Name Constraints prüft). Das ist die Schadensbegrenzung, die eine Vault-PKI in meinem Setup nie hatte.
path len: 0 auf der Sub-CA heißt zudem: Sie kann keine weiteren CAs ausstellen, nur Blätter. Die Kette endet kontrolliert.
Die CA generieren — air-gapped
Die Erzeugung läuft offline. Ein Skript baut die komplette Kette; der Root-Key darf danach nie wieder eine Netzwerkkarte sehen:
CERT_ORG_SHORT=… CERT_ENV=… INTERNAL_DOMAIN=… TAILNET_DOMAIN=… \
bash .taskfiles/cert-manager/scripts/gen-offline-ca.sh
Heraus fallen die Artefakte — und ein klarer Befehl, was wohin gehört:
| Datei | Verwendung |
|---|---|
root.key / root.crt | Root CA — root.key auf Offline-Medium bewegen |
intermediate.key/.crt | Intermediate — Key offline halten |
subca.key/.crt | Issuing/Sub CA |
internal-ca.crt (Sub+Int) / .key (Sub) | → cert-manager-Secret internal-ca |
trust-chain.crt (Root+Int) | → Trust-Bundle internal-ca-bundle |
Nur das Sub-CA-Material wandert in den Cluster. Root und Intermediate bleiben kalt.
Wie es verdrahtet ist
Alles liegt in der gemeinsamen common-Kustomization, gilt also für jeden Cluster:
- Secret
internal-ca(SOPS-verschlüsselt):tls.crt= Sub+Intermediate,tls.key= Sub-CA,ca.crt= Root. Genau die Kette, die ein Aussteller braucht, plus den Trust-Anchor. - ClusterIssuer
internal:ca.secretName: internal-ca. Stellt die public-facing Gateway-Certs aus. - Trust-Bundle
internal-ca-bundlevia trust-manager : verteilt die Offline-Root-Kette cluster-weit als ConfigMap — konsumiert von Statusseite, Endgeräten und allem, was${INTERNAL_DOMAIN}-Certs validieren muss. - Gateways annotieren
cert-manager.io/cluster-issuer: internal.
Das SOPS-Secret schließt den Kreis zur Secrets-Pipeline: Der private Sub-CA-Key liegt verschlüsselt in Git, Flux entschlüsselt ihn beim Reconcile, cert-manager stellt damit aus.
Der staged Cutover — ohne Trust-Lücke
Das eigentlich Knifflige ist nicht das Erzeugen, sondern das Umschalten einer laufenden PKI, ohne dass für eine Sekunde irgendwo eine Kette bricht. Die Reihenfolge ist alles:
- Trust zuerst. Die neue
trust-chain.crtkommt insinternal-ca-bundlezusätzlich zur alten — eine Union. Erst reconcilen, bestätigen, dass die neue Root überall verteilt ist. Niemand validiert noch gegen sie, aber alle könnten. - Sub-CA importieren. Das
internal-ca-SOPS-Secret bauen und deninternal-Issuer darauf zeigen lassen. - Force reissue. Das Ändern der Backing-CA erneuert bestehende Certs nicht automatisch. Also
task cert-manager:renew-all-certs— jedes Blatt wird vom neuen Sub-CA neu signiert. Um die Trust-Lücke wirklich auf null zu drücken, bleibt die alte Root so lange im Bundle, bis das Reissue durch ist. - Consumer umlegen. Gateways auf den
internal-Issuer, Trust-Consumer auf das Bundle. - Aufräumen. Erst nach Verifikation die abgelösten Quellen aus dem Bundle entfernen.
Die Idee dahinter ist simpel und alt: Vertrauen aufbauen, bevor man es benutzt; Vertrauen abbauen, nachdem niemand es mehr braucht. Solange beide Roots im Bundle stehen, ist jede Kette — alt wie neu — gültig.
Verifizieren
kubectl get clusterissuer internal # Ready
kubectl get certificate -A # keine Renewal-Fehler
openssl verify -CAfile root.crt -untrusted intermediate.crt subca.crt
openssl x509 -in <leaf>.crt -noout -issuer # CN=… Issuing CA
kubectl -n envoy-gateway-system get gateway # PROGRAMMED=True
Rotation: was wie oft
Die Lebensdauern sind so gewählt, dass Rotation selten und planbar ist:
- Blätter: automatisch über cert-manager, ≤ 1 Jahr. Nichts zu tun.
- Sub-CA (3 J): offline neu erzeugen (signiert vom selben Intermediate), staged Import wiederholen. Revocation läuft hier über Rotation der Sub-CA — kein CRL/OCSP. Für kurzlebige interne Blätter ist das ein akzeptabler Trade-off.
- Root/Intermediate: langlebig; Rotation ist ein geplantes Re-Bootstrap des gesamten Vertrauens.
Was Vault noch tut
Die PKI hängt nicht mehr an Vault — das war das Ziel und ist erreicht. Als reiner Secret-Backend läuft Vault vorerst weiter, wandert aber schrittweise nach SOPS zurück; danach fliegt der Vault-App-Stack ganz raus. Der Reiz der Umstellung war nie nur Sicherheit. Es war die Erkenntnis, dass eine CA kein Dienst sein muss. Die wertvollsten Schlüssel meines Labs liegen jetzt dort, wo sie hingehören: kalt, offline, und außerhalb der Reichweite eines jeden Pods, der je abstürzen könnte.
