Passwörter sicher per GitOps deployen mit SealedSecrets

Keine Kommentare

In einem GitOps-Workflow beschreibt das Entwicklungsteam alle Ressourcen eines Kubernetes-Projekts in einem Git-Repository. Dadurch kann sowohl das Entwicklungsteam als auch das Infrastrukturteam alle Bestandteile eines Projektes überblicken. Was jedoch aus Sicherheitsgründen nie in einem Git-Repository liegen darf, sind Zugangsdaten wie Passwörter oder API-Schlüssel zu Drittanbieterschnittstellen. An diesem Punkt setzt das SealedSecrets-Projekt an. Es bietet die Möglichkeit, mittels Public-Key-Kryptografie die schützenswerten Daten zu verschlüsseln, um sie in dieser Form in einem Git-Repository speichern zu können. Damit liegen alle Bestandteile der Infrastruktur einer Projekts gemeinsam an einer Stelle.

In diesem Blogpost sehen wir einen einfachen Anwendungsfall von SealedSecrets bei einem passwortgeschützten Webserver. Den Webserver rollen wir über GitOps in einem Kubernetes-Cluster aus. Wir schauen uns die Sicherheitsthematik an, die SealedSecrets mit sich bringen und zeigen abschließend zwei mögliche Alternativen auf.

Was sind SealedSecrets?

Der Kern des SealedSecrets-Projekts ist ein Kubernetes-Controller, der verschlüsselte Daten entschlüsselt und als Kubernetes-Secrets bereitstellt. Der Controller erstellt einen sogenannten SealingKey, der aus einem öffentlichen und einem privaten Schlüsselteil besteht. Das Kommandozeilenwerkzeug kubeseal nutzt den öffentlichen Teil des SealingKeys, um Daten zu verschlüsseln. Der Controller kann diese Daten mit dem privaten Teil des SealingKeys wieder entschlüsseln. Dieser private Teil liegt — vor Zugriffen geschützt — im Kubernetes-Cluster und ausschließlich der SealedSecrets-Controller kann ihn einlesen.

SealedSecrets unterstützen den GitOps-Workflow

Als DevOps-Engineer verwende ich einen GitOps-Workflow, damit ich alle Kubernetes-Ressourcen an einem einzigen Ort verwalten kann. Das verschafft mir eine Übersicht über das gesamte System. Dank der Versionierung von Git erhalte ich außerdem einen Audit-Trail. Das heißt, ich kann alle Änderungen einer Tageszeit und einem Menschen – oder einer Maschine – zuordnen. Damit lassen sich Fehler leichter analysieren. Gleichzeitig bietet Git eine einfache Möglichkeit, Änderungen rückgängig zu machen. Bei einem Fehler lässt sich so schnell ein vorheriger Stand wieder herstellen.

Gängigerweise sind schützenswerte Daten wie Passwörter oder API-Keys nicht Teil des Git-Repositorys. Committen wir z. B. Zugangsdaten für AWS in ein öffentliches Repository, dann können wir innerhalb von Minuten einen Missbrauch feststellen. Mit dem Einsatz von SealedSecrets können wir auch diese eigentlich geheimen Daten verschlüsselt in ein Git-Repository einchecken. Damit ist der komplette Zustand eines Systems in Git beschrieben.

Fallbeispiel Nginx

Wir schauen uns nun ein einfaches Fallbeispiel mit einem Nginx-Webserver an. Das gesamte Repository ist auf GitHub zu finden. Die Readme im GitHub-Projekt erklärt Schritt für Schritt, wie wir den lokalen Kubernetes-Cluster aufsetzen, der auf Änderungen des GitHub-Repositorys reagiert. Für die GitOps-Funktionalität installieren wir Flux im Cluster. Flux legt die Ressourcen, die im GitHub-Repository beschrieben sind, auch im Cluster an.

Übersicht

Das folgende Diagramm beschreibt, wie der Passwort-Hash von einem Entwickler zu einem Nginx-Pod in einem Kubernetes-Cluster kommt, ohne dass der Klartext einsehbar ist.

Diagramm Datenfluss SealedSecrets von Entwicklungsmaschine zu Kubernetes-Cluster

Der Entwickler nutzt das kubeseal-Kommandozeilenwerkzeug, um den Passwort-Hash lokal zu verschlüsseln. Kubeseal lädt dazu den öffentlichen Teil des SealingKeys aus dem Cluster. Der Git-Commit, der den Hash beinhaltet, kommt durch ein git push in das Repository auf GitHub. Flux beobachtet das Git-Repository und erstellt die SealedSecret Custom Resource Definition (CRD) für den verschlüsselten Hash. Der SealedSecrets-Controller nutzt den SealingKey, um das SealedSecret in ein normales Kubernetes-Secret zu entschlüsseln. Das Secret wird schließlich dem Nginx-Pod zur Verfügung gestellt.

Nginx

Der Webserver schützt seine Standardseite durch ein Passwort, das mit Basic-Auth-Authentifizierung überprüft wird. Wir wählen Basic-Auth aus dem Grund, weil es einfach zu konfigurieren ist. In Produktivumgebungen setzen wir auf Identity Provider wie KeyCloak, die Passwörter ausreichend sicher hashen.

In unserem Fall konfigurieren wir unseren Nginx als Kubernetes-Deployment. Das geladene Image ist nginx, das unter Port 80 seine Dienste bereitstellt. Als volumeMounts geben wir eine ConfigMap und ein Secret an. In der nginx-config-ConfigMap verweisen wir auf den Pfad /etc/nginx-htpasswd, sodass Nginx unser Passwort von dort laden kann. Die ConfigMap existiert bereits im GitHub-Repository und enthält eine Minimalkonfiguration, um die Standardseite von Nginx mit einem Basic-Auth-Passwort zu schützen. Das nginx-htpasswd Secret darf nie im Repository landen. Erst der SealedSecrets-Controller erstellt es aus dem SealedSecret, das wir im nächsten Schritt erzeugen.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-dpl
  namespace: default
spec:
  template:
    spec:
      containers:
        - image: nginx
          ports:
            - containerPort: 80
              name: http
          volumeMounts:
            - mountPath: /etc/nginx-htpasswd
              name: htpasswd
            - mountPath: /etc/nginx
              name: config
      volumes:
        - name: htpasswd
          secret:
            secretName: nginx-htpasswd
        - name: config
          configMap:
            name: nginx-config

Verschlüsselung mit kubeseal

Das folgende Kommando erzeugt einen Passwort-Hash für den Nutzer nginx-user mit dem Passwort weakpassword, wandelt es in ein Kubernetes Secret und verschlüsselt es mit kubeseal. Das Kommando ist umfangreich, deshalb gehen wir im Anschluss die einzelnen Schritte durch. Eine Warnung vorweg: Die Shell-Historie zeichnet normalerweise alle Kommandos auf. So können Passwörter im Klartext auf der Festplatte landen. Jede Shell bietet einen Weg, die Historie temporär zu deaktivieren. In bash oder zsh macht das z. B. das Kommando unset HISTFILE.

USERNAME=nginx-user \
PASSWORD=weakpassword \
HTPASSWD=$USERNAME:$(openssl passwd -apr1 $PASSWORD) \
kubectl create secret generic nginx-htpasswd \
  --namespace default \
  --from-literal=htpasswd=$HTPASSWD \
  --dry-run=client \
  --output=yaml \
| kubeseal -o yaml \
> ./cluster/nginx-htpasswd-sealed.yaml

Die Zeilen 1-3 erzeugen ein gültiges Nutzer-Passwort-Hash-Tupel, das Nginx verwenden kann. Dafür verwenden wir das openssl-Kommando mit apr1 als Hashing-Algorithmus. Für Basic-Auth unter Nginx gibt es keine sicheren Hash-Verfahren. Deshalb ist es umso wichtiger, dass wir den Hash nicht unverschlüsselt in Git einchecken.

Mit dem kubectl create-Befehl in den Zeilen 4-8 erzeugen wir ein Kubernetes-Secret. Das --from-literal in Zeile 6 bestimmt als Inhalt des Secrets eine Datei mit dem Namen htpasswd und als Inhalt den vorher generierten Passwort-Hash. Wir wollen das Secret nicht direkt im Cluster anlegen, sondern nur lokal die Repräsentation im YAML-Format ausgeben. Das geschieht durch --dry-run=client und --output=yaml in den Zeilen 7 und 8.

Die Ausgabe von kubectl leiten wir in Zeile 9 an kubeseal weiter, welches das Secret in SealedSecret verschlüsselt. Dazu fordert es aus dem Cluster den öffentlichen Teil des SealingKeys an. Das SealedSecret speichern wir als yaml-Datei im cluster-Verzeichnis. Wir leiten die Ausgabe in eine Datei unter dem cluster/-Verzeichnis um. Diese Datei enthält nun ein SealedSecret mit verschlüsselte Daten.

apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: nginx-htpasswd
  namespace: default
spec:
  encryptedData:
    htpasswd: AgAsn2 [.. snip 8< 8< 8< ..] yJk=
  template:
    metadata:
      name: nginx-htpasswd
      namespace: default

Zu sehen ist, dass der Name der Datei htpasswd lesbar bleibt. Der Inhalt wurde mit dem öffentlichen Schlüssel durch kubeseal verschlüsselt als encryptedData abgelegt. Die Datei nginx-htpasswd-sealed.yaml fügen wir unserem Git-Repository hinzu. Sobald Flux die Änderungen mitbekommt, erstellt es das SealedSecret als Ressource im Cluster. Das wiederum löst im SealedSecrets-Controller die Entschlüsselung aus. Der Controller nutzt den privaten Teil des SealingKeys, um die Passwortdatei entschlüsselt als Kubernetes-Secret bereitzustellen.

Funktionstest

Nun kann der Nginx-Pod starten, da das Secret nginx-htpasswd verfügbar geworden ist.

$ kubectl get events
LAST SEEN REASON       OBJECT                         MESSAGE
2m14s     FailedMount  pod/nginx-dpl-bddb77d95-9rtqv  MountVolume.SetUp failed for volume "htpasswd" : secret "nginx-htpasswd" not found
20s       Unsealed     sealedsecret/nginx-htpasswd    SealedSecret unsealed successfully
9s        Started      pod/nginx-dpl-bddb77d95-9rtqv  Started container nginx

Jetzt testen wir noch, dass unser Passwort auch funktioniert. Dafür erstellen wir eine Portweiterleitung, die den Zugriff von unserer Maschine auf unseren Service im Minikube-Cluster erlaubt.

$ kubectl port-forward service/nginx-svc 8888:80

Unter http://localhost:8888 fragt unser Browser nun nach Authentifizierung. Wir loggen uns mit nginx-user und weakpassword ein und sehen die Startseite des Webservers. Das Klartextpasswort haben wir in diesem Workflow nie auf die Festplatte gespeichert. Das Passwort steht nur in verschlüsselter Form in unserem Git-Repository, womit wir die gesamte Infrastruktur unseres Beispiels beschrieben haben.

Sicherheit

Die kryptografische Dokumentation von SealedSecrets geht auf die technischen Details der Verschlüsselung ein. Eine Kryptoanalyse würde leider den Rahmen dieses Posts sprengen. Deswegen gehen wir davon aus, dass die Entwickler von SealedSecrets die kryptografischen Algorithmen korrekt verwenden. Bei der Verwendung von SealedSecrets basiert die Sicherheit auf der Geheimhaltung des privaten Teils des SealingKeys. Falls eine Angreiferin Zugriff auf den privaten Schlüsselteil bekommt, dann kann sie alle anderen geheimen Daten entschlüsseln.

RBAC

Mit “Rule Based Access Control” (RBAC) schützen wir den privaten Teil des SealingKeys vor unprivilegierten Nutzern. Einzig der SealedSecrets-Controller darf Zugriff auf den privaten SealingKey besitzen. Kubernetes regelt Zugriffskontrolle per RBAC. Bei der Installation erstellt der SealedSecrets-Controller bereits die nötigen RBAC-Ressourcen. Ein Administrator des Clusters muss allerdings die Konfiguration überprüfen und sicherstellen, dass die Berechtigungen korrekt eingerichtet sind.

Renewal des SealingKeys

Der SealedSecrets-Controller bringt einen Mechanismus mit, um regelmäßig den SealingKey zu erneuern. Das ist sinnvoll, denn falls ein SealingKey doch einmal kompromittiert werden sollte, dann können die aktuellen SealedSecrets nicht mit einem alten SealingKey entschlüsselt werden. Seit Version v0.9.0 generiert der Controller standardmäßig alle 30 Tage einen neuen SealingKey. Die alten SealingKeys bleiben erhalten, damit der SealedSecrets-Controller alle existierenden SealedSecrets weiterhin entschlüsseln kann. Weil die alten SealingKeys weiter in Verwendung bleiben, sprechen wir hier von Renewal und nicht von Rotation. Wenn wir neue Secrets hinzufügen, oder alte rotieren, dann kommt der jeweils aktuellste SealingKey zum Einsatz.

Sobald wir den Verdacht haben, dass der SealingKey kompromittiert wurde, ist es zwingend nötig, dass wir sofort einen neuen SealingKey erzeugen und im Anschluss alle Passwörter rotieren, die durch diesen privaten Schlüssel geschützt waren. Ein Rotieren der Passwörter muss dementsprechend schnell durchführbar sein. Im Idealfall können wir die Rotation automatisiert durchführen.

Wenn wir in diesem Fall nur verschlüsselten Passwörter ändern würden, dann könnte die Angreiferin auch die neuen Passwörter mit dem kompromittierten SealingKey entschlüsseln. Bloß einen neuen SealingKey erzeugen hilft uns auch noch nicht, denn die Passwörter – verschlüsselt mit dem bereits kompromittierten SealingKey – sind in der Git-History enthalten.

In unserem Beispiel können wir das Renewal des SealingKey erzwingen, indem wir die Umgebungsvariable SEALED_SECRETS_KEY_CUTOFF_TIME des Deployments des SealedSecrets-Controllers auf die aktuelle Zeit setzen.

$ kubectl set env deployment/sealed-secrets-controller SEALED_SECRETS_KEY_CUTOFF_TIME=$(date -R) --namespace kube-system

Damit startet der Controller neu und legt einen neuen SealingKey an. Wir kontrollieren das, indem wir uns die aktiven Keys anzeigen lassen. Wie erwähnt, nutzt der Controller immer den aktuellsten Key.

$ kubectl get secret -l sealedsecrets.bitnami.com/sealed-secrets-key=active --namespace kube-system
NAME                      TYPE                DATA   AGE
sealed-secrets-keybppmh   kubernetes.io/tls   2      4s
sealed-secrets-keygcbgs   kubernetes.io/tls   2      7d21h

Rotation des Passworts

Nach dem Renewal des SealingKeys wechseln wir das Passwort unseres Nginx-Benutzers mit demselben kubectl/kubeseal-Befehl wie vorher. Nur setzen wir diesmal ein anderes Passwort. Die veränderte Datei nginx-htpasswd-sealed.yaml committen und pushen wir erneut in unser Git-Repository. Nachdem die Änderung im Cluster angekommen ist, müssen wir den Webserver neu starten.

$ kubectl rollout restart deployment nginx-dpl

Kubernetes legt zuerst einen neuen Pod an, und terminiert im Anschluss den alten. Wäre unser Webserver über ein Helm-Chart deployt, dann könnte Helm sogar für ein automatisches, rollendes Deployment sorgen, sobald sich das Secret ändert.

Beim Neustart liest der Webserver das neue nginx-htaccess Secret ein. Jetzt können wir uns mit dem neuen Kennwort auf dem Webserver mit dem existierenden Port-Forwarding anmelden.

Idealerweise können wir diese Rotation automatisiert durchführen. Das zahlt sich aus, wenn wir den Katastrophenfall betrachten. Falls z. B. der gesamte Kubernetes-Cluster gelöscht wurde, dann sind die SealingKeys unwiederbringlich verloren.

Ohne SealingKey können sämtliche existierende SealedSecrets nicht mehr entschlüsselt werden. Wir müssen alle Passwörter nun erneut mit dem aktuellen SealingKey verschlüsseln. Wenn wir die Passwortrotation automatisiert haben, dann können wir den Cluster schnell wieder in einen funktionierenden Zustand bringen.

Alternativen zu SealedSecrets

SealedSecrets ist nicht das einzige Projekt, das Secrets in GitOps-Workflows unter Kubernetes unterstützt. SealedSecrets ist nur für den Einsatz in einem einzelnen Kubernetes-Cluster vorgesehen. Um dasselbe SealedSecret in mehreren Clustern zu entschlüsseln, müssten wir über einen eigenen Mechanismus den privaten Schlüsselteil in alle Cluster replizieren. Generell sollte jedoch der private Schlüsselteil den Cluster nicht verlassen, um das Risiko des Schlüsselverlustes gering zu halten. Falls die Entwickler über mehrere Cluster hinweg arbeiten, dann empfehlen sich diese Alternativen, die den Schlüssel einem Cloud-Provider anvertrauen, anstatt ihn im Cluster zu halten.

Mozilla SOPS bietet sehr ähnlich zu SealedSecrets die Verschlüsselung von Config-Dateien. SOPS basiert nicht auf Kubernetes, es existieren aber mehrere Kubernetes-Operator. Als Speicher für den Schlüssel unterstützt SOPS auch Cloud-Plattformen wie AWS KMS, GCP KMS und Azure Key Vault.

ExternalSecrets speichert eine Referenz auf ein Secrets, das an einer anderen Stelle gespeichert wird. Die gesamte Verschlüsselungsmechanik ist nun an eine externe Stelle ausgelagert. ExternalSecrets unterstützt mehr als ein Dutzend externe Provider, z. B. die SecretsManager von AWS und GCP sowie Azure Key Vault.

Fazit

Wir haben gezeigt, wie wir mit SealedSecrets den Passwort-Hash für einen Webserver verschlüsselt in einem öffentlichen Git-Repository ablegen können. Durch unseren GitOps-Workflow mit Flux kommt ein geändertes Passwort in Git direkt im Kubernetes-Cluster an. In unserem Git-Repository befindet sich der Passwort-Hash lediglich verschlüsselt, aber nie als Klartext.

Außerdem haben wir das Renewal des SealingKeys betrachtet. Sowohl die Passwörter selbst, als auch die SealingKeys müssen wir in regelmäßigen Abständen rotieren bzw neu generieren. Beides haben wir am Beispiel selbst durchgeführt.

Damit haben wir einen GitOps-Workflow geschaffen, der alle Ressourcen in einem einzigen Git-Repository abbildet.

Raffael ist Software Engineer mit einem Fokus auf Backend Technologien. Er schreibt leidenschaftlich gerne wartbaren Code in agilen, autonomen Teams. Als DevOps Engineer unterhält er Continuous Deployment Pipelines um rasend schnelle Release Cycle Times zu erreichen.

Über 1.000 Abonnenten sind up to date!

Die neuesten Tipps, Tricks, Tools und Technologien.
Jede Woche direkt in deine Inbox.

Kostenfrei anmelden und immer auf dem neuesten Stand bleiben!
(Keine Sorge, du kannst dich jederzeit abmelden.)

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht.