Files
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

390 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```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:<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.