Zum Inhalt springen

Env Manager: ein Secrets-Speicher, der seine eigenen Secrets nicht lesen kann

Ein Security-Review hat darauf hingewiesen, dass unser Secrets-Manager seinen eigenen Administratoren vertraut. Die Antwort war Zero-Knowledge-Ende-zu-Ende-Verschlüsselung, und der ehrliche Teil des Entwurfs ist die Liste der drei Stellen, an denen der Server trotzdem Klartext sieht, und warum wir jede davon akzeptiert haben.

Patrick Lehmann
6 Min. Lesezeit
Der Server hält jeden Wert, liefert jeden Wert aus, autorisiert jeden Lesezugriff, und kann eine Zeile nicht von der anderen unterscheiden.

Env Manager war als die unambitionierteste Anwendung dieser Serie gedacht. Workspaces, Projekte, Umgebungen, Variablen; eine Rails-App, ein React-Frontend, ein kleines Thor-CLI, das eine .env-Datei in ein Checkout schreibt und Variablen nach GitLab CI synchronisiert. Selbstgehostetes Doppler, ungefähr. Die interessanten Probleme sollten in der Rollenleiter und in der CLI-Ergonomie liegen.

Dann stellte ein Security-Review eine einzige Frage: Wer kann eine Variable lesen?

Die Antwort lautete damals: Active Record Encryption mit einem anwendungsweiten Schlüssel. Also jeder, der diesen Schlüssel hat. Also der Applikationsserver, alles, was dessen Umgebung lesen kann, jedes Backup davon, und ich. Nicht «ich, falls ich meinen Zugriff missbrauche». Ich, strukturell, per Entwurf, ohne jeden Mechanismus, der das irgendjemandem hätte anzeigen können.

Ein Secrets-Manager, dessen Bedrohungsmodell den eigenen Betreiber nicht enthält, ist ein Aktenschrank mit einer guten Login-Seite.

Zero-Knowledge, und was das tatsächlich einschränkt

Der Neuentwurf hat jede kryptografische Operation auf den Client verlagert: libsodium-wasm im Browser, RbNaCl im CLI. Der Server speichert undurchsichtige Blobs und öffentliche Schlüssel, setzt durch, wer welchen Blob holen oder ablegen darf, und koordiniert Queues. Er erhält nie eine Passphrase, leitet nie einen Key-Encryption-Key ab, wrappt und unwrappt nie einen Schlüssel und entschlüsselt nie einen Wert.

Das ist die Invariante, und sie steht als Erstes in der Spezifikation, weil es der einzige Satz ist, gegen den jede künftige Änderung geprüft werden muss:

Die Schlüsselhierarchie, die daraus folgt, ist unauffällig, sobald man einmal eine gesehen hat:

Passphrase ──Argon2id──▶ KEK ──secretbox──▶ persönlicher X25519-Privatschlüssel  [Blob auf dem Server]
persönlicher Privatschlüssel ──seal_open──▶ Umgebungs-Privatschlüssel (pro Generation)
Umgebungs-Privatschlüssel ──seal_open──▶ Variablenwert

Eine Eigenschaft dieser Kette leistet mehr als der Rest: Zum Schreiben einer Variable genügt ein öffentlicher Schlüssel. Ein Wert wird auf den öffentlichen Schlüssel der Umgebung versiegelt; Anlegen und Aktualisieren brauchen also gar kein Entsperren. Nur Lesen braucht Schlüsselmaterial.

Das ist keine kryptografische Feinheit, sondern das, was das Produkt benutzbar macht. Es heisst, dass CI schreiben kann. Es heisst, dass jemand im Team eine Variable ohne Zeremonie hinzufügen kann. Und es heisst, dass der Server selbst versiegeln kann, was den Import aus GitLab überhaupt erst möglich macht.

Die drei Stellen, an denen der Server trotzdem Klartext sieht

Das ist der Abschnitt, den ich in einem fremden Erfahrungsbericht zuerst lesen wollen würde, und derjenige, der am häufigsten fehlt.

Leck Warum es akzeptiert ist
Variablennamen und Flags liegen im Klartext Ein Datenbank-Dump verrät, welche Dienste wir anbinden, STRIPE_SECRET_KEY, nie das Credential. Erkauft: serverseitige Suche, Blättern ohne Entsperren, GitLab-Konflikterkennung nach Schlüssel und lesbare Audit-Referenzen. Denselben Handel macht SOPS.
Push-Proxy: Klartext im Server-RAM für die Dauer eines Requests Der GitLab-Push wird von der aufrufenden Person ausgelöst. Der Client entschlüsselt, POSTet das Bündel, der Server leitet es über das Bot-Token weiter und verwirft es. Dieselben Werte werden in GitLab ohnehin für Maintainer lesbar; der Browser kann die GitLab-API nicht direkt aufrufen, und das Bot-Token muss serverseitig bleiben.
Pull-Import: eingehender Klartext vor dem Versiegeln Werte, die aus GitLab importiert werden, passieren auf dem Weg zur Versiegelung das Server-RAM. Sie stammen aus GitLabs Vertrauensbereich; unversiegelt persistiert werden sie nie.

Zwei weitere gehören genannt, auch wenn sie nicht wirklich «der Server» sind. Eine schwache Passphrase plus ein Datenbankleck ist offline knackbar, abgemildert durch eine zxcvbn-Mindestbewertung und Argon2id auf mittlerer Kostenstufe, nicht beseitigt. Und jedes web-ausgelieferte Ende-zu-Ende-Produkt, dieses eingeschlossen, teilt den Vorbehalt, dass ein kompromittierter Server beim Entsperren bösartiges JavaScript ausliefern könnte, das Schlüssel abgreift. Bitwarden hat ihn. EnvKeys Web-Client hat ihn. Das lokal installierte CLI ist der Pfad mit der höheren Sicherheitsannahme, und das zu sagen ist nützlicher, als das Gegenteil zu behaupten.

Die Asymmetrie im GitLab-Sync

Push- und Pull-Pfad sehen aus, als müssten sie einander spiegeln. Sie können es nicht, und der Grund folgt unmittelbar aus der Invariante.

Pull läuft serverseitig: Der Server holt die Variablen mit dem Bot-Token aus GitLab und kann jeden Wert auf den öffentlichen Schlüssel der Umgebung versiegeln, ohne irgendeinen privaten Schlüssel zu halten. Genau dafür ist die Eigenschaft «Schreiben braucht nur den öffentlichen Schlüssel» da.

Push kann nicht serverseitig laufen, denn den Klartext für GitLab zu erzeugen setzt Entschlüsseln voraus, und der Server kann nicht entschlüsseln. Der Push wird deshalb vom Client ausgelöst: Der Client entsperrt, entschlüsselt das Bündel, prüft GitLabs Masking-Regeln lokal (damit es eine brauchbare Warnung gibt statt einer Ablehnung) und schickt das Bündel an einen Proxy-Endpunkt, der weiterleitet und verwirft.

Keine der beiden Richtungen ist eine verteilte Transaktion, und die Spezifikation sagt das ausdrücklich, statt es nur nahezulegen:

  • Paginiertes Lesen liefert keinen projektweiten Schnappschuss, entfernte Variablen können sich während des Abrufs ändern.
  • Push setzt einen Remote-Request pro Variable ab; GitLab-Schreibvorgänge, die vor einem späteren Fehler durchgingen, lassen sich lokal nicht zurückrollen.
  • Wiederholungen gleichen deshalb auf Schlüsselebene ab. Die lokale Import-Transaktion ist atomar; die GitLab-Seite ist es nicht.

Widerruf, und die Weigerung, Sicherheit aufzuführen

Der Teil des Entwurfs, an dem ich am meisten hänge, ist der, der weniger tut, als man erwarten würde.

Umgebungsschlüssel sind generationell. Ein Mitglied zu entfernen löscht dessen gewrappte Kopien über alle Generationen, widerruft dessen Tokens und stellt eine Rotation in die Warteschlange; die nächste entsperrte Maintainerin erzeugt Generation g+1, gewrappt auf die verbleibenden Empfänger. Künftige Versionen versiegeln auf die neue Generation.

Alte Versionen werden nicht neu verschlüsselt. Das ist Absicht. Ein widerrufenes Mitglied konnte diese Werte lesen, solange es autorisiert war; Ciphertext neu zu versiegeln, den es jederzeit hätte kopieren können, schützt niemanden. Es ist ein Fortschrittsbalken, der ein Gefühl erzeugt.

Die Oberfläche tut deshalb das Einzige, was wirklich hilft: Sie nennt die betroffenen Umgebungen, listet die Variablen und fordert dazu auf, die zugrunde liegenden Credentials an ihrer Quelle zu rotieren. Diese Aufforderung ist in der Spezifikation ein erstklassiger Schritt, keine Fussnote, denn sie ist die einzige echte Gegenmassnahme, und ein System, das still die unechte ausführt, macht es unwahrscheinlicher, dass man die echte noch angeht.

Der günstigstmögliche Zeitpunkt

Eine ehrliche Anmerkung dazu, warum dieser Neuentwurf bezahlbar war. Die Anwendung war genau einmal deployt worden, ohne Produktivdaten. Das Schema wurde auf der grünen Wiese neu gebaut: Es existiert keine Migrationsmaschinerie für Werte, weil keine gebraucht wurde.

Sechs Monate und einen echten Kunden später hätte dieselbe Entscheidung einen Neuverschlüsselungspfad bedeutet, eine Doppellesephase, eine Umstellung und einen Rollback-Plan: für ein System, dessen ganzer Sinn darin besteht, dass der Server die zu migrierenden Daten nicht lesen kann.

Die Lehre ist nicht «von Tag eins an auf Zero-Knowledge entwerfen»: viele Produkte brauchen das zu Recht nie. Sie lautet, dass die Kostenkurve einer kryptografischen Architektur viel steiler verläuft als die der meisten Refactorings, und dass das Review, das sie auslöst, früh einzuladen deutlich billiger ist, als es spät zu bekommen.

Was ich behalten würde

  • Die Invariante als einen prüfbaren Satz schreiben und jeden Endpunkt daran messen. «Der Server fasst nie einen privaten Schlüssel an» fängt Entwurfsfehler, die eine Bedrohungsmodell-Tabelle durchlässt.
  • Die Restrisiken neben die Garantien schreiben. Ein Zero-Knowledge-Produkt ohne Liste akzeptierter Risiken ist eine Marketingseite.
  • Die Asymmetrie asymmetrisch lassen. Push und Pull haben unterschiedliche Form, weil die Kryptografie ihnen unterschiedliche Form gibt; Symmetrie zu erzwingen hätte geheissen, dem Server einen Schlüssel zu geben.
  • Das Sicherheitstheater verweigern und stattdessen zur echten Massnahme auffordern. Historie beim Widerruf neu zu verschlüsseln sieht nach Sorgfalt aus und ist keine.

Was dies mit dem Rest der Serie verbindet, ist kleiner, als es beim Aufschreiben aussah: Jeder Beitrag drehte sich um einen einzigen Satz, der vor dem Code geschrieben wurde, und jeder benennt ehrlich, was er nicht geschlossen hat.

Patrick Lehmann

Architecture & Governance Lead

Squibble GmbH

Bringt seit über zwanzig Jahren Struktur in gewachsene IT-Landschaften — als Architekt, Entwickler und Betreiber. Schreibt hier über die Systeme, die bei Squibble tatsächlich laufen, und über die Entscheide dahinter.

Weiterlesen

Boards: ein Kanban, das nicht weiss, wo seine Karten liegen

Jedes Board scheitert irgendwann daran, dass es zur zweiten Wahrheit wird: es merkt sich, in welcher Spalte eine Karte liegt, und ab da muss dieses Wissen gepflegt werden. Workbench merkt es sich gar nicht: Spalten sind Regeln, die beim Lesen ausgewertet werden.

Patrick Lehmann
9 Min. Lesezeit

Workbench: ein Delivery-Index, der falsch liegen darf

Wir haben eine mandantenfähige Sicht über mehrere GitLab-Instanzen gebaut, ohne zur zweiten Wahrheit zu werden. Die Regel, die das trägt: die lokale Datenbank ist wegwerfbar, und korrekt macht sie die Reconciliation, nicht die Webhooks.

Patrick Lehmann
5 Min. Lesezeit

Observability: das Lesemodell, das handelt

Workbench darf seine Datenbank wegwerfen und neu aufbauen. Diese Anwendung pollt Graylog und legt GitLab-Issues an, und ein Seiteneffekt hat kein Upstream. Warum Dry-Run zur Datenbankspalte wurde, warum das Dashboard keinen Löschen-Knopf hat, und warum die Schutzschranke an der HTTP-Grenze sitzt statt in der Konfiguration.

Patrick Lehmann
5 Min. Lesezeit