Files
sshkeyvault/docs/PLAN.md
T
florian.wildeandClaude Opus 5.5 cd83542482 docs: Projektplan, Issues und Konventionen anlegen
Plan nach Freigabe vom 12.10.2026 mit den Antworten auf die sieben
Rueckfragen. Issues fuer alle fuenf Phasen als Dateien, bis der
Gitea-Zugang da ist (#030).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:02:42 +02:00

19 KiB
Raw Permalink Blame History

SSHKeyVault (sshkv) – Planung

Stand: 12.10.2026 · Status: freigegeben (12.10.2026)


1. Architektur

                 internes Netz / VPN
                        │ HTTPS (Reverse Proxy, z. B. Traefik/Caddy – nicht Teil von sshkv)
┌───────────────────────▼────────────────────────┐
│  sshkv-web  (Next.js 16, App Router, Node 24)   │
│  - UI (Server Components, Tailwind v4)          │
│  - OIDC authentik + Notfall-Admin      │
│  - Server Actions / Route Handlers              │
│  - schreibt Jobs in pg-boss, liest Ergebnisse   │
│  - KEINE SSH-Verbindungen, KEIN Master-Key*     │
└───────────────┬────────────────────────────────┘
                │ PostgreSQL 16 (Prisma + pg-boss-Schema)
┌───────────────▼────────────────────────────────┐
│  sshkv-worker  (gleiches Image, anderer Start)  │
│  - pg-boss-Consumer                             │
│  - Master-Key (Datei, read-only gemountet)      │
│  - ssh2: Verbindungen zu Hosts, strikt geprüft  │
│  - Schlüsselerzeugung, Ver-/Entschlüsselung     │
│  - Scheduler: Abgleich, Ablaufwarnungen         │
└───────────────┬────────────────────────────────┘
                │ SSH (nur Deployment-Key)
         ┌──────▼──────┐  ┌─────────────┐
         │ srv-pve01   │  │ srv-app01 … │
         └─────────────┘  └─────────────┘

* Ausnahme „Private Key anzeigen/exportieren“: Der Web-Prozess fordert die Entschlüsselung als Job an und reicht das Ergebnis genau einmal an den Browser durch (kein Caching, kein Logging). Alternative: Master-Key auch im Web-Container – bewusst nicht vorgesehen, damit eine Lücke im Webprozess allein nicht reicht, um alle Private Keys zu entschlüsseln.

Begründung Trennung Web/Worker: Lange SSH-Vorgänge laufen nie im Request; nur der Worker hält Master-Key und Deployment-Key. Der Web-Container hat keinen Netzwerkpfad zu den Hosts nötig (per Docker-Netz trennbar).

Bausteine

Bereich Wahl Bemerkung
Framework Next.js 16 (App Router), TypeScript strict output: "standalone" für schlankes Image
DB PostgreSQL 17 + Prisma 7 (adapter-pg) Migrationen versioniert im Repo
Jobs pg-boss 10 kein Redis nötig; Cron-Schedules eingebaut
Auth openid-client (OIDC, PKCE) + jose (Sitzungs-Cookie); Notfall-Admin mit argon2id + TOTP kein Auth.js: v5 ist dauerhaft Beta (wie in Laufzettel/ProxLedger)
SSH ssh2 hostVerifier gegen gespeicherten Host-Key
Schlüssel Node crypto (ed25519, RSA-4096) + eigener OpenSSH-Serializer kein ssh-keygen mit Temp-Dateien; Tests prüfen Kompatibilität gegen ssh-keygen -y
Krypto at rest AES-256-GCM (Node crypto), Envelope Encryption keine nativen Zusatz-Abhängigkeiten
UI Tailwind v4 mit Design-Tokens als CSS-Variablen, @fontsource keine externen CDNs
Validierung zod an allen Server-Grenzen
Tests Vitest (Unit), Testcontainers + Debian-sshd-Image (Integration), Playwright (E2E + Screenshots)
Betrieb Docker Compose: sshkv-web, sshkv-worker, sshkv-db .env.example mit Präfix SSHKV_

Remote-Operationen

Alle Änderungen auf Hosts laufen über ein kleines POSIX-sh-Skript, das per exec mit Inhalt über stdin gestartet wird (keine Dateien auf dem sshkv-Server, keine Abhängigkeit von SFTP-Rechten):

  1. Ziel-Benutzer prüfen (getent passwd), Home ermitteln, AuthorizedKeysFile per sshd -T auslesen (abweichende Pfade werden erkannt).
  2. Dateioperationen in fremden Home-Verzeichnissen als Zielbenutzer (runuser -u <user> --), damit Symlink-Tricks im Home eines Benutzers keine Root-Schreibrechte erlangen.
  3. Backup authorized_keys.sshkv.bak.<YYYYMMDD-HHMMSS> (Aufbewahrung: letzte N, konfigurierbar).
  4. Schreiben in …/.sshkv.tmp.<rand> + chmod 0600 + mv (atomar), ~/.ssh 0700.
  5. Rückgabe von Vorher-Hash und Nachher-Inhalt → Audit-Diff.
  6. Optimistische Sperre: Der Dry-Run merkt sich den SHA-256 der Datei; hat sich die Datei bis zur Ausführung geändert, bricht der Job ab und fordert einen neuen Dry-Run.

Private Keys werden ausschließlich über stdin derselben SSH-Verbindung übertragen.

authorized_keys – verwalteter Block

# BEGIN SSHKV MANAGED – nicht manuell bearbeiten
restrict,from="10.0.0.12" ssh-ed25519 AAAA… sshkv:k_8f3a backup-srv-backup01
# END SSHKV MANAGED
  • Parser (reine Funktion, umfangreich getestet): Optionen mit Anführungszeichen/Escapes/Kommas, Kommentare, Leerzeilen, CRLF, BOM, doppelte Keys, cert-authority, unbekannte Schlüsseltypen, kaputte Zeilen, fehlendes END-Marker, mehrere Blöcke.
  • Merge: Alles außerhalb des Blocks bleibt bytegenau erhalten (inkl. Zeilenenden). Innerhalb wird der Block deterministisch aus dem Soll-Zustand neu erzeugt.
  • Erkennung eines verwalteten Keys über Fingerprint (nicht über Kommentar); Kommentar sshkv:<id> <name> dient nur der Lesbarkeit.
  • Duplikat außerhalb des Blocks → Hinweis im Dry-Run, keine automatische Löschung.
  • Aussperrschutz: Der Deployment-Key ist in jedem root- bzw. Dienstbenutzer-Block unabänderlich enthalten; Aktionen, die ihn entfernen würden, werden serverseitig verweigert.

2. Datenmodell (Prisma, verkürzt)

Umgesetzt mit deutschen Bezeichnern (Konvention, siehe CLAUDE.md); die Zuordnung steht dort. Die Skizze unten zeigt die Struktur.

enum Role            { ADMIN OPERATOR READER }
enum KeyType         { ED25519 RSA4096 }
enum KeyCategory     { PERSON SERVICE DEPLOYMENT }
enum KeyStatus       { ACTIVE ROTATING REVOKED EXPIRED }
enum HostKeyStatus   { PENDING TRUSTED CHANGED REJECTED }
enum UsageState      { PLANNED DEPLOYED DRIFT REMOVING REMOVED FAILED }

model User {                       // Anmeldekonten der Oberfläche
  id String @id @default(cuid())
  oidcSub String? @unique
  email String? @unique
  name String
  role Role
  isBreakGlass Boolean @default(false)   // lokaler Notfall-Admin
  passwordHash String?                    // nur Notfall-Admin (argon2id)
  totpSecretEnc Bytes?                    // nur Notfall-Admin, verschlüsselt
  lastLoginAt DateTime?
  person Person?
}

model Person {                     // Menschen, denen Personen-Keys gehören
  id String @id @default(cuid())
  name String
  email String?
  organisation String?             // z. B. Dienstleister
  userId String? @unique           // Verknüpfung für Self-Service
  keys SshKey[]
}

model Customer { id String @id @default(cuid()); name String @unique; hosts Host[] }

model Host {
  id String @id @default(cuid())
  name String @unique              // srv-pve01
  address String                   // Hostname/IP
  port Int @default(22)
  description String?
  tags String[]
  customerId String?
  connectUser String @default("root")      // siehe offene Frage 1
  deploymentKeyId String?                  // null = Instanz-Default
  bootstrapState String                    // NEW | BOOTSTRAPPED
  reachable Boolean?
  lastCheckAt DateTime?
  lastSyncAt DateTime?
  driftState String?                       // IN_SYNC | DRIFT | UNKNOWN
  sshdFacts Json?                          // PermitRootLogin, PasswordAuthentication, AuthorizedKeysFile
  groups HostGroupMember[]
  hostKeys HostKey[]
}

model HostGroup {
  id String @id @default(cuid())
  name String @unique
  description String?
  deploymentKeyId String?          // optional Deployment-Key je Gruppe (später)
  members HostGroupMember[]
}
model HostGroupMember { hostId String; groupId String; @@id([hostId, groupId]) }

model HostKey {                    // TOFU
  id String @id @default(cuid())
  hostId String
  algorithm String                 // ssh-ed25519, ecdsa-…, rsa-sha2-512
  publicKey String
  fingerprint String               // SHA256:…
  status HostKeyStatus
  firstSeenAt DateTime
  confirmedById String?
  confirmedAt DateTime?
}

model SshKey {
  id String @id @default(cuid())
  name String                      // Zweck, z. B. „Backup srv-backup01 → srv-pve01“
  slug String @unique              // für Dateinamen: backup-srv-backup01-srv-pve01
  type KeyType
  category KeyCategory
  status KeyStatus
  publicKey String                 // OpenSSH-Zeile ohne Optionen
  fingerprint String @unique       // SHA256
  comment String                   // Default: sshkv:<slug>
  hasPrivateKey Boolean
  privateKeyEnc Bytes?             // Envelope-Ciphertext, nie in Listen-Queries selektiert
  dekEnc Bytes?                    // verschlüsselter Data-Key
  masterKeyVersion Int?
  passphraseProtected Boolean @default(false)
  personId String?
  templateId String?
  tags String[]
  expiresAt DateTime?
  createdAt DateTime @default(now())
  lastUsedAt DateTime?             // aus Verbindungstests/Abgleich
  rotatedFromId String?            // Rotationskette
  publicUsages PublicKeyUsage[]
  privateUsages PrivateKeyUsage[]
}

model PublicKeyUsage {             // Soll: Public Key in authorized_keys
  id String @id @default(cuid())
  keyId String
  hostId String?                   // genau eins von hostId / groupId
  groupId String?
  targetUser String                // root, deploy …
  options String?                  // restrict,from="…",command="…"
  state UsageState
  lastAppliedAt DateTime?
}

model PrivateKeyUsage {            // Soll: Private-Key-Datei auf Host
  id String @id @default(cuid())
  keyId String
  hostId String
  targetUser String
  path String                      // ~/.ssh/id_ed25519_<slug>
  sshConfigBlock Json?             // { alias, hostName, user, port, identitiesOnly }
  knownHostsFor String[]           // Host-IDs, deren Host-Key in known_hosts gepflegt wird
  state UsageState
  onRevoke String @default("DELETE") // DELETE | RENAME
  lastAppliedAt DateTime?
}

model ManagedAccount {             // vom sshkv angelegte Benutzer (deploy) und sudoers-Dateien
  id String @id @default(cuid())
  hostId String
  username String
  createdBySshkv Boolean
  sudoersFile String?              // /etc/sudoers.d/sshkv-deploy
  sudoersContent String?
}

model Template {                   // „Deployment-Zugang“, „Backup-Zugang“, …
  id String @id @default(cuid())
  name String @unique
  builtin Boolean @default(false)
  keyType KeyType
  category KeyCategory
  defaultTtlDays Int?
  targetUser String
  createTargetUser Boolean
  sudoMode String?                 // NONE | COMMANDS | FULL
  sudoCommands String[]
  authorizedKeysOptions String?    // mit Platzhaltern, z. B. from="{{sourceHostIp}}"
  sshConfigAlias String?           // z. B. deploy-{{targetHost}}
}

model Observation {                // Ist-Zustand aus dem letzten Abgleich
  id String @id @default(cuid())
  hostId String
  targetUser String
  file String
  sha256 String
  entries Json                     // geparste Zeilen inkl. inBlock/fingerprint/options
  observedAt DateTime
}

model DriftFinding {
  id String @id @default(cuid())
  hostId String
  targetUser String
  kind String                      // UNKNOWN_KEY | MISSING_KEY | EXTRA_IN_BLOCK | FILE_CHANGED | PERMS | HOSTKEY_CHANGED
  fingerprint String?
  detail Json
  openedAt DateTime
  resolvedAt DateTime?
}

model Operation {                  // fachliche Klammer um pg-boss-Jobs (für UI-Status)
  id String @id @default(cuid())
  kind String                      // DRY_RUN | APPLY | BOOTSTRAP | SYNC | TEST | ROTATE | REVOKE …
  status String                    // QUEUED | RUNNING | AWAITING_CONFIRM | DONE | FAILED
  requestedById String
  plan Json?                       // Dry-Run-Ergebnis inkl. Datei-Hashes
  result Json?
  createdAt DateTime @default(now())
  finishedAt DateTime?
}

model AuditEvent {                 // append-only (DB-Trigger verbietet UPDATE/DELETE)
  id BigInt @id @default(autoincrement())
  at DateTime @default(now())
  actorId String?                  // null = System/Scheduler
  actorName String
  action String                    // key.create, key.private.view, usage.apply, host.hostkey.confirm …
  hostName String?
  targetUser String?
  fingerprint String?
  diff String?                     // Vorher/Nachher (nur Public-Daten)
  result String                    // SUCCESS | FAILURE | DENIED
  detail Json?
  prevHash String                  // Hash-Kette → Manipulation erkennbar
  hash String
}

model Setting { key String @id; value Json }

Hinweise:

  • privateKeyEnc/dekEnc liegen technisch in SshKey, werden aber nur über ein einziges Modul (lib/secrets) im Worker gelesen; Prisma-Queries nutzen sonst feste select-Listen ohne diese Felder (per Lint-Regel/Test abgesichert).
  • Der Deployment-Key ist ein SshKey mit category = DEPLOYMENT.

3. Sicherheitskonzept (Kurzfassung, Langfassung in docs/security.md)

  • Envelope Encryption: pro Private Key ein zufälliger 256-bit-DEK, Key mit AES-256-GCM verschlüsselt (AAD = Key-ID + Fingerprint), DEK mit Master-Key (AES-256-GCM) verschlüsselt. Master-Key: 32 Byte aus Datei SSHKV_MASTER_KEY_FILE (Docker Secret), nur im Worker. masterKeyVersion erlaubt Master-Key-Rotation (CLI-Kommando sshkv rewrap).
  • Re-Auth für kritische Aktionen: OIDC mit prompt=login + max_age=0, Prüfung von auth_time (≤ 5 min); Notfall-Admin: Passwort + TOTP erneut.
  • Web: Sitzungs-Cookie (jose, HS256) (httpOnly, Secure, SameSite=Lax), CSRF: Server Actions (Origin-Prüfung) + Token für Route Handler, Rate-Limit (Postgres-basiert) auf Login/Re-Auth/kritische Aktionen, CSP mit Nonce, HSTS, X-Frame-Options: DENY, Referrer-Policy: no-referrer.
  • Logging: strukturierter Logger mit Redaction (privateKey, password, passphrase, -----BEGIN); Test, der bewusst einen Key durch Fehlerpfade schickt und Logs prüft.
  • Bootstrap-Passwort: nur im Arbeitsspeicher des Workers für genau einen Job, nie in DB/Job-Payload im Klartext → wird vom Web-Prozess mit einem kurzlebigen Job-Schlüssel verschlüsselt, Job-Datensatz wird nach Abschluss gelöscht.
  • Bedrohungsanalyse (Doku): Kompromittierung sshkv = root auf allen Hosts. Gegenmaßnahmen: nur intern/VPN, Worker ohne eingehende Ports, Master-Key getrennt, Audit mit Hash-Kette + Syslog-Export, Notfallplan „Deployment-Key auf allen Hosts tauschen“ (Rotation des Deployment-Keys als eigener Ablauf), optional from="<sshkv-IP>" am Deployment-Key.
  • Backup: pg_dump + Master-Key getrennt sichern; Doku mit Restore-Test.

4. Rollen & Rechte

Aktion Admin Operator Leser
Ansehen (Hosts, Keys, Matrix, Audit) ✓ ✓ ✓
Hosts anlegen/bootstrappen, Host-Key bestätigen ✓ ✓ –
Public Key zuweisen/ausrollen/entfernen ✓ ✓ –
Schlüssel erzeugen/importieren ✓ ✓ –
Private Key ausrollen ✓ – –
Private Key anzeigen/exportieren (Re-Auth) ✓ – –
Deployment-Key ändern/rotieren (Re-Auth) ✓ – –
Massenwiderruf (Re-Auth) ✓ – –
Benutzer anlegen + sudoers (Re-Auth) ✓ – –
Einstellungen, Vorlagen ✓ – –
Eigene Public Keys hinterlegen (Self-Service) ✓ ✓ ✓

Rollenzuordnung über authentik-Gruppen: sshkv-admins, sshkv-operators, sshkv-readers (Namen konfigurierbar per SSHKV_OIDC_GROUP_*).


5. Phasenplan

Versionierung: 0.N.0 am Ende jeder Phase, 1.0.0 nach Phase 5 + Abnahme.

Phase 0 – Planung (jetzt)

Plan, Freigabe, Repo + Gitea-Issues.

Phase 1 – Grundgerüst → v0.1.0

  • Repo, Next.js, Prisma-Schema, Docker Compose (web/worker/db), .env.example, CI-Basis (Lint, Typecheck, Tests).
  • Design-System: Tokens, Schriften, Logo-Dateien + Favicon/PWA-Icons, Layout mit Seitenleiste.
  • Auth: OIDC authentik, Notfall-Admin (argon2id + TOTP), Rollen, Re-Auth-Mechanik.
  • Audit-Grundlage (append-only, Hash-Kette).
  • Master-Key-Handling + lib/secrets.
  • Deployment-Key erzeugen.
  • Hosts/Hostgruppen CRUD, TOFU-Host-Key-Verifizierung, Erreichbarkeitsprüfung.
  • Bootstrap: Einmal-Passwort-Login oder Copy-Paste-Befehl; sshd-Check (PermitRootLogin, PasswordAuthentication).
  • Test-SSH-Container (Debian 12/13) für Integrationstests.

Phase 2 – Schlüssel & Public-Key-Ausrollen → v0.2.0

  • Erzeugen (ed25519, rsa-4096, optional Passphrase), Import (Public/Paar), Export.
  • Personen, Kategorien, Tags, Ablaufdatum.
  • Private Key anzeigen/Download mit Re-Auth + Audit.
  • authorized_keys-Parser/Merger (Unit-Tests mit allen Randfällen).
  • Zuweisungen (Host/Gruppe × Benutzer, Optionen), Dry-Run-Diff, Backup, atomares Schreiben, Aussperrschutz.

Phase 3 – Private-Key-Ausrollen & Host-zu-Host → v0.3.0

  • Private Key auf Host A, ~/.ssh/config-Block (verwaltet, eigener Marker), known_hosts-Pflege.
  • Benutzer anlegen + sudoers (Vorschau, visudo -c).
  • Vorlagen (eingebaut „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in Einstellungen).
  • Host-zu-Host-Assistent inkl. Verbindungstest A → B.
  • Option „Private Key nach Ausrollen löschen“.

Phase 4 – Abgleich, Import, Rotation, Widerruf → v0.4.0

  • Periodischer + manueller Abgleich, Drift-Befunde, Host-Status.
  • Import bestehender authorized_keys (Zuordnung zu Person/Zweck).
  • Geführte Rotation, Ein-Klick-Widerruf (inkl. Benutzer/sudoers entfernen), Massenwiderruf.
  • Zuweisungsmatrix.

Phase 5 – Dashboard & Feinschliff → v0.5.0 / v1.0.0

  • Übersicht laut Design, Audit-Ansicht + CSV-Export, Syslog-Weiterleitung.
  • Benachrichtigungen (SMTP M365 / Webhook) bei Drift und Ablauf.
  • Anleitung mit Screenshots (Installation, Bootstrap, Kern-Anwendungsfall), Bedrohungsanalyse, Backup-Konzept.

Später (eigene Phasen)

  • Heller Modus, SSH-CA (Zertifikate), Deployment-Key je Hostgruppe (Datenmodell ist vorbereitet).

6. Arbeitsweise

  • Gitea: pro Phase ein Milestone, Issues je Arbeitspaket, Commits referenzieren Issues (refs #12), Conventional Commits.
  • devlog.md (Datum, Dauer, Tätigkeit), CHANGELOG.md (Keep a Changelog), SemVer-Tags.
  • Sub-Agenten parallel für Tests, Doku, UI-Komponenten.
  • Nach jeder Phase Screenshots (Playwright) und kurzer Bericht, Freigabe der nächsten Phase.

7. Entscheidungen aus der Freigabe (12.10.2026)

  1. Anmeldung auf den Hosts nur per Schlüssel. Standard: Dienstbenutzer sshkv mit /etc/sudoers.d/sshkv (NOPASSWD); root direkt ist pro Host wählbar.
  2. Gitea-Zugang folgt; bis dahin liegen die Issues als Dateien in docs/issues/ (#030).
  3. Private Keys ausrollen: nur Admin.
  4. Self-Service: Jeder angemeldete Benutzer darf eigene Public Keys hinterlegen; wirksam erst nach Zuweisung durch Operator/Admin.
  5. Notfall-Admin: Passwort + verpflichtendes TOTP.
  6. Passphrasen werden nie gespeichert; beim Ausrollen eines passphrasengeschützten Keys erscheint eine Warnung.
  7. authentik vorerst über einen Mock-OIDC-Anbieter; echte Anbindung später.