Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)

# Conflicts:
#	src/components/vorgang/useVorgang.ts
#	src/worker/handler.ts
This commit is contained in:
2026-10-12 10:54:09 +02:00
76 changed files with 5899 additions and 325 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 152 KiB

+45 -8
View File
@@ -1,23 +1,60 @@
# #012 Schlüssel erzeugen, importieren, exportieren
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** backend, ui
**Geschätzt:** 4 h **Tatsächlich:** –
**Geschätzt:** 4 h **Tatsächlich:** 0 h 25 min
## Ziel
Schlüsselliste und -formular: erzeugen (ed25519, rsa-4096), importieren (Public oder Paar), exportieren; Kategorien, Tags, Ablaufdatum.
## Aufgaben
- [ ] Liste mit Filter
- [ ] Erzeugen-Dialog
- [ ] Import mit Prüfung
- [ ] Export Public Key
- [ ] Schlüsseldetail-Gerüst mit Verwendungen
- [x] Liste mit Filter
- [x] Erzeugen-Dialog
- [x] Import mit Prüfung
- [x] Export Public Key
- [x] Schlüsseldetail-Gerüst mit Verwendungen
## Akzeptanzkriterien
- [ ] Private Keys erscheinen nie in Listen-Antworten
- [x] Private Keys erscheinen nie in Listen-Antworten
## Abhängigkeiten
#006
## Notizen / Entscheidungen
- **Migration `20261012100000_schluessel_vorgaenge`:** Vorgangsarten `SCHLUESSEL_ERZEUGEN`,
`SCHLUESSEL_IMPORTIEREN`, `PRIVATE_KEY_LOESCHEN` (für #014 gleich mit), CHECK
`schluessel_slug_gueltig` (`^[a-z0-9-]{1,64}$`, der Slug landet in Dateinamen) und
`schluessel_geloescht_ohne_private_key`.
- **Erzeugen und Paar-Import laufen im Worker** (`src/worker/ablaeufe/schluessel.ts`). Das Web
prüft nur Metadaten (Slug frei, Person/Vorlage vorhanden, Ablauf nicht in der Vergangenheit)
und reiht den Vorgang ein. Passphrase und importierter Private Key reisen wie das
Bootstrap-Passwort mit `SSHKV_APP_SECRET` verschlüsselt im Job (`geheim`), nie in
`Vorgang.eingabe`; der Worker löscht den Job danach. Der Worker prüft die Metadaten erneut.
- **Kategorie:** Erzeugen und Paar-Import legen immer `DIENST` an. Personen-Keys speichert sshkv
nie mit Private Key (DB-CHECK), Deployment-Keys verwaltet der Worker selbst. Der
Public-Key-Import kennt `PERSON` und `DIENST`.
- **Passphrase:** beim Erzeugen optional; gespeichert wird dann der passphrasengeschützte
OpenSSH-Key (`passphraseGeschuetzt = true`). Beim Import dient sie nur zum Lesen von Public Key
und Fingerprint und wird verworfen; gespeichert wird der Key so, wie er kam. Hinweis in der
Oberfläche: „Für automatische Anmeldungen (Host-zu-Host) ungeeignet.“
- **Typ:** `RSA4096` nur bei genau 4096 Bit, sonst `ANDERER` (z. B. RSA-3072, ecdsa). Die RSA-
Mindestlänge 2048 gilt beim Import.
- **Slug:** aus dem Zweck abgeleitet (Umlaute gefaltet), bei Kollision `-2`, `-3` …; ein
ausdrücklich gewünschter Kurzname muss frei sein. Slug, Typ und Kommentar sind danach nicht
mehr änderbar – sie stehen schon in Dateinamen und authorized_keys-Zeilen.
- **Status von Hand** nur `AKTIV` ↔ `ABGELAUFEN`; `ROTIERT`/`WIDERRUFEN` setzen #023/#024,
Deployment-Keys sind ausgenommen (sonst erzeugte der Worker beim Start einen neuen).
- **Ablaufdatum** ohne Angabe aus `Vorlage.standardLaufzeitTage`.
- **„Private Key gespeichert?“** liest das Web aus `masterKeyVersion` (der CHECK
`schluessel_umschlag_vollstaendig` koppelt sie an das Chiffrat) – das Chiffrat wird im Web
nie selektiert.
- **Grenze abgesichert:** `tests/schluessel-grenze.test.ts` verlangt für jeden zeilenliefernden
`prisma.schluessel.*`-Aufruf außerhalb von `src/worker` ein `select` und verbietet
`schluessel: true` als Relation; `tests/schluessel.test.ts` prüft, dass weder Vorgang,
Protokoll noch pg-boss-Job nach Erzeugen/Import den Key oder die Passphrase enthalten.
- **Platzhalter** für #016/#017: `src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx` und
`PrivateAusrollenAktion.tsx`, Props `{ schluesselId: string; darf: boolean }`, rendern `null`.
- Personen-Keys zeigen im Detail nur die Karte „Public Key ausgerollt auf“ (eine Private-Karte
wäre immer leer).
- Prototyp `/schluessel/beispiel` samt `src/beispiel/schluessel.ts` entfernt; die Übersicht
(noch Prototyp, #026) verlinkt auf `/schluessel`.
+26 -5
View File
@@ -1,20 +1,41 @@
# #013 Personen und Self-Service
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** backend, ui
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
## Ziel
Personen verwalten; jeder angemeldete Benutzer kann eigene Public Keys hinterlegen, wirksam erst nach Zuweisung.
## Aufgaben
- [ ] Personenliste/-detail
- [ ] Seite „Meine Schlüssel“
- [x] Personenliste/-detail
- [x] Seite „Meine Schlüssel“
## Akzeptanzkriterien
- [ ] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
- [x] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
## Abhängigkeiten
#012
## Notizen / Entscheidungen
- **Personen pflegt, wer Schlüssel verwaltet** (Recht `schluesselVerwalten`, Admin und Operator) –
ein eigenes Recht wäre nur ein Alias. Lesen dürfen alle Rollen.
- **Archivieren statt Löschen:** die Schlüssel bleiben der Person zugeordnet (wer hatte bis wann
welchen Key?), die Kontoverknüpfung entfällt, damit der Self-Service nicht bei einer
archivierten Person landet. Die Notiz steht nicht im Protokoll, nur dass sie sich änderte.
- **Self-Service `/meine-schluessel`** (Recht `eigeneSchluesselHinterlegen`, alle Rollen,
Navigation „Meine Schlüssel“): beim ersten Aufruf wird die eigene Person angelegt oder
verknüpft – per `benutzerId`, sonst per E-Mail, aber **nur bei vom Anbieter bestätigter
Adresse** und genau einer freien, nicht archivierten Person. Dafür neue Spalte
`Benutzer.emailBestaetigt` (Migration `20261012103000_benutzer_email_bestaetigt`), gesetzt bei
jeder OIDC-Anmeldung. Ohne sie könnte jemand mit selbst gewählter Adresse die Keys einer fremden
Person sehen.
- Hinterlegte Keys: Kategorie `PERSON`, `selbstHinterlegt = true`, Protokoll
`schluessel.selbst_hinterlegt`. Hinweis in der Oberfläche: „Wirksam erst, wenn ein Operator
oder Admin den Schlüssel einem Host zuweist.“
- **Entfernen** nur eigene, selbst hinterlegte Keys ohne jede Verwendung (auch keine entfernte –
der Verlauf am Key bleibt). Das Löschen ist bedingt (`deleteMany` mit „keine Verwendungen“),
damit eine gleichzeitige Zuweisung nicht per Cascade verschwindet. Fremde IDs gelten als „nicht
vorhanden“. Tests: `tests/personen.test.ts`.
- Die Services nehmen den angemeldeten Benutzer entgegen und leiten die Person selbst ab; die
Actions reichen keine `personId` von außen durch (struktureller Test).
+37 -5
View File
@@ -1,20 +1,52 @@
# #014 Private Key anzeigen und herunterladen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
## Ziel
Nur Admin, nur nach Re-Auth, immer protokolliert; Entschlüsselung im Worker.
## Aufgaben
- [ ] Einmal-Abholung
- [ ] Option „Private Key nach Ausrollen löschen“
- [x] Einmal-Abholung
- [x] Option „Private Key nach Ausrollen löschen“
## Akzeptanzkriterien
- [ ] Kein Caching, kein Logging
- [x] Kein Caching, kein Logging
## Abhängigkeiten
#012, #004
## Notizen / Entscheidungen
- **Ablauf:** Web legt einen Vorgang `PRIVATE_KEY_ABHOLEN` an (Protokoll `privatekey.angefordert`)
→ der Worker öffnet den Umschlag und legt den Key mit `SSHKV_APP_SECRET` verschlüsselt in
`Vorgang.ergebnis` ab (`{ abholung: "v1.…" }`) → das Web holt ihn **genau einmal** ab: ein
einziges `UPDATE … FROM (SELECT … FOR UPDATE) RETURNING` liest das alte Ergebnis und setzt
`{ abgeholt: true }`; fünf gleichzeitige Abholungen ergeben genau eine (Test). Abholen darf
nur, wer angefordert hat. Protokoll `privatekey.angezeigt` / `privatekey.heruntergeladen` mit
Fingerprint, nie mit Key.
- **Die Status-Schnittstelle** `/api/vorgaenge/[id]` zeigt für eine bereitliegende Abholung nur
`{ bereit: true }`, nie das Chiffrat.
- **Verfall nach 2 Minuten** (`ABHOLFRIST_MS` in `src/lib/schluessel.ts`), dreifach abgesichert:
der Worker räumt alle 30 s und vor jeder neuen Abholung auf (`abholungenAufraeumen`, ein
einziges UPDATE → `{ verfallen: true }`), und das Web gibt eine ältere Abholung nicht mehr
heraus (überschreibt sie aber trotzdem). Ein Worker-Takt statt pg-boss-Zeitplan: dessen Raster
ist eine Minute.
- **Anzeige** im Dialog (Monospace, Kopier-Knopf, „Wird nicht erneut angezeigt“); der Key lebt
nur im Zustand des Dialogs und wird beim Schließen verworfen. **Download** als Datei `<slug>`
über `POST /api/private-key/[vorgangId]` (Formular, kein GET – ein Link oder Vorabruf würde die
einmalige Abholung verbrauchen), `Content-Disposition: attachment`,
`Cache-Control: no-store`. Der Route Handler prüft selbst Recht, Bestätigung und Herkunft:
wegen `Referrer-Policy: no-referrer` senden Browser bei Formular-POSTs `Origin: null`,
maßgeblich ist deshalb `Sec-Fetch-Site: same-origin` (oder ein passender Origin).
- **Re-Auth:** Fehlt die frische Bestätigung, leitet die Oberfläche nach
`/bestaetigen?ziel=/schluessel/<id>?privatekey=1` und öffnet danach den Dialog wieder. Der
Mock-OIDC liefert `auth_time` nur, wenn man es in die Claims schreibt – die E2E tut das.
- **Löschen** („Private Key aus SSHKeyVault löschen“): neues Recht `privateKeyLoeschen` (nur
Admin, mit Bestätigung), ausgeführt als Worker-Vorgang `PRIVATE_KEY_LOESCHEN`
(`privateKeyVerwerfen` in `src/worker/geheimnisse.ts`), damit nur diese Datei die
Chiffrat-Felder anfasst – auch zum Leeren. Setzt Chiffrat, DEK und Version auf `null` und
`privatGeloeschtAm`; Protokoll `privatekey.geloescht`. CHECK
`schluessel_geloescht_ohne_private_key` (Migration aus #012).
- **Deployment-Keys** gibt sshkv weder heraus noch löscht es deren Private Key: der Key ist root
auf allen Hosts, und ohne ihn käme der Worker nirgends mehr hin. Im Notfall wird rotiert.