# 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 --`), damit Symlink-Tricks im Home eines Benutzers keine Root-Schreibrechte erlangen. 3. Backup `authorized_keys.sshkv.bak.` (Aufbewahrung: letzte N, konfigurierbar). 4. Schreiben in `…/.sshkv.tmp.` + `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: ` 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. ```prisma 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: 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_ 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=""` 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.