skip to content
dz
4 min read Von nix eine Ahnung #3

NixOS: Secrets im Git-Repo mit sops-nix

Read this in English →

Mein Server holt sich seine Konfiguration aus Git, aber die meisten Dienste brauchen ein Passwort oder einen Token, und die gehören nicht im Klartext in ein Repo. Mit sops-nix liegen sie verschlüsselt direkt neben der Konfiguration, und nur der Server und ich können sie lesen.

In Teil 2 habe ich den Server so eingerichtet, dass er sich stündlich selbst aus Git neu baut. Wer das Konfigurationsrepo lesen kann, sieht damit auch alles, was darin steht. Mein Repo ist privat, aber ein Passwort im Klartext wäre trotzdem eine schlechte Idee. Alles, was in der Konfiguration steht, landet beim Bauen im Nix-Store, und der ist von Haus aus für alle Benutzer des Servers lesbar.

Mit sops nutze ich ein Tool, das die Werte einer YAML-Datei verschlüsselt. Die Namen bleiben lesbar, und im Git-Diff sehe ich, welches Secret ich angefasst habe.

Den Stand nach diesem Teil gibt es im zugehörigen Repository nixos-series auf dem Branch part-3-secrets-with-sops-nix, und was gegenüber Teil 2 dazukommt, zeigt der Vergleich der beiden Branches. Die verschlüsselte secrets.yaml fehlt dort, die legt ihr mit euren eigenen Schlüsseln an.

Wer darf was lesen?

sops verschlüsselt mit age. Ich nutze dafür einen age-Schlüssel, den ich sonst für nichts verwende. Für den Server nehme ich den SSH-Host-Key, den er seit der Installation hat: ssh-to-age rechnet ihn in einen age-Schlüssel um, und NixOS leitet daraus beim Start selbst den privaten Schlüssel ab. Auf dem Server muss ich dafür nichts anlegen, und es gibt keinen zweiten Schlüssel, den ich sichern müsste.

Die Secrets sind für zwei Empfänger verschlüsselt: für mich, damit ich sie bearbeiten kann, und für den Server, damit er sie beim Start entschlüsseln kann.

Schritt 1: Werkzeug und Schlüssel

sops und age installiere ich am Mac mit Homebrew und sonst mit dem Paketmanager der Wahl:

brew install sops age

ssh-to-age rechnet SSH-Schlüssel in age-Schlüssel um. Wer Nix auf dem Rechner hat (Teil 1 setzt es voraus), muss nichts installieren: nix run nixpkgs#ssh-to-age -- ersetzt unten jeden Aufruf von ssh-to-age. Ohne Nix gibt es auf der Release-Seite ein fertiges Binary.

Meinen age-Schlüssel erzeuge ich dort, wo sops ihn von allein sucht. Unter macOS ist das ~/Library/Application Support/sops/age/keys.txt, unter Linux ~/.config/sops/age/keys.txt:

d="$HOME/Library/Application Support/sops/age"
mkdir -p "$d"
age-keygen -o "$d/keys.txt"

age-keygen gibt den öffentlichen Schlüssel (age1...) aus, den ich gleich für die Konfiguration brauche. Er steht auch als Kommentar in der Datei. Die Datei ist nur für mich lesbar, aber nicht verschlüsselt, und wer sie verliert, kommt nicht mehr an seine Secrets. Eine Kopie gehört deshalb in den Passwortmanager. Mit dem falschen Pfad meldet sops später no identity matched any of the recipients.

Ich selbst habe den Schlüssel gar nicht auf der Platte. Er liegt in meinem Passwortmanager, und über die Umgebungsvariable SOPS_AGE_KEY_CMD sage ich sops, mit welchem Befehl es ihn von dort holt. Das geht mit jedem Passwortmanager, der eine Kommandozeile hat. Hier bleibe ich bei der einfachen Variante.

Für den Server lese ich den öffentlichen Host-Key per SSH aus und rechne ihn um:

ssh root@<ipv4> cat /etc/ssh/ssh_host_ed25519_key.pub \
  | ssh-to-age

Auch diese Ausgabe beginnt mit age1.

Schritt 2: Empfänger festlegen

Im Root des Repos liegt eine .sops.yaml. Sie sagt sops, für wen es welche Datei verschlüsselt:

keys:
  - &ich age1...
  - &hetzner age1...
creation_rules:
  - path_regex: secrets/secrets\.yaml$
    key_groups:
      - age:
          - *ich
          - *hetzner

Die beiden Schlüssel sind die öffentlichen Schlüssel aus Schritt 1. Das Muster secrets/secrets\.yaml$ gilt für die Datei, die ich jetzt anlege.

Schritt 3: Das erste Secret

sops secrets/secrets.yaml öffnet die Datei im Editor. Gibt es sie noch nicht, legt sops sie beim Speichern verschlüsselt an. Als Beispiel nehme ich die Zugangsdaten für Miniflux, den Feed-Reader, den ich auf meinem Server betreibe. Er steht hier nur stellvertretend: Jeder Dienst, der seine Zugangsdaten aus einer Datei liest, funktioniert genauso. Miniflux erwartet eine Datei mit KEY=VALUE-Zeilen, also lege ich das als mehrzeiligen Wert ab:

miniflux-admin: |
  ADMIN_USERNAME=daniel
  ADMIN_PASSWORD=ein-langes-passwort

Nach dem Speichern sieht die Datei im Repo ungefähr so aus (gekürzt):

miniflux-admin: ENC[AES256_GCM,data:3fG...,type:str]
sops:
  age:
    - recipient: age1...
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        ...

Der Name miniflux-admin steht im Klartext, der Wert nicht. Zum Lesen und Ändern öffne ich die Datei immer wieder mit sops secrets/secrets.yaml, und ein einzelnes Secret gibt beispielsweise sops -d --extract '["miniflux-admin"]' secrets/secrets.yaml aus.

Schritt 4: NixOS anbinden

In flake.nix kommt sops-nix als Input dazu und sein Modul in die Liste:

inputs = {
  # ...
  sops-nix = {
    url = "github:Mic92/sops-nix";
    inputs.nixpkgs.follows = "nixpkgs";
  };
};

outputs = { self, nixpkgs, disko, sops-nix, ... }: {
  nixosConfigurations.hetzner = nixpkgs.lib.nixosSystem {
    modules = [
      disko.nixosModules.disko
      sops-nix.nixosModules.sops
      # ...
    ];
  };
};

In configuration.nix stehen drei Dinge: wo die verschlüsselte Datei liegt, woher der Server seinen Schlüssel nimmt, und welche Secrets er daraus bereitstellt. Miniflux bekommt den Pfad zum Secret als adminCredentialsFile:

{ config, ... }:
{
  sops.defaultSopsFile = ../../secrets/secrets.yaml;
  sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];

  sops.secrets.miniflux-admin.restartUnits = [
    "miniflux.service"
  ];

  services.miniflux = {
    enable = true;
    adminCredentialsFile =
      config.sops.secrets.miniflux-admin.path;
  };
}

config.sops.secrets.miniflux-admin.path ist /run/secrets/miniflux-admin. Die Datei entsteht erst beim Aktivieren des Systems auf dem Server. Im Repo und im Nix-Store steht nur der verschlüsselte Wert. restartUnits sorgt dafür, dass Miniflux neu startet, wenn ich das Passwort ändere. Ohne die Zeile bekommt der Dienst vom neuen Wert nichts mit.

Dann committen und pushen wie immer, und spätestens eine Stunde später läuft es. Wer nicht warten will, stößt den Rebuild an wie in Teil 2. Ob es geklappt hat, zeigt ein Blick auf den Server:

ssh root@<ipv4> "systemctl status miniflux"
ssh root@<ipv4> "ls -l /run/secrets"

Wenn der Server neu installiert wird

Der Haken an dieser Lösung: Der Server entschlüsselt mit seinem Host-Key. Installiere ich ihn neu, erzeugt er einen neuen, und der alte Inhalt von secrets.yaml ist für ihn nicht mehr lesbar. Deshalb habe ich den Host-Key einmal verschlüsselt im Repo abgelegt, nur für mich:

ssh root@<ipv4> cat /etc/ssh/ssh_host_ed25519_key \
  | sops encrypt --filename-override secrets/hostkey.enc \
      --input-type binary --output-type binary /dev/stdin \
  > secrets/hostkey.enc

Dazu braucht .sops.yaml eine zweite Regel mit secrets/hostkey\.enc$, die nur *ich enthält. Bei einer Neuinstallation lege ich den Schlüssel in ein Verzeichnis, das nixos-anywhere mit --extra-files auf den neuen Server kopiert. Das Rettungssystem starte ich dafür wie in Teil 1:

umask 077
mkdir -p /tmp/extra/etc/ssh
sops decrypt --input-type binary --output-type binary \
  secrets/hostkey.enc \
  > /tmp/extra/etc/ssh/ssh_host_ed25519_key
ssh-keygen -y -f /tmp/extra/etc/ssh/ssh_host_ed25519_key \
  > /tmp/extra/etc/ssh/ssh_host_ed25519_key.pub
nix run github:nix-community/nixos-anywhere -- \
  --build-on-remote \
  --extra-files /tmp/extra \
  --flake .#hetzner \
  root@<ipv4>
rm -rf /tmp/extra

Damit hat der Server von Anfang an denselben age-Schlüssel wie vorher und kann seine Secrets sofort entschlüsseln.

Wer einen Server ganz neu anlegt, kann sich den Umweg über das Auslesen sparen. Er erzeugt den Host-Key gleich im --extra-files-Verzeichnis, rechnet dessen öffentlichen Teil mit ssh-to-age um und trägt ihn in .sops.yaml ein. Bei der Installation gibt er den Schlüssel mit, und der Server kann seine Secrets vom ersten Start an entschlüsseln:

umask 077
mkdir -p /tmp/extra/etc/ssh
ssh-keygen -q -t ed25519 -N '' \
  -f /tmp/extra/etc/ssh/ssh_host_ed25519_key
ssh-to-age < /tmp/extra/etc/ssh/ssh_host_ed25519_key.pub

Den privaten Schlüssel verschlüssele ich wie oben für secrets/hostkey.enc, nur liest sops diesmal die lokale Datei statt der Ausgabe von ssh. Das Verzeichnis lösche ich erst nach der Installation.

Im nächsten Teil liefert der Server endlich eine Webseite aus.