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>
19 KiB
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):
- Ziel-Benutzer prüfen (
getent passwd), Home ermitteln,AuthorizedKeysFilepersshd -Tauslesen (abweichende Pfade werden erkannt). - Dateioperationen in fremden Home-Verzeichnissen als Zielbenutzer (
runuser -u <user> --), damit Symlink-Tricks im Home eines Benutzers keine Root-Schreibrechte erlangen. - Backup
authorized_keys.sshkv.bak.<YYYYMMDD-HHMMSS>(Aufbewahrung: letzte N, konfigurierbar). - Schreiben in
…/.sshkv.tmp.<rand>+chmod 0600+mv(atomar),~/.ssh0700. - Rückgabe von Vorher-Hash und Nachher-Inhalt → Audit-Diff.
- 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/dekEncliegen technisch inSshKey, werden aber nur über ein einziges Modul (lib/secrets) im Worker gelesen; Prisma-Queries nutzen sonst festeselect-Listen ohne diese Felder (per Lint-Regel/Test abgesichert).- Der Deployment-Key ist ein
SshKeymitcategory = 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.masterKeyVersionerlaubt Master-Key-Rotation (CLI-Kommandosshkv rewrap). - Re-Auth für kritische Aktionen: OIDC mit
prompt=login+max_age=0, Prüfung vonauth_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)
- Anmeldung auf den Hosts nur per Schlüssel. Standard: Dienstbenutzer
sshkvmit/etc/sudoers.d/sshkv(NOPASSWD);rootdirekt ist pro Host wählbar. - Gitea-Zugang folgt; bis dahin liegen die Issues als Dateien in
docs/issues/(#030). - Private Keys ausrollen: nur Admin.
- Self-Service: Jeder angemeldete Benutzer darf eigene Public Keys hinterlegen; wirksam erst nach Zuweisung durch Operator/Admin.
- Notfall-Admin: Passwort + verpflichtendes TOTP.
- Passphrasen werden nie gespeichert; beim Ausrollen eines passphrasengeschützten Keys erscheint eine Warnung.
- authentik vorerst über einen Mock-OIDC-Anbieter; echte Anbindung später.