Eintrag

Secrets im Git – aber richtig: sops + age (und ein Manager drumherum)

Secrets im Git – aber richtig: sops + age (und ein Manager drumherum)

Irgendwann hat jedes Homelab dasselbe Problem: Du willst deine Konfiguration versionieren – docker-compose.yml, Ansible-Playbooks, ein paar Skripte. Nur stecken da überall Geheimnisse drin: API-Tokens, Passwörter, Keys. Ins Git-Repo dürfen die im Klartext auf keinen Fall, und ein zweiter „geheimer” Ablageort neben dem Repo wird schnell zur Karteileiche.

Die saubere Antwort heißt sops + age: verschlüsselte Werte, die trotzdem im Git leben – mit kleinen, lesbaren Diffs. Dieser Post erklärt beide Werkzeuge von Grund auf und zeigt dann, wie ich darauf einen kleinen self-hosted Credential-Manager gebaut habe (Beispiele natürlich anonymisiert).

Das Problem mit Secrets im Git

Git ist gebaut, um alles aufzuheben – auch das, was du später löschst. Ein einmal committetes Token bleibt in der History, selbst wenn du es im nächsten Commit entfernst. Die naiven Auswege taugen alle nicht so recht:

  • .gitignore + Datei daneben – funktioniert, aber die Datei ist nicht versioniert, nicht gesichert, und auf drei Geräten schon dreimal anders.
  • Alles verschlüsseln (z. B. git-crypt) – dann sind aber auch harmlose Metadaten blind, und jeder Diff ist ein unlesbarer Binär-Blob.
  • Externer Secrets-Dienst (Vault & Co.) – mächtig, aber für ein Homelab oft ein Dampfhammer mit eigenem Betriebsaufwand.

Was man eigentlich will: nur die Werte verschlüsseln, die Struktur und Metadaten lesbar lassen, und das Ganze in ein ganz normales Git-Repo legen. Genau das ist die Nische von sops.

age: Verschlüsselung ohne Zeremonie

age („actually good encryption”) ist ein modernes, winziges Verschlüsselungswerkzeug – der geistige Nachfolger von „GPG, aber ohne die 20 Jahre Ballast”. Ein Schlüsselpaar ist zwei Zeilen:

1
2
age-keygen -o key.txt
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7wcajuf7f7z3xr...
  • Der öffentliche Schlüssel (age1…, der Recipient) darf überall stehen – mit ihm wird verschlüsselt.
  • Der private Schlüssel (key.txt) entschlüsselt und bleibt geheim auf dem Host.

Das Elegante: Eine Datei kann für mehrere Recipients verschlüsselt werden. Jeder Host bekommt sein eigenes Schlüsselpaar, und die verschlüsselte Datei trägt einfach mehrere Empfänger – keiner muss den privaten Key eines anderen kennen. Diese Multi-Recipient-Fähigkeit ist später der Schlüssel für ein sauberes Mehr-Host-Setup.

sops: nur die Werte verschlüsseln

sops (Secrets OPerationS, ursprünglich von Mozilla) ist der Aufsatz, der age erst richtig git-tauglich macht. Statt die ganze Datei zu verschlüsseln, geht sops durch die Struktur (YAML, JSON, ENV, INI) und chiffriert nur die Werte – die Schlüsselnamen und die Form bleiben im Klartext.

Der Kern ist eine .sops.yaml mit creation rules:

1
2
3
4
creation_rules:
  - path_regex: .*\.sops\.yaml$
    encrypted_regex: '^(secret|passwort)$'   # NUR diese Schlüssel verschlüsseln
    age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7wcajuf7f7z3xr...,age1lujaf7f7z3xr8hjy54pw3hyww5ay...
  • path_regex – auf welche Dateien die Regel greift.
  • age – ein oder mehrere Recipients (kommagetrennt = Multi-Recipient).
  • encrypted_regexwelche Schlüsselnamen verschlüsselt werden. Alles andere bleibt Klartext.

Aus einer Klartext-Datei…

1
2
3
4
5
6
credentials:
  - id: dns-api
    typ: api-token
    titel: "DNS-API"
    secret: "super-geheimes-token"
    export_als: DNS_API_TOKEN

…wird nach sops -e -i secrets.sops.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
credentials:
  - id: dns-api
    typ: api-token
    titel: "DNS-API"
    secret: ENC[AES256_GCM,data:9ec4949…,iv:…,tag:…,type:str]
    export_als: DNS_API_TOKEN
sops:
  age:
    - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7wca...
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        ...

Sieh dir an, was hier passiert: id, typ, titel, export_als – alles lesbar. Nur secret wurde zu ENC[…]. Genau deshalb sind die Git-Diffs so angenehm: Änderst du ein Token, ändert sich eine ENC[…]-Zeile, nicht die halbe Datei.

encrypted_regex matcht Schlüsselnamen an beliebiger Tiefe im Baum. Ein Feld secret wird verschlüsselt, egal ob es oben liegt oder tief verschachtelt – das wird gleich noch wichtig.

Die tägliche Bedienung ist knapp:

1
2
3
4
sops secrets.sops.yaml                       # im $EDITOR öffnen (transparent ent-/verschlüsseln)
sops -d secrets.sops.yaml                     # entschlüsselt nach stdout
sops set secrets.sops.yaml '["credentials"][0]["secret"]' '"neuer-wert"'
sops -d --extract '["credentials"][0]["secret"]' secrets.sops.yaml   # nur EIN Wert

Das --extract ist Gold wert: Du holst genau einen Wert heraus, ohne den ganzen Baum zu entschlüsseln und durch die Prozessliste zu jagen.

Warum die Kombi ins Homelab passt

flowchart LR
  DEV["Editor / CLI"] -->|"sops -e (age)"| FILE["secrets.sops.yaml<br/>nur Werte verschlüsselt"]
  FILE -->|"git push"| GIT[("Git-Repo<br/>Single Source of Truth")]
  GIT -->|"git pull"| H1["Host A<br/>age-Key A"]
  GIT -->|"git pull"| H2["Host B<br/>age-Key B"]
  H1 -->|"sops -d"| ENV1["$ENV / App-Config"]
  H2 -->|"sops -d"| ENV2["$ENV / App-Config"]
  • Versioniert & gesichert: Jede Änderung ist ein Commit. Dein Git-Backup ist automatisch dein Secrets-Backup – der Chiffretext ist ja harmlos.
  • Kleine Diffs: Nur geänderte Werte wandern, Reviews bleiben lesbar.
  • Pro Host ein Key: Dank Multi-Recipient trägt dieselbe Datei die Keys aller berechtigten Hosts. Fällt ein Host weg, nimmst du seinen Recipient raus und verschlüsselst neu.
  • Kein laufender Dienst nötig: Entschlüsselt wird lokal mit dem age-Key – offline, ohne Server.

Der age-Private-Key ist die Kronjuwele. Wer ihn hat, liest alles. Er gehört in einen Passwort-Manager (off-site!) und mit chmod 600 auf den Host – niemals selbst ins Git.

Vom Werkzeug zur Lösung: ein Manager drumherum

sops + age auf der Kommandozeile ist stark – aber im Alltag fehlt der Überblick: Welche Tokens gibt es? Wofür? Wann laufen sie ab? Welches sollte mal rotiert werden? YAML im Terminal beantwortet das nicht gut. Also habe ich eine kleine Web-GUI darübergesetzt (FastAPI + Vanilla-JS, kein schweres Framework) – bewusst ohne eigene Krypto: sie ruft nur sops/age auf.

Der wichtigste Design-Entscheid: ein einziger Schreiber.

flowchart TD
  subgraph W["Manager-VM · ALLEINIGER SCHREIBER"]
    GUI["Web-GUI + API<br/>(Auth, CRUD)"]
    CLONE["lokaler Git-Klon"]
    GUI -->|"sops set/unset<br/>nur Geheimfeld ENC"| CLONE
    GUI -->|"lock → pull → commit → push"| CLONE
  end
  GIT[("Git-Repo<br/>Single Source of Truth")]
  subgraph C["Konsument · pull-only"]
    CLONE2["lokaler Git-Klon"]
    LOADER["ENV-Loader<br/>(age-Key, Shell-Start)"]
    ENVV["$ENV"]
    CLONE2 --> LOADER --> ENVV
  end
  GUI --> GIT
  CLONE -->|"push (ff-only)"| GIT
  GIT -->|"pull --ff-only<br/>(Cron / Button)"| CLONE2
  • Genau eine Instanz schreibt – atomar (gelockt, pull → commit → push, nur Fast-Forward). So kann das Repo strukturell nie divergieren.
  • Konsumenten lesen nur (git pull). Ein pre-commit-Guardrail blockt versehentliche Commits.
  • Werte landen als $ENV – beim Shell-Start entschlüsselt der lokale age-Key den Block direkt aus der Datei. Ganz ohne laufenden Manager: Der Manager ist nur der Bearbeitungs-Weg, nicht der Laufzeitpfad.

Darunter liegt eine schlichte, typisierte Liste – die eine Wahrheit, aus der die flache ENV-Sicht generiert wird:

1
2
3
4
5
6
7
8
credentials:
  - id: git-pat
    typ: api-token            # api-token | user-pass | endpoint | ssh-key | composite
    titel: "Git PAT"
    secret: ENC[…]            # das EINZIG verschlüsselte Feld
    benutzt_fuer: "push, MR-API"
    laeuft_ab: null
    export_als: GIT_TOKEN     # speist den Shell-ENV-Block

Multi-Feld: mehrere Werte, ein Eintrag

Manche Zugänge brauchen mehrere zusammengehörige Werte. Eine DNS-API etwa will oft Kundennummer und API-Key und API-Passwort. Als drei getrennte Einträge ist das unübersichtlich und leicht auseinanderzureißen. Also gibt es einen Typ composite, der die Werte in einem Eintrag mit einer felder-Liste bündelt – jedes Feld mit eigenem ENV-Namen und einem „geheim”-Schalter:

1
2
3
4
5
6
7
- id: dns-api
  typ: composite
  titel: "DNS-API"
  felder:
    - { name: Kundennummer, export_als: DNS_CUSTOMER, geheim: false, wert: "12345" }
    - { name: API-Key,      export_als: DNS_API_KEY,  geheim: true,  secret: ENC[] }
    - { name: API-Passwort, export_als: DNS_API_PW,   geheim: true,  secret: ENC[] }

Und hier zahlt sich die vorhin erwähnte Eigenschaft aus: Weil encrypted_regex: '^(secret|passwort)$' Schlüsselnamen in beliebiger Tiefe matcht, wird das verschachtelte secret in jedem Feld automatisch verschlüsselt – ganz ohne Krypto-Sonderbehandlung. Das nicht-geheime wert bleibt Klartext. sops set/--extract sprechen die tiefen Pfade (["credentials"][0]["felder"][1]["secret"]) genauso an wie flache Felder.

Genau ein Trick macht Multi-Feld möglich, ohne die Verschlüsselungsregel anzufassen: die Regel greift auf den Namen des Feldes, nicht auf seine Position. „Geheim” heißt schlicht: leg den Wert unter den Schlüssel secret.

Sicherheits-Lektionen (ehrlich eingeordnet)

  • Secrets nie in argv oder Logs. Schreiben über sops set --value-stdin (Wert kommt per stdin, nicht als Kommandozeilen-Argument), Anzeigen über gezieltes --extract. Fehlermeldungen bleiben generisch – ein Stacktrace darf nie einen Wert nach außen tragen.
  • Der age-Key ist bewusst unverschlüsselt auf dem Host, damit der Shell-Loader ihn beim Start ohne Passwort-Prompt nutzen kann. Das ist ein ehrlicher Trade-off: Jeder Prozess dieses Users kann ohnehin entschlüsseln. Die zusätzliche Barriere ist deshalb nicht noch eine Passphrase, sondern die Netz-Härtung (interne Bindung, VPN, GUI-Login) – gegen Zugriff ohne Shell.
  • Single-Writer schlägt Locking-Hoffnung. Zwei Schreiber auf dieselbe verschlüsselte Datei = Merge-Konflikt im Chiffretext = Kopfschmerzen. Ein Schreiber, alle anderen pull-only – strukturell konfliktfrei.
  • Backup ist geschenkt: die Git-History in jedem Klon plus der age-Key off-site. Aus jedem Klon/Backup ist alles wiederherstellbar, solange der Key existiert.

sops + age vs. die Alternativen

Ansatz Verschlüsselt Git-Diffs Betrieb Passt für
sops + age nur die Werte klein & lesbar kein Dienst nötig versionierte Config mit Secrets, Homelab
git-crypt ganze Dateien Binär-Blob transparent im Git „alles oder nichts”-Dateien
Ansible Vault Werte/Dateien mittel an Ansible gebunden reine Ansible-Setups
HashiCorp Vault zentraler Tresor – (nicht in Git) eigener Dienst + HA Firmen, dynamische Secrets
Bitwarden/Vaultwarden Tresor-DB – (nicht in Git) eigener Dienst Menschliche Passwörter, Browser

Für „Konfiguration + Secrets zusammen versioniert, ohne extra Dienst” ist sops + age der Sweet-Spot. Die Web-GUI obendrauf ist reiner Komfort – die Sicherheit trägt weiterhin sops/age, und die Werkzeuge funktionieren auch dann, wenn die GUI mal aus ist.

Fang klein an: eine .sops.yaml, ein age-Key, eine verschlüsselte Datei im Repo. Den Manager baust du erst, wenn dich das YAML-im-Terminal wirklich nervt – und selbst dann bleibt sops der Kern.

Dieser Eintrag ist vom Autor unter CC BY 4.0 lizensiert.