diff --git a/README.md b/README.md index c5e7861..ad2a968 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,15 @@ SSHKeyVault hat **root-Zugriff auf alle verwalteten Hosts**. Deshalb: - Private Keys liegen nur verschlüsselt in der Datenbank (AES-256-GCM, Envelope Encryption); entschlüsseln kann ausschließlich der Worker, der Webprozess hat den Master-Key nicht. -Mehr dazu in [`docs/PLAN.md`](docs/PLAN.md), Abschnitt 3. +Mehr dazu in [`docs/betrieb/sicherheit.md`](docs/betrieb/sicherheit.md) (mit Bedrohungsanalyse und +Notfallplan) und [`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md). + +## Dokumentation + +- [Handbuch](docs/anleitung/README.md) – Bedienung Schritt für Schritt mit Bildern +- [Installation und Betrieb](docs/betrieb/installation.md) – Docker Compose, authentik, Reverse Proxy +- [Sicherung und Wiederherstellung](docs/betrieb/sicherung.md) – `deploy/sichern.sh`, `deploy/wiederherstellen.sh` +- [Sicherheit](docs/betrieb/sicherheit.md) – Aufbau, Bedrohungsanalyse, Notfallplan ## Installation (Docker Compose) diff --git a/deploy/sichern.sh b/deploy/sichern.sh new file mode 100755 index 0000000..6a16f46 --- /dev/null +++ b/deploy/sichern.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SSHKeyVault sichern (#029). +# +# bash deploy/sichern.sh # nach /var/backups/sshkv +# bash deploy/sichern.sh /mnt/sicherung/sshkv # anderes Ziel +# +# Sichert die Datenbank (pg_dump mit --clean, damit sie sich auch in eine nicht leere +# Datenbank zurueckspielen laesst) und legt einen Pruefwert des Master-Keys daneben. +# +# Den MASTER-KEY selbst sichert dieses Skript absichtlich NICHT mit: Datenbank und Master-Key +# zusammen geben alle Private Keys preis. Den Master-Key einmalig und GETRENNT aufbewahren +# (Passwortmanager, Tresor, zweites Medium) – ohne ihn ist jede Sicherung wertlos. +# Siehe docs/betrieb/sicherung.md. + +set -o errexit +set -o nounset +set -o pipefail + +VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$VERZEICHNIS" + +gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; } +gelb() { printf '\033[1;33m! %s\033[0m\n' "$*"; } +fehler() { + printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2 + exit 1 +} + +ZIEL="${1:-/var/backups/sshkv}" +BEHALTEN="${SSHKV_SICHERUNGEN_BEHALTEN:-14}" +STAND=$(date +%Y%m%d-%H%M%S) + +mkdir -p "$ZIEL" +chmod 0700 "$ZIEL" + +# Datenbank: als Klartext-SQL, gzip-komprimiert. Enthaelt nur verschluesselte Private Keys. +# Nur das Schema public: Die Warteschlange (Schema pgboss) legt der Worker beim Start neu an, +# und ihre partitionierten Tabellen lassen sich mit --clean nicht zurueckspielen (in der +# Wiederherstellungsprobe aufgefallen). +docker compose exec -T db pg_dump -U sshkv --clean --if-exists --no-owner --schema=public sshkv \ + | gzip -9 > "${ZIEL}/datenbank-${STAND}.sql.gz" \ + || fehler "pg_dump ist fehlgeschlagen. Läuft der Dienst db?" +[ -s "${ZIEL}/datenbank-${STAND}.sql.gz" ] || fehler "Die Sicherung ist leer." +chmod 0600 "${ZIEL}/datenbank-${STAND}.sql.gz" +gruen "Datenbank gesichert: ${ZIEL}/datenbank-${STAND}.sql.gz" + +# Pruefwert des Master-Keys: verraet nichts ueber den Schluessel, zeigt beim Zurueckspielen +# aber, ob der richtige Master-Key bereitliegt. +ENV_DATEI="${SSHKV_ENV_DATEI:-.env}" +SCHLUESSEL="$(grep -E '^SSHKV_MASTER_KEY_HOST_FILE=' "$ENV_DATEI" 2>/dev/null | cut -d= -f2- || true)" +SCHLUESSEL="${SCHLUESSEL:-./geheim/master.key}" +if [ -r "$SCHLUESSEL" ]; then + sha256sum "$SCHLUESSEL" | cut -d' ' -f1 > "${ZIEL}/master-key-pruefwert-${STAND}.txt" + chmod 0600 "${ZIEL}/master-key-pruefwert-${STAND}.txt" + gruen "Prüfwert des Master-Keys abgelegt (nicht der Schlüssel selbst)." +else + gelb "Master-Key ${SCHLUESSEL} nicht lesbar – kein Prüfwert abgelegt (mit sudo ausführen?)." +fi + +# Alte Sicherungen aufraeumen (samt Pruefwert). +ls -1t "${ZIEL}"/datenbank-*.sql.gz 2>/dev/null | tail -n +"$((BEHALTEN + 1))" | while read -r alt; do + alter_stand=$(basename "$alt" .sql.gz) + alter_stand=${alter_stand#datenbank-} + rm -f -- "$alt" "${ZIEL}/master-key-pruefwert-${alter_stand}.txt" +done + +gelb "Den Master-Key getrennt aufbewahren – er gehört NICHT neben diese Sicherung." diff --git a/deploy/wiederherstellen.sh b/deploy/wiederherstellen.sh new file mode 100755 index 0000000..fa93334 --- /dev/null +++ b/deploy/wiederherstellen.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# SSHKeyVault aus einer Sicherung wiederherstellen (#029). +# +# bash deploy/wiederherstellen.sh /var/backups/sshkv # jüngste Sicherung +# bash deploy/wiederherstellen.sh /var/backups/sshkv 20261012-170000 +# bash deploy/wiederherstellen.sh … --ja # ohne Rückfrage +# +# Voraussetzung: Der zur Sicherung gehoerende Master-Key liegt an der in .env genannten Stelle +# (SSHKV_MASTER_KEY_HOST_FILE). Das Skript vergleicht ihn mit dem Pruefwert der Sicherung und +# bricht ab, wenn er nicht passt – sonst laeuft sshkv ohne lesbare Private Keys an. + +set -o errexit +set -o nounset +set -o pipefail + +VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$VERZEICHNIS" + +blau() { printf '\033[1;36m› %s\033[0m\n' "$*"; } +gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; } +fehler() { + printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2 + exit 1 +} + +QUELLE="${1:-/var/backups/sshkv}" +STAND="" +OHNE_RUECKFRAGE="nein" +for argument in "${@:2}"; do + if [ "$argument" = "--ja" ]; then OHNE_RUECKFRAGE="ja"; else STAND="$argument"; fi +done + +[ -d "$QUELLE" ] || fehler "Kein Sicherungsordner unter ${QUELLE}." +if [ -z "$STAND" ]; then + DATENBANK=$(ls -1t "${QUELLE}"/datenbank-*.sql.gz 2>/dev/null | head -n1 || true) +else + DATENBANK="${QUELLE}/datenbank-${STAND}.sql.gz" +fi +[ -n "${DATENBANK:-}" ] && [ -f "$DATENBANK" ] || fehler "Keine Datenbanksicherung in ${QUELLE} gefunden." +PRUEFWERT="${DATENBANK/datenbank-/master-key-pruefwert-}" +PRUEFWERT="${PRUEFWERT/.sql.gz/.txt}" + +ENV_DATEI="${SSHKV_ENV_DATEI:-.env}" +SCHLUESSEL="$(grep -E '^SSHKV_MASTER_KEY_HOST_FILE=' "$ENV_DATEI" 2>/dev/null | cut -d= -f2- || true)" +SCHLUESSEL="${SCHLUESSEL:-./geheim/master.key}" +if [ -f "$PRUEFWERT" ]; then + [ -r "$SCHLUESSEL" ] || fehler "Master-Key ${SCHLUESSEL} nicht lesbar (mit sudo ausführen?)." + [ "$(sha256sum "$SCHLUESSEL" | cut -d' ' -f1)" = "$(cat "$PRUEFWERT")" ] \ + || fehler "Der Master-Key unter ${SCHLUESSEL} gehört NICHT zu dieser Sicherung. Abbruch." + gruen "Master-Key passt zur Sicherung." +else + printf '\033[1;33m! Kein Prüfwert zur Sicherung – der Worker prüft den Master-Key beim Start.\033[0m\n' +fi + +printf '\033[1;33m! Achtung: Der aktuelle Datenbestand wird durch die Sicherung ersetzt.\033[0m\n' +echo " Sicherung: $(basename "$DATENBANK")" +if [ "$OHNE_RUECKFRAGE" != "ja" ]; then + read -r -p "Wirklich zurückspielen? [tippen Sie ja] " antwort + [ "$antwort" = "ja" ] || fehler "Abgebrochen." +fi + +blau "Weboberfläche und Worker anhalten …" +docker compose stop web worker +blau "Datenbank zurückspielen …" +# Eine Transaktion: scheitert etwas, bleibt der alte Stand vollstaendig erhalten (in der +# Wiederherstellungsprobe hatte ein Abbruch ohne Transaktion das Schema pgboss halb geloescht). +gunzip -c "$DATENBANK" | docker compose exec -T db psql -U sshkv -d sshkv -v ON_ERROR_STOP=1 --single-transaction -q >/dev/null \ + || fehler "Zurückspielen fehlgeschlagen. Web und Worker sind angehalten – nach Klärung: docker compose up -d" +# Die Warteschlange gehoert nicht zur Sicherung: frisch anlegen lassen (macht der Worker beim +# Start), offene Vorgaenge abbrechen – ihre Jobs sind damit weg. +docker compose exec -T db psql -U sshkv -d sshkv -q -c "DROP SCHEMA IF EXISTS pgboss CASCADE" >/dev/null +docker compose exec -T db psql -U sshkv -d sshkv -q -c \ + "UPDATE \"Vorgang\" SET status = 'ABGEBROCHEN', fehler = 'Durch Wiederherstellung abgebrochen.', \"beendetAm\" = now() WHERE status IN ('WARTEND','LAEUFT')" >/dev/null +blau "Dienste starten …" +docker compose up -d --wait web worker +sleep 3 +if docker compose logs --tail 20 worker 2>&1 | grep -q "passt nicht zur Datenbank"; then + fehler "Der Worker meldet: Master-Key passt nicht zur Datenbank. Siehe docker compose logs worker." +fi +gruen "Wiederhergestellt aus $(basename "$DATENBANK")." diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md new file mode 100644 index 0000000..6202e9c --- /dev/null +++ b/docs/anleitung/README.md @@ -0,0 +1,198 @@ +# SSHKeyVault – Handbuch + +SSHKeyVault (`sshkv`) verwaltet SSH-Schlüssel zentral: Schlüsselpaare erzeugen, Public Keys in +`authorized_keys` verteilen, Private Keys gezielt auf andere Hosts legen, Ist und Soll abgleichen – +alles protokolliert. Installation und Betrieb: [../betrieb/installation.md](../betrieb/installation.md), +Sicherung: [../betrieb/sicherung.md](../betrieb/sicherung.md), Sicherheit: +[../betrieb/sicherheit.md](../betrieb/sicherheit.md). + +**Grundsatz:** Auf einem Host ändert sich nur etwas, nachdem Sie einen **Dry-Run** gesehen und +„Änderung anwenden“ geklickt haben. Alles, was sshkv in `authorized_keys`, `~/.ssh/config` und +`known_hosts` schreibt, steht in einem verwalteten Block: + +``` +# BEGIN SSHKV MANAGED – nicht manuell bearbeiten +… +# END SSHKV MANAGED +``` + +Zeilen außerhalb dieses Blocks bleiben unverändert – außer Sie übernehmen einen Schlüssel ausdrücklich +in die Verwaltung. + +1. [Anmelden](#1-anmelden) +2. [Übersicht](#2-übersicht) +3. [Einen Host aufnehmen](#3-einen-host-aufnehmen) +4. [Kern-Anwendungsfall: Host A meldet sich bei Host B an](#4-kern-anwendungsfall-host-a-meldet-sich-bei-host-b-an) +5. [Deployment-Zugang für Claude Code mit dem Assistenten](#5-deployment-zugang-für-claude-code-mit-dem-assistenten) +6. [Abgleich, Drift und Übernahme](#6-abgleich-drift-und-übernahme) +7. [Rotation und Widerruf](#7-rotation-und-widerruf) +8. [Personen, Self-Service und Zuweisungsmatrix](#8-personen-self-service-und-zuweisungsmatrix) +9. [Vorlagen](#9-vorlagen) +10. [Private Key anzeigen oder löschen](#10-private-key-anzeigen-oder-löschen) + +--- + +## 1. Anmelden + +![Anmeldeseite](bilder/anmelden-desktop.png) + +- **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den + authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt. +- **Notfallzugang** – nur, wenn authentik ausfällt. Anmeldename, Passwort und ein Code aus einer + Authenticator-App. Bei der ersten Anmeldung richtet der Notfall-Admin den zweiten Faktor ein + (QR-Code scannen, Code bestätigen). + +Kritische Aktionen (Private Key anzeigen, Benutzer mit sudo anlegen, Massenwiderruf, Host-Key +ersetzen) verlangen eine **erneute Bestätigung**; sie gilt fünf Minuten. + +## 2. Übersicht + +![Übersicht](bilder/uebersicht-desktop.png) + +Kennzahlen, Hosts mit Abweichung, bald ablaufende Schlüssel und die letzten Aktionen. Von hier aus: +**Jetzt abgleichen** (alle Hosts), **Host-zu-Host-Assistent**, **Neuer Schlüssel**. + +## 3. Einen Host aufnehmen + +**Hosts → + Host anlegen:** Name (z. B. `srv-pve01`), Adresse, Port, Gruppen, Tags. Bei +**Anmeldung** ist „Dienstbenutzer `sshkv` mit sudo“ empfohlen – im auth.log des Hosts ist dann +eindeutig zu sehen, was von sshkv kam, und root-Login kann aus bleiben. + +**Host-Key bestätigen.** „Host-Key abrufen“ holt den Fingerprint, ohne sich anzumelden. Vergleichen +Sie ihn direkt am Host: + +```bash +ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub +``` + +![Host-Key bestätigen](bilder/hostkey-bestaetigen-desktop.png) + +Erst nach „Fingerprint stimmt – vertrauen“ verbindet sich sshkv. Ändert sich der Host-Key später, +bricht jede Verbindung ab und die Hostseite warnt deutlich – annehmen nur, wenn der Host wirklich neu +installiert wurde. + +**Bootstrap.** Zwei Wege, den Deployment-Key von sshkv auf den Host zu bringen: + +- **Einmalig per Passwort** (root oder ein Benutzer mit sudo). Das Passwort wird nicht gespeichert. +- **Per Befehl:** den angezeigten Befehl auf dem Host einfügen, danach „Einrichtung prüfen“. + +![Eingerichteter Host](bilder/host-eingerichtet-desktop.png) + +Danach zeigt die Hostseite den **sshd-Befund**. Steht dort „root darf sich per Passwort anmelden“, +empfiehlt sich `PermitRootLogin prohibit-password` in `/etc/ssh/sshd_config` – sshkv ändert das nicht +von sich aus. + +## 4. Kern-Anwendungsfall: Host A meldet sich bei Host B an + +Beispiel: Der Backup-Server `srv-backup01` soll sich als `root` per SSH auf `srv-pve01` anmelden. +Beide Hosts sind aufgenommen (Kapitel 3). + +**1. Schlüssel erzeugen.** *Schlüssel → + Schlüssel anlegen → Erzeugen*: Zweck „Backup srv-backup01 → +srv-pve01“, Typ ed25519, optional Ablaufdatum. Keine Passphrase – ein Cronjob kann sie nicht eingeben. + +![Schlüssel anlegen](bilder/schluessel-neu-desktop.png) + +**2a. Private Key auf A.** In der Karte *Private Key ausgerollt auf* → **+ Ausrollen**: Quell-Host +`srv-backup01`, Benutzer `root`, Pfad `~/.ssh/id_ed25519_…`. Mit **Eintrag in ~/.ssh/config** und +Ziel-Host `srv-pve01` legt sshkv einen Alias an und trägt den (bereits bestätigten) Host-Key von B in +`known_hosts` von A ein. *HostName* ist die Adresse, unter der **A** den Host B erreicht. + +**2b. Public Key auf B.** In der Karte *Public Key ausgerollt auf* → **+ Ausrollen**: Ziel +`srv-pve01`, Benutzer `root`, optional Einschränkungen wie `from="10.0.0.12",no-pty`. + +2a und 2b sind unabhängig und jederzeit nachholbar. + +**3. Dry-Run und anwenden.** *Dry-Run starten* zeigt je Host, was sich ändert – beim Private Key nur +„wird angelegt“, nie den Inhalt. + +![Dry-Run](bilder/kern-dry-run-desktop.png) + +„Änderung anwenden“ schreibt genau das, was Sie gesehen haben (mit Sicherung der Dateien). Hat sich +in der Zwischenzeit etwas auf dem Host geändert, bricht sshkv ab – dann den Dry-Run neu starten. + +**4. Verbindung testen.** In der Private-Karte „Verbindung testen“ ruft auf A +`ssh -o BatchMode=yes true` auf – genau das, was später der Cronjob braucht. + +![Ausgerollt und getestet](bilder/kern-ausgerollt-desktop.png) + +## 5. Deployment-Zugang für Claude Code mit dem Assistenten + +Szenario: Claude Code auf `srv-rz-claude-linux` soll eine Anwendung per SSH auf `srv-app01` +ausrollen. **Übersicht → Host-zu-Host-Assistent** (nur Admins): + +1. **Vorlage „Deployment-Zugang“** wählen, Zweck „Claude Code Deployment → srv-app01“. Die Vorlage + setzt Benutzer `deploy`, 90 Tage Laufzeit, `from=""` und den Alias `deploy-`. +2. **Quelle:** `srv-rz-claude-linux`, Benutzer, unter dem Claude Code läuft. +3. **Ziel:** `srv-app01`, Benutzer `deploy` – „Benutzer anlegen“ ist vorgewählt; sudo nach Bedarf + (keine, bestimmte Befehle mit Vorschau der sudoers-Datei, oder voll). Fingerprint von B bestätigen. + +![Assistent – Ziel](bilder/assistent-ziel-desktop.png) + +4. **Prüfen** und „Vorbereiten“: sshkv erzeugt den Schlüssel, legt `deploy` an (frische Bestätigung + nötig – sie öffnet sich in einem neuen Tab, Ihre Eingaben bleiben stehen) und legt die + Zuweisungen an. +5. **Ausrollen:** Dry-Run, anwenden, „Verbindung von A nach B testen“. + +![Assistent – fertig](bilder/assistent-fertig-desktop.png) + +Claude Code kann danach einfach `ssh deploy-srv-app01` benutzen. Wählen Sie `root` als Ziel, warnt +der Assistent: Ein KI-Agent hätte dann uneingeschränkten Zugriff. + +**Nach Projektende** (oder Ablaufdatum): auf der Schlüsselseite **Widerrufen** – entfernt Public und +Private Key, config- und known_hosts-Eintrag und auf Wunsch den Benutzer `deploy` samt sudoers-Datei. + +## 6. Abgleich, Drift und Übernahme + +Nachts um 02:30 (einstellbar) und auf Knopfdruck vergleicht sshkv jeden Host mit dem Soll. Gefunden +werden fehlende Schlüssel, von Hand geänderte Blöcke, **unbekannte Schlüssel** außerhalb des Blocks, +falsche Rechte von `~/.ssh` und veränderte Private Keys. Der Abgleich ändert auf dem Host nichts. + +![Abgleich](bilder/abgleich-desktop.png) + +Ein unbekannter Schlüssel lässt sich **übernehmen**: nur als bekannt aufnehmen (der Befund +verschwindet) oder „in den verwalteten Block holen“ – dann wandert die Zeile beim nächsten Ausrollen +(mit Dry-Run) in den Block. + +## 7. Rotation und Widerruf + +**Rotieren** (Schlüsselseite): Ein neuer Schlüssel kommt an alle Stellen des alten, der Alias in +`~/.ssh/config` zeigt auf die neue Datei, die Verbindungen werden getestet – **erst dann** wird der +alte entfernt. Die Rotation lässt sich jederzeit fortsetzen. + +![Rotation](bilder/rotation-desktop.png) + +**Widerrufen** (Schlüsselseite, Gefahr-Knopf): Der Dialog zeigt, was entfernt wird; danach Dry-Run +und anwenden. Ist ein Host nicht erreichbar, bleibt der Schlüssel als „Widerruf unvollständig“ +markiert, bis auch dort alles entfernt ist. **Massenwiderruf:** in der Schlüsselliste mehrere +auswählen (nur Admin). + +![Widerruf](bilder/widerruf-desktop.png) + +## 8. Personen, Self-Service und Zuweisungsmatrix + +- **Personen:** Admins, Mitarbeiter, Dienstleister mit ihren Schlüsseln. Private Keys von Personen + speichert sshkv nie. +- **Meine Schlüssel:** Jede angemeldete Person kann eigene Public Keys hinterlegen. Wirksam werden + sie erst, wenn ein Operator oder Admin sie einem Host zuweist. + +![Meine Schlüssel](bilder/meine-schluessel-desktop.png) + +- **Zuweisungen:** Matrix Schlüssel × Host mit Filtern; ein Klick auf eine Zelle zeigt die + Zuweisungen und erlaubt neue. Export als CSV. + +![Zuweisungen](bilder/zuweisungen-desktop.png) + +## 9. Vorlagen + +Vorlagen legen Schlüsseltyp, Laufzeit, Zielbenutzer (samt Anlegen und sudo), `authorized_keys`- +Optionen und das Alias-Muster vor. Platzhalter: `{{quellHost}}`, `{{quellHostIp}}`, `{{zielHost}}`, +`{{zielHostIp}}`. Eingebaut: „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“. + +![Vorlage bearbeiten](bilder/vorlage-bearbeiten-desktop.png) + +## 10. Private Key anzeigen oder löschen + +Nur Admins, nur nach erneuter Bestätigung: „anzeigen (Re-Auth)“ in der Info-Leiste eines Schlüssels. +Der Key wird genau einmal ausgeliefert und nirgends zwischengespeichert; jede Anzeige steht im +Protokoll. **Private Key löschen** entfernt ihn endgültig aus sshkv – auf den Hosts bleibt er; neu +ausrollen geht danach nicht mehr. diff --git a/docs/anleitung/bilder/host-eingerichtet-desktop.png b/docs/anleitung/bilder/host-eingerichtet-desktop.png index d747bb5..c561c89 100644 Binary files a/docs/anleitung/bilder/host-eingerichtet-desktop.png and b/docs/anleitung/bilder/host-eingerichtet-desktop.png differ diff --git a/docs/anleitung/bilder/hostkey-bestaetigen-desktop.png b/docs/anleitung/bilder/hostkey-bestaetigen-desktop.png new file mode 100644 index 0000000..a86bc8b Binary files /dev/null and b/docs/anleitung/bilder/hostkey-bestaetigen-desktop.png differ diff --git a/docs/betrieb/installation.md b/docs/betrieb/installation.md new file mode 100644 index 0000000..23baa7b --- /dev/null +++ b/docs/betrieb/installation.md @@ -0,0 +1,112 @@ +# Installation und Betrieb + +SSHKeyVault läuft als Docker-Compose-Stapel aus drei Diensten plus einem einmaligen Migrationslauf: + +| Dienst | Aufgabe | Netz | Geheimnisse | +|---|---|---|---| +| `db` | PostgreSQL 17 | nur intern (`internal: true`) | Datenbankpasswort | +| `migrate` | Schema einspielen, Notfall-Admin und Vorlagen anlegen (läuft bei jedem Start einmal) | intern | Notfall-Passwort beim ersten Start | +| `web` | Weboberfläche (Port 3400) | intern + nach außen zum OIDC-Anbieter | `SSHKV_APP_SECRET`, OIDC-Client-Secret – **kein Master-Key** | +| `worker` | SSH zu den Hosts, Ver-/Entschlüsselung, Zeitpläne | intern + zu den Hosts | Master-Key (Docker-Secret), `SSHKV_APP_SECRET` | + +## Voraussetzungen + +- Linux-Host mit Docker und Docker Compose v2, **im internen Netz bzw. nur per VPN erreichbar**. +- Der Worker muss die zu verwaltenden Hosts per SSH erreichen (Port 22 oder den jeweiligen Port). +- authentik (oder ein anderer OIDC-Anbieter) für die Anmeldung. +- Ein Reverse Proxy mit TLS vor der Weboberfläche. + +## Schritt für Schritt + +```bash +sudo mkdir -p /opt/sshkv && cd /opt/sshkv +git clone . # oder Release entpacken +cp .env.example .env && chmod 600 .env +``` + +In `.env` eintragen (Kommentare in der Datei erklären jede Variable): + +- `POSTGRES_PASSWORD` – `openssl rand -hex 24` +- `SSHKV_APP_SECRET` – `openssl rand -base64 48` +- `SSHKV_APP_URL` – die Adresse, unter der Benutzer sshkv aufrufen, z. B. `https://sshkv.intern.example.de` +- `SSHKV_BREAKGLASS_PASSWORD` – mindestens 16 Zeichen; Notfall-Admin `notfall` +- `SSHKV_OIDC_*` – siehe unten + +Master-Key erzeugen und nur für den Worker lesbar machen: + +```bash +sudo mkdir -p geheim +sudo sh -c 'head -c 32 /dev/urandom > geheim/master.key' +sudo chown 1001:1001 geheim/master.key && sudo chmod 400 geheim/master.key +``` + +Starten: + +```bash +docker compose up -d --build +docker compose logs -f worker # „sshkv-worker … bereit“ +``` + +Beim ersten Start legt `migrate` das Schema, den Notfall-Admin und die eingebauten Vorlagen an; der +Worker erzeugt den **Deployment-Key** der Instanz. Danach sofort den Master-Key sichern +([sicherung.md](sicherung.md)). + +## authentik einrichten + +1. **Provider** „OAuth2/OpenID Provider“ anlegen: Client-Typ *confidential*, Redirect-URI + `https://sshkv.intern.example.de/api/auth/oidc/rueckruf`, Scopes `openid profile email` und ein + Scope-Mapping, das die Gruppen als Claim `groups` liefert (in authentik Standard). +2. **Application** „SSHKeyVault“ mit diesem Provider anlegen. +3. **Gruppen** `sshkv-admins`, `sshkv-operators`, `sshkv-readers` anlegen und Benutzer zuordnen + (Namen änderbar über `SSHKV_OIDC_GROUP_*`). Wer in keiner Gruppe ist, kommt nicht hinein. +4. In `.env`: `SSHKV_OIDC_ISSUER=https://authentik.example.de/application/o/sshkv/`, + `SSHKV_OIDC_CLIENT_ID`, `SSHKV_OIDC_CLIENT_SECRET`, `SSHKV_OIDC_NAME=authentik`. + +Erneute Bestätigung vor kritischen Aktionen schickt `prompt=login` und `max_age=0`; sshkv prüft +`auth_time` im ID-Token. authentik liefert `auth_time` standardmäßig. + +| Rolle | darf | +|---|---| +| Leser | alles ansehen, eigene Public Keys hinterlegen | +| Operator | zusätzlich Hosts verwalten, Host-Keys bestätigen, Schlüssel erzeugen, Public Keys ausrollen, abgleichen | +| Admin | zusätzlich Private Keys ausrollen/anzeigen/löschen, Zielkonten mit sudo, Massenwiderruf, Einstellungen, Vorlagen | + +## Reverse Proxy (Beispiel Caddy) + +```caddyfile +sshkv.intern.example.de { + tls internal # oder das interne Zertifikat + @extern not remote_ip 10.0.0.0/8 192.168.0.0/16 + respond @extern 403 + reverse_proxy 127.0.0.1:3400 +} +``` + +Wichtig: `SSHKV_APP_URL` muss die Adresse mit `https://` sein – davon hängen Secure-Cookie und +OIDC-Rückruf ab. + +## Hosts vorbereiten + +- **Debian/Ubuntu** mit `openssh-server`, `sudo`, `runuser` (util-linux), `base64`, `sha256sum`, + `visudo` (bei Dienstbenutzer) – auf Standardinstallationen vorhanden. +- Für den Bootstrap per Passwort einmalig ein Konto mit Passwort-Login (root oder ein Benutzer mit + sudo). Danach kann Passwort-Login aus; sshkv zeigt den sshd-Befund an. + +## Aktualisieren + +```bash +cd /opt/sshkv +sudo bash deploy/sichern.sh +git pull +docker compose up -d --build # migrate spielt neue Migrationen ein +``` + +## Variablen (Auswahl) + +| Variable | Bedeutung | +|---|---| +| `SSHKV_SYNC_CRON` | Zeitplan des Abgleichs (Standard `30 2 * * *`, Europe/Berlin) | +| `SSHKV_MELDUNG_CRON` | tägliche Zusammenfassung der Benachrichtigungen | +| `SSHKV_SYSLOG_HOST`/`_PORT`/`_PROTOKOLL` | Kopie des Protokolls an einen Syslog-Server | +| `SSHKV_WORKER_USER` | Benutzer des Worker-Containers, falls die Master-Key-Datei nicht UID 1001 gehört | +| `SSHKV_BIND` / `SSHKV_PORT` | Bindeadresse/Port der Weboberfläche (Standard `127.0.0.1:3400`) | diff --git a/docs/betrieb/sicherheit.md b/docs/betrieb/sicherheit.md new file mode 100644 index 0000000..d00f7e9 --- /dev/null +++ b/docs/betrieb/sicherheit.md @@ -0,0 +1,84 @@ +# Sicherheit und Bedrohungsanalyse + +SSHKeyVault (`sshkv`) verwaltet die SSH-Zugänge zu Servern – und hat dafür selbst +**root-gleichen Zugriff auf alle verwalteten Hosts**. Wer sshkv übernimmt, übernimmt die +Infrastruktur. Diese Seite beschreibt, wie sshkv aufgebaut ist, um das zu erschweren, was +trotzdem passieren kann und was im Ernstfall zu tun ist. + +## Was sshkv besitzt + +| Wert | Wo | Schutz | +|---|---|---| +| **Deployment-Key** (Private Key) | Datenbank, verschlüsselt | Envelope Encryption mit Master-Key; nur der Worker entschlüsselt | +| **Private Keys** von Service-Schlüsseln | Datenbank, verschlüsselt | wie oben; Anzeige nur für Admins mit frischer Bestätigung, protokolliert | +| **Master-Key** | Datei auf dem Docker-Host, nur im Worker eingebunden | Dateirechte 0400, nicht in der Datenbank, nicht im Image, nicht im Repo | +| `SSHKV_APP_SECRET` | `.env` | signiert Sitzungen, verschlüsselt TOTP-Geheimnis, SMTP-Passwort, Webhook-Geheimnis und Job-Transporte | +| Host-Keys der Hosts | Datenbank | öffentlich; schützen vor Man-in-the-Middle (strikte Prüfung) | +| Protokoll | Datenbank | append-only per Trigger, Hash-Kette, optional Syslog | + +**Personen-Keys** speichert sshkv nur als Public Key – ein Datenbankregel verhindert, dass dort je ein +Private Key steht. Bootstrap-Passwörter werden nie gespeichert. + +## Aufbau, der Angriffe erschwert + +- **Web und Worker getrennt.** Der Webprozess kennt den Master-Key nicht und spricht kein SSH. Eine + Lücke in der Weboberfläche allein entschlüsselt keine Private Keys; ein Test hält strukturell fest, + dass der Webprozess nichts aus `src/worker` importiert und nur eine einzige Datei die + verschlüsselten Felder liest. +- **Envelope Encryption** (AES-256-GCM): je Private Key ein eigener Datenschlüssel, gebunden an ID und + Fingerprint des Schlüssels. Ein Datenbank-Dump allein gibt keinen Schlüssel preis (Test). +- **Strikte Host-Key-Prüfung** bei jeder Verbindung. Ein geänderter Host-Key bricht die Verbindung vor + dem Senden von Zugangsdaten ab; Ersetzen nur mit Häkchen und frischer Bestätigung. +- **Dateien auf den Hosts** schreibt sshkv als jeweiliger Zielbenutzer (`runuser`): Ein Symlink im + Home eines Benutzers kann root nicht dazu bringen, `/etc/shadow` zu lesen oder zu überschreiben + (Integrationstest). +- **Dry-Run mit optimistischer Sperre:** Ausgerollt wird nur, was ein Mensch gesehen hat. Hat sich die + Datei oder die Zuweisung seitdem geändert, bricht der Vorgang ab. +- **Rollen:** Private Keys anzeigen, ausrollen und löschen, Deployment-Key, Zielkonten mit sudo und + Massenwiderruf nur für Admins; kritische Aktionen zusätzlich mit erneuter Anmeldung (5 Minuten). + Ohne passende authentik-Gruppe gibt es keinen Zugang, auch keinen lesenden. +- **Notfall-Admin** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten. +- **Weboberfläche:** Content-Security-Policy mit Nonce, `X-Frame-Options: DENY`, `Referrer-Policy: + no-referrer`, HttpOnly-/SameSite-Cookies, Secure hinter HTTPS; Server Actions mit Origin-Prüfung. + +## Was passiert, wenn sshkv kompromittiert wird? + +| Angreifer hat … | Folge | Gegenmaßnahme | +|---|---|---| +| nur ein **Datenbank-Backup** | Hosts, Zuweisungen, Public Keys, Protokoll sichtbar; **keine** Private Keys | Backup trotzdem vertraulich behandeln (verrät die Struktur der Infrastruktur) | +| **Datenbank + Master-Key** | alle Private Keys inkl. Deployment-Key → root auf allen Hosts | Master-Key getrennt aufbewahren; Notfallplan unten | +| die **Weboberfläche** als Admin (gestohlene Sitzung) | kann Schlüssel zuweisen und ausrollen; Private Keys nur nach erneuter Anmeldung | kurze Sitzungen (8 h), Re-Auth, Protokoll + Syslog zeigen jede Aktion | +| den **Webprozess** (Codeausführung) | Datenbankzugriff, aber kein Master-Key | Worker getrennt; Master-Key nur im Worker-Container | +| den **Worker** oder Docker-Host | alles | Host härten, nur internes Netz, Updates, Zugriff auf den Docker-Host streng begrenzen | +| einen **verwalteten Host** | kann sshkv nicht angreifen; sieht nur den Public Key des Deployment-Keys | — | + +### Notfallplan „sshkv kompromittiert“ + +1. **Sofort:** Netzwerkzugang zu sshkv sperren (Firewall/VPN), Container anhalten + (`docker compose stop`). Die Hosts bleiben erreichbar – sshkv ist kein Teil des Logins. +2. **Deployment-Key auf allen Hosts entfernen:** Auf jedem Host in `~sshkv/.ssh/authorized_keys` + (bzw. `/root/.ssh/authorized_keys` bei Anmeldeart root) die Zeile mit `sshkv-deployment@…` löschen + und `/etc/sudoers.d/sshkv` entfernen. Mit einem Konfigurationsmanagement oder einer Schleife über + die Hostliste (CSV-Export der Zuweisungen bzw. Hostliste). +3. **Ausgerollte Service-Keys tauschen:** Jeder Private Key, den sshkv kannte, gilt als bekannt. + Nach dem Wiederaufbau alle Service-Schlüssel rotieren (geführte Rotation) und alte widerrufen. +4. **Neu aufsetzen:** neuer Master-Key, neues `SSHKV_APP_SECRET`, frische Datenbank oder geprüftes + Backup; der Worker erzeugt einen neuen Deployment-Key; Hosts per Bootstrap neu aufnehmen. +5. **Protokoll auswerten:** Syslog-Kopie (unveränderlich außerhalb von sshkv) mit dem Protokoll + vergleichen; `protokoll_kette_pruefen()` zeigt manipulierte Zeilen. + +> Eine eingebaute **Rotation des Deployment-Keys** gibt es noch nicht (siehe `docs/issues`). Bis dahin +> ist Schritt 2 Handarbeit. + +## Betriebsregeln + +- **Nur intern betreiben.** Die Weboberfläche gehört ins interne Netz oder hinter ein VPN, nie ins + Internet. Compose bindet standardmäßig nur an `127.0.0.1`; ein Reverse Proxy mit TLS steht davor + (siehe [installation.md](installation.md)). +- **Docker-Host wie einen Bastion-Host behandeln:** wenige Admins, SSH nur mit Schlüssel, Updates, + keine anderen Dienste. +- **Master-Key** getrennt sichern, Rechte 0400, nur für den Worker-Benutzer lesbar. +- **Syslog** einschalten (`SSHKV_SYSLOG_*`), damit eine Kopie des Protokolls außerhalb von sshkv liegt. +- **Hosts:** `PermitRootLogin prohibit-password` (sshkv zeigt das als Hinweis an), Anmeldeart + „Dienstbenutzer“ bevorzugen – dann kann root-Login ganz aus bleiben. +- **Benachrichtigungen** für Drift und geänderte Host-Keys aktivieren, damit Abweichungen auffallen. diff --git a/docs/betrieb/sicherung.md b/docs/betrieb/sicherung.md new file mode 100644 index 0000000..89a6be0 --- /dev/null +++ b/docs/betrieb/sicherung.md @@ -0,0 +1,92 @@ +# Sicherung und Wiederherstellung + +Eine Sicherung von SSHKeyVault besteht aus **zwei Teilen, die getrennt aufbewahrt werden**: + +| Teil | Inhalt | Wie oft | Wohin | +|---|---|---|---| +| **Datenbank** | Hosts, Schlüssel (Private Keys nur verschlüsselt), Zuweisungen, Protokoll, Einstellungen | täglich | normales Backup-Ziel (`/var/backups/sshkv`, von dort auf das Sicherungssystem) | +| **Master-Key** | 32 Byte, entschlüsselt alle Private Keys | einmalig nach der Installation und nach jeder Master-Key-Rotation | **getrennt:** Passwortmanager, Tresor, verschlüsselter Datenträger | +| `.env` | `SSHKV_APP_SECRET`, OIDC-Zugang, Datenbankpasswort | nach jeder Änderung | wie der Master-Key | + +> **Ohne Master-Key ist jedes Datenbank-Backup wertlos** – die Private Keys lassen sich nicht mehr +> entschlüsseln. **Mit Master-Key neben dem Backup** gibt ein gestohlenes Backup alle Private Keys +> preis. Deshalb niemals beides am selben Ort. +> +> Ohne das richtige `SSHKV_APP_SECRET` gehen TOTP des Notfall-Admins, SMTP-Passwort und +> Webhook-Geheimnis verloren (neu einrichten genügt); Private Keys hängen nur am Master-Key. + +## Sichern + +```bash +cd /opt/sshkv # Verzeichnis mit docker-compose.yml und .env +sudo bash deploy/sichern.sh # nach /var/backups/sshkv +sudo bash deploy/sichern.sh /mnt/sicherung/sshkv +``` + +Das Skript + +- schreibt `datenbank-.sql.gz` (`pg_dump --clean --if-exists`, nur das Schema `public` – die + Warteschlange legt der Worker selbst neu an), +- legt `master-key-pruefwert-.txt` daneben: den SHA-256 der Master-Key-Datei. Er verrät nichts + über den Schlüssel, zeigt beim Zurückspielen aber, ob der richtige bereitliegt, +- behält die letzten 14 Sicherungen (`SSHKV_SICHERUNGEN_BEHALTEN`), +- sichert den Master-Key **nicht** mit. + +Als täglicher Cronjob auf dem Docker-Host: + +```cron +15 3 * * * root cd /opt/sshkv && bash deploy/sichern.sh >/var/log/sshkv-sicherung.log 2>&1 +``` + +## Master-Key sichern (einmalig) + +```bash +sudo base64 /opt/sshkv/geheim/master.key # Ausgabe in den Passwortmanager, Eintrag „sshkv Master-Key“ +sudo sha256sum /opt/sshkv/geheim/master.key # Prüfwert dazuschreiben +``` + +Zurück in eine Datei: `echo '' | base64 -d > geheim/master.key && chmod 400 geheim/master.key` +und den Eigentümer setzen (Container-Benutzer 1001 oder `SSHKV_WORKER_USER`). + +## Wiederherstellen + +```bash +cd /opt/sshkv +sudo bash deploy/wiederherstellen.sh /var/backups/sshkv # jüngste Sicherung +sudo bash deploy/wiederherstellen.sh /var/backups/sshkv 20261012-163958 +``` + +Das Skript + +1. prüft den Master-Key unter `SSHKV_MASTER_KEY_HOST_FILE` gegen den Prüfwert der Sicherung und + **bricht ab, wenn er nicht passt**, +2. fragt nach (`--ja` überspringt), +3. hält Weboberfläche und Worker an, +4. spielt die Datenbank **in einer Transaktion** zurück – scheitert etwas, bleibt der alte Stand + vollständig erhalten, +5. setzt die Warteschlange zurück und bricht offene Vorgänge ab (ihre Jobs gehören nicht zur + Sicherung), +6. startet die Dienste und prüft, dass der Worker den Master-Key akzeptiert. + +Zusätzlich prüft der **Worker bei jedem Start**, ob er den Deployment-Key mit dem eingebundenen +Master-Key entschlüsseln kann, und startet sonst nicht („Der Master-Key passt nicht zur Datenbank“). + +### Geprobt am 12.10.2026 + +Auf einem Wegwerf-Stapel (gleiches `docker-compose.yml`): + +1. Markierung angelegt (Host `srv-probe`, Protokolleintrag), gesichert. +2. Markierung gelöscht, weiterer Protokolleintrag geschrieben. +3. Zurückgespielt → Host wieder da, Protokoll wieder auf dem gesicherten Stand, Hash-Kette + unversehrt (`protokoll_kette_pruefen()`), Weboberfläche gesund, Worker bereit. +4. Mit **falschem Master-Key** zurückgespielt → Abbruch vor jeder Änderung. + +Dabei fielen zwei Fehler auf, die jetzt behoben sind: Das Schema der Warteschlange ließ sich mit +`--clean` nicht zurückspielen, und ein Abbruch ohne Transaktion hatte es halb gelöscht. + +## Nach einer Wiederherstellung + +- **„Jetzt abgleichen“** auf der Übersicht: Der Stand der Hosts kann neuer sein als die Sicherung. +- Zuweisungen, die nach der Sicherung ausgerollt wurden, kennt sshkv nicht mehr – der Abgleich zeigt + sie als unbekannte Schlüssel; übernehmen oder entfernen. +- Wurde nach der Sicherung ein Schlüssel widerrufen, steht er wieder als aktiv da: erneut widerrufen. diff --git a/src/worker/geheimnisse.ts b/src/worker/geheimnisse.ts index cb875e6..b932a87 100644 --- a/src/worker/geheimnisse.ts +++ b/src/worker/geheimnisse.ts @@ -219,3 +219,21 @@ export async function deploymentKeySicherstellen(): Promise { }); return neu.id; } + +/** + * Passt der Master-Key zur Datenbank? (#029) Oeffnet probeweise den Deployment-Key. Schlaegt + * das fehl, ist entweder die falsche Master-Key-Datei eingebunden oder eine Sicherung ohne den + * dazugehoerigen Master-Key zurueckgespielt worden – beides soll beim Start auffallen, nicht + * erst beim ersten Ausrollen. + */ +export async function masterKeyPruefen(deploymentKeyId: string): Promise { + try { + const klartext = await privateKeyLaden(deploymentKeyId); + klartext.fill(0); + } catch { + throw new Error( + "Der Master-Key passt nicht zur Datenbank: Der Deployment-Key lässt sich nicht entschlüsseln. " + + "Ist SSHKV_MASTER_KEY_FILE die Datei, die zu dieser Datenbank gehört (Sicherung)?", + ); + } +} diff --git a/src/worker/index.ts b/src/worker/index.ts index 3cf7d37..b99af1e 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -10,7 +10,7 @@ import { } from "@/server/vorgaenge/warteschlange"; import { vorgangAusfuehren } from "@/worker/ausfuehren"; import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey"; -import { deploymentKeySicherstellen } from "@/worker/geheimnisse"; +import { deploymentKeySicherstellen, masterKeyPruefen } from "@/worker/geheimnisse"; import { HANDLER } from "@/worker/handler"; /** @@ -20,7 +20,7 @@ import { HANDLER } from "@/worker/handler"; async function starten(): Promise { // Zuerst: ohne lesbaren Master-Key und Deployment-Key kann der Worker nichts tun, und das // soll beim Start auffallen, nicht beim ersten Vorgang. - await deploymentKeySicherstellen(); + await masterKeyPruefen(await deploymentKeySicherstellen()); const boss = await warteschlangeHolen(); await boss.work( diff --git a/tests/e2e/host-einrichten.spec.ts b/tests/e2e/host-einrichten.spec.ts index 5a28ac9..b86c2f7 100644 --- a/tests/e2e/host-einrichten.spec.ts +++ b/tests/e2e/host-einrichten.spec.ts @@ -48,6 +48,7 @@ test("neuen Host einrichten", async ({ page }) => { await expect(page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" })).toBeVisible({ timeout: 30_000, }); + await page.screenshot({ path: "docs/anleitung/bilder/hostkey-bestaetigen-desktop.png", fullPage: true }); await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click(); await expect(page.getByText("Einmalig per Passwort")).toBeVisible({ timeout: 15_000 }); await expect(page.getByLabel("Bootstrap-Befehl", { exact: true })).toContainText("SSHKV_BOOTSTRAP"); diff --git a/tests/geheimnisse-worker.test.ts b/tests/geheimnisse-worker.test.ts index a5f5ac1..61bab75 100644 --- a/tests/geheimnisse-worker.test.ts +++ b/tests/geheimnisse-worker.test.ts @@ -73,3 +73,22 @@ describe("Deployment-Key", () => { expect(dump).not.toContain(kern.slice(0, 40)); }); }); + +describe("Master-Key-Pruefung beim Start (#029)", () => { + it("meldet einen falschen Master-Key verstaendlich", async () => { + const { masterKeyPruefen } = await import("@/worker/geheimnisse"); + const id = await deploymentKeySicherstellen(); + await expect(masterKeyPruefen(id)).resolves.toBeUndefined(); + const falsch = path.join(verzeichnis, "falsch.key"); + writeFileSync(falsch, randomBytes(32), { mode: 0o600 }); + const richtig = process.env["SSHKV_MASTER_KEY_FILE"]; + process.env["SSHKV_MASTER_KEY_FILE"] = falsch; + masterKeyVergessen(); + try { + await expect(masterKeyPruefen(id)).rejects.toThrow(/passt nicht zur Datenbank/); + } finally { + process.env["SSHKV_MASTER_KEY_FILE"] = richtig; + masterKeyVergessen(); + } + }); +});