diff --git a/docs/anleitung/bilder/host-detail-desktop.png b/docs/anleitung/bilder/host-detail-desktop.png index b15493b..60b9bf6 100644 Binary files a/docs/anleitung/bilder/host-detail-desktop.png and b/docs/anleitung/bilder/host-detail-desktop.png differ diff --git a/docs/anleitung/bilder/host-detail-mobil.png b/docs/anleitung/bilder/host-detail-mobil.png index 7f4e23c..ddc92b3 100644 Binary files a/docs/anleitung/bilder/host-detail-mobil.png and b/docs/anleitung/bilder/host-detail-mobil.png differ diff --git a/docs/anleitung/bilder/hosts-desktop.png b/docs/anleitung/bilder/hosts-desktop.png index 55217cd..fa096fa 100644 Binary files a/docs/anleitung/bilder/hosts-desktop.png and b/docs/anleitung/bilder/hosts-desktop.png differ diff --git a/docs/anleitung/bilder/meine-schluessel-desktop.png b/docs/anleitung/bilder/meine-schluessel-desktop.png new file mode 100644 index 0000000..b386654 Binary files /dev/null and b/docs/anleitung/bilder/meine-schluessel-desktop.png differ diff --git a/docs/anleitung/bilder/meine-schluessel-mobil.png b/docs/anleitung/bilder/meine-schluessel-mobil.png new file mode 100644 index 0000000..2517e1f Binary files /dev/null and b/docs/anleitung/bilder/meine-schluessel-mobil.png differ diff --git a/docs/anleitung/bilder/personen-desktop.png b/docs/anleitung/bilder/personen-desktop.png new file mode 100644 index 0000000..1bdd0f2 Binary files /dev/null and b/docs/anleitung/bilder/personen-desktop.png differ diff --git a/docs/anleitung/bilder/personen-mobil.png b/docs/anleitung/bilder/personen-mobil.png new file mode 100644 index 0000000..068b7d5 Binary files /dev/null and b/docs/anleitung/bilder/personen-mobil.png differ diff --git a/docs/anleitung/bilder/schluessel-desktop.png b/docs/anleitung/bilder/schluessel-desktop.png new file mode 100644 index 0000000..a9d0a15 Binary files /dev/null and b/docs/anleitung/bilder/schluessel-desktop.png differ diff --git a/docs/anleitung/bilder/schluessel-detail-desktop.png b/docs/anleitung/bilder/schluessel-detail-desktop.png index 38c925b..5e344b2 100644 Binary files a/docs/anleitung/bilder/schluessel-detail-desktop.png and b/docs/anleitung/bilder/schluessel-detail-desktop.png differ diff --git a/docs/anleitung/bilder/schluessel-detail-mobil.png b/docs/anleitung/bilder/schluessel-detail-mobil.png index 719f4ad..2b517f9 100644 Binary files a/docs/anleitung/bilder/schluessel-detail-mobil.png and b/docs/anleitung/bilder/schluessel-detail-mobil.png differ diff --git a/docs/anleitung/bilder/schluessel-mobil.png b/docs/anleitung/bilder/schluessel-mobil.png new file mode 100644 index 0000000..4bf1a7f Binary files /dev/null and b/docs/anleitung/bilder/schluessel-mobil.png differ diff --git a/docs/anleitung/bilder/schluessel-neu-desktop.png b/docs/anleitung/bilder/schluessel-neu-desktop.png new file mode 100644 index 0000000..1c93803 Binary files /dev/null and b/docs/anleitung/bilder/schluessel-neu-desktop.png differ diff --git a/docs/anleitung/bilder/schluessel-neu-mobil.png b/docs/anleitung/bilder/schluessel-neu-mobil.png new file mode 100644 index 0000000..9772b34 Binary files /dev/null and b/docs/anleitung/bilder/schluessel-neu-mobil.png differ diff --git a/docs/anleitung/bilder/uebersicht-desktop.png b/docs/anleitung/bilder/uebersicht-desktop.png index 3a39f31..8562869 100644 Binary files a/docs/anleitung/bilder/uebersicht-desktop.png and b/docs/anleitung/bilder/uebersicht-desktop.png differ diff --git a/docs/issues/012-schluessel-verwalten.md b/docs/issues/012-schluessel-verwalten.md index 08f0082..d8be397 100644 --- a/docs/issues/012-schluessel-verwalten.md +++ b/docs/issues/012-schluessel-verwalten.md @@ -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`. diff --git a/docs/issues/013-personen-selbstauskunft.md b/docs/issues/013-personen-selbstauskunft.md index e814e0e..affbede 100644 --- a/docs/issues/013-personen-selbstauskunft.md +++ b/docs/issues/013-personen-selbstauskunft.md @@ -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). diff --git a/docs/issues/014-private-key-anzeigen.md b/docs/issues/014-private-key-anzeigen.md index f2c6ffa..5dd6f27 100644 --- a/docs/issues/014-private-key-anzeigen.md +++ b/docs/issues/014-private-key-anzeigen.md @@ -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 `` + ü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/?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. diff --git a/prisma/migrations/20261012100000_schluessel_vorgaenge/migration.sql b/prisma/migrations/20261012100000_schluessel_vorgaenge/migration.sql new file mode 100644 index 0000000..8aeee70 --- /dev/null +++ b/prisma/migrations/20261012100000_schluessel_vorgaenge/migration.sql @@ -0,0 +1,19 @@ +-- Schluessel erzeugen, importieren, Private Key loeschen (#012, #014). +-- +-- Neue Vorgangsarten: Erzeugen und Import eines Paars laufen im Worker, weil nur er den +-- Master-Key hat. Das Loeschen eines gespeicherten Private Keys laeuft ebenfalls dort, damit +-- die Grenze „nur src/worker/geheimnisse.ts fasst das Chiffrat an“ einfach bleibt. +ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_ERZEUGEN'; +ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_IMPORTIEREN'; +ALTER TYPE "VorgangsArt" ADD VALUE 'PRIVATE_KEY_LOESCHEN'; + +-- Der Slug landet in Dateinamen (.pub, ~/.ssh/id_) und Kommentaren. Was hier +-- nicht passt, kommt nie bis in einen Pfad oder ein Shell-Skript. +ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_slug_gueltig" CHECK ( + "slug" ~ '^[a-z0-9-]{1,64}$' +); + +-- „Private Key geloescht“ und ein gespeichertes Chiffrat schliessen sich aus (#014). +ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_geloescht_ohne_private_key" CHECK ( + "privatGeloeschtAm" IS NULL OR "privatVerschluesselt" IS NULL +); diff --git a/prisma/migrations/20261012103000_benutzer_email_bestaetigt/migration.sql b/prisma/migrations/20261012103000_benutzer_email_bestaetigt/migration.sql new file mode 100644 index 0000000..aa9ea4e --- /dev/null +++ b/prisma/migrations/20261012103000_benutzer_email_bestaetigt/migration.sql @@ -0,0 +1,4 @@ +-- Self-Service „Meine Schluessel“ (#013): ein Konto wird per E-Mail nur dann mit einer +-- vorhandenen Person verknuepft, wenn der OIDC-Anbieter die Adresse bestaetigt hat. Sonst +-- koennte jemand mit selbst gewaehlter Adresse die Schluessel einer fremden Person sehen. +ALTER TABLE "Benutzer" ADD COLUMN "emailBestaetigt" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36b04b7..6b67b8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -104,6 +104,12 @@ enum VorgangsArt { ROTATION WIDERRUF PRIVATE_KEY_ABHOLEN + /// Schluesselpaar im Worker erzeugen und versiegeln (#012). + SCHLUESSEL_ERZEUGEN + /// Importiertes Paar im Worker pruefen und versiegeln (#012). + SCHLUESSEL_IMPORTIEREN + /// Gespeicherten Private Key endgueltig verwerfen (#014). + PRIVATE_KEY_LOESCHEN } enum VorgangsStatus { @@ -129,6 +135,9 @@ model Benutzer { /// `sub` des OIDC-Anbieters; leer beim Notfall-Admin. oidcSub String? @unique email String? @unique + /// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der + /// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013). + emailBestaetigt Boolean @default(false) /// Anmeldename des Notfall-Admins. anmeldename String? @unique name String diff --git a/prisma/seed.ts b/prisma/seed.ts index 8764e19..b442676 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -124,10 +124,99 @@ async function demoDaten(): Promise { console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`); } +/** + * Demo-Personen und -Schluessel (#012, #013). Nur Public Keys: einen Private Key kann nur + * der Worker versiegeln. Die Paare werden hier frisch erzeugt und der Private Key sofort + * verworfen – es sind Attrappen, die nirgends Zugang geben. + */ +const DEMO_PERSONEN = [ + { name: "Florian Wilde", email: "florian@example.org", organisation: "IT" }, + { name: "Anna Berger", email: "a.berger@netzwerk-gmbh.example", organisation: "Netzwerk GmbH" }, + { name: "Mehmet Yılmaz", email: "mehmet@example.org", organisation: "IT" }, +] as const; + +const TAG_MS = 24 * 60 * 60 * 1000; + +const DEMO_SCHLUESSEL: { + name: string; + slug: string; + kategorie: "PERSON" | "DIENST"; + person?: string; + tags: string[]; + ablaufInTagen?: number; + kommentar: string; +}[] = [ + { + name: "Laptop Florian", + slug: "laptop-florian", + kategorie: "PERSON", + person: "Florian Wilde", + tags: ["laptop"], + kommentar: "florian@laptop", + }, + { + name: "Wartungszugang Netzwerk GmbH", + slug: "wartung-netzwerk-gmbh", + kategorie: "PERSON", + person: "Anna Berger", + tags: ["dienstleister"], + ablaufInTagen: 18, + kommentar: "anna@netzwerk-gmbh", + }, + { + name: "Workstation Mehmet", + slug: "workstation-mehmet", + kategorie: "PERSON", + person: "Mehmet Yılmaz", + tags: [], + ablaufInTagen: 200, + kommentar: "mehmet@ws-07", + }, + { + name: "Monitoring zabbix → alle Hosts", + slug: "monitoring-zabbix", + kategorie: "DIENST", + tags: ["monitoring"], + kommentar: "zabbix@srv-mon01", + }, +]; + +async function demoSchluessel(): Promise { + if (process.env["SSHKV_SEED_DEMO"] !== "1") return; + const { schluesselpaarErzeugen } = await import("../src/lib/ssh/openssh"); + const personen = new Map(); + for (const p of DEMO_PERSONEN) { + const vorhanden = await prisma.person.findFirst({ where: { name: p.name }, select: { id: true } }); + const person = vorhanden ?? (await prisma.person.create({ data: p, select: { id: true } })); + personen.set(p.name, person.id); + } + for (const s of DEMO_SCHLUESSEL) { + if (await prisma.schluessel.findUnique({ where: { slug: s.slug }, select: { id: true } })) continue; + const paar = await schluesselpaarErzeugen("ED25519", { kommentar: s.kommentar }); + await prisma.schluessel.create({ + data: { + name: s.name, + slug: s.slug, + typ: "ED25519", + kategorie: s.kategorie, + publicKey: paar.publicKeyZeile, + fingerprint: paar.fingerprint, + kommentar: s.kommentar, + tags: s.tags, + personId: s.person ? (personen.get(s.person) ?? null) : null, + ablaufAm: s.ablaufInTagen ? new Date(Date.now() + s.ablaufInTagen * TAG_MS) : null, + }, + select: { id: true }, + }); + } + console.log(`Demo-Daten geprüft: ${DEMO_PERSONEN.length} Personen, ${DEMO_SCHLUESSEL.length} Schlüssel.`); +} + try { await notfallAdmin(); await vorlagen(); await demoDaten(); + await demoSchluessel(); } finally { await prisma.$disconnect(); } diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index 1b806cd..e7edef6 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -27,7 +27,16 @@ const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [ { name: "anmelden", pfad: "/anmelden", oeffentlich: true }, { name: "uebersicht", pfad: "/" }, - { name: "schluessel-detail", pfad: "/schluessel/beispiel" }, + { name: "schluessel", pfad: "/schluessel" }, + { name: "schluessel-neu", pfad: "/schluessel/neu" }, + // Der Demo-Key mit nahem Ablaufdatum zeigt die meisten Badges (SSHKV_SEED_DEMO=1, #012). + { + name: "schluessel-detail", + pfad: "/schluessel?q=wartung", + folgen: 'main table a[href^="/schluessel/"]', + }, + { name: "personen", pfad: "/personen" }, + { name: "meine-schluessel", pfad: "/meine-schluessel" }, { name: "hosts", pfad: "/hosts" }, { name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' }, ]; diff --git a/src/app/(app)/meine-schluessel/page.tsx b/src/app/(app)/meine-schluessel/page.tsx new file mode 100644 index 0000000..a46fd77 --- /dev/null +++ b/src/app/(app)/meine-schluessel/page.tsx @@ -0,0 +1,78 @@ +import { KeyRound } from "lucide-react"; +import type { Metadata } from "next"; +import { KeyEntfernen, KeyHinterlegen } from "@/components/personen/MeineSchluessel"; +import { StatusBadge, TypBadge } from "@/components/schluessel/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { MonoKopieren } from "@/components/ui/MonoKopieren"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { datum } from "@/lib/format"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { eigeneSchluessel } from "@/server/services/meine-schluessel"; + +export const metadata: Metadata = { title: "Meine Schlüssel" }; + +/** + * Self-Service (#013): eigene Public Keys hinterlegen und – solange nirgends zugewiesen – + * wieder entfernen. Beim ersten Aufruf entsteht die eigene Person (oder wird verknuepft). + */ +export default async function MeineSchluesselSeite() { + const { benutzer } = await sitzungErforderlich("eigeneSchluesselHinterlegen", "/meine-schluessel"); + const { schluessel } = await eigeneSchluessel(benutzer); + + return ( +
+ + + + {schluessel.length === 0 ? ( + } + titel="Noch keine Schlüssel" + text="Hinterlegen Sie unten Ihren Public Key. Danach kann ein Operator oder Admin ihn den Servern zuweisen, auf die Sie Zugriff brauchen." + /> + ) : ( +
    + {schluessel.map((s) => ( +
  • +
    +

    {s.name}

    + + + + + {s.verwendungen > 0 ? ( + + {s.verwendungen === 1 ? "1 Host-Zuweisung" : `${s.verwendungen} Host-Zuweisungen`} + + ) : ( + noch nicht zugewiesen + )} + seit {datum(s.angelegtAm)} + +
    + {s.selbstHinterlegt && s.verwendungen === 0 && ( + + )} +
  • + ))} +
+ )} +
+ + +
+ ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 5ffe255..a6a1447 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -136,7 +136,7 @@ export default async function Uebersicht() { return (
  • diff --git a/src/app/(app)/personen/[id]/bearbeiten/page.tsx b/src/app/(app)/personen/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..3c5d094 --- /dev/null +++ b/src/app/(app)/personen/[id]/bearbeiten/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PersonFormular } from "@/components/personen/PersonFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { kontenAuswahl, personDetail } from "@/server/queries/personen"; + +export const metadata: Metadata = { title: "Person bearbeiten" }; + +export default async function PersonBearbeiten({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await sitzungErforderlich("schluesselVerwalten", `/personen/${id}/bearbeiten`); + const [person, konten] = await Promise.all([personDetail(id), kontenAuswahl(id)]); + if (!person || person.archiviertAm) notFound(); + return ( +
    + + } + titel="Person bearbeiten" + /> + +
    + ); +} diff --git a/src/app/(app)/personen/[id]/page.tsx b/src/app/(app)/personen/[id]/page.tsx new file mode 100644 index 0000000..bc6a8d8 --- /dev/null +++ b/src/app/(app)/personen/[id]/page.tsx @@ -0,0 +1,153 @@ +import { KeyRound, Pencil, Plus } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; +import { PersonArchivieren } from "@/components/personen/PersonFormular"; +import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Button } from "@/components/ui/Button"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { datum } from "@/lib/format"; +import { fingerprintKurz } from "@/lib/schluessel"; +import { darf } from "@/server/auth/rechte"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { personDetail } from "@/server/queries/personen"; + +export const metadata: Metadata = { title: "Person" }; + +function Info({ titel, children }: { titel: string; children: ReactNode }) { + return ( +
    +
    {titel}
    +
    {children}
    +
    + ); +} + +/** Persondetail (#013) mit ihren Schlüsseln. */ +export default async function PersonSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("lesen", `/personen/${id}`); + const person = await personDetail(id); + if (!person) notFound(); + const archiviert = person.archiviertAm !== null; + const verwalten = darf(benutzer.rolle, "schluesselVerwalten") && !archiviert; + const jetzt = new Date(); + + return ( +
    + } + titel={person.name} + badges={ + <> + {archiviert && person.archiviertAm && ( + archiviert am {datum(person.archiviertAm)} + )} + {person.organisation && {person.organisation}} + {person.benutzer && Konto verknüpft} + + } + aktionen={ + verwalten && ( + + ) + } + /> + + +
    + {person.email ?? –} + + {person.benutzer ? ( + <> + {person.benutzer.name} + {person.benutzer.email && ( + {person.benutzer.email} + )} + + ) : ( + nicht verknüpft + )} + + {datum(person.angelegtAm)} +
    + {person.notiz && ( +

    + {person.notiz} +

    + )} +
    + + } + > + Public Key hinterlegen + + ) + } + > + {person.schluessel.length === 0 ? ( + } + titel="Noch keine Schlüssel" + text="Die Person kann ihre Public Keys auch selbst unter „Meine Schlüssel“ hinterlegen, sobald ihr Konto verknüpft ist." + /> + ) : ( +
      + {person.schluessel.map((s) => ( +
    • + + {s.name} + + + {fingerprintKurz(s.fingerprint)} + + + + + + {s.selbstHinterlegt && selbst hinterlegt} + + + {s.verwendungen === 0 + ? "· noch keinem Host zugewiesen" + : `· ${s.verwendungen} ${s.verwendungen === 1 ? "Verwendung" : "Verwendungen"}`} + + +
    • + ))} +
    + )} +
    + + {verwalten && ( + +
    +

    + Archivierte Personen verschwinden aus Listen. Ihre Schlüssel bleiben bestehen und lassen sich + weiterhin widerrufen. +

    + +
    +
    + )} +
    + ); +} diff --git a/src/app/(app)/personen/neu/page.tsx b/src/app/(app)/personen/neu/page.tsx new file mode 100644 index 0000000..f337aa8 --- /dev/null +++ b/src/app/(app)/personen/neu/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { LEERE_PERSON, PersonFormular } from "@/components/personen/PersonFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { kontenAuswahl } from "@/server/queries/personen"; + +export const metadata: Metadata = { title: "Person anlegen" }; + +export default async function PersonNeu() { + await sitzungErforderlich("schluesselVerwalten", "/personen/neu"); + const konten = await kontenAuswahl(); + return ( +
    + } + titel="Person anlegen" + untertitel="Mitarbeitende, Admins oder Dienstleister, denen Personen-Keys gehören." + /> + +
    + ); +} diff --git a/src/app/(app)/personen/page.tsx b/src/app/(app)/personen/page.tsx index 15f9afd..0296f9c 100644 --- a/src/app/(app)/personen/page.tsx +++ b/src/app/(app)/personen/page.tsx @@ -1,13 +1,137 @@ +import { Plus, Search, Users } from "lucide-react"; import type { Metadata } from "next"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import Link from "next/link"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Feld } from "@/components/ui/Feld"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { personenListe } from "@/server/queries/personen"; export const metadata: Metadata = { title: "Personen" }; -export default async function Personen() { +type Suchparameter = Promise>; + +function einzeln(wert: string | string[] | undefined): string { + return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? ""; +} + +function Anzahl({ n }: { n: number }) { + return n === 0 ? keine : <>{n}; +} + +/** Personenliste (#013): wem welche Schlüssel gehören. */ +export default async function Personen({ searchParams }: { searchParams: Suchparameter }) { // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); + const { benutzer } = await sitzungErforderlich("lesen", "/personen"); + const q = einzeln((await searchParams)["q"]); + const personen = await personenListe({ q }); + const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); + const anlegen = verwalten && ( + + ); + return ( - +
    + +
    + +
    + + {q && ( + + )} +
    + + + {personen.length === 0 ? ( + } + titel={q ? "Keine Treffer" : "Noch keine Personen"} + text={ + q + ? "Keine Person passt zur Suche." + : "Personen entstehen hier oder von selbst, wenn jemand unter „Meine Schlüssel“ einen Key hinterlegt." + } + aktion={q ? : anlegen} + /> + ) : ( + +
      + {personen.map((p) => ( +
    • + + {p.name} + {p.email && {p.email}} + + {p.organisation && {p.organisation}} + {p.benutzer && Konto verknüpft} + + + Aktive Schlüssel: + + +
    • + ))} +
    + + {personen.map((p) => ( + + + + {p.name} + + + {p.email ?? "–"} + {p.organisation ?? –} + + {p.benutzer ? p.benutzer.name : –} + + + + + + ))} + +
    + )} +
    ); } diff --git a/src/app/(app)/schluessel/[id]/PrivateAusrollenAktion.tsx b/src/app/(app)/schluessel/[id]/PrivateAusrollenAktion.tsx new file mode 100644 index 0000000..bad4e25 --- /dev/null +++ b/src/app/(app)/schluessel/[id]/PrivateAusrollenAktion.tsx @@ -0,0 +1,10 @@ +"use client"; + +/** + * Platzhalter fuer „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#012). + * Inhalt folgt mit #017 (Private Key auf einen Host legen); bis dahin bewusst leer. + */ +export function PrivateAusrollenAktion(props: { schluesselId: string; darf: boolean }): null { + void props; + return null; +} diff --git a/src/app/(app)/schluessel/[id]/PrivateKeyAktionen.tsx b/src/app/(app)/schluessel/[id]/PrivateKeyAktionen.tsx new file mode 100644 index 0000000..6c4ab32 --- /dev/null +++ b/src/app/(app)/schluessel/[id]/PrivateKeyAktionen.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { Download, Eye, Trash2, TriangleAlert } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useRef, useState, useTransition } from "react"; +import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui"; +import { MonoBlock } from "@/components/ui/MonoBlock"; +import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang"; +import { + abholungAnfordernAktion, + privateKeyAnzeigenAktion, + privateKeyLoeschenAktion, + type PrivateKeyAntwort, +} from "@/server/actions/privatekey"; + +/** + * Private Key anzeigen, herunterladen und loeschen (#014). Alles verlangt frische + * Bestaetigung; fehlt sie, geht es nach /bestaetigen und von dort mit `?privatekey=1` zurueck, + * damit der Dialog gleich wieder offen ist. + * + * Der Key lebt nur im Zustand dieses Dialogs und wird beim Schliessen verworfen. + */ +const DIALOG_KLASSEN = + "border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-2xl border p-5 backdrop:bg-black/70 sm:p-6"; + +function useBestaetigungUmleitung() { + const router = useRouter(); + const pfad = usePathname(); + return (antwort: PrivateKeyAntwort): boolean => { + if (!antwort.bestaetigen) return false; + router.push(`/bestaetigen?ziel=${encodeURIComponent(`${pfad}?privatekey=1`)}`); + return true; + }; +} + +export function PrivateKeyAnzeigen({ schluesselId, name }: { schluesselId: string; name: string }) { + const dialog = useRef(null); + const download = useRef(null); + const suche = useSearchParams(); + const umleiten = useBestaetigungUmleitung(); + const [zweck, setZweck] = useState<"anzeigen" | "herunterladen">(); + const [vorgangId, setVorgangId] = useState(); + const [privateKey, setPrivateKey] = useState(); + const [fehler, setFehler] = useState(); + const [startet, starten] = useTransition(); + const { stand, abfrageFehler } = useVorgang(vorgangId); + const abgeholt = useRef(undefined); + + // Zurueck von /bestaetigen: Dialog gleich oeffnen. + useEffect(() => { + if (suche.get("privatekey") === "1") dialog.current?.showModal(); + }, [suche]); + + // Bereit? Dann genau einmal abholen – als Anzeige oder als Download-Formular. + useEffect(() => { + if (!vorgangId || stand?.status !== "ERLEDIGT" || abgeholt.current === vorgangId) return; + abgeholt.current = vorgangId; + if (zweck === "herunterladen") { + download.current?.submit(); + return; + } + void privateKeyAnzeigenAktion(vorgangId).then((antwort) => { + if (umleiten(antwort)) return; + if (antwort.fehler) setFehler(antwort.fehler); + else setPrivateKey(antwort.privateKey); + }); + }, [vorgangId, stand?.status, zweck, umleiten]); + + function anfordern(art: "anzeigen" | "herunterladen") { + starten(async () => { + setFehler(undefined); + setPrivateKey(undefined); + setZweck(art); + const antwort = await abholungAnfordernAktion(schluesselId, art); + if (umleiten(antwort)) return; + if (antwort.fehler) setFehler(antwort.fehler); + else setVorgangId(antwort.vorgangId); + }); + } + + function schliessen() { + // Nichts vom Key bleibt im Zustand stehen. + setPrivateKey(undefined); + setVorgangId(undefined); + setFehler(undefined); + dialog.current?.close(); + } + + const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler); + const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined; + const downloadGestartet = + zweck === "herunterladen" && vorgangId !== undefined && stand?.status === "ERLEDIGT"; + + return ( + <> + + +
    +

    + Private Key von „{name}“ +

    + {privateKey ? ( + <> + +

    + + Wird nicht erneut angezeigt. Kopieren Sie ihn jetzt an einen sicheren Ort. +

    + + ) : downloadGestartet ? ( +

    + Download gestartet. Der Private Key wird nicht erneut angeboten. +

    + ) : ( + <> +

    + Der Private Key wird im Worker entschlüsselt und genau einmal ausgeliefert. Jede Anzeige und + jeder Download steht im Audit-Log. +

    +
    + + +
    + + )} + +
    + +
    +
    + {vorgangId &&
    + + ); +} + +/** „Private Key aus SSHKeyVault loeschen“ (#014) – endgueltig, mit Rueckfrage. */ +export function PrivateKeyLoeschen({ schluesselId, name }: { schluesselId: string; name: string }) { + const dialog = useRef(null); + const router = useRouter(); + const umleiten = useBestaetigungUmleitung(); + const [verstanden, setVerstanden] = useState(false); + const [vorgangId, setVorgangId] = useState(); + const [fehler, setFehler] = useState(); + const [startet, starten] = useTransition(); + const { stand, abfrageFehler } = useVorgang(vorgangId); + + useEffect(() => { + if (stand?.status === "ERLEDIGT") { + dialog.current?.close(); + router.refresh(); + } + }, [stand?.status, router]); + + const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler); + const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined; + + return ( + <> + + +
    +

    + Private Key von „{name}“ aus SSHKeyVault löschen? +

    +

    + sshkv verwirft den verschlüsselten Private Key endgültig. Der Public Key und alle Zuweisungen + bleiben; auf den Hosts ändert sich nichts. Danach kann sshkv den Private Key weder anzeigen noch + auf weitere Hosts ausrollen. +

    + setVerstanden(e.target.checked)} + beschriftung="Ich habe verstanden, dass sich das nicht rückgängig machen lässt." + /> + +
    + + +
    +
    +
    + + ); +} diff --git a/src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx b/src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx new file mode 100644 index 0000000..261b3cd --- /dev/null +++ b/src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx @@ -0,0 +1,10 @@ +"use client"; + +/** + * Platzhalter fuer „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#012). + * Inhalt folgt mit #016 (Zuweisung, Dry-Run, Ausrollen); bis dahin bewusst leer. + */ +export function PublicAusrollenAktion(props: { schluesselId: string; darf: boolean }): null { + void props; + return null; +} diff --git a/src/app/(app)/schluessel/[id]/bearbeiten/page.tsx b/src/app/(app)/schluessel/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..f8d4301 --- /dev/null +++ b/src/app/(app)/schluessel/[id]/bearbeiten/page.tsx @@ -0,0 +1,65 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { SchluesselBearbeiten } from "@/components/schluessel/SchluesselBearbeiten"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { personenAuswahl, schluesselDetail, vorlagenAuswahl } from "@/server/queries/schluessel"; + +export const metadata: Metadata = { title: "Schlüssel bearbeiten" }; + +export default async function SchluesselBearbeitenSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/bearbeiten`); + const [s, personen, vorlagen] = await Promise.all([ + schluesselDetail(id), + personenAuswahl(), + vorlagenAuswahl(), + ]); + if (!s) notFound(); + + // Eine inzwischen archivierte Person oder Vorlage bleibt waehlbar, solange sie zugeordnet + // ist – sonst loeschte schon das blosse Speichern die Zuordnung. + const personListe = personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation })); + if (s.person && !personListe.some((p) => p.id === s.person?.id)) + personListe.push({ ...s.person, zusatz: null }); + const vorlagenListe = vorlagen.map((v) => ({ id: v.id, name: v.name })); + if (s.vorlage && !vorlagenListe.some((v) => v.id === s.vorlage?.id)) vorlagenListe.push(s.vorlage); + + return ( +
    + + } + titel="Schlüssel bearbeiten" + untertitel={ + <> + Kurzname {s.slug} und Kommentar bleiben unverändert. + + } + /> + +
    + ); +} diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx new file mode 100644 index 0000000..e6ec5ca --- /dev/null +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -0,0 +1,312 @@ +import { Download, Lock, LockOpen, Pencil } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; +import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Button } from "@/components/ui/Button"; +import { InnereBox, Karte } from "@/components/ui/Karte"; +import { MonoBlock } from "@/components/ui/MonoBlock"; +import { MonoKopieren } from "@/components/ui/MonoKopieren"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt"; +import { datum, datumZeit } from "@/lib/format"; +import { darf } from "@/server/auth/rechte"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel"; +import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; +import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; +import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; + +export const metadata: Metadata = { title: "Schlüssel" }; + +function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) { + return ( +
    +
    {titel}
    +
    {children}
    +
    + ); +} + +/** Soll-Zustand einer Verwendung als Statuspunkt – Form und Text tragen die Information mit. */ +const ZUSTAND: Record = { + GEPLANT: { status: "unbekannt", text: "geplant" }, + AUSGEROLLT: { status: "synchron", text: "ausgerollt" }, + ABWEICHUNG: { status: "drift", text: "Abweichung" }, + ENTFERNEN: { status: "unbekannt", text: "wird entfernt" }, + FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" }, +}; + +type Eintrag = { id: string; ziel: string; href?: string; pfad: string; zusatz?: string; zustand: string }; + +function AusrollKarte({ + titel, + untertitel, + eintraege, + leer, + aktion, +}: { + titel: string; + untertitel: string; + eintraege: Eintrag[]; + leer: string; + aktion: ReactNode; +}) { + return ( + + {eintraege.length === 0 ? ( +

    + {leer} +

    + ) : ( +
      + {eintraege.map((e) => { + const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand }; + return ( +
    • + +
      +

      + {e.href ? ( + + {e.ziel} + + ) : ( + e.ziel + )} +

      +

      {e.pfad}

      + {e.zusatz &&

      {e.zusatz}

      } +
      + +
      +
    • + ); + })} +
    + )} +
    + ); +} + +function privateEintraege(s: SchluesselDetail): Eintrag[] { + return s.privateVerwendungen.map((v) => { + const config = + v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null; + return { + id: v.id, + ziel: `${v.zielbenutzer}@${v.host.name}`, + href: `/hosts/${v.host.id}`, + pfad: v.pfad, + ...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}), + zustand: v.zustand, + }; + }); +} + +function publicEintraege(s: SchluesselDetail): Eintrag[] { + return s.publicVerwendungen.map((v) => ({ + id: v.id, + ziel: v.host + ? `${v.zielbenutzer}@${v.host.name}` + : `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`, + ...(v.host + ? { href: `/hosts/${v.host.id}` } + : v.hostgruppe + ? { href: `/hostgruppen/${v.hostgruppe.id}` } + : {}), + pfad: "~/.ssh/authorized_keys", + ...(v.optionen ? { zusatz: v.optionen } : {}), + zustand: v.zustand, + })); +} + +/** Was mit dem Private Key ist – ohne ihn je zu lesen (masterKeyVersion verraet es). */ +function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNode }) { + if (s.privatGespeichert) { + return ( + + + verschlüsselt gespeichert + {s.passphraseGeschuetzt && · mit Passphrase} + {anzeigen} + + ); + } + if (s.privatGeloeschtAm) { + return ( + + + gelöscht am + + ); + } + return ( + + nicht gespeichert{s.kategorie === "PERSON" ? " (Personen-Key)" : ""} + + ); +} + +/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */ +export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`); + const s = await schluesselDetail(id); + if (!s) notFound(); + + const aktiv = s.status === "AKTIV"; + const personenKey = s.kategorie === "PERSON"; + const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); + // Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst. + const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT"; + const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen"); + const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen"); + const jetzt = new Date(); + + return ( +
    + } + titel={s.name} + untertitel={ + s.person ? ( + <> + Person:{" "} + + {s.person.name} + + + ) : undefined + } + badges={ + <> + + + + {s.instanzStandard && Instanz-Standard} + {s.selbstHinterlegt && selbst hinterlegt} + {s.vorlage && Vorlage: {s.vorlage.name}} + + {s.tags.map((t) => ( + + #{t} + + ))} + + } + aktionen={ + verwalten && ( + + ) + } + /> + + +
    + + + + + + + {s.erstelltVon ? `von ${s.erstelltVon.name}` : "von sshkv"} + + + + + · + + + ) + } + /> + +
    +
    + + {/* Personen-Keys haben nie einen gespeicherten Private Key (DB-CHECK) – die Karte dafuer + waere immer leer. */} +
    + {!personenKey && ( + + } + /> + )} + + } + /> +
    + + + Kommentar: {s.kommentar || "–"} + + } + aktionen={ + + + {s.slug}.pub + + } + > + + + + {darfLoeschen && ( + +
    +

    + Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann + ihn niemand mehr aus SSHKeyVault holen – auch kein Admin. +

    + +
    +
    + )} +
    + ); +} diff --git a/src/app/(app)/schluessel/beispiel/page.tsx b/src/app/(app)/schluessel/beispiel/page.tsx deleted file mode 100644 index 4bc2b7f..0000000 --- a/src/app/(app)/schluessel/beispiel/page.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react"; -import type { Metadata } from "next"; -import type { ReactNode } from "react"; -import { - beispielSchluessel as s, - dryRunDatei, - dryRunDiff, - privatAusgerollt, - publicAusgerollt, - zusatzaktionen, - type Ausrollung, -} from "@/beispiel/schluessel"; -import { BEISPIEL_JETZT } from "@/beispiel/uebersicht"; -import { Badge } from "@/components/ui/Badge"; -import { Breadcrumb } from "@/components/ui/Breadcrumb"; -import { Button } from "@/components/ui/Button"; -import { DiffBox } from "@/components/ui/DiffBox"; -import { InnereBox, Karte } from "@/components/ui/Karte"; -import { MonoKopieren } from "@/components/ui/MonoKopieren"; -import { Seitenkopf } from "@/components/ui/Seitenkopf"; -import { Statuspunkt } from "@/components/ui/Statuspunkt"; -import { datum, datumZeit, tageBis } from "@/lib/format"; -import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; - -export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` }; - -/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */ -export default async function SchluesselBeispiel() { - // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); - const tage = tageBis(s.ablauf, BEISPIEL_JETZT); - return ( -
    - } - titel={s.zweck} - badges={ - <> - {s.typ} - {s.kategorie} - Vorlage: {s.vorlage} - - - läuft ab am · in {tage} Tagen - - - - Beispieldaten - - - } - aktionen={ - <> - - - - - } - /> - - -
    - - - - - - von {s.erstelltVon} - - - - - verschlüsselt gespeichert · - - - -
    -
    - -
    - - -
    - - - Abgleich für backup@srv-pve02 – noch nichts geschrieben - - } - > - -
    - Zusätzlich ausführen -
      - {zusatzaktionen.map((z) => ( -
    • - -
    • - ))} -
    -
    -
    - - -
    -
    -
    - ); -} - -function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) { - return ( -
    -
    {titel}
    -
    {children}
    -
    - ); -} - -function AusrollKarte({ - titel, - untertitel, - eintraege, -}: { - titel: string; - untertitel: string; - eintraege: Ausrollung[]; -}) { - return ( - }> - Ausrollen - - } - > -
      - {eintraege.map((e) => ( -
    • - -
      -

      {e.ziel}

      -

      {e.pfad}

      - {e.zusatz &&

      {e.zusatz}

      } -
      - -
      -
    • - ))} -
    -
    - ); -} diff --git a/src/app/(app)/schluessel/neu/page.tsx b/src/app/(app)/schluessel/neu/page.tsx new file mode 100644 index 0000000..aaba816 --- /dev/null +++ b/src/app/(app)/schluessel/neu/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import { SchluesselNeu } from "@/components/schluessel/SchluesselNeu"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { personenAuswahl, vorlagenAuswahl } from "@/server/queries/schluessel"; + +export const metadata: Metadata = { title: "Schlüssel anlegen" }; + +type Suchparameter = Promise>; + +export default async function SchluesselNeuSeite({ searchParams }: { searchParams: Suchparameter }) { + await sitzungErforderlich("schluesselVerwalten", "/schluessel/neu"); + const person = (await searchParams)["person"]; + const [personen, vorlagen] = await Promise.all([personenAuswahl(), vorlagenAuswahl()]); + const vorgabePersonId = + typeof person === "string" && personen.some((p) => p.id === person) ? person : undefined; + return ( +
    + + } + titel="Schlüssel anlegen" + untertitel="Neu erzeugen oder einen vorhandenen Schlüssel übernehmen." + /> + ({ id: p.id, name: p.name, zusatz: p.organisation }))} + vorlagen={vorlagen.map((v) => ({ + id: v.id, + name: v.name, + zusatz: v.standardLaufzeitTage ? `${v.standardLaufzeitTage} Tage` : null, + }))} + {...(vorgabePersonId ? { vorgabePersonId } : {})} + /> +
    + ); +} diff --git a/src/app/(app)/schluessel/page.tsx b/src/app/(app)/schluessel/page.tsx index 59a4f34..335a139 100644 --- a/src/app/(app)/schluessel/page.tsx +++ b/src/app/(app)/schluessel/page.tsx @@ -1,27 +1,223 @@ +import { KeyRound, Lock, Plus, Search } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige"; +import { Button } from "@/components/ui/Button"; +import { Auswahlliste, Feld } from "@/components/ui/Feld"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { datum } from "@/lib/format"; +import { ablaufZustand, fingerprintKurz, KATEGORIE_TEXT, STATUS_TEXT } from "@/lib/schluessel"; +import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { schluesselListe, type SchluesselListenEintrag } from "@/server/queries/schluessel"; export const metadata: Metadata = { title: "Schlüssel" }; -export default async function Schluessel() { - // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); +type Suchparameter = Promise>; + +function einzeln(wert: string | string[] | undefined): string { + return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? ""; +} + +function Verwendungen({ anzahl }: { anzahl: number }) { + if (anzahl === 0) return keine; + return <>{anzahl}; +} + +/** In der Tabelle: Badge, wenn es drängt; sonst nur das Datum. */ +function Ablauf({ ablaufAm, jetzt }: { ablaufAm: Date | null; jetzt: Date }) { + if (!ablaufAm) return –; + if (ablaufZustand(ablaufAm, jetzt).art === "spaeter") { + return ( + + ); + } + return ; +} + +function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) { + if (!s.privatGespeichert) return null; return ( -
    - + + Private Key gespeichert + + ); +} + +/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */ +export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) { + // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). + const { benutzer } = await sitzungErforderlich("lesen", "/schluessel"); + const parameter = await searchParams; + const q = einzeln(parameter["q"]); + const kategorie = einzeln(parameter["kategorie"]); + const status = einzeln(parameter["status"]); + const liste = await schluesselListe({ q, kategorie, status }); + const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); + const gefiltert = q !== "" || kategorie !== "" || status !== ""; + const jetzt = new Date(); + + const anlegen = verwalten && ( + + ); + + return ( +
    + -

    - Prototyp ansehen:{" "} - - Schlüsseldetail mit Beispieldaten - -

    + + + + + + {Object.entries(KATEGORIE_TEXT).map(([wert, text]) => ( + + ))} + + + + {Object.entries(STATUS_TEXT).map(([wert, text]) => ( + + ))} + +
    + + {gefiltert && ( + + )} +
    + + + {liste.length === 0 ? ( + gefiltert ? ( + } + titel="Keine Treffer" + text="Kein Schlüssel passt zu Suche und Filter." + aktion={} + /> + ) : ( + } + titel="Noch keine Schlüssel" + text="Erzeugen Sie einen Schlüssel oder importieren Sie einen vorhandenen Public Key." + aktion={anlegen} + /> + ) + ) : ( + + {/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */} +
      + {liste.map((s) => ( +
    • + + + {s.name} + + + + {fingerprintKurz(s.fingerprint)} + + + + + + + + + Verwendungen: + {s.person && <> · {s.person.name}} + + +
    • + ))} +
    + + {liste.map((s) => ( + + + + + {s.name} + + + + + {s.person && {s.person.name}} + + + + + + + + + {fingerprintKurz(s.fingerprint)} + + + + + + + + + ))} + +
    + )}
    ); } diff --git a/src/app/api/private-key/[vorgangId]/route.ts b/src/app/api/private-key/[vorgangId]/route.ts new file mode 100644 index 0000000..1d22615 --- /dev/null +++ b/src/app/api/private-key/[vorgangId]/route.ts @@ -0,0 +1,63 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { akteurVon } from "@/server/actions/formular"; +import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { HostFehler } from "@/server/services/hosts"; +import { privateKeyAbholen } from "@/server/services/privatekey"; + +/** + * Download des Private Keys als Datei `` (#014). Nur POST aus einem Formular dieser + * Anwendung: ein GET liesse sich per Link oder Vorabruf ausloesen und verbraeuchte die + * einmalige Abholung. Fremde Seiten haelt die Herkunftspruefung fern. + * + * Prueft selbst Recht und frische Bestaetigung. Antwort ohne Caching; Fehlertexte enthalten + * nie Schluesselmaterial. + */ +const KEIN_CACHE = { "Cache-Control": "no-store, max-age=0", Pragma: "no-cache" } as const; + +function text(meldung: string, status: number): NextResponse { + return new NextResponse(`${meldung}\n`, { + status, + headers: { "Content-Type": "text/plain; charset=utf-8", ...KEIN_CACHE }, + }); +} + +/** + * Kommt die Anfrage von dieser Anwendung? Wegen `Referrer-Policy: no-referrer` schicken + * Browser bei Formular-POSTs `Origin: null`; massgeblich ist deshalb `Sec-Fetch-Site`, das + * Seiten nicht faelschen koennen. Ein passender Origin genuegt ebenso. + */ +function vonHier(anfrage: NextRequest): boolean { + if (anfrage.headers.get("sec-fetch-site") === "same-origin") return true; + return anfrage.headers.get("origin") === oeffentlicheAdresse("/", anfrage.nextUrl.origin).origin; +} + +export async function POST(anfrage: NextRequest, { params }: { params: Promise<{ vorgangId: string }> }) { + if (!vonHier(anfrage)) return text("Anfrage von fremder Herkunft.", 403); + + let benutzer; + try { + ({ benutzer } = await rechtErforderlich("privateKeyAnzeigen")); + } catch (fehler) { + if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler) + return text(fehler.message, 403); + throw fehler; + } + + const { vorgangId } = await params; + try { + const abholung = await privateKeyAbholen(vorgangId.slice(0, 64), "herunterladen", akteurVon(benutzer)); + return new NextResponse(abholung.privateKey, { + headers: { + "Content-Type": "application/octet-stream", + // Der Slug passt per DB-CHECK in einen Dateinamen; so heisst die Datei wie in ~/.ssh. + "Content-Disposition": `attachment; filename="${abholung.slug}"`, + "X-Content-Type-Options": "nosniff", + ...KEIN_CACHE, + }, + }); + } catch (fehler) { + if (fehler instanceof HostFehler) return text(fehler.message, 410); + throw fehler; + } +} diff --git a/src/app/api/schluessel/[id]/public-key/route.ts b/src/app/api/schluessel/[id]/public-key/route.ts new file mode 100644 index 0000000..f275ea9 --- /dev/null +++ b/src/app/api/schluessel/[id]/public-key/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { publicKeyExport } from "@/server/queries/schluessel"; + +/** + * Public Key als Datei `.pub` (#012). Ein Public Key ist kein Geheimnis – jede + * angemeldete Rolle darf ihn holen. Der Slug passt per DB-CHECK in einen Dateinamen. + */ +export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) { + try { + await rechtErforderlich("lesen"); + } catch (fehler) { + if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 }); + throw fehler; + } + const schluessel = await publicKeyExport((await params).id); + if (!schluessel) return NextResponse.json({ fehler: "Unbekannter Schlüssel." }, { status: 404 }); + return new NextResponse(`${schluessel.publicKey}\n`, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": `attachment; filename="${schluessel.slug}.pub"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/beispiel/schluessel.ts b/src/beispiel/schluessel.ts deleted file mode 100644 index 679edf0..0000000 --- a/src/beispiel/schluessel.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * BEISPIELDATEN für den Prototyp des Schlüsseldetails (#002) – kein echter Schlüssel. - * Ersetzt durch das Lesemodell aus #012/#016/#017. - */ -import type { HostStatus } from "@/components/ui/Statuspunkt"; -import type { DiffZeile } from "@/components/ui/DiffBox"; - -export const beispielSchluessel = { - zweck: "Backup srv-backup01 → srv-pve01", - typ: "ed25519", - kategorie: "Dienst", - vorlage: "Backup-Zugang", - ablauf: "2026-10-30T10:00:00Z", - fingerprint: "SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k", - erstellt: "2026-04-30T08:12:00Z", - erstelltVon: "Florian Wilde", -}; - -export type Ausrollung = { - ziel: string; - pfad: string; - zusatz?: string; - status: HostStatus; - statusText?: string; -}; - -export const privatAusgerollt: Ausrollung[] = [ - { - ziel: "backup@srv-backup01", - pfad: "~/.ssh/id_ed25519_pve01", - zusatz: "config: Host srv-pve01", - status: "synchron", - }, -]; - -export const publicAusgerollt: Ausrollung[] = [ - { - ziel: "backup@srv-pve01", - pfad: "~/.ssh/authorized_keys", - zusatz: 'from="10.20.0.15",no-pty,no-agent-forwarding', - status: "synchron", - }, - { - ziel: "backup@srv-pve02", - pfad: "~/.ssh/authorized_keys", - zusatz: 'from="10.20.0.15",no-pty', - status: "drift", - statusText: "Optionen geändert", - }, -]; - -export const dryRunDatei = "backup@srv-pve02:~/.ssh/authorized_keys"; - -export const dryRunDiff: DiffZeile[] = [ - { art: "kontext", text: "# --- sshkv verwaltet: Anfang ---" }, - { - art: "kontext", - text: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvq2Lr7… monitoring@zabbix", - }, - { - art: "entfernt", - text: 'from="10.20.0.15",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01', - }, - { - art: "hinzu", - text: 'from="10.20.0.15",no-pty,no-agent-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01', - }, - { art: "kontext", text: "# --- sshkv verwaltet: Ende ---" }, -]; - -export const zusatzaktionen = [ - { id: "backup", text: "Backup der Datei anlegen (authorized_keys.sshkv-bak)", an: true }, - { id: "config", text: "~/.ssh/config-Eintrag auf srv-backup01 anlegen", an: true }, - { id: "knownhosts", text: "known_hosts auf srv-backup01 aktualisieren", an: false }, -]; diff --git a/src/components/huelle/nav-punkte.ts b/src/components/huelle/nav-punkte.ts index 0537ba3..c331639 100644 --- a/src/components/huelle/nav-punkte.ts +++ b/src/components/huelle/nav-punkte.ts @@ -1,6 +1,7 @@ import { Boxes, KeyRound, + KeySquare, LayoutDashboard, LayoutTemplate, Network, @@ -20,6 +21,8 @@ export const HAUPTNAVIGATION: NavPunkt[] = [ { href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes }, { href: "/schluessel", titel: "Schlüssel", icon: KeyRound }, { href: "/personen", titel: "Personen", icon: Users }, + // Self-Service fuer jede Rolle (#013): eigene Public Keys hinterlegen. + { href: "/meine-schluessel", titel: "Meine Schlüssel", icon: KeySquare }, { href: "/zuweisungen", titel: "Zuweisungen", icon: Network }, { href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate }, ]; diff --git a/src/components/personen/MeineSchluessel.tsx b/src/components/personen/MeineSchluessel.tsx new file mode 100644 index 0000000..a8267de --- /dev/null +++ b/src/components/personen/MeineSchluessel.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; +import { useActionState, useEffect, useRef } from "react"; +import { useFormularAktion } from "@/components/hosts/useFormularAktion"; +import { Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui"; +import type { FormularZustand } from "@/server/actions/formular"; +import { + eigenenKeyEntfernenAktion, + eigenenKeyHinterlegenAktion, + type HinterlegenZustand, +} from "@/server/actions/meine-schluessel"; + +/** Eigenen Public Key hinterlegen (#013). Nach Erfolg leert sich das Formular. */ +export function KeyHinterlegen() { + const formular = useRef(null); + const { zustand, absenden, laeuft } = useFormularAktion( + eigenenKeyHinterlegenAktion as (v: FormularZustand, f: FormData) => Promise, + ); + const erfolg = (zustand as HinterlegenZustand).erfolg; + useEffect(() => { + if (erfolg) formular.current?.reset(); + }, [erfolg]); + const f = zustand.feldfehler ?? {}; + return ( + +
    + + {erfolg && ( +

    + {erfolg} +

    + )} + + +

    + Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist. Den Private Key + behalten Sie bei sich – sshkv fragt nie danach. +

    +
    + +
    + +
    + ); +} + +/** Eigenen, noch nirgends zugewiesenen Key entfernen – mit Rueckfrage. */ +export function KeyEntfernen({ schluesselId, name }: { schluesselId: string; name: string }) { + const dialog = useRef(null); + const [zustand, aktion, laeuft] = useActionState(eigenenKeyEntfernenAktion, {}); + return ( + <> + + +
    + +

    + „{name}“ entfernen? +

    +

    + Der Public Key verschwindet aus sshkv. Er ist noch keinem Host zugewiesen; auf keinem Server + ändert sich etwas. +

    + +
    + + +
    + +
    + + ); +} diff --git a/src/components/personen/PersonFormular.tsx b/src/components/personen/PersonFormular.tsx new file mode 100644 index 0000000..fe1cc1a --- /dev/null +++ b/src/components/personen/PersonFormular.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { Archive } from "lucide-react"; +import { useActionState, useRef } from "react"; +import { useFormularAktion } from "@/components/hosts/useFormularAktion"; +import { Auswahlliste, Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui"; +import type { FormularZustand } from "@/server/actions/formular"; +import { + personAnlegenAktion, + personArchivierenAktion, + personBearbeitenAktion, +} from "@/server/actions/personen"; + +export type PersonFormularVorgabe = { + id?: string; + name: string; + email: string; + organisation: string; + notiz: string; + benutzerId: string; +}; + +export const LEERE_PERSON: PersonFormularVorgabe = { + name: "", + email: "", + organisation: "", + notiz: "", + benutzerId: "", +}; + +/** Formular fuer neue und bestehende Personen (#013). Regeln prueft der Server. */ +export function PersonFormular({ + vorgabe, + konten, + abbrechenZiel, +}: { + vorgabe: PersonFormularVorgabe; + konten: { id: string; name: string; email: string | null }[]; + abbrechenZiel: string; +}) { + const bearbeiten = Boolean(vorgabe.id); + const { zustand, absenden, laeuft } = useFormularAktion( + bearbeiten ? personBearbeitenAktion : personAnlegenAktion, + ); + const f = zustand.feldfehler ?? {}; + return ( +
    + {vorgabe.id && } + + +
    + + + + + + {konten.map((k) => ( + + ))} + + {f["benutzerId"] &&

    {f["benutzerId"]}

    } + +
    +
    +
    + + +
    + + ); +} + +/** Archivieren mit Rueckfrage, nativer `` wie bei Hosts (#008). */ +export function PersonArchivieren({ personId, name }: { personId: string; name: string }) { + const dialog = useRef(null); + const [zustand, aktion, laeuft] = useActionState(personArchivierenAktion, {}); + return ( + <> + + +
    + +

    + {name} archivieren? +

    +

    + Die Person verschwindet aus Listen und Auswahlfeldern, die Verknüpfung zum Konto entfällt. Ihre + Schlüssel bleiben zugeordnet und unverändert – widerrufen ist ein eigener Schritt. +

    + +
    + + +
    + +
    + + ); +} diff --git a/src/components/schluessel/Anzeige.tsx b/src/components/schluessel/Anzeige.tsx new file mode 100644 index 0000000..885630c --- /dev/null +++ b/src/components/schluessel/Anzeige.tsx @@ -0,0 +1,66 @@ +import { Badge } from "@/components/ui/Badge"; +import { datum } from "@/lib/format"; +import { + ablaufZustand, + KATEGORIE_TEXT, + STATUS_TEXT, + typAnzeige, + type Kategorie, + type Status, +} from "@/lib/schluessel"; + +/** Kleine Anzeigebausteine fuer Schluessel (#012) – reine Darstellung. */ + +export function TypBadge({ typ, publicKey }: { typ: "ED25519" | "RSA4096" | "ANDERER"; publicKey: string }) { + return {typAnzeige(typ, publicKey)}; +} + +export function KategorieBadge({ kategorie }: { kategorie: Kategorie }) { + return {KATEGORIE_TEXT[kategorie]}; +} + +/** AKTIV zeigen wir nicht eigens – nur Abweichungen vom Normalfall. */ +export function StatusBadge({ status }: { status: Status }) { + if (status === "AKTIV") return null; + return {STATUS_TEXT[status]}; +} + +/** + * Ablauf: in Messing, sobald er in hoechstens 30 Tagen ansteht (Design #002), in Koralle, + * wenn er vorbei ist. `lang` zeigt zusaetzlich das Datum (Detailseite). + */ +export function AblaufBadge({ + ablaufAm, + jetzt, + lang = false, +}: { + ablaufAm: Date | null; + jetzt: Date; + lang?: boolean; +}) { + const zustand = ablaufZustand(ablaufAm, jetzt); + if (zustand.art === "keiner" || !ablaufAm) return null; + const zeit = ; + if (zustand.art === "abgelaufen") { + return {lang ? abgelaufen am {zeit} : "abgelaufen"}; + } + const in_ = zustand.tage === 0 ? "heute" : zustand.tage === 1 ? "morgen" : `in ${zustand.tage} Tagen`; + if (zustand.art === "bald") { + return ( + + {lang ? ( + + läuft ab am {zeit} · {in_} + + ) : ( + läuft ab {in_} + )} + + ); + } + return lang ? ( + + läuft ab am {zeit} + + ) : null; +} diff --git a/src/components/schluessel/SchluesselBearbeiten.tsx b/src/components/schluessel/SchluesselBearbeiten.tsx new file mode 100644 index 0000000..0a1911d --- /dev/null +++ b/src/components/schluessel/SchluesselBearbeiten.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Auswahlliste, Button, Feld, Formularfehler, Karte } from "@/components/ui"; +import { useFormularAktion } from "@/components/hosts/useFormularAktion"; +import { schluesselBearbeitenAktion } from "@/server/actions/schluessel"; +import type { Auswahl } from "./SchluesselNeu"; + +export type SchluesselFormularWerte = { + id: string; + name: string; + tags: string[]; + /** JJJJ-MM-TT oder leer */ + ablaufAm: string; + personId: string; + vorlageId: string; + status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN"; + statusAenderbar: boolean; +}; + +/** + * Metadaten bearbeiten (#012). Slug, Typ und Kommentar bleiben – sie stehen schon in + * Dateinamen und authorized_keys-Zeilen auf den Hosts. + */ +export function SchluesselBearbeiten({ + werte, + personen, + vorlagen, +}: { + werte: SchluesselFormularWerte; + personen: Auswahl[]; + vorlagen: Auswahl[]; +}) { + const { zustand, absenden, laeuft } = useFormularAktion(schluesselBearbeitenAktion); + const f = zustand.feldfehler ?? {}; + return ( +
    + + + +
    + + + + {personen.map((p) => ( + + ))} + + + + {vorlagen.map((v) => ( + + ))} + + + + {werte.statusAenderbar && ( + + + + + )} +
    +
    +
    + + +
    + + ); +} diff --git a/src/components/schluessel/SchluesselNeu.tsx b/src/components/schluessel/SchluesselNeu.tsx new file mode 100644 index 0000000..48ed8e5 --- /dev/null +++ b/src/components/schluessel/SchluesselNeu.tsx @@ -0,0 +1,400 @@ +"use client"; + +import { Download, KeyRound, Sparkles } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useTransition, type FormEvent, type ReactNode } from "react"; +import { clsx } from "clsx"; +import { + Ankreuzfeld, + Auswahlliste, + Button, + Feld, + Feldgruppe, + Formularfehler, + Karte, + Textbereich, +} from "@/components/ui"; +import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang"; +import { + paarImportierenAktion, + publicKeyImportierenAktion, + schluesselErzeugenAktion, + type SchluesselAntwort, +} from "@/server/actions/schluessel"; + +/** + * Neuer Schluessel (#012): erzeugen oder importieren (nur Public Key oder Paar). + * + * Erzeugen und Paar-Import laufen als Vorgang im Worker; das Formular verfolgt ihn und + * springt nach Erfolg zum Schluessel. Passphrase und Private Key stehen nur so lange im + * Formular, bis sie abgeschickt sind. + */ +type Modus = "erzeugen" | "public" | "paar"; + +export type Auswahl = { id: string; name: string; zusatz?: string | null }; + +const HINWEIS_PASSPHRASE = "Für automatische Anmeldungen (Host-zu-Host) ungeeignet."; + +function Umschalter({ + wert, + setzen, + optionen, + beschriftung, +}: { + wert: string; + setzen: (wert: string) => void; + optionen: { wert: string; text: string; icon?: ReactNode }[]; + beschriftung: string; +}) { + return ( +
    + {optionen.map((o) => ( + + ))} +
    + ); +} + +export function SchluesselNeu({ + personen, + vorlagen, + vorgabePersonId, +}: { + personen: Auswahl[]; + vorlagen: Auswahl[]; + vorgabePersonId?: string; +}) { + const router = useRouter(); + const [art, setArt] = useState<"erzeugen" | "importieren">("erzeugen"); + const [importArt, setImportArt] = useState<"public" | "paar">("public"); + const modus: Modus = art === "erzeugen" ? "erzeugen" : importArt; + + const [antwort, setAntwort] = useState({}); + const [sendet, senden] = useTransition(); + const { stand, abfrageFehler } = useVorgang(antwort.vorgangId); + + // Geheimnisse kontrolliert, damit sie nach dem Absenden sofort aus dem Formular verschwinden. + const [privateKey, setPrivateKey] = useState(""); + const [passphrase, setPassphrase] = useState(""); + const [wiederholung, setWiederholung] = useState(""); + + const ergebnisId = + stand?.status === "ERLEDIGT" && stand.ergebnis && typeof stand.ergebnis === "object" + ? (stand.ergebnis as { schluesselId?: unknown }).schluesselId + : undefined; + + useEffect(() => { + if (typeof ergebnisId === "string") router.push(`/schluessel/${ergebnisId}`); + }, [ergebnisId, router]); + + const vorgangLaeuft = antwort.vorgangId !== undefined && istLaufend(stand) && !abfrageFehler; + const beschaeftigt = sendet || vorgangLaeuft || typeof ergebnisId === "string"; + const f = antwort.feldfehler ?? {}; + const fehler = + antwort.fehler ?? + abfrageFehler ?? + (stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined); + + function absenden(ereignis: FormEvent) { + ereignis.preventDefault(); + const daten = new FormData(ereignis.currentTarget); + const aktion = + modus === "erzeugen" + ? schluesselErzeugenAktion + : modus === "paar" + ? paarImportierenAktion + : publicKeyImportierenAktion; + senden(async () => { + setAntwort({}); + const neu = await aktion({}, daten); + if (neu.vorgangId || neu.schluesselId) { + setPrivateKey(""); + setPassphrase(""); + setWiederholung(""); + } + if (neu.schluesselId) router.push(`/schluessel/${neu.schluesselId}`); + setAntwort(neu); + }); + } + + const knopfText = beschaeftigt + ? modus === "erzeugen" + ? "Erzeuge …" + : "Importiere …" + : modus === "erzeugen" + ? "Schlüssel erzeugen" + : "Schlüssel importieren"; + + return ( +
    +
    + { + setArt(w as typeof art); + setAntwort({}); + }} + optionen={[ + { wert: "erzeugen", text: "Erzeugen", icon: }, + { wert: "importieren", text: "Importieren", icon: }, + ]} + /> + {art === "importieren" && ( + { + setImportArt(w as typeof importArt); + setAntwort({}); + }} + optionen={[ + { wert: "public", text: "Nur Public Key" }, + { wert: "paar", text: "Schlüsselpaar" }, + ]} + /> + )} +
    + + + + +
    + + + " : "aus dem Schlüssel"} + hinweis="Steht hinter dem Public Key in authorized_keys." + fehler={f["kommentar"]} + /> +
    +
    + + {modus === "erzeugen" && ( + +
    + +
    + ed25519} + erklaerung="Empfohlen – kurz, schnell, sicher." + /> + rsa-4096} + erklaerung="Nur für alte Gegenstellen ohne ed25519." + /> +
    +
    +

    + Kategorie: Dienst – Personen hinterlegen ihre Public Keys + selbst unter „Meine Schlüssel“, deren Private Keys speichert sshkv nie. +

    +
    + setPassphrase(e.target.value)} + hinweis={`Wird nicht gespeichert. ${HINWEIS_PASSPHRASE}`} + fehler={f["passphrase"]} + /> + setWiederholung(e.target.value)} + disabled={!passphrase} + fehler={f["passphraseWiederholung"]} + /> +
    +
    +
    + )} + + {modus === "public" && ( + +
    + + +
    + + +
    +
    +
    +
    + )} + + {modus === "paar" && ( + +
    + setPrivateKey(e.target.value)} + placeholder={"-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----"} + fehler={f["privateKey"]} + className="[&_textarea]:font-mono [&_textarea]:text-[12.5px]" + /> + setPassphrase(e.target.value)} + hinweis={`Nur zur Prüfung, wird verworfen. Der Key bleibt passphrasengeschützt gespeichert – ${HINWEIS_PASSPHRASE.toLowerCase()}`} + fehler={f["passphrase"]} + className="sm:max-w-md" + /> +

    + Kategorie: Dienst – Private Keys von Personen speichert sshkv + nie. +

    +
    +
    + )} + + +
    + + + {personen.map((p) => ( + + ))} + + + + {vorlagen.map((v) => ( + + ))} + + + +
    +
    + +
    + + +

    + {vorgangLaeuft && "Der Worker arbeitet …"} +

    +
    + + ); +} diff --git a/src/components/ui/MonoBlock.tsx b/src/components/ui/MonoBlock.tsx index a4e71cc..cbbb18f 100644 --- a/src/components/ui/MonoBlock.tsx +++ b/src/components/ui/MonoBlock.tsx @@ -1,5 +1,6 @@ "use client"; +import { clsx } from "clsx"; import { Check, Copy } from "lucide-react"; import { useState } from "react"; @@ -8,10 +9,13 @@ export function MonoBlock({ wert, beschriftung, hoehe, + umbrechen = false, }: { wert: string; beschriftung: string; hoehe?: string; + /** Lange Einzeiler (Public Keys) umbrechen statt seitlich scrollen (#012). */ + umbrechen?: boolean; }) { const [kopiert, setKopiert] = useState(false); return ( @@ -33,7 +37,10 @@ export function MonoBlock({ {kopiert ? : }
    ();
    -  const [fehler, setFehler] = useState<{ id: string; text: string }>();
    +type Gemerkt = { readonly id: string; readonly stand?: VorgangsStand; readonly fehler?: string };
    +
    +export function useVorgang(vorgangId: string | undefined): {
    +  stand: VorgangsStand | undefined;
    +  /** Die Statusabfrage selbst scheiterte (nicht der Vorgang). */
    +  abfrageFehler: string | undefined;
    +  /** Gleichbedeutend mit abfrageFehler (aeltere Aufrufer, #016). */
    +  fehler: string | undefined;
    +} {
    +  const [gemerkt, setGemerkt] = useState();
     
       useEffect(() => {
         if (!vorgangId) return;
    -    let abgebrochen = false;
    -    void (async () => {
    -      while (!abgebrochen) {
    +    let abbrechen = false;
    +    const abfragen = async () => {
    +      while (!abbrechen) {
             const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
    +        if (abbrechen) return;
             if (!antwort.ok) {
    -          setFehler({ id: vorgangId, text: "Status des Vorgangs nicht abrufbar." });
    +          setGemerkt({ id: vorgangId, fehler: "Status des Vorgangs nicht abrufbar." });
               return;
             }
             const neu = (await antwort.json()) as VorgangsStand;
    -        if (abgebrochen) return;
    -        setStand(neu);
    +        if (abbrechen) return;
    +        setGemerkt({ id: vorgangId, stand: neu });
             if (!LAUFEND.has(neu.status)) return;
    -        await new Promise((weiter) => setTimeout(weiter, 1000));
    +        await new Promise((weiter) => setTimeout(weiter, 800));
           }
    -    })();
    +    };
    +    void abfragen();
         return () => {
    -      abgebrochen = true;
    +      abbrechen = true;
         };
       }, [vorgangId]);
     
    -  return {
    -    ...(stand && stand.id === vorgangId ? { stand } : {}),
    -    ...(fehler && fehler.id === vorgangId ? { fehler: fehler.text } : {}),
    -  };
    +  const aktuell = gemerkt && gemerkt.id === vorgangId ? gemerkt : undefined;
    +  return { stand: aktuell?.stand, abfrageFehler: aktuell?.fehler, fehler: aktuell?.fehler };
     }
    diff --git a/src/lib/personen.ts b/src/lib/personen.ts
    new file mode 100644
    index 0000000..ed5458a
    --- /dev/null
    +++ b/src/lib/personen.ts
    @@ -0,0 +1,52 @@
    +import { z } from "@/lib/validierung";
    +
    +/**
    + * Eingaben fuer Personen und den Self-Service (#013) – framework-frei, damit Service,
    + * Actions und Tests dieselben Regeln teilen.
    + */
    +
    +const optionalerText = (max: number) =>
    +  z
    +    .string()
    +    .trim()
    +    .max(max, `Höchstens ${max} Zeichen.`)
    +    .nullish()
    +    .transform((wert) => (wert ? wert : null));
    +
    +export const personEingabeSchema = z.object({
    +  name: z.string().trim().min(1, "Bitte einen Namen angeben.").max(120, "Höchstens 120 Zeichen."),
    +  email: z
    +    .string()
    +    .trim()
    +    .toLowerCase()
    +    .max(254)
    +    .nullish()
    +    .transform((wert) => (wert ? wert : null))
    +    .refine(
    +      (wert) => wert === null || z.email().safeParse(wert).success,
    +      "Bitte eine gültige E-Mail-Adresse.",
    +    ),
    +  organisation: optionalerText(120),
    +  notiz: optionalerText(1000),
    +  benutzerId: z
    +    .string()
    +    .trim()
    +    .max(64)
    +    .nullish()
    +    .transform((wert) => (wert ? wert : null)),
    +});
    +
    +export type PersonEingabe = z.input;
    +export type PersonDaten = z.output;
    +
    +/** Eigener Public Key im Self-Service: Zweck und die Zeile, sonst nichts. */
    +export const eigenerKeySchema = z.object({
    +  name: z.string().trim().min(1, "Bitte eine Bezeichnung angeben.").max(120, "Höchstens 120 Zeichen."),
    +  publicKey: z
    +    .string()
    +    .trim()
    +    .min(1, "Bitte den Public Key einfügen.")
    +    .max(16 * 1024, "Der Text ist zu lang für einen Public Key."),
    +});
    +
    +export type EigenerKeyEingabe = z.input;
    diff --git a/src/lib/schluessel.ts b/src/lib/schluessel.ts
    new file mode 100644
    index 0000000..31c6c5d
    --- /dev/null
    +++ b/src/lib/schluessel.ts
    @@ -0,0 +1,292 @@
    +import { MAX_TAGS, TAG } from "@/lib/hosts";
    +import { tageBis } from "@/lib/format";
    +import { z } from "@/lib/validierung";
    +
    +/**
    + * Eingaben und Anzeigeregeln fuer Schluessel (#012).
    + *
    + * Framework-frei: Web-Service, Server Actions, Worker-Ablaeufe und Tests teilen dieselben
    + * Regeln. Der Worker prueft die Metadaten aus `Vorgang.eingabe` noch einmal, weil zwischen
    + * Anfrage und Ausfuehrung Zeit vergeht und die Eingabe in der Datenbank liegt.
    + */
    +
    +/** Wie der DB-CHECK `schluessel_slug_gueltig`. */
    +export const SLUG = /^[a-z0-9-]{1,64}$/;
    +export const NAME_MAX = 120;
    +export const KOMMENTAR_MAX = 200;
    +/** Ein OpenSSH-Private-Key mit RSA-16384 bleibt unter 16 KiB; mehr ist kein Schluessel. */
    +export const PRIVATE_KEY_MAX = 16 * 1024;
    +export const PASSPHRASE_MAX = 1024;
    +/**
    + * Wie lange ein vom Worker geoeffneter Private Key zur Abholung bereitliegt (#014). Worker
    + * (Aufraeumen) und Web (Abholen) pruefen dieselbe Frist.
    + */
    +export const ABHOLFRIST_MS = 2 * 60 * 1000;
    +/** Ab hier wird ein Ablaufdatum in Messing hervorgehoben. */
    +export const ABLAUF_WARNUNG_TAGE = 30;
    +
    +const UMLAUTE: Record = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" };
    +
    +/**
    + * Slug aus dem Zweck: „Backup srv-backup01 → srv-pve01“ wird zu
    + * `backup-srv-backup01-srv-pve01`. Ohne verwertbare Zeichen bleibt „schluessel“.
    + */
    +export function slugAus(name: string): string {
    +  const slug = name
    +    .toLowerCase()
    +    .replace(/[äöüß]/g, (z) => UMLAUTE[z] ?? z)
    +    .normalize("NFKD")
    +    .replace(/[̀-ͯ]/g, "")
    +    .replace(/[^a-z0-9]+/g, "-")
    +    .replace(/^-+|-+$/g, "")
    +    .slice(0, 64)
    +    .replace(/-+$/, "");
    +  return slug || "schluessel";
    +}
    +
    +/** Haengt `-2`, `-3` … an, bis `istFrei` zustimmt; kuerzt vorher, damit 64 Zeichen reichen. */
    +export async function freierSlug(
    +  basis: string,
    +  istFrei: (slug: string) => Promise,
    +): Promise {
    +  if (await istFrei(basis)) return basis;
    +  for (let n = 2; n < 1000; n++) {
    +    const anhang = `-${n}`;
    +    const kandidat = `${basis.slice(0, 64 - anhang.length).replace(/-+$/, "")}${anhang}`;
    +    if (await istFrei(kandidat)) return kandidat;
    +  }
    +  throw new Error("Kein freier Slug gefunden.");
    +}
    +
    +/** Freitext aus einem Formular: leer heisst „nicht gesetzt“. */
    +const optionalerText = (max: number) =>
    +  z
    +    .string()
    +    .trim()
    +    .max(max, `Höchstens ${max} Zeichen.`)
    +    .nullish()
    +    .transform((wert) => (wert ? wert : null));
    +
    +const optionaleId = z
    +  .string()
    +  .trim()
    +  .max(64)
    +  .nullish()
    +  .transform((wert) => (wert ? wert : null));
    +
    +const tagsSchema = z
    +  .preprocess(
    +    (wert) => (typeof wert === "string" ? wert.split(/[\s,]+/).filter(Boolean) : (wert ?? [])),
    +    z.array(
    +      z
    +        .string()
    +        .trim()
    +        .toLowerCase()
    +        .regex(TAG, "Tags: 1–32 Zeichen aus a–z, 0–9, Punkt, Unterstrich, Bindestrich."),
    +    ),
    +  )
    +  .transform((tags) => [...new Set(tags)])
    +  .refine((tags) => tags.length <= MAX_TAGS, `Höchstens ${MAX_TAGS} Tags.`);
    +
    +/**
    + * Ablaufdatum als `JJJJ-MM-TT` aus ``. Gespeichert wird Mitternacht UTC
    + * des Tages – `datum()` zeigt in Europe/Berlin denselben Kalendertag.
    + */
    +const ablaufSchema = z
    +  .string()
    +  .trim()
    +  .nullish()
    +  .transform((wert, kontext) => {
    +    if (!wert) return null;
    +    if (!/^\d{4}-\d{2}-\d{2}$/.test(wert)) {
    +      kontext.addIssue({ code: "custom", message: "Bitte ein Datum angeben." });
    +      return z.NEVER;
    +    }
    +    const datum = new Date(`${wert}T00:00:00Z`);
    +    if (Number.isNaN(datum.getTime()) || datum.toISOString().slice(0, 10) !== wert) {
    +      kontext.addIssue({ code: "custom", message: "Bitte ein gültiges Datum angeben." });
    +      return z.NEVER;
    +    }
    +    return datum;
    +  });
    +
    +/** Kommentar der Public-Key-Zeile: keine Steuerzeichen, sie braechen authorized_keys auf. */
    +const kommentarSchema = optionalerText(KOMMENTAR_MAX).refine(
    +  (wert) => wert === null || !/[\x00-\x1f\x7f]/.test(wert),
    +  "Der Kommentar enthält unzulässige Zeichen.",
    +);
    +
    +const slugSchema = z
    +  .string()
    +  .trim()
    +  .toLowerCase()
    +  .nullish()
    +  .transform((wert) => (wert ? wert : null))
    +  .refine((wert) => wert === null || SLUG.test(wert), "1–64 Zeichen aus a–z, 0–9 und Bindestrich.");
    +
    +/** Metadaten, die jeder Schluessel hat – beim Anlegen und Bearbeiten. */
    +const metadatenFelder = {
    +  name: z.string().trim().min(1, "Bitte den Zweck angeben.").max(NAME_MAX, `Höchstens ${NAME_MAX} Zeichen.`),
    +  tags: tagsSchema,
    +  ablaufAm: ablaufSchema,
    +  personId: optionaleId,
    +  vorlageId: optionaleId,
    +};
    +
    +/**
    + * Erzeugen (#012). Kategorie ist fest DIENST: Personen-Keys speichert sshkv nie mit Private
    + * Key (DB-CHECK `schluessel_person_ohne_private_key`), Deployment-Keys verwaltet der Worker.
    + */
    +export const erzeugenSchema = z.object({
    +  ...metadatenFelder,
    +  slug: slugSchema,
    +  typ: z.enum(["ED25519", "RSA4096"]).default("ED25519"),
    +  kommentar: kommentarSchema,
    +});
    +
    +/** Import eines Paars: dieselben Regeln wie beim Erzeugen, der Typ steckt im Key selbst. */
    +export const paarImportSchema = z.object({
    +  ...metadatenFelder,
    +  slug: slugSchema,
    +  kommentar: kommentarSchema,
    +});
    +
    +/** Import nur des Public Keys: auch fuer Personen-Keys. */
    +export const publicImportSchema = z.object({
    +  ...metadatenFelder,
    +  slug: slugSchema,
    +  kategorie: z.enum(["PERSON", "DIENST"]).default("DIENST"),
    +  publicKey: z
    +    .string()
    +    .trim()
    +    .min(1, "Bitte den Public Key einfügen.")
    +    .max(16 * 1024, "Der Text ist zu lang für einen Public Key."),
    +  kommentar: kommentarSchema,
    +});
    +
    +/**
    + * Bearbeiten: Slug, Typ und Kommentar bleiben – sie stehen schon in Dateinamen und
    + * authorized_keys-Zeilen auf den Hosts. ROTIERT/WIDERRUFEN setzen nur Rotation und Widerruf
    + * (#023, #024); von Hand gibt es nur AKTIV und ABGELAUFEN.
    + */
    +export const bearbeitenSchema = z.object({
    +  ...metadatenFelder,
    +  status: z.enum(["AKTIV", "ABGELAUFEN"]).optional(),
    +});
    +
    +/** Tags kommen als Text („pve, backup“) oder Liste; `z.preprocess` liesse sonst nur `unknown` uebrig. */
    +type MitTags = Omit & { tags?: string | string[] };
    +export type ErzeugenEingabe = MitTags>;
    +export type ErzeugenDaten = z.output;
    +export type PaarImportEingabe = MitTags>;
    +export type PaarImportDaten = z.output;
    +export type PublicImportEingabe = MitTags>;
    +export type PublicImportDaten = z.output;
    +export type BearbeitenEingabe = MitTags>;
    +export type BearbeitenDaten = z.output;
    +
    +/**
    + * Was ein Erzeugen- oder Import-Vorgang in `Vorgang.eingabe` traegt – nur Metadaten, nie ein
    + * Geheimnis. Datumswerte als ISO-Text, weil `eingabe` JSON ist.
    + */
    +export type SchluesselVorgangEingabe = {
    +  readonly name: string;
    +  readonly slug: string;
    +  readonly tags: string[];
    +  readonly ablaufAm: string | null;
    +  readonly personId: string | null;
    +  readonly vorlageId: string | null;
    +  readonly kommentar: string | null;
    +  readonly typ?: "ED25519" | "RSA4096";
    +  /** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
    +  readonly mitPassphrase?: boolean;
    +};
    +
    +/** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */
    +export const vorgangEingabeSchema = z.object({
    +  name: z.string().trim().min(1).max(NAME_MAX),
    +  slug: z.string().regex(SLUG),
    +  tags: z.array(z.string().regex(TAG)).max(MAX_TAGS),
    +  ablaufAm: z.iso.datetime().nullable(),
    +  personId: z.string().max(64).nullable(),
    +  vorlageId: z.string().max(64).nullable(),
    +  kommentar: z
    +    .string()
    +    .max(KOMMENTAR_MAX)
    +    .refine((wert) => !/[\x00-\x1f\x7f]/.test(wert))
    +    .nullable(),
    +  typ: z.enum(["ED25519", "RSA4096"]).optional(),
    +  mitPassphrase: z.boolean().optional(),
    +});
    +
    +/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
    +export type SchluesselGeheimnis = {
    +  readonly privateKey?: string;
    +  readonly passphrase?: string;
    +};
    +
    +// ---------------------------------------------------------------- Anzeige
    +
    +export type Kategorie = "PERSON" | "DIENST" | "DEPLOYMENT";
    +export type Status = "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
    +
    +export const KATEGORIE_TEXT: Record = {
    +  PERSON: "Person",
    +  DIENST: "Dienst",
    +  DEPLOYMENT: "Deployment",
    +};
    +
    +export const STATUS_TEXT: Record = {
    +  AKTIV: "aktiv",
    +  ROTIERT: "rotiert",
    +  WIDERRUFEN: "widerrufen",
    +  ABGELAUFEN: "abgelaufen",
    +};
    +
    +/** Kurzform fuer Listen: `ed25519`, `rsa-4096`, sonst der Typ aus der Public-Key-Zeile. */
    +export function typAnzeige(typ: "ED25519" | "RSA4096" | "ANDERER", publicKey: string): string {
    +  if (typ === "ED25519") return "ed25519";
    +  if (typ === "RSA4096") return "rsa-4096";
    +  const algorithmus = publicKey.split(/\s+/)[0] ?? "";
    +  if (algorithmus === "ssh-rsa") return "rsa";
    +  return algorithmus.replace(/^ssh-/, "").replace(/@openssh\.com$/, "") || "unbekannt";
    +}
    +
    +/** `SHA256:q3vN8xP0…Qe0k` – lang genug zum Wiedererkennen, kurz genug fuer eine Tabellenzeile. */
    +export function fingerprintKurz(fingerprint: string): string {
    +  const [praefix, rest] = fingerprint.includes(":") ? fingerprint.split(":", 2) : ["", fingerprint];
    +  const wert = rest ?? "";
    +  if (wert.length <= 14) return fingerprint;
    +  return `${praefix ? `${praefix}:` : ""}${wert.slice(0, 8)}…${wert.slice(-4)}`;
    +}
    +
    +export type AblaufZustand =
    +  | { readonly art: "keiner" }
    +  | { readonly art: "spaeter"; readonly tage: number }
    +  | { readonly art: "bald"; readonly tage: number }
    +  | { readonly art: "abgelaufen"; readonly tage: number };
    +
    +/** Wie dringend ist das Ablaufdatum? `bald` heisst: in hoechstens 30 Tagen. */
    +export function ablaufZustand(ablaufAm: Date | string | null, jetzt: Date = new Date()): AblaufZustand {
    +  if (!ablaufAm) return { art: "keiner" };
    +  const tage = tageBis(ablaufAm, jetzt);
    +  if (tage < 0) return { art: "abgelaufen", tage };
    +  if (tage <= ABLAUF_WARNUNG_TAGE) return { art: "bald", tage };
    +  return { art: "spaeter", tage };
    +}
    +
    +/** Typ fuer die Datenbank aus Algorithmus und Laenge: nur ed25519 und RSA-4096 haben eigene Werte. */
    +export function schluesselTypVon(
    +  algorithmus: string,
    +  bits: number | null,
    +): "ED25519" | "RSA4096" | "ANDERER" {
    +  if (algorithmus === "ssh-ed25519") return "ED25519";
    +  if (algorithmus === "ssh-rsa" && bits === 4096) return "RSA4096";
    +  return "ANDERER";
    +}
    +
    +/** Public-Key-Zeile mit dem gespeicherten Kommentar: `typ base64 kommentar`. */
    +export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): string {
    +  const [typ, base64] = zeile.trim().split(/\s+/);
    +  return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
    +}
    diff --git a/src/server/actions/meine-schluessel.ts b/src/server/actions/meine-schluessel.ts
    new file mode 100644
    index 0000000..170a332
    --- /dev/null
    +++ b/src/server/actions/meine-schluessel.ts
    @@ -0,0 +1,43 @@
    +"use server";
    +
    +import { revalidatePath } from "next/cache";
    +import { fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
    +import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
    +import { eigenenPublicKeyHinterlegen, eigenenSchluesselEntfernen } from "@/server/services/meine-schluessel";
    +
    +/**
    + * Self-Service (#013). Recht `eigeneSchluesselHinterlegen` hat jede Rolle; der Service
    + * arbeitet ausschliesslich mit der Person des angemeldeten Kontos.
    + */
    +export type HinterlegenZustand = FormularZustand & { readonly erfolg?: string };
    +
    +export async function eigenenKeyHinterlegenAktion(
    +  _vorher: HinterlegenZustand,
    +  formular: FormData,
    +): Promise {
    +  try {
    +    const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen");
    +    const neu = await eigenenPublicKeyHinterlegen(benutzer, {
    +      name: textfeld(formular, "name") ?? "",
    +      publicKey: textfeld(formular, "publicKey") ?? "",
    +    });
    +    revalidatePath("/meine-schluessel");
    +    return { erfolg: `Hinterlegt: ${neu.fingerprint}` };
    +  } catch (fehler) {
    +    return fehlerZustand(fehler);
    +  }
    +}
    +
    +export async function eigenenKeyEntfernenAktion(
    +  _vorher: FormularZustand,
    +  formular: FormData,
    +): Promise {
    +  try {
    +    const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen");
    +    await eigenenSchluesselEntfernen(benutzer, textfeld(formular, "id") ?? "");
    +    revalidatePath("/meine-schluessel");
    +    return {};
    +  } catch (fehler) {
    +    return fehlerZustand(fehler);
    +  }
    +}
    diff --git a/src/server/actions/personen.ts b/src/server/actions/personen.ts
    new file mode 100644
    index 0000000..5db83cc
    --- /dev/null
    +++ b/src/server/actions/personen.ts
    @@ -0,0 +1,68 @@
    +"use server";
    +
    +import { revalidatePath } from "next/cache";
    +import { redirect } from "next/navigation";
    +import type { PersonEingabe } from "@/lib/personen";
    +import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
    +import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
    +import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen";
    +
    +/**
    + * Formular-Aktionen fuer Personen (#013). Personen pflegt, wer Schluessel verwaltet (Admin,
    + * Operator) – eine Person ist die Klammer um Personen-Keys.
    + */
    +
    +function personFelder(formular: FormData): PersonEingabe {
    +  return {
    +    name: textfeld(formular, "name") ?? "",
    +    email: textfeld(formular, "email"),
    +    organisation: textfeld(formular, "organisation"),
    +    notiz: textfeld(formular, "notiz"),
    +    benutzerId: textfeld(formular, "benutzerId"),
    +  };
    +}
    +
    +export async function personAnlegenAktion(
    +  _vorher: FormularZustand,
    +  formular: FormData,
    +): Promise {
    +  let id: string;
    +  try {
    +    const { benutzer } = await rechtErforderlich("schluesselVerwalten");
    +    ({ id } = await personAnlegen(personFelder(formular), akteurVon(benutzer)));
    +  } catch (fehler) {
    +    return fehlerZustand(fehler);
    +  }
    +  revalidatePath("/personen");
    +  redirect(`/personen/${id}`);
    +}
    +
    +export async function personBearbeitenAktion(
    +  _vorher: FormularZustand,
    +  formular: FormData,
    +): Promise {
    +  const id = textfeld(formular, "id") ?? "";
    +  try {
    +    const { benutzer } = await rechtErforderlich("schluesselVerwalten");
    +    await personBearbeiten(id, personFelder(formular), akteurVon(benutzer));
    +  } catch (fehler) {
    +    return fehlerZustand(fehler);
    +  }
    +  revalidatePath("/personen", "layout");
    +  redirect(`/personen/${id}`);
    +}
    +
    +export async function personArchivierenAktion(
    +  _vorher: FormularZustand,
    +  formular: FormData,
    +): Promise {
    +  const id = textfeld(formular, "id") ?? "";
    +  try {
    +    const { benutzer } = await rechtErforderlich("schluesselVerwalten");
    +    await personArchivieren(id, akteurVon(benutzer));
    +  } catch (fehler) {
    +    return fehlerZustand(fehler);
    +  }
    +  revalidatePath("/personen", "layout");
    +  redirect("/personen");
    +}
    diff --git a/src/server/actions/privatekey.ts b/src/server/actions/privatekey.ts
    new file mode 100644
    index 0000000..cac2f03
    --- /dev/null
    +++ b/src/server/actions/privatekey.ts
    @@ -0,0 +1,70 @@
    +"use server";
    +
    +import { revalidatePath } from "next/cache";
    +import { z } from "@/lib/validierung";
    +import { akteurVon } from "@/server/actions/formular";
    +import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
    +import { HostFehler } from "@/server/services/hosts";
    +import {
    +  abholungAnfordern,
    +  privateKeyAbholen,
    +  privateKeyLoeschenAnfordern,
    +} from "@/server/services/privatekey";
    +
    +/**
    + * Private Key anzeigen und loeschen (#014). Jede Aktion prueft selbst Recht **und** frische
    + * Bestaetigung (`rechtErforderlich` mit einem Recht aus BESTAETIGUNG_NOETIG). Fehlt die
    + * Bestaetigung, meldet die Antwort `bestaetigen: true`, und die Oberflaeche leitet nach
    + * /bestaetigen weiter.
    + *
    + * `privateKeyAnzeigenAktion` ist die einzige Stelle, an der ein Private Key als Antwort an den
    + * Browser geht – genau einmal, fuer die Anzeige im Dialog.
    + */
    +export type PrivateKeyAntwort = {
    +  readonly vorgangId?: string;
    +  readonly fehler?: string;
    +  readonly bestaetigen?: boolean;
    +  readonly privateKey?: string;
    +};
    +
    +const id = z.string().min(1).max(64);
    +const zweck = z.enum(["anzeigen", "herunterladen"]);
    +
    +function fehlerAntwort(fehler: unknown): PrivateKeyAntwort {
    +  if (fehler instanceof BestaetigungFehler) return { fehler: fehler.message, bestaetigen: true };
    +  if (fehler instanceof RechteFehler || fehler instanceof HostFehler) return { fehler: fehler.message };
    +  if (fehler instanceof z.ZodError) return { fehler: "Ungültige Anfrage." };
    +  throw fehler;
    +}
    +
    +export async function abholungAnfordernAktion(schluesselId: string, art: string): Promise {
    +  try {
    +    const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
    +    return {
    +      vorgangId: await abholungAnfordern(id.parse(schluesselId), zweck.parse(art), akteurVon(benutzer)),
    +    };
    +  } catch (fehler) {
    +    return fehlerAntwort(fehler);
    +  }
    +}
    +
    +export async function privateKeyAnzeigenAktion(vorgangId: string): Promise {
    +  try {
    +    const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
    +    const { privateKey } = await privateKeyAbholen(id.parse(vorgangId), "anzeigen", akteurVon(benutzer));
    +    return { privateKey };
    +  } catch (fehler) {
    +    return fehlerAntwort(fehler);
    +  }
    +}
    +
    +export async function privateKeyLoeschenAktion(schluesselId: string): Promise {
    +  try {
    +    const { benutzer } = await rechtErforderlich("privateKeyLoeschen");
    +    const vorgangId = await privateKeyLoeschenAnfordern(id.parse(schluesselId), akteurVon(benutzer));
    +    revalidatePath(`/schluessel/${schluesselId}`);
    +    return { vorgangId };
    +  } catch (fehler) {
    +    return fehlerAntwort(fehler);
    +  }
    +}
    diff --git a/src/server/actions/schluessel.ts b/src/server/actions/schluessel.ts
    new file mode 100644
    index 0000000..03b6fed
    --- /dev/null
    +++ b/src/server/actions/schluessel.ts
    @@ -0,0 +1,137 @@
    +"use server";
    +
    +import { revalidatePath } from "next/cache";
    +import { redirect } from "next/navigation";
    +import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
    +import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
    +import {
    +  erzeugenAnfordern,
    +  paarImportAnfordern,
    +  publicKeyImportieren,
    +  schluesselBearbeiten,
    +} from "@/server/services/schluessel";
    +
    +/**
    + * Aktionen fuer Schluessel (#012). Duenn: Recht pruefen, Felder lesen, Service rufen.
    + *
    + * Passphrase und Private Key verlassen diese Funktionen nur verschluesselt im Job (der
    + * Service verschluesselt sie) – nie in einer Antwort, einem Log oder dem Protokoll.
    + */
    +export type SchluesselAntwort = FormularZustand & {
    +  /** Vorgang im Worker (Erzeugen, Paar-Import) – die Oberflaeche verfolgt ihn. */
    +  readonly vorgangId?: string;
    +  /** Sofort angelegt (Public-Key-Import). */
    +  readonly schluesselId?: string;
    +};
    +
    +function metadaten(formular: FormData) {
    +  return {
    +    name: textfeld(formular, "name") ?? "",
    +    slug: textfeld(formular, "slug"),
    +    kommentar: textfeld(formular, "kommentar"),
    +    tags: textfeld(formular, "tags") ?? "",
    +    ablaufAm: textfeld(formular, "ablaufAm"),
    +    personId: textfeld(formular, "personId"),
    +    vorlageId: textfeld(formular, "vorlageId"),
    +  };
    +}
    +
    +/** Passphrase mit Wiederholung – die Wiederholung prueft der Server, nicht nur das Formular. */
    +function passphraseLesen(formular: FormData): { passphrase?: string; fehler?: SchluesselAntwort } {
    +  const passphrase = textfeld(formular, "passphrase");
    +  const wiederholung = textfeld(formular, "passphraseWiederholung");
    +  if (passphrase && passphrase !== wiederholung) {
    +    return {
    +      fehler: {
    +        fehler: "Die Passphrasen stimmen nicht überein.",
    +        feldfehler: { passphraseWiederholung: "Stimmt nicht mit der Passphrase überein." },
    +      },
    +    };
    +  }
    +  return passphrase ? { passphrase } : {};
    +}
    +
    +export async function schluesselErzeugenAktion(
    +  _vorher: SchluesselAntwort,
    +  formular: FormData,
    +): Promise {
    +  try {
    +    const { benutzer } = await rechtErforderlich("schluesselVerwalten");
    +    const { passphrase, fehler } = passphraseLesen(formular);
    +    if (fehler) return fehler;
    +    const vorgangId = await erzeugenAnfordern(
    +      { ...metadaten(formular), typ: textfeld(formular, "typ") as "ED25519" | "RSA4096" | undefined },
    +      passphrase,
    +      akteurVon(benutzer),
    +    );
    +    return { vorgangId };
    +  } catch (fehler) {
    +    return fehlerZustand(fehler);
    +  }
    +}
    +
    +export async function paarImportierenAktion(
    +  _vorher: SchluesselAntwort,
    +  formular: FormData,
    +): Promise {
    +  try {
    +    const { benutzer } = await rechtErforderlich("schluesselVerwalten");
    +    const vorgangId = await paarImportAnfordern(
    +      metadaten(formular),
    +      textfeld(formular, "privateKey"),
    +      // Beim Import nur zur Pruefung – keine Wiederholung noetig, falsche merkt der Worker.
    +      textfeld(formular, "passphrase"),
    +      akteurVon(benutzer),
    +    );
    +    return { vorgangId };
    +  } catch (fehler) {
    +    return fehlerZustand(fehler);
    +  }
    +}
    +
    +export async function publicKeyImportierenAktion(
    +  _vorher: SchluesselAntwort,
    +  formular: FormData,
    +): Promise {
    +  try {
    +    const { benutzer } = await rechtErforderlich("schluesselVerwalten");
    +    const schluessel = await publicKeyImportieren(
    +      {
    +        ...metadaten(formular),
    +        kategorie: textfeld(formular, "kategorie") as "PERSON" | "DIENST" | undefined,
    +        publicKey: textfeld(formular, "publicKey") ?? "",
    +      },
    +      akteurVon(benutzer),
    +    );
    +    revalidatePath("/schluessel");
    +    return { schluesselId: schluessel.id };
    +  } catch (fehler) {
    +    return fehlerZustand(fehler);
    +  }
    +}
    +
    +export async function schluesselBearbeitenAktion(
    +  _vorher: FormularZustand,
    +  formular: FormData,
    +): Promise {
    +  const id = textfeld(formular, "id") ?? "";
    +  try {
    +    const { benutzer } = await rechtErforderlich("schluesselVerwalten");
    +    await schluesselBearbeiten(
    +      id,
    +      {
    +        name: textfeld(formular, "name") ?? "",
    +        tags: textfeld(formular, "tags") ?? "",
    +        ablaufAm: textfeld(formular, "ablaufAm"),
    +        personId: textfeld(formular, "personId"),
    +        vorlageId: textfeld(formular, "vorlageId"),
    +        status: textfeld(formular, "status") as "AKTIV" | "ABGELAUFEN" | undefined,
    +      },
    +      akteurVon(benutzer),
    +    );
    +  } catch (fehler) {
    +    return fehlerZustand(fehler);
    +  }
    +  revalidatePath("/schluessel", "layout");
    +  redirect(`/schluessel/${id}`);
    +}
    diff --git a/src/server/auth/rechte.ts b/src/server/auth/rechte.ts
    index d822225..4e618b9 100644
    --- a/src/server/auth/rechte.ts
    +++ b/src/server/auth/rechte.ts
    @@ -16,6 +16,7 @@ export type Recht =
       | "publicKeyAusrollen"
       | "privateKeyAusrollen"
       | "privateKeyAnzeigen"
    +  | "privateKeyLoeschen"
       | "deploymentKeyVerwalten"
       | "massenwiderruf"
       | "zielkontenVerwalten"
    @@ -33,6 +34,8 @@ export const RECHTE: Readonly> = {
       // Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins.
       privateKeyAusrollen: ["ADMIN"],
       privateKeyAnzeigen: ["ADMIN"],
    +  // Endgueltig und nicht rueckgaengig zu machen (#014) – wie Anzeigen nur mit Re-Auth.
    +  privateKeyLoeschen: ["ADMIN"],
       deploymentKeyVerwalten: ["ADMIN"],
       massenwiderruf: ["ADMIN"],
       zielkontenVerwalten: ["ADMIN"],
    @@ -42,6 +45,7 @@ export const RECHTE: Readonly> = {
     /** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
     export const BESTAETIGUNG_NOETIG: ReadonlySet = new Set([
       "privateKeyAnzeigen",
    +  "privateKeyLoeschen",
       "deploymentKeyVerwalten",
       "massenwiderruf",
       "zielkontenVerwalten",
    diff --git a/src/server/queries/personen.ts b/src/server/queries/personen.ts
    new file mode 100644
    index 0000000..d4ddcd1
    --- /dev/null
    +++ b/src/server/queries/personen.ts
    @@ -0,0 +1,103 @@
    +import type { Prisma } from "@/generated/prisma/client";
    +import { prisma } from "@/lib/db";
    +
    +/**
    + * Lesemodelle fuer Personen (#013). Jede angemeldete Rolle darf sie sehen; Schluessel nur mit
    + * festem `select` ohne Chiffrat-Felder.
    + */
    +
    +export type PersonFilter = { readonly q?: string; readonly archivierte?: boolean };
    +
    +export async function personenListe(filter: PersonFilter = {}) {
    +  const q = filter.q?.trim().slice(0, 100);
    +  const where: Prisma.PersonWhereInput = {
    +    ...(filter.archivierte ? {} : { archiviertAm: null }),
    +    ...(q
    +      ? {
    +          OR: [
    +            { name: { contains: q, mode: "insensitive" } },
    +            { email: { contains: q, mode: "insensitive" } },
    +            { organisation: { contains: q, mode: "insensitive" } },
    +          ],
    +        }
    +      : {}),
    +  };
    +  const personen = await prisma.person.findMany({
    +    where,
    +    orderBy: { name: "asc" },
    +    select: {
    +      id: true,
    +      name: true,
    +      email: true,
    +      organisation: true,
    +      archiviertAm: true,
    +      benutzer: { select: { id: true, name: true } },
    +      _count: { select: { schluessel: { where: { status: "AKTIV" } } } },
    +    },
    +  });
    +  return personen.map(({ _count, ...rest }) => ({ ...rest, aktiveSchluessel: _count.schluessel }));
    +}
    +
    +export type PersonListenEintrag = Awaited>[number];
    +
    +export async function personDetail(id: string) {
    +  const person = await prisma.person.findUnique({
    +    where: { id },
    +    select: {
    +      id: true,
    +      name: true,
    +      email: true,
    +      organisation: true,
    +      notiz: true,
    +      archiviertAm: true,
    +      angelegtAm: true,
    +      benutzerId: true,
    +      benutzer: { select: { id: true, name: true, email: true, rolle: true } },
    +      schluessel: {
    +        orderBy: [{ status: "asc" }, { name: "asc" }],
    +        select: {
    +          id: true,
    +          name: true,
    +          typ: true,
    +          kategorie: true,
    +          status: true,
    +          publicKey: true,
    +          fingerprint: true,
    +          ablaufAm: true,
    +          selbstHinterlegt: true,
    +          _count: {
    +            select: {
    +              publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
    +              privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
    +            },
    +          },
    +        },
    +      },
    +    },
    +  });
    +  if (!person) return null;
    +  return {
    +    ...person,
    +    schluessel: person.schluessel.map(({ _count, ...rest }) => ({
    +      ...rest,
    +      verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
    +    })),
    +  };
    +}
    +
    +export type PersonDetail = NonNullable>>;
    +
    +/**
    + * Konten, die sich mit einer Person verknuepfen lassen: noch keiner Person zugeordnet, oder
    + * der Person `personId` selbst. Der Notfall-Admin ist kein Mensch mit eigenen Keys.
    + */
    +export async function kontenAuswahl(personId?: string) {
    +  return prisma.benutzer.findMany({
    +    where: {
    +      istNotfall: false,
    +      OR: [{ person: null }, ...(personId ? [{ person: { id: personId } }] : [])],
    +    },
    +    orderBy: { name: "asc" },
    +    select: { id: true, name: true, email: true },
    +  });
    +}
    diff --git a/src/server/queries/schluessel.ts b/src/server/queries/schluessel.ts
    new file mode 100644
    index 0000000..a0badc6
    --- /dev/null
    +++ b/src/server/queries/schluessel.ts
    @@ -0,0 +1,168 @@
    +import type { Prisma } from "@/generated/prisma/client";
    +import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
    +import { prisma } from "@/lib/db";
    +
    +/**
    + * Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
    + *
    + * Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md, „Feste Regeln“). Ob ein Private Key
    + * gespeichert ist, verraet `masterKeyVersion`: der DB-CHECK `schluessel_umschlag_vollstaendig`
    + * setzt sie genau dann, wenn auch ein Chiffrat da ist – so muss das Chiffrat nie gelesen
    + * werden, nicht einmal fuer ein „ist null?“.
    + */
    +
    +export const SCHLUESSEL_LISTE_SELECT = {
    +  id: true,
    +  name: true,
    +  slug: true,
    +  typ: true,
    +  kategorie: true,
    +  status: true,
    +  publicKey: true,
    +  fingerprint: true,
    +  ablaufAm: true,
    +  masterKeyVersion: true,
    +  passphraseGeschuetzt: true,
    +  person: { select: { id: true, name: true } },
    +  _count: {
    +    select: {
    +      publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
    +      privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
    +    },
    +  },
    +} satisfies Prisma.SchluesselSelect;
    +
    +export const SCHLUESSEL_DETAIL_SELECT = {
    +  id: true,
    +  name: true,
    +  slug: true,
    +  typ: true,
    +  kategorie: true,
    +  status: true,
    +  publicKey: true,
    +  fingerprint: true,
    +  kommentar: true,
    +  masterKeyVersion: true,
    +  privatGeloeschtAm: true,
    +  passphraseGeschuetzt: true,
    +  instanzStandard: true,
    +  selbstHinterlegt: true,
    +  tags: true,
    +  ablaufAm: true,
    +  zuletztVerwendetAm: true,
    +  angelegtAm: true,
    +  personId: true,
    +  vorlageId: true,
    +  person: { select: { id: true, name: true } },
    +  vorlage: { select: { id: true, name: true } },
    +  erstelltVon: { select: { name: true } },
    +  publicVerwendungen: {
    +    where: { zustand: { not: "ENTFERNT" } },
    +    orderBy: { angelegtAm: "asc" },
    +    select: {
    +      id: true,
    +      zielbenutzer: true,
    +      optionen: true,
    +      zustand: true,
    +      zuletztAngewendetAm: true,
    +      host: { select: { id: true, name: true } },
    +      hostgruppe: { select: { id: true, name: true } },
    +    },
    +  },
    +  privateVerwendungen: {
    +    where: { zustand: { not: "ENTFERNT" } },
    +    orderBy: { angelegtAm: "asc" },
    +    select: {
    +      id: true,
    +      zielbenutzer: true,
    +      pfad: true,
    +      sshConfig: true,
    +      zustand: true,
    +      zuletztAngewendetAm: true,
    +      host: { select: { id: true, name: true } },
    +    },
    +  },
    +} satisfies Prisma.SchluesselSelect;
    +
    +export type SchluesselFilter = {
    +  readonly q?: string;
    +  readonly kategorie?: string;
    +  readonly status?: string;
    +};
    +
    +const KATEGORIEN: readonly SchluesselKategorie[] = ["PERSON", "DIENST", "DEPLOYMENT"];
    +const STATI: readonly SchluesselStatus[] = ["AKTIV", "ROTIERT", "WIDERRUFEN", "ABGELAUFEN"];
    +
    +function alsKategorie(wert: string | undefined): SchluesselKategorie | undefined {
    +  return KATEGORIEN.find((k) => k === wert);
    +}
    +
    +function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
    +  return STATI.find((s) => s === wert);
    +}
    +
    +export async function schluesselListe(filter: SchluesselFilter = {}) {
    +  const q = filter.q?.trim().slice(0, 100);
    +  const kategorie = alsKategorie(filter.kategorie);
    +  const status = alsStatus(filter.status);
    +  const where: Prisma.SchluesselWhereInput = {
    +    ...(kategorie ? { kategorie } : {}),
    +    ...(status ? { status } : {}),
    +    ...(q
    +      ? {
    +          OR: [
    +            { name: { contains: q, mode: "insensitive" } },
    +            { slug: { contains: q.toLowerCase() } },
    +            { fingerprint: { contains: q } },
    +            { kommentar: { contains: q, mode: "insensitive" } },
    +            { tags: { has: q.toLowerCase() } },
    +            { person: { name: { contains: q, mode: "insensitive" } } },
    +          ],
    +        }
    +      : {}),
    +  };
    +  const zeilen = await prisma.schluessel.findMany({
    +    where,
    +    orderBy: [{ name: "asc" }],
    +    select: SCHLUESSEL_LISTE_SELECT,
    +  });
    +  return zeilen.map(({ _count, masterKeyVersion, ...rest }) => ({
    +    ...rest,
    +    privatGespeichert: masterKeyVersion !== null,
    +    verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
    +  }));
    +}
    +
    +export type SchluesselListenEintrag = Awaited>[number];
    +
    +export async function schluesselDetail(id: string) {
    +  const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
    +  if (!schluessel) return null;
    +  const { masterKeyVersion, ...rest } = schluessel;
    +  return { ...rest, privatGespeichert: masterKeyVersion !== null };
    +}
    +
    +export type SchluesselDetail = NonNullable>>;
    +
    +/** Nur Slug und Public Key – fuer den Download `.pub`. */
    +export async function publicKeyExport(id: string) {
    +  return prisma.schluessel.findUnique({ where: { id }, select: { slug: true, publicKey: true } });
    +}
    +
    +// ---------------------------------------------------------------- Auswahllisten
    +
    +export async function personenAuswahl() {
    +  return prisma.person.findMany({
    +    where: { archiviertAm: null },
    +    orderBy: { name: "asc" },
    +    select: { id: true, name: true, organisation: true },
    +  });
    +}
    +
    +export async function vorlagenAuswahl() {
    +  return prisma.vorlage.findMany({
    +    where: { archiviertAm: null },
    +    orderBy: { name: "asc" },
    +    select: { id: true, name: true, standardLaufzeitTage: true },
    +  });
    +}
    diff --git a/src/server/services/anmeldung.ts b/src/server/services/anmeldung.ts
    index 946f7bf..3b2a768 100644
    --- a/src/server/services/anmeldung.ts
    +++ b/src/server/services/anmeldung.ts
    @@ -73,6 +73,8 @@ export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promi
       const daten = {
         oidcSub: anspruecke.sub,
         email: anspruecke.email,
    +    // Fuer die Verknuepfung im Self-Service (#013).
    +    emailBestaetigt: anspruecke.emailBestaetigt,
         name: anspruecke.name,
         rolle,
         letzteAnmeldungAm: new Date(),
    diff --git a/src/server/services/fachfehler.ts b/src/server/services/fachfehler.ts
    new file mode 100644
    index 0000000..0f21f71
    --- /dev/null
    +++ b/src/server/services/fachfehler.ts
    @@ -0,0 +1,15 @@
    +import { HostFehler } from "@/server/services/hosts";
    +
    +/**
    + * Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld (#012, #013).
    + *
    + * Erbt von `HostFehler`, weil `fehlerZustand()` in src/server/actions/formular.ts genau den
    + * als „Meldung fuers Formular“ erkennt. So bleibt die eine Stelle, die Fehler in
    + * Formularzustaende uebersetzt, unveraendert.
    + */
    +export class FachFehler extends HostFehler {
    +  constructor(nachricht: string, feldfehler: Record = {}) {
    +    super(nachricht, feldfehler);
    +    this.name = "FachFehler";
    +  }
    +}
    diff --git a/src/server/services/meine-schluessel.ts b/src/server/services/meine-schluessel.ts
    new file mode 100644
    index 0000000..c2b1c0b
    --- /dev/null
    +++ b/src/server/services/meine-schluessel.ts
    @@ -0,0 +1,195 @@
    +import type { Benutzer } from "@/generated/prisma/client";
    +import { prisma } from "@/lib/db";
    +import { feldfehlerAus } from "@/lib/hosts";
    +import { eigenerKeySchema, type EigenerKeyEingabe } from "@/lib/personen";
    +import { FachFehler } from "@/server/services/fachfehler";
    +import { istEindeutigkeitsFehler } from "@/server/services/hosts";
    +import { protokollieren } from "@/server/services/protokoll";
    +import { publicKeyAnlegen } from "@/server/services/schluessel";
    +
    +/**
    + * Self-Service „Meine Schluessel“ (#013, Freigabe Frage 4): jeder angemeldete Benutzer
    + * hinterlegt eigene Public Keys. Wirksam werden sie erst, wenn ein Operator oder Admin sie
    + * einem Host zuweist – hier wird nichts ausgerollt.
    + *
    + * Jede Funktion nimmt den angemeldeten Benutzer und arbeitet nur mit **seiner** Person; eine
    + * Schluessel-ID von aussen wird immer gegen sie geprueft.
    + */
    +type Konto = Pick;
    +
    +/**
    + * Liefert die Person des Kontos und legt sie beim ersten Aufruf an. Verknuepft per
    + * `benutzerId`, sonst per E-Mail – aber nur, wenn der Anbieter die Adresse bestaetigt hat und
    + * genau eine freie, nicht archivierte Person dazu passt.
    + */
    +export async function eigenePersonSicherstellen(konto: Konto): Promise<{ id: string; name: string }> {
    +  const vorhanden = await prisma.person.findUnique({
    +    where: { benutzerId: konto.id },
    +    select: { id: true, name: true },
    +  });
    +  if (vorhanden) return vorhanden;
    +
    +  const akteur = { id: konto.id, name: konto.name };
    +  try {
    +    if (konto.email && konto.emailBestaetigt) {
    +      const passend = await prisma.person.findMany({
    +        where: {
    +          email: { equals: konto.email, mode: "insensitive" },
    +          benutzerId: null,
    +          archiviertAm: null,
    +        },
    +        select: { id: true },
    +        take: 2,
    +      });
    +      // Bedingt: hat inzwischen jemand anderes die Person verknuepft, aendert sich nichts,
    +      // und das Konto bekommt unten eine eigene Person.
    +      const verknuepft =
    +        passend.length === 1
    +          ? await prisma.person.updateMany({
    +              where: { id: passend[0]!.id, benutzerId: null },
    +              data: { benutzerId: konto.id },
    +            })
    +          : { count: 0 };
    +      if (verknuepft.count === 1) {
    +        const person = await prisma.person.findUniqueOrThrow({
    +          where: { benutzerId: konto.id },
    +          select: { id: true, name: true },
    +        });
    +        await protokollieren({
    +          akteur,
    +          aktion: "person.verknuepft",
    +          ergebnis: "ERFOLG",
    +          details: { person: person.name, ueber: "email" },
    +        });
    +        return person;
    +      }
    +    }
    +    const person = await prisma.person.create({
    +      data: { name: konto.name, email: konto.email, benutzerId: konto.id },
    +      select: { id: true, name: true },
    +    });
    +    await protokollieren({
    +      akteur,
    +      aktion: "person.angelegt",
    +      ergebnis: "ERFOLG",
    +      details: { name: konto.name, ueber: "selbstauskunft" },
    +    });
    +    return person;
    +  } catch (fehler) {
    +    // Zwei gleichzeitige erste Aufrufe: der zweite findet die Person des ersten.
    +    if (istEindeutigkeitsFehler(fehler)) {
    +      return prisma.person.findUniqueOrThrow({
    +        where: { benutzerId: konto.id },
    +        select: { id: true, name: true },
    +      });
    +    }
    +    throw fehler;
    +  }
    +}
    +
    +/** Die eigenen Schluessel – mit genau den Feldern, die die Seite zeigt. */
    +export async function eigeneSchluessel(konto: Konto) {
    +  const person = await eigenePersonSicherstellen(konto);
    +  const schluessel = await prisma.schluessel.findMany({
    +    where: { personId: person.id },
    +    orderBy: { angelegtAm: "desc" },
    +    select: {
    +      id: true,
    +      name: true,
    +      typ: true,
    +      status: true,
    +      publicKey: true,
    +      fingerprint: true,
    +      kommentar: true,
    +      selbstHinterlegt: true,
    +      angelegtAm: true,
    +      _count: {
    +        select: {
    +          publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
    +          privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
    +        },
    +      },
    +    },
    +  });
    +  return {
    +    person,
    +    schluessel: schluessel.map(({ _count, ...rest }) => ({
    +      ...rest,
    +      verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
    +    })),
    +  };
    +}
    +
    +export async function eigenenPublicKeyHinterlegen(konto: Konto, eingabe: EigenerKeyEingabe) {
    +  const ergebnis = eigenerKeySchema.safeParse(eingabe);
    +  if (!ergebnis.success) {
    +    throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
    +  }
    +  const person = await eigenePersonSicherstellen(konto);
    +  return publicKeyAnlegen(
    +    {
    +      name: ergebnis.data.name,
    +      slug: null,
    +      kategorie: "PERSON",
    +      publicKey: ergebnis.data.publicKey,
    +      kommentar: null,
    +      tags: [],
    +      ablaufAm: null,
    +      personId: person.id,
    +      vorlageId: null,
    +      selbstHinterlegt: true,
    +    },
    +    { id: konto.id, name: konto.name },
    +    "schluessel.selbst_hinterlegt",
    +  );
    +}
    +
    +/**
    + * Entfernt einen eigenen, selbst hinterlegten Key, solange er nirgends zugewiesen ist – auch
    + * keine entfernte Verwendung, damit der Verlauf am Key nicht verloren geht. Fremde Keys sind
    + * fuer diese Funktion „nicht vorhanden“, damit sie nichts ueber fremde IDs verraet.
    + */
    +export async function eigenenSchluesselEntfernen(konto: Konto, schluesselId: string): Promise {
    +  const person = await eigenePersonSicherstellen(konto);
    +  const schluessel = await prisma.schluessel.findFirst({
    +    where: { id: schluesselId, personId: person.id },
    +    select: {
    +      id: true,
    +      name: true,
    +      fingerprint: true,
    +      selbstHinterlegt: true,
    +      _count: { select: { publicVerwendungen: true, privateVerwendungen: true } },
    +    },
    +  });
    +  if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es unter Ihren Schlüsseln nicht.");
    +  if (!schluessel.selbstHinterlegt) {
    +    throw new FachFehler(
    +      "Diesen Schlüssel hat ein Operator oder Admin angelegt – bitte dort entfernen lassen.",
    +    );
    +  }
    +  if (schluessel._count.publicVerwendungen + schluessel._count.privateVerwendungen > 0) {
    +    throw new FachFehler(
    +      "Der Schlüssel ist schon einem Host zugewiesen. Bitte einen Operator oder Admin bitten.",
    +    );
    +  }
    +  await prisma.$transaction(async (tx) => {
    +    // Bedingt geloescht: weist ein Operator den Key gerade zu, darf das Loeschen die neue
    +    // Verwendung nicht per Cascade mitnehmen.
    +    const geloescht = await tx.schluessel.deleteMany({
    +      where: { id: schluessel.id, publicVerwendungen: { none: {} }, privateVerwendungen: { none: {} } },
    +    });
    +    if (geloescht.count !== 1) {
    +      throw new FachFehler("Der Schlüssel wurde gerade zugewiesen und bleibt deshalb bestehen.");
    +    }
    +    await protokollieren(
    +      {
    +        akteur: { id: konto.id, name: konto.name },
    +        aktion: "schluessel.selbst_entfernt",
    +        ergebnis: "ERFOLG",
    +        fingerprint: schluessel.fingerprint,
    +        details: { name: schluessel.name },
    +      },
    +      tx,
    +    );
    +  });
    +}
    diff --git a/src/server/services/personen.ts b/src/server/services/personen.ts
    new file mode 100644
    index 0000000..283b4d1
    --- /dev/null
    +++ b/src/server/services/personen.ts
    @@ -0,0 +1,139 @@
    +import type { Prisma } from "@/generated/prisma/client";
    +import { prisma } from "@/lib/db";
    +import { feldfehlerAus } from "@/lib/hosts";
    +import { personEingabeSchema, type PersonDaten, type PersonEingabe } from "@/lib/personen";
    +import { FachFehler } from "@/server/services/fachfehler";
    +import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
    +import { protokollieren } from "@/server/services/protokoll";
    +
    +/**
    + * Personen anlegen, bearbeiten, archivieren (#013).
    + *
    + * Archivieren statt Loeschen: an einer Person haengen Schluessel und Protokoll-Bezuege. Die
    + * Schluessel bleiben der archivierten Person zugeordnet – wer bis wann welchen Key hatte, soll
    + * nachvollziehbar bleiben. Widerrufen ist ein eigener Schritt (#024).
    + */
    +
    +function pruefen(eingabe: PersonEingabe): PersonDaten {
    +  const ergebnis = personEingabeSchema.safeParse(eingabe);
    +  if (!ergebnis.success) {
    +    throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
    +  }
    +  return ergebnis.data;
    +}
    +
    +function benutzerVergeben(): FachFehler {
    +  return new FachFehler("Dieses Konto ist schon mit einer anderen Person verknüpft.", {
    +    benutzerId: "Schon mit einer anderen Person verknüpft.",
    +  });
    +}
    +
    +/** Das gewaehlte Konto muss existieren und frei sein (oder schon zu dieser Person gehoeren). */
    +async function benutzerPruefen(
    +  tx: Prisma.TransactionClient,
    +  benutzerId: string | null,
    +  personId?: string,
    +): Promise {
    +  if (!benutzerId) return null;
    +  const benutzer = await tx.benutzer.findUnique({
    +    where: { id: benutzerId },
    +    select: { name: true, person: { select: { id: true } } },
    +  });
    +  if (!benutzer)
    +    throw new FachFehler("Das gewählte Konto gibt es nicht.", { benutzerId: "Bitte neu wählen." });
    +  if (benutzer.person && benutzer.person.id !== personId) throw benutzerVergeben();
    +  return benutzer.name;
    +}
    +
    +export async function personAnlegen(eingabe: PersonEingabe, akteur: Akteur) {
    +  const daten = pruefen(eingabe);
    +  try {
    +    return await prisma.$transaction(async (tx) => {
    +      const konto = await benutzerPruefen(tx, daten.benutzerId);
    +      const person = await tx.person.create({ data: daten, select: { id: true, name: true } });
    +      await protokollieren(
    +        {
    +          akteur,
    +          aktion: "person.angelegt",
    +          ergebnis: "ERFOLG",
    +          details: { name: daten.name, email: daten.email, organisation: daten.organisation, konto },
    +        },
    +        tx,
    +      );
    +      return person;
    +    });
    +  } catch (fehler) {
    +    if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben();
    +    throw fehler;
    +  }
    +}
    +
    +export async function personBearbeiten(id: string, eingabe: PersonEingabe, akteur: Akteur) {
    +  const daten = pruefen(eingabe);
    +  const alt = await prisma.person.findUnique({
    +    where: { id },
    +    select: {
    +      name: true,
    +      email: true,
    +      organisation: true,
    +      notiz: true,
    +      benutzerId: true,
    +      archiviertAm: true,
    +    },
    +  });
    +  if (!alt) throw new FachFehler("Diese Person gibt es nicht.");
    +  if (alt.archiviertAm) throw new FachFehler("Archivierte Personen lassen sich nicht bearbeiten.");
    +
    +  const geaendert = Object.fromEntries(
    +    (Object.keys(daten) as (keyof PersonDaten)[])
    +      .filter((feld) => daten[feld] !== alt[feld])
    +      // Die Notiz kann Persoenliches enthalten – im Protokoll steht nur, dass sie sich aenderte.
    +      .map((feld) => [feld, feld === "notiz" ? "geändert" : { vorher: alt[feld], nachher: daten[feld] }]),
    +  );
    +  try {
    +    return await prisma.$transaction(async (tx) => {
    +      await benutzerPruefen(tx, daten.benutzerId, id);
    +      const person = await tx.person.update({ where: { id }, data: daten, select: { id: true, name: true } });
    +      if (Object.keys(geaendert).length > 0) {
    +        await protokollieren(
    +          { akteur, aktion: "person.geaendert", ergebnis: "ERFOLG", details: geaendert },
    +          tx,
    +        );
    +      }
    +      return person;
    +    });
    +  } catch (fehler) {
    +    if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben();
    +    throw fehler;
    +  }
    +}
    +
    +/**
    + * Archiviert eine Person. Die Verknuepfung zum Konto faellt weg, damit der Self-Service beim
    + * naechsten Aufruf nicht wieder bei einer archivierten Person landet.
    + */
    +export async function personArchivieren(id: string, akteur: Akteur) {
    +  const person = await prisma.person.findUnique({
    +    where: { id },
    +    select: { name: true, archiviertAm: true, _count: { select: { schluessel: true } } },
    +  });
    +  if (!person) throw new FachFehler("Diese Person gibt es nicht.");
    +  if (person.archiviertAm) throw new FachFehler("Diese Person ist bereits archiviert.");
    +  return prisma.$transaction(async (tx) => {
    +    const archiviert = await tx.person.update({
    +      where: { id },
    +      data: { archiviertAm: new Date(), benutzerId: null },
    +      select: { id: true },
    +    });
    +    await protokollieren(
    +      {
    +        akteur,
    +        aktion: "person.archiviert",
    +        ergebnis: "ERFOLG",
    +        details: { name: person.name, schluessel: person._count.schluessel },
    +      },
    +      tx,
    +    );
    +    return archiviert;
    +  });
    +}
    diff --git a/src/server/services/privatekey.ts b/src/server/services/privatekey.ts
    new file mode 100644
    index 0000000..2638a9c
    --- /dev/null
    +++ b/src/server/services/privatekey.ts
    @@ -0,0 +1,123 @@
    +import { prisma } from "@/lib/db";
    +import { entschluesseln } from "@/lib/geheimnisse";
    +import { ABHOLFRIST_MS } from "@/lib/schluessel";
    +import { FachFehler } from "@/server/services/fachfehler";
    +import type { Akteur } from "@/server/services/hosts";
    +import { protokollieren } from "@/server/services/protokoll";
    +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
    +
    +/**
    + * Private Key anzeigen, herunterladen, loeschen (#014). Die Rechte (`privateKeyAnzeigen`,
    + * `privateKeyLoeschen`, beide nur Admin mit frischer Bestaetigung) prueft die Action bzw. der
    + * Route Handler; hier stehen die fachlichen Regeln.
    + *
    + * Der Web-Prozess hat keinen Master-Key. Er bittet den Worker per Vorgang, den Key zu oeffnen;
    + * der legt ihn mit SSHKV_APP_SECRET verschluesselt in `Vorgang.ergebnis` ab, und
    + * `privateKeyAbholen` holt ihn genau einmal ab. Der Key steht nie im Protokoll, in einem Log
    + * oder einer Fehlermeldung – nur Fingerprint und Name.
    + */
    +export type Zweck = "anzeigen" | "herunterladen";
    +
    +/** Felder fuer die Pruefung – ob ein Key da ist, verraet `masterKeyVersion`, nie das Chiffrat. */
    +async function schluesselMitPrivateKey(schluesselId: string) {
    +  const schluessel = await prisma.schluessel.findUnique({
    +    where: { id: schluesselId },
    +    select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
    +  });
    +  if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
    +  if (schluessel.masterKeyVersion === null) {
    +    throw new FachFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
    +  }
    +  // Der Deployment-Key ist root auf allen Hosts; er verlaesst sshkv nie. Im Notfall wird er
    +  // rotiert, nicht exportiert.
    +  if (schluessel.kategorie === "DEPLOYMENT") {
    +    throw new FachFehler(
    +      "Den Private Key eines Deployment-Keys gibt sshkv nicht heraus und löscht ihn nicht.",
    +    );
    +  }
    +  return schluessel;
    +}
    +
    +/** Bittet den Worker, den Private Key zur einmaligen Abholung bereitzulegen. */
    +export async function abholungAnfordern(schluesselId: string, zweck: Zweck, akteur: Akteur): Promise {
    +  const schluessel = await schluesselMitPrivateKey(schluesselId);
    +  const vorgangId = await vorgangEinreihen({
    +    art: "PRIVATE_KEY_ABHOLEN",
    +    angefordertVonId: akteur.id,
    +    schluesselId: schluessel.id,
    +    eingabe: { zweck },
    +  });
    +  await protokollieren({
    +    akteur,
    +    aktion: "privatekey.angefordert",
    +    ergebnis: "ERFOLG",
    +    fingerprint: schluessel.fingerprint,
    +    details: { name: schluessel.name, zweck },
    +  });
    +  return vorgangId;
    +}
    +
    +type Abholung = { ergebnis: unknown; beendetAm: Date | null; schluesselId: string | null };
    +
    +/**
    + * Holt den bereitgelegten Private Key genau einmal ab: Lesen und Ueberschreiben geschehen in
    + * einer Anweisung unter Zeilensperre, ein zweiter Aufruf findet nichts mehr. Nur wer den
    + * Vorgang angefordert hat, kann abholen – und nur innerhalb der Abholfrist.
    + */
    +export async function privateKeyAbholen(
    +  vorgangId: string,
    +  zweck: Zweck,
    +  akteur: Akteur,
    +  jetzt: Date = new Date(),
    +): Promise<{ privateKey: string; slug: string; name: string; fingerprint: string }> {
    +  const [zeile] = await prisma.$queryRaw`
    +    WITH alt AS (
    +      SELECT "id", "ergebnis", "beendetAm", "schluesselId" FROM "Vorgang"
    +      WHERE "id" = ${vorgangId}
    +        AND "art" = 'PRIVATE_KEY_ABHOLEN'
    +        AND "status" = 'ERLEDIGT'
    +        AND "angefordertVonId" = ${akteur.id}
    +        AND "ergebnis" ? 'abholung'
    +      FOR UPDATE
    +    )
    +    UPDATE "Vorgang" v SET "ergebnis" = '{"abgeholt": true}'::jsonb
    +    FROM alt WHERE v."id" = alt."id"
    +    RETURNING alt."ergebnis", alt."beendetAm", alt."schluesselId"`;
    +
    +  if (!zeile || !zeile.schluesselId) {
    +    throw new FachFehler("Nichts abzuholen – der Private Key wurde schon abgeholt oder ist verfallen.");
    +  }
    +  if (!zeile.beendetAm || jetzt.getTime() - zeile.beendetAm.getTime() > ABHOLFRIST_MS) {
    +    throw new FachFehler("Die Abholung ist verfallen. Bitte den Private Key erneut anfordern.");
    +  }
    +  const chiffrat = (zeile.ergebnis as { abholung?: unknown }).abholung;
    +  if (typeof chiffrat !== "string") throw new FachFehler("Die Abholung ist unvollständig.");
    +
    +  const schluessel = await prisma.schluessel.findUniqueOrThrow({
    +    where: { id: zeile.schluesselId },
    +    select: { name: true, slug: true, fingerprint: true },
    +  });
    +  const privateKey = entschluesseln(chiffrat);
    +  await protokollieren({
    +    akteur,
    +    aktion: zweck === "anzeigen" ? "privatekey.angezeigt" : "privatekey.heruntergeladen",
    +    ergebnis: "ERFOLG",
    +    fingerprint: schluessel.fingerprint,
    +    details: { name: schluessel.name },
    +  });
    +  return { privateKey, ...schluessel };
    +}
    +
    +/**
    + * Fordert das endgueltige Loeschen des gespeicherten Private Keys an (#014, „Private Key aus
    + * SSHKeyVault loeschen“). Ausgefuehrt im Worker – nur src/worker/geheimnisse.ts fasst die
    + * Chiffrat-Felder an, auch zum Leeren.
    + */
    +export async function privateKeyLoeschenAnfordern(schluesselId: string, akteur: Akteur): Promise {
    +  const schluessel = await schluesselMitPrivateKey(schluesselId);
    +  return vorgangEinreihen({
    +    art: "PRIVATE_KEY_LOESCHEN",
    +    angefordertVonId: akteur.id,
    +    schluesselId: schluessel.id,
    +  });
    +}
    diff --git a/src/server/services/schluessel.ts b/src/server/services/schluessel.ts
    new file mode 100644
    index 0000000..c1dfde5
    --- /dev/null
    +++ b/src/server/services/schluessel.ts
    @@ -0,0 +1,396 @@
    +import type { Prisma } from "@/generated/prisma/client";
    +import { prisma } from "@/lib/db";
    +import { verschluesseln } from "@/lib/geheimnisse";
    +import { feldfehlerAus } from "@/lib/hosts";
    +import {
    +  bearbeitenSchema,
    +  erzeugenSchema,
    +  freierSlug,
    +  PASSPHRASE_MAX,
    +  paarImportSchema,
    +  PRIVATE_KEY_MAX,
    +  publicImportSchema,
    +  publicKeyZeileMitKommentar,
    +  schluesselTypVon,
    +  slugAus,
    +  type BearbeitenEingabe,
    +  type ErzeugenEingabe,
    +  type PaarImportEingabe,
    +  type PublicImportEingabe,
    +  type SchluesselGeheimnis,
    +  type SchluesselVorgangEingabe,
    +} from "@/lib/schluessel";
    +import { publicKeyZeileLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
    +import type { z } from "@/lib/validierung";
    +import { FachFehler } from "@/server/services/fachfehler";
    +import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
    +import { protokollieren } from "@/server/services/protokoll";
    +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
    +
    +/**
    + * Schluessel anlegen, importieren, bearbeiten (#012).
    + *
    + * Alles, was einen Private Key beruehrt (Erzeugen, Import eines Paars), wird hier nur
    + * geprueft und als Vorgang an den Worker gegeben – der Private Key reist mit
    + * SSHKV_APP_SECRET verschluesselt im Job, nie in `Vorgang.eingabe`. Ein reiner Public Key
    + * ist kein Geheimnis und wird direkt hier angelegt.
    + *
    + * Jede Prisma-Abfrage auf `Schluessel` hat ein festes `select` ohne Chiffrat-Felder
    + * (tests/schluessel-grenze.test.ts).
    + */
    +
    +/** Was die Oberflaeche nach dem Anlegen braucht – und nicht mehr. */
    +const ANGELEGT = { id: true, slug: true, fingerprint: true } satisfies Prisma.SchluesselSelect;
    +
    +function pruefen(schema: S, eingabe: unknown): z.output {
    +  const ergebnis = schema.safeParse(eingabe);
    +  if (!ergebnis.success) {
    +    throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
    +  }
    +  return ergebnis.data;
    +}
    +
    +async function slugFrei(slug: string, client: Prisma.TransactionClient = prisma): Promise {
    +  return (await client.schluessel.findUnique({ where: { slug }, select: { id: true } })) === null;
    +}
    +
    +/**
    + * Ein ausdruecklich gewuenschter Slug muss frei sein; ein aus dem Namen abgeleiteter bekommt
    + * bei Bedarf eine Nummer angehaengt.
    + */
    +export async function eindeutigerSlug(wunsch: string | null, name: string): Promise {
    +  if (wunsch) {
    +    if (!(await slugFrei(wunsch))) {
    +      throw new FachFehler(`Der Kurzname „${wunsch}“ ist schon vergeben.`, {
    +        slug: "Dieser Kurzname ist schon vergeben.",
    +      });
    +    }
    +    return wunsch;
    +  }
    +  return freierSlug(slugAus(name), (kandidat) => slugFrei(kandidat));
    +}
    +
    +/** Gibt es diesen Fingerprint schon? Fuer eine Meldung mit Verweis auf den vorhandenen Key. */
    +export async function schluesselMitFingerprint(fingerprint: string) {
    +  return prisma.schluessel.findUnique({
    +    where: { fingerprint },
    +    select: { id: true, name: true, slug: true },
    +  });
    +}
    +
    +export function bereitsVerwaltet(vorhanden: { id: string; name: string }): FachFehler {
    +  return new FachFehler(
    +    `Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“ (/schluessel/${vorhanden.id}).`,
    +    { publicKey: "Dieser Schlüssel ist schon in sshkv." },
    +  );
    +}
    +
    +/** Heute 00:00 UTC – Vergleichsbasis fuer „Ablaufdatum liegt nicht in der Vergangenheit“. */
    +function heute(jetzt = new Date()): Date {
    +  return new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate()));
    +}
    +
    +type Bezuege = { readonly personId: string | null; readonly vorlageId: string | null };
    +
    +/**
    + * Prueft Person und Vorlage und setzt ein fehlendes Ablaufdatum aus der Standardlaufzeit der
    + * Vorlage. Exportiert, weil der Worker dieselbe Pruefung beim Ausfuehren wiederholt.
    + */
    +export async function bezuegePruefen(
    +  daten: Bezuege & { readonly ablaufAm: Date | null },
    +  client: Prisma.TransactionClient = prisma,
    +): Promise<{ ablaufAm: Date | null }> {
    +  if (daten.personId) {
    +    const person = await client.person.findUnique({
    +      where: { id: daten.personId },
    +      select: { archiviertAm: true },
    +    });
    +    if (!person || person.archiviertAm) {
    +      throw new FachFehler("Die gewählte Person gibt es nicht (mehr).", { personId: "Bitte neu wählen." });
    +    }
    +  }
    +  let ablaufAm = daten.ablaufAm;
    +  if (daten.vorlageId) {
    +    const vorlage = await client.vorlage.findUnique({
    +      where: { id: daten.vorlageId },
    +      select: { archiviertAm: true, standardLaufzeitTage: true },
    +    });
    +    if (!vorlage || vorlage.archiviertAm) {
    +      throw new FachFehler("Die gewählte Vorlage gibt es nicht (mehr).", { vorlageId: "Bitte neu wählen." });
    +    }
    +    if (!ablaufAm && vorlage.standardLaufzeitTage) {
    +      ablaufAm = new Date(heute().getTime() + vorlage.standardLaufzeitTage * 24 * 60 * 60 * 1000);
    +    }
    +  }
    +  return { ablaufAm };
    +}
    +
    +function ablaufNichtVergangen(ablaufAm: Date | null): void {
    +  if (ablaufAm && ablaufAm < heute()) {
    +    throw new FachFehler("Das Ablaufdatum liegt in der Vergangenheit.", {
    +      ablaufAm: "Bitte ein Datum ab heute wählen.",
    +    });
    +  }
    +}
    +
    +function passphrasePruefen(passphrase: string | undefined): string | undefined {
    +  if (!passphrase) return undefined;
    +  if (passphrase.length > PASSPHRASE_MAX) {
    +    throw new FachFehler("Die Passphrase ist zu lang.", {
    +      passphrase: `Höchstens ${PASSPHRASE_MAX} Zeichen.`,
    +    });
    +  }
    +  return passphrase;
    +}
    +
    +// ---------------------------------------------------------------- Erzeugen und Paar-Import
    +
    +/**
    + * Fordert ein neues Schluesselpaar an (#012). Liefert die Vorgangs-ID; die Oberflaeche
    + * verfolgt den Vorgang und springt nach Erfolg zum Schluessel.
    + */
    +export async function erzeugenAnfordern(
    +  eingabe: ErzeugenEingabe,
    +  passphraseRoh: string | undefined,
    +  akteur: Akteur,
    +): Promise {
    +  const daten = pruefen(erzeugenSchema, eingabe);
    +  const passphrase = passphrasePruefen(passphraseRoh);
    +  ablaufNichtVergangen(daten.ablaufAm);
    +  const { ablaufAm } = await bezuegePruefen(daten);
    +  const slug = await eindeutigerSlug(daten.slug, daten.name);
    +
    +  const vorgangsEingabe: SchluesselVorgangEingabe = {
    +    name: daten.name,
    +    slug,
    +    tags: daten.tags,
    +    ablaufAm: ablaufAm?.toISOString() ?? null,
    +    personId: daten.personId,
    +    vorlageId: daten.vorlageId,
    +    kommentar: daten.kommentar,
    +    typ: daten.typ,
    +    mitPassphrase: Boolean(passphrase),
    +  };
    +  const geheimnis: SchluesselGeheimnis = passphrase ? { passphrase } : {};
    +  return vorgangEinreihen({
    +    art: "SCHLUESSEL_ERZEUGEN",
    +    angefordertVonId: akteur.id,
    +    eingabe: vorgangsEingabe,
    +    ...(passphrase ? { geheim: verschluesseln(JSON.stringify(geheimnis)) } : {}),
    +  });
    +}
    +
    +/**
    + * Fordert den Import eines Paars an (#012). Der Private Key wird hier nur grob angesehen
    + * (Groesse, Kopfzeile) – lesen, pruefen und versiegeln tut der Worker. Eine Passphrase dient
    + * dort nur der Pruefung und wird verworfen; gespeichert wird der Key, wie er kam.
    + */
    +export async function paarImportAnfordern(
    +  eingabe: PaarImportEingabe,
    +  privateKey: string | undefined,
    +  passphraseRoh: string | undefined,
    +  akteur: Akteur,
    +): Promise {
    +  const daten = pruefen(paarImportSchema, eingabe);
    +  // Wie ssh-keygen ihn schreibt: LF-Zeilenenden und genau ein Zeilenumbruch am Ende.
    +  const text = (privateKey ?? "").replace(/\r\n?/g, "\n").trim();
    +  if (!text) {
    +    throw new FachFehler("Bitte den Private Key einfügen.", {
    +      privateKey: "Bitte den Private Key einfügen.",
    +    });
    +  }
    +  if (text.length > PRIVATE_KEY_MAX || !text.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) {
    +    // Feste Meldung – nie etwas aus dem eingefuegten Text zurueckgeben.
    +    throw new FachFehler("Das ist kein Private Key im OpenSSH-Format.", {
    +      privateKey: "Erwartet wird „-----BEGIN OPENSSH PRIVATE KEY-----“ …",
    +    });
    +  }
    +  const passphrase = passphrasePruefen(passphraseRoh);
    +  ablaufNichtVergangen(daten.ablaufAm);
    +  const { ablaufAm } = await bezuegePruefen(daten);
    +  const slug = await eindeutigerSlug(daten.slug, daten.name);
    +
    +  const vorgangsEingabe: SchluesselVorgangEingabe = {
    +    name: daten.name,
    +    slug,
    +    tags: daten.tags,
    +    ablaufAm: ablaufAm?.toISOString() ?? null,
    +    personId: daten.personId,
    +    vorlageId: daten.vorlageId,
    +    kommentar: daten.kommentar,
    +    mitPassphrase: Boolean(passphrase),
    +  };
    +  const geheimnis: SchluesselGeheimnis = { privateKey: `${text}\n`, ...(passphrase ? { passphrase } : {}) };
    +  return vorgangEinreihen({
    +    art: "SCHLUESSEL_IMPORTIEREN",
    +    angefordertVonId: akteur.id,
    +    eingabe: vorgangsEingabe,
    +    geheim: verschluesseln(JSON.stringify(geheimnis)),
    +  });
    +}
    +
    +// ---------------------------------------------------------------- Public-Key-Import
    +
    +/** Liest eine Public-Key-Zeile und uebersetzt Formatfehler in eine Feldmeldung. */
    +export function publicKeyPruefen(zeile: string) {
    +  try {
    +    // Die RSA-Mindestlaenge gilt beim Import (Standard von publicKeyZeileLesen).
    +    return publicKeyZeileLesen(zeile);
    +  } catch (fehler) {
    +    if (fehler instanceof SchluesselFormatFehler) {
    +      throw new FachFehler(fehler.message, { publicKey: fehler.message });
    +    }
    +    throw fehler;
    +  }
    +}
    +
    +/**
    + * Legt einen Schluessel nur mit Public Key an (#012). Auch Grundlage des Self-Service
    + * (#013), der Kategorie, Person und `selbstHinterlegt` selbst setzt.
    + */
    +export async function publicKeyAnlegen(
    +  daten: {
    +    readonly name: string;
    +    readonly slug: string | null;
    +    readonly kategorie: "PERSON" | "DIENST";
    +    readonly publicKey: string;
    +    readonly kommentar: string | null;
    +    readonly tags: string[];
    +    readonly ablaufAm: Date | null;
    +    readonly personId: string | null;
    +    readonly vorlageId: string | null;
    +    readonly selbstHinterlegt?: boolean;
    +  },
    +  akteur: Akteur,
    +  aktion = "schluessel.importiert",
    +) {
    +  const info = publicKeyPruefen(daten.publicKey);
    +  const vorhanden = await schluesselMitFingerprint(info.fingerprint);
    +  if (vorhanden) throw bereitsVerwaltet(vorhanden);
    +  ablaufNichtVergangen(daten.ablaufAm);
    +  const { ablaufAm } = await bezuegePruefen(daten);
    +  const slug = await eindeutigerSlug(daten.slug, daten.name);
    +  const kommentar = daten.kommentar ?? (info.kommentar || `sshkv:${slug}`);
    +
    +  try {
    +    return await prisma.$transaction(async (tx) => {
    +      const schluessel = await tx.schluessel.create({
    +        data: {
    +          name: daten.name,
    +          slug,
    +          typ: schluesselTypVon(info.typ, info.bits),
    +          kategorie: daten.kategorie,
    +          publicKey: publicKeyZeileMitKommentar(daten.publicKey, kommentar),
    +          fingerprint: info.fingerprint,
    +          kommentar,
    +          tags: daten.tags,
    +          ablaufAm,
    +          personId: daten.personId,
    +          vorlageId: daten.vorlageId,
    +          erstelltVonId: akteur.id,
    +          selbstHinterlegt: daten.selbstHinterlegt ?? false,
    +        },
    +        select: ANGELEGT,
    +      });
    +      await protokollieren(
    +        {
    +          akteur,
    +          aktion,
    +          ergebnis: "ERFOLG",
    +          fingerprint: schluessel.fingerprint,
    +          details: {
    +            name: daten.name,
    +            slug,
    +            kategorie: daten.kategorie,
    +            art: "public",
    +            algorithmus: info.typ,
    +          },
    +        },
    +        tx,
    +      );
    +      return schluessel;
    +    });
    +  } catch (fehler) {
    +    // Zwischen Pruefung und Insert kann derselbe Key oder Slug angelegt worden sein.
    +    if (istEindeutigkeitsFehler(fehler)) {
    +      const jetzt = await schluesselMitFingerprint(info.fingerprint);
    +      if (jetzt) throw bereitsVerwaltet(jetzt);
    +      throw new FachFehler("Der Kurzname wurde gerade vergeben. Bitte erneut speichern.");
    +    }
    +    throw fehler;
    +  }
    +}
    +
    +export async function publicKeyImportieren(eingabe: PublicImportEingabe, akteur: Akteur) {
    +  const daten = pruefen(publicImportSchema, eingabe);
    +  return publicKeyAnlegen(daten, akteur);
    +}
    +
    +// ---------------------------------------------------------------- Bearbeiten
    +
    +export async function schluesselBearbeiten(id: string, eingabe: BearbeitenEingabe, akteur: Akteur) {
    +  const daten = pruefen(bearbeitenSchema, eingabe);
    +  const alt = await prisma.schluessel.findUnique({
    +    where: { id },
    +    select: {
    +      name: true,
    +      kategorie: true,
    +      status: true,
    +      tags: true,
    +      ablaufAm: true,
    +      personId: true,
    +      vorlageId: true,
    +      fingerprint: true,
    +    },
    +  });
    +  if (!alt) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
    +  if (daten.status && daten.status !== alt.status) {
    +    if (alt.status !== "AKTIV" && alt.status !== "ABGELAUFEN") {
    +      throw new FachFehler("Rotierte oder widerrufene Schlüssel lassen sich nicht wieder aktivieren.", {
    +        status: "Status nicht änderbar.",
    +      });
    +    }
    +    // Der Deployment-Key der Instanz wird nur ueber seine eigene Rotation ersetzt; ein
    +    // „abgelaufener“ Deployment-Key liesse den Worker beim naechsten Start einen neuen erzeugen.
    +    if (alt.kategorie === "DEPLOYMENT") {
    +      throw new FachFehler("Der Status eines Deployment-Keys lässt sich hier nicht ändern.", {
    +        status: "Status nicht änderbar.",
    +      });
    +    }
    +  }
    +  if (daten.ablaufAm?.getTime() !== alt.ablaufAm?.getTime()) ablaufNichtVergangen(daten.ablaufAm);
    +  if (daten.personId !== alt.personId || daten.vorlageId !== alt.vorlageId) {
    +    await bezuegePruefen({ personId: daten.personId, vorlageId: daten.vorlageId, ablaufAm: daten.ablaufAm });
    +  }
    +
    +  const nachher = {
    +    name: daten.name,
    +    tags: daten.tags,
    +    ablaufAm: daten.ablaufAm,
    +    personId: daten.personId,
    +    vorlageId: daten.vorlageId,
    +    status: daten.status ?? alt.status,
    +  };
    +  const geaendert = Object.fromEntries(
    +    Object.entries(nachher)
    +      .map(([feld, wert]) => [feld, { vorher: alt[feld as keyof typeof nachher], nachher: wert }] as const)
    +      .filter(([, a]) => JSON.stringify(a.vorher) !== JSON.stringify(a.nachher)),
    +  );
    +
    +  return prisma.$transaction(async (tx) => {
    +    const schluessel = await tx.schluessel.update({ where: { id }, data: nachher, select: ANGELEGT });
    +    if (Object.keys(geaendert).length > 0) {
    +      await protokollieren(
    +        {
    +          akteur,
    +          aktion: "schluessel.geaendert",
    +          ergebnis: "ERFOLG",
    +          fingerprint: alt.fingerprint,
    +          details: geaendert,
    +        },
    +        tx,
    +      );
    +    }
    +    return schluessel;
    +  });
    +}
    diff --git a/src/server/vorgaenge/vorgaenge.ts b/src/server/vorgaenge/vorgaenge.ts
    index a44f2ac..2e0f65b 100644
    --- a/src/server/vorgaenge/vorgaenge.ts
    +++ b/src/server/vorgaenge/vorgaenge.ts
    @@ -35,8 +35,20 @@ export async function vorgangEinreihen(daten: VorgangAnlegen): Promise {
       return vorgang.id;
     }
     
    -/** Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. */
    +/**
    + * Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. Eine bereitliegende
    + * Private-Key-Abholung (#014) wird nie ausgeliefert, auch nicht verschluesselt: abgeholt wird
    + * nur ueber src/server/services/privatekey.ts.
    + */
     export async function vorgangLesen(id: string) {
    +  const vorgang = await vorgangRoh(id);
    +  if (vorgang?.art !== "PRIVATE_KEY_ABHOLEN") return vorgang;
    +  const ergebnis = vorgang.ergebnis;
    +  const bereit = typeof ergebnis === "object" && ergebnis !== null && "abholung" in ergebnis;
    +  return { ...vorgang, ergebnis: bereit ? { bereit: true } : ergebnis };
    +}
    +
    +async function vorgangRoh(id: string) {
       return prisma.vorgang.findUnique({
         where: { id },
         select: {
    diff --git a/src/worker/ablaeufe/privatekey.ts b/src/worker/ablaeufe/privatekey.ts
    new file mode 100644
    index 0000000..39f6f5b
    --- /dev/null
    +++ b/src/worker/ablaeufe/privatekey.ts
    @@ -0,0 +1,84 @@
    +import { prisma } from "@/lib/db";
    +import { verschluesseln } from "@/lib/geheimnisse";
    +import { ABHOLFRIST_MS } from "@/lib/schluessel";
    +import { protokollieren, type ProtokollEintrag } from "@/server/services/protokoll";
    +import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
    +import { privateKeyLaden, privateKeyVerwerfen } from "@/worker/geheimnisse";
    +
    +/**
    + * Private Key abholen und loeschen (#014).
    + *
    + * Abholen: Der Worker oeffnet den Umschlag (nur er hat den Master-Key) und legt den Key mit
    + * SSHKV_APP_SECRET verschluesselt in `Vorgang.ergebnis` ab. Das Web holt ihn genau einmal ab
    + * und ueberschreibt das Ergebnis dabei; was nach ABHOLFRIST noch liegt, raeumt
    + * `abholungenAufraeumen()` weg – der Worker ruft es vor jeder Abholung und regelmaessig auf.
    + */
    +
    +/**
    + * Ueberschreibt nicht abgeholte Private Keys, deren Vorgang laenger als ABHOLFRIST fertig ist.
    + * Liefert die Anzahl. Ein einziges UPDATE – kein Zeitfenster zwischen Lesen und Schreiben.
    + */
    +export async function abholungenAufraeumen(jetzt: Date = new Date()): Promise {
    +  const grenze = new Date(jetzt.getTime() - ABHOLFRIST_MS);
    +  return prisma.$executeRaw`
    +    UPDATE "Vorgang" SET "ergebnis" = '{"verfallen": true}'::jsonb
    +    WHERE "art" = 'PRIVATE_KEY_ABHOLEN'
    +      AND "ergebnis" ? 'abholung'
    +      AND "beendetAm" < ${grenze}`;
    +}
    +
    +async function anforderer(angefordertVonId: string | null): Promise {
    +  if (!angefordertVonId) return "System";
    +  const benutzer = await prisma.benutzer.findUnique({
    +    where: { id: angefordertVonId },
    +    select: { id: true, name: true },
    +  });
    +  return benutzer ?? "System";
    +}
    +
    +/** Nur Felder, die verraten, ob ein Key da ist – das Chiffrat liest allein geheimnisse.ts. */
    +async function schluesselLaden(schluesselId: string | null) {
    +  if (!schluesselId) throw new VorgangsFehler("Der Vorgang nennt keinen Schlüssel.");
    +  const schluessel = await prisma.schluessel.findUnique({
    +    where: { id: schluesselId },
    +    select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
    +  });
    +  if (!schluessel) throw new VorgangsFehler("Den Schlüssel gibt es nicht mehr.");
    +  if (schluessel.masterKeyVersion === null) {
    +    throw new VorgangsFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
    +  }
    +  return schluessel;
    +}
    +
    +export const privateKeyAbholenHandler: Handler = async ({ vorgang }) => {
    +  await abholungenAufraeumen();
    +  const schluessel = await schluesselLaden(vorgang.schluesselId);
    +  const klartext = await privateKeyLaden(schluessel.id);
    +  try {
    +    // JS-Strings lassen sich nicht ueberschreiben; der Puffer wenigstens wird sofort genullt.
    +    return {
    +      ergebnis: { abholung: verschluesseln(klartext.toString("utf8")), fingerprint: schluessel.fingerprint },
    +    };
    +  } finally {
    +    klartext.fill(0);
    +  }
    +};
    +
    +export const privateKeyLoeschenHandler: Handler = async ({ vorgang }) => {
    +  const schluessel = await schluesselLaden(vorgang.schluesselId);
    +  // Ohne Deployment-Key kaeme der Worker auf keinen Host mehr (Aussperrschutz).
    +  if (schluessel.kategorie === "DEPLOYMENT") {
    +    throw new VorgangsFehler("Den Private Key eines Deployment-Keys löscht sshkv nicht.");
    +  }
    +  if (!(await privateKeyVerwerfen(schluessel.id))) {
    +    throw new VorgangsFehler("Der Private Key war bereits gelöscht.");
    +  }
    +  await protokollieren({
    +    akteur: await anforderer(vorgang.angefordertVonId),
    +    aktion: "privatekey.geloescht",
    +    ergebnis: "ERFOLG",
    +    fingerprint: schluessel.fingerprint,
    +    details: { name: schluessel.name, slug: schluessel.slug },
    +  });
    +  return { ergebnis: { geloescht: true } };
    +};
    diff --git a/src/worker/ablaeufe/schluessel.ts b/src/worker/ablaeufe/schluessel.ts
    new file mode 100644
    index 0000000..6b5dd57
    --- /dev/null
    +++ b/src/worker/ablaeufe/schluessel.ts
    @@ -0,0 +1,204 @@
    +import type { Vorgang } from "@/generated/prisma/client";
    +import { prisma } from "@/lib/db";
    +import { entschluesseln } from "@/lib/geheimnisse";
    +import {
    +  publicKeyZeileMitKommentar,
    +  schluesselTypVon,
    +  vorgangEingabeSchema,
    +  type SchluesselGeheimnis,
    +} from "@/lib/schluessel";
    +import { privateKeyLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
    +import { HostFehler, istEindeutigkeitsFehler } from "@/server/services/hosts";
    +import { protokollieren, type ProtokollEintrag } from "@/server/services/protokoll";
    +import { bezuegePruefen, schluesselMitFingerprint } from "@/server/services/schluessel";
    +import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
    +import { importiertenKeyVersiegeln, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
    +
    +/**
    + * Schluessel erzeugen und ein Paar importieren (#012) – im Worker, weil nur er den Master-Key
    + * hat. Passphrase und importierter Private Key kommen mit SSHKV_APP_SECRET verschluesselt im
    + * Job (`geheim`); der Worker loescht den Job nach dem Lauf (src/worker/index.ts).
    + *
    + * Fehlermeldungen sind feste Saetze: aus `SchluesselFormatFehler` (die nie Eingaben
    + * zitieren) oder von hier. Nichts aus dem Private Key landet in `Vorgang.fehler`.
    + */
    +
    +/** Wer den Vorgang angefordert hat – fuer Protokoll und `erstelltVonId`. */
    +async function anforderer(vorgang: Vorgang): Promise {
    +  if (!vorgang.angefordertVonId) return "System";
    +  const benutzer = await prisma.benutzer.findUnique({
    +    where: { id: vorgang.angefordertVonId },
    +    select: { id: true, name: true },
    +  });
    +  return benutzer ?? "System";
    +}
    +
    +function geheimnisLesen(geheim: string | undefined): SchluesselGeheimnis {
    +  if (!geheim) return {};
    +  try {
    +    const wert = JSON.parse(entschluesseln(geheim)) as unknown;
    +    if (!wert || typeof wert !== "object") throw new Error("kein Objekt");
    +    const { privateKey, passphrase } = wert as Record;
    +    return {
    +      ...(typeof privateKey === "string" ? { privateKey } : {}),
    +      ...(typeof passphrase === "string" && passphrase ? { passphrase } : {}),
    +    };
    +  } catch {
    +    throw new VorgangsFehler("Die verschlüsselten Angaben des Vorgangs sind unlesbar. Bitte erneut starten.");
    +  }
    +}
    +
    +/** Eingabe pruefen, Bezuege nachsehen – Fachfehler werden zu Meldungen des Vorgangs. */
    +async function eingabeAufloesen(vorgang: Vorgang) {
    +  const ergebnis = vorgangEingabeSchema.safeParse(vorgang.eingabe);
    +  if (!ergebnis.success) throw new VorgangsFehler("Die Angaben zum Schlüssel sind unvollständig.");
    +  const eingabe = ergebnis.data;
    +  try {
    +    const { ablaufAm } = await bezuegePruefen({
    +      personId: eingabe.personId,
    +      vorlageId: eingabe.vorlageId,
    +      ablaufAm: eingabe.ablaufAm ? new Date(eingabe.ablaufAm) : null,
    +    });
    +    return { ...eingabe, ablaufAm };
    +  } catch (fehler) {
    +    if (fehler instanceof HostFehler) throw new VorgangsFehler(fehler.message);
    +    throw fehler;
    +  }
    +}
    +
    +function schonVerwaltet(vorhanden: { name: string }): VorgangsFehler {
    +  return new VorgangsFehler(`Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“.`);
    +}
    +
    +/** Der Vorgang soll auf „seinen“ Schluessel zeigen – fuer Verlauf und Oberflaeche. */
    +async function vorgangVerknuepfen(vorgangId: string, schluesselId: string): Promise {
    +  await prisma.vorgang.update({ where: { id: vorgangId }, data: { schluesselId } });
    +}
    +
    +export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) => {
    +  const eingabe = await eingabeAufloesen(vorgang);
    +  const { passphrase } = geheimnisLesen(geheim);
    +  if (eingabe.mitPassphrase && !passphrase) {
    +    throw new VorgangsFehler("Die Passphrase fehlt – bitte erneut erzeugen.");
    +  }
    +  const akteur = await anforderer(vorgang);
    +  const typ = eingabe.typ ?? "ED25519";
    +  const kommentar = eingabe.kommentar ?? `sshkv:${eingabe.slug}`;
    +
    +  const neu = await schluesselpaarVersiegeltErzeugen(typ, kommentar, passphrase);
    +  try {
    +    await prisma.schluessel.create({
    +      data: {
    +        id: neu.id,
    +        name: eingabe.name,
    +        slug: eingabe.slug,
    +        typ,
    +        kategorie: "DIENST",
    +        publicKey: neu.publicKeyZeile,
    +        fingerprint: neu.fingerprint,
    +        kommentar,
    +        tags: eingabe.tags,
    +        ablaufAm: eingabe.ablaufAm,
    +        personId: eingabe.personId,
    +        vorlageId: eingabe.vorlageId,
    +        erstelltVonId: akteur === "System" ? null : akteur.id,
    +        passphraseGeschuetzt: Boolean(passphrase),
    +        ...neu.versiegelt,
    +      },
    +      select: { id: true },
    +    });
    +  } catch (fehler) {
    +    if (istEindeutigkeitsFehler(fehler)) {
    +      throw new VorgangsFehler(
    +        `Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
    +      );
    +    }
    +    throw fehler;
    +  }
    +  await vorgangVerknuepfen(vorgang.id, neu.id);
    +  await protokollieren({
    +    akteur,
    +    aktion: "schluessel.erzeugt",
    +    ergebnis: "ERFOLG",
    +    fingerprint: neu.fingerprint,
    +    details: {
    +      name: eingabe.name,
    +      slug: eingabe.slug,
    +      typ,
    +      kategorie: "DIENST",
    +      passphraseGeschuetzt: Boolean(passphrase),
    +    },
    +  });
    +  return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
    +};
    +
    +export const schluesselImportierenHandler: Handler = async ({ vorgang, geheim }) => {
    +  const eingabe = await eingabeAufloesen(vorgang);
    +  const { privateKey, passphrase } = geheimnisLesen(geheim);
    +  if (!privateKey) throw new VorgangsFehler("Der Private Key fehlt – bitte den Import erneut starten.");
    +  const akteur = await anforderer(vorgang);
    +
    +  let info;
    +  try {
    +    // Die Passphrase dient nur dem Lesen von Public Key und Fingerprint und wird danach
    +    // verworfen; gespeichert wird der Key so, wie er kam (Freigabe Frage 6).
    +    info = privateKeyLesen(privateKey, passphrase);
    +  } catch (fehler) {
    +    if (fehler instanceof SchluesselFormatFehler) throw new VorgangsFehler(fehler.message);
    +    throw fehler;
    +  }
    +  const vorhanden = await schluesselMitFingerprint(info.fingerprint);
    +  if (vorhanden) throw schonVerwaltet(vorhanden);
    +
    +  const algorithmus = info.typ === "ED25519" ? "ssh-ed25519" : "ssh-rsa";
    +  const typ = schluesselTypVon(algorithmus, info.bits);
    +  const kommentar = eingabe.kommentar ?? (info.kommentar || `sshkv:${eingabe.slug}`);
    +  const { id, versiegelt } = await importiertenKeyVersiegeln(info.fingerprint, privateKey);
    +  try {
    +    await prisma.schluessel.create({
    +      data: {
    +        id,
    +        name: eingabe.name,
    +        slug: eingabe.slug,
    +        typ,
    +        kategorie: "DIENST",
    +        publicKey: publicKeyZeileMitKommentar(info.publicKeyZeile, kommentar),
    +        fingerprint: info.fingerprint,
    +        kommentar,
    +        tags: eingabe.tags,
    +        ablaufAm: eingabe.ablaufAm,
    +        personId: eingabe.personId,
    +        vorlageId: eingabe.vorlageId,
    +        erstelltVonId: akteur === "System" ? null : akteur.id,
    +        passphraseGeschuetzt: info.verschluesselt,
    +        ...versiegelt,
    +      },
    +      select: { id: true },
    +    });
    +  } catch (fehler) {
    +    if (istEindeutigkeitsFehler(fehler)) {
    +      const jetzt = await schluesselMitFingerprint(info.fingerprint);
    +      if (jetzt) throw schonVerwaltet(jetzt);
    +      throw new VorgangsFehler(
    +        `Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut importieren.`,
    +      );
    +    }
    +    throw fehler;
    +  }
    +  await vorgangVerknuepfen(vorgang.id, id);
    +  await protokollieren({
    +    akteur,
    +    aktion: "schluessel.importiert",
    +    ergebnis: "ERFOLG",
    +    fingerprint: info.fingerprint,
    +    details: {
    +      name: eingabe.name,
    +      slug: eingabe.slug,
    +      typ,
    +      kategorie: "DIENST",
    +      art: "paar",
    +      passphraseGeschuetzt: info.verschluesselt,
    +    },
    +  });
    +  return { ergebnis: { schluesselId: id, fingerprint: info.fingerprint } };
    +};
    diff --git a/src/worker/geheimnisse.ts b/src/worker/geheimnisse.ts
    index 1e423f8..cb875e6 100644
    --- a/src/worker/geheimnisse.ts
    +++ b/src/worker/geheimnisse.ts
    @@ -126,13 +126,16 @@ export type NeuerSchluessel = {
     
     /**
      * Erzeugt ein Schluesselpaar und versiegelt den Private Key sofort. Die ID wird hier
    - * vergeben, weil sie in die Zusatzdaten des Umschlags eingeht.
    + * vergeben, weil sie in die Zusatzdaten des Umschlags eingeht. Mit `passphrase` liegt im
    + * Umschlag der bereits passphrasengeschuetzte OpenSSH-Key; die Passphrase selbst wird nie
    + * gespeichert (Freigabe Frage 6, #012).
      */
     export async function schluesselpaarVersiegeltErzeugen(
       typ: ErzeugbarerSchluesseltyp,
       kommentar: string,
    +  passphrase?: string,
     ): Promise {
    -  const paar = await schluesselpaarErzeugen(typ, { kommentar });
    +  const paar = await schluesselpaarErzeugen(typ, { kommentar, ...(passphrase ? { passphrase } : {}) });
       const id = randomUUID();
       return {
         id,
    @@ -142,6 +145,35 @@ export async function schluesselpaarVersiegeltErzeugen(
       };
     }
     
    +/**
    + * Versiegelt einen importierten Private Key (#012) so, wie er kam – ein
    + * passphrasengeschuetzter Key bleibt es. Die ID wird hier vergeben (Zusatzdaten).
    + */
    +export async function importiertenKeyVersiegeln(
    +  fingerprint: string,
    +  privateKeyOpenssh: string,
    +): Promise<{ readonly id: string; readonly versiegelt: VersiegelterKey }> {
    +  const id = randomUUID();
    +  return { id, versiegelt: await privateKeyVersiegeln(id, fingerprint, privateKeyOpenssh) };
    +}
    +
    +/**
    + * Verwirft den gespeicherten Private Key endgueltig (#014): Chiffrat, Data-Key und Version
    + * auf null, Zeitpunkt festhalten. Liefert false, wenn keiner (mehr) gespeichert war.
    + */
    +export async function privateKeyVerwerfen(schluesselId: string): Promise {
    +  const ergebnis = await prisma.schluessel.updateMany({
    +    where: { id: schluesselId, privatVerschluesselt: { not: null } },
    +    data: {
    +      privatVerschluesselt: null,
    +      dekVerschluesselt: null,
    +      masterKeyVersion: null,
    +      privatGeloeschtAm: new Date(),
    +    },
    +  });
    +  return ergebnis.count === 1;
    +}
    +
     /**
      * Stellt beim Start des Workers sicher, dass es einen Deployment-Key der Instanz gibt
      * (#006). Gibt die ID zurueck. Gleichzeitige Starts scheitern am partiellen Index
    diff --git a/src/worker/handler.ts b/src/worker/handler.ts
    index dae834e..81f4d94 100644
    --- a/src/worker/handler.ts
    +++ b/src/worker/handler.ts
    @@ -2,6 +2,8 @@ import type { HandlerTabelle } from "@/worker/ausfuehren";
     import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
     import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
     import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
    +import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
    +import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
     
     /**
      * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
    @@ -12,4 +14,8 @@ export const HANDLER: HandlerTabelle = {
       BOOTSTRAP: bootstrapHandler,
       TROCKENLAUF: trockenlaufHandler,
       AUSROLLEN: ausrollenHandler,
    +  SCHLUESSEL_ERZEUGEN: schluesselErzeugenHandler,
    +  SCHLUESSEL_IMPORTIEREN: schluesselImportierenHandler,
    +  PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
    +  PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
     };
    diff --git a/src/worker/index.ts b/src/worker/index.ts
    index ca0b997..219e5b3 100644
    --- a/src/worker/index.ts
    +++ b/src/worker/index.ts
    @@ -3,6 +3,7 @@ import type { Job } from "pg-boss";
     import { VERSION } from "@/lib/version";
     import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
     import { vorgangAusfuehren } from "@/worker/ausfuehren";
    +import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
     import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
     import { HANDLER } from "@/worker/handler";
     
    @@ -30,10 +31,25 @@ async function starten(): Promise {
         },
       );
     
    +  // Nicht abgeholte Private Keys (#014) verfallen nach zwei Minuten. Ein Takt im Worker statt
    +  // eines pg-boss-Zeitplans: dessen Raster ist eine Minute, und ein Fehler hier soll den
    +  // Worker nicht anhalten. Zusaetzlich raeumt jede neue Abholung vorher auf, und das Web
    +  // gibt eine abgelaufene Abholung ohnehin nicht mehr heraus.
    +  const aufraeumen = () =>
    +    abholungenAufraeumen().catch((fehler: unknown) =>
    +      console.error(
    +        "Aufräumen der Abholungen fehlgeschlagen:",
    +        fehler instanceof Error ? fehler.message : fehler,
    +      ),
    +    );
    +  await aufraeumen();
    +  const takt = setInterval(() => void aufraeumen(), 30_000);
    +
       console.log(`sshkv-worker ${VERSION} bereit.`);
     
       const beenden = async (signal: string) => {
         console.log(`${signal} empfangen, beende laufende Vorgänge …`);
    +    clearInterval(takt);
         await boss.stop({ graceful: true, timeout: 30_000 });
         process.exit(0);
       };
    diff --git a/tests/anmeldung.test.ts b/tests/anmeldung.test.ts
    index 5b35b7f..b51624b 100644
    --- a/tests/anmeldung.test.ts
    +++ b/tests/anmeldung.test.ts
    @@ -73,7 +73,13 @@ describe("Rechte", () => {
     
       it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
         expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
    -      ["deploymentKeyVerwalten", "massenwiderruf", "privateKeyAnzeigen", "zielkontenVerwalten"].sort(),
    +      [
    +        "deploymentKeyVerwalten",
    +        "massenwiderruf",
    +        "privateKeyAnzeigen",
    +        "privateKeyLoeschen",
    +        "zielkontenVerwalten",
    +      ].sort(),
         );
         for (const recht of BESTAETIGUNG_NOETIG) {
           expect(RECHTE[recht]).toEqual(["ADMIN"]);
    diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts
    index 5696fdb..6e3b69b 100644
    --- a/tests/datenbankregeln.test.ts
    +++ b/tests/datenbankregeln.test.ts
    @@ -20,6 +20,8 @@ const REGELN = [
       "privateverwendung_pfad_gueltig",
       "publicverwendung_host_oder_gruppe",
       "benutzer_notfall_vollstaendig",
    +  "schluessel_slug_gueltig",
    +  "schluessel_geloescht_ohne_private_key",
     ].sort();
     
     const INDIZES = [
    @@ -90,6 +92,25 @@ describe("Datenbankregeln", () => {
         ).rejects.toThrow();
       });
     
    +  it("laesst nur Slugs zu, die in Dateinamen passen (#012)", async () => {
    +    for (const slug of ["Gross", "mit leer", "../x", "a_b", "", "x".repeat(65)]) {
    +      await expect(schluesselAnlegen({ slug })).rejects.toThrow();
    +    }
    +    await expect(schluesselAnlegen({ slug: "backup-srv-pve01" })).resolves.toBeDefined();
    +  });
    +
    +  it("kennt „Private Key geloescht“ nur ohne Chiffrat (#014)", async () => {
    +    await expect(
    +      schluesselAnlegen({
    +        privatGeloeschtAm: new Date(),
    +        privatVerschluesselt: Buffer.from("x"),
    +        dekVerschluesselt: Buffer.from("y"),
    +        masterKeyVersion: 1,
    +      }),
    +    ).rejects.toThrow();
    +    await expect(schluesselAnlegen({ privatGeloeschtAm: new Date() })).resolves.toBeDefined();
    +  });
    +
       it("laesst nur einen aktiven Instanz-Deployment-Key zu", async () => {
         await expect(schluesselAnlegen({ kategorie: "DIENST", instanzStandard: true })).rejects.toThrow();
         await schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true });
    diff --git a/tests/e2e/schluessel.spec.ts b/tests/e2e/schluessel.spec.ts
    new file mode 100644
    index 0000000..330747d
    --- /dev/null
    +++ b/tests/e2e/schluessel.spec.ts
    @@ -0,0 +1,197 @@
    +import { expect, test, type Page } from "@playwright/test";
    +import pg from "pg";
    +import "dotenv/config";
    +import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
    +
    +/**
    + * Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
    + * und bleiben liegen (Schluessel werden nicht geloescht, nur spaeter widerrufen).
    + */
    +test.setTimeout(90_000);
    +
    +async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") {
    +  await page.goto("/anmelden");
    +  await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
    +  await page.locator(`button[data-rolle="${rolle}"]`).click();
    +  await page.getByRole("button", { name: "Anmelden", exact: true }).click();
    +  await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
    +}
    +
    +async function datenbank(arbeit: (client: pg.Client) => Promise): Promise {
    +  const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
    +  await client.connect();
    +  try {
    +    return await arbeit(client);
    +  } finally {
    +    await client.end();
    +  }
    +}
    +
    +function eindeutig(): string {
    +  return `zz-e2e-${Date.now().toString(36)}`;
    +}
    +
    +test("Operator erzeugt einen Schlüssel und landet im Detail", async ({ page }) => {
    +  const name = `${eindeutig()} Backup`;
    +  await oidcAnmelden(page, "operator");
    +  await page.goto("/schluessel/neu");
    +  await page.getByLabel("Zweck").fill(name);
    +  await page.getByLabel("Tags").fill("e2e");
    +  await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
    +
    +  await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
    +  await expect(page).toHaveURL(/\/schluessel\/[^/]+$/);
    +  await expect(page.getByText("verschlüsselt gespeichert")).toBeVisible();
    +  await expect(page.getByText("ed25519", { exact: true })).toBeVisible();
    +  await expect(page.getByLabel("Public Key", { exact: true })).toContainText("ssh-ed25519 AAAA");
    +  await expect(page.getByLabel("Public Key", { exact: true })).toContainText(
    +    `sshkv:${name.toLowerCase().replace(/ /g, "-")}`,
    +  );
    +
    +  await page.goto(`/schluessel?q=${encodeURIComponent(name)}`);
    +  await expect(page.getByRole("link", { name })).toBeVisible();
    +});
    +
    +test("Public Key importieren, Duplikat wird erkannt", async ({ page }) => {
    +  const name = `${eindeutig()} Laptop`;
    +  const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "e2e@laptop" });
    +  await oidcAnmelden(page, "operator");
    +  await page.goto("/schluessel/neu");
    +  await page.getByRole("radio", { name: "Importieren" }).click();
    +  await page.getByLabel("Zweck").fill(name);
    +  await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
    +  await page.locator(`input[name="kategorie"][value="PERSON"]`).check();
    +  await page.getByRole("button", { name: "Schlüssel importieren" }).click();
    +
    +  await expect(page.getByRole("heading", { name })).toBeVisible();
    +  await expect(page.getByText("nicht gespeichert (Personen-Key)")).toBeVisible();
    +  await expect(page.getByText(paar.fingerprint)).toBeVisible();
    +
    +  await page.goto("/schluessel/neu");
    +  await page.getByRole("radio", { name: "Importieren" }).click();
    +  await page.getByLabel("Zweck").fill(`${name} nochmal`);
    +  await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
    +  await page.getByRole("button", { name: "Schlüssel importieren" }).click();
    +  await expect(page.getByRole("alert").first()).toContainText(`bereits verwaltet: „${name}“`);
    +});
    +
    +test("Leser sieht Schlüssel, darf aber keine anlegen", async ({ page }) => {
    +  await oidcAnmelden(page, "reader");
    +  await page.goto("/schluessel");
    +  await expect(page.getByRole("heading", { name: "Schlüssel", exact: true })).toBeVisible();
    +  await expect(page.getByRole("link", { name: "Schlüssel anlegen" })).toHaveCount(0);
    +  await page.goto("/schluessel/neu");
    +  await expect(page).toHaveURL(/\/kein-zugriff/);
    +});
    +
    +test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async ({ page }) => {
    +  const name = `${eindeutig()} Laptop`;
    +  const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "reader@laptop" });
    +  await oidcAnmelden(page, "reader");
    +  await page.getByRole("link", { name: "Meine Schlüssel" }).first().click();
    +  await expect(page.getByRole("heading", { name: "Meine Schlüssel" })).toBeVisible();
    +  await expect(page.getByText(/Wirksam erst, wenn ein Operator oder Admin/).first()).toBeVisible();
    +
    +  await page.getByLabel("Bezeichnung").fill(name);
    +  await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
    +  await page.getByRole("button", { name: "Hinterlegen" }).click();
    +  await expect(page.getByText(`Hinterlegt: ${paar.fingerprint}`)).toBeVisible();
    +  const eintrag = page.locator("li", { hasText: name });
    +  await expect(eintrag.getByText("noch nicht zugewiesen")).toBeVisible();
    +
    +  await eintrag.getByRole("button", { name: `${name} entfernen` }).click();
    +  await page.getByRole("dialog").getByRole("button", { name: "Entfernen", exact: true }).click();
    +  await expect(page.locator("li", { hasText: name })).toHaveCount(0);
    +});
    +
    +/**
    + * Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
    + * Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
    + */
    +async function oidcBestaetigen(page: Page) {
    +  await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
    +  await page.getByRole("link", { name: /erneut anmelden/ }).click();
    +  await page.locator('button[data-rolle="admin"]').click();
    +  const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record;
    +  await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
    +  await page.getByRole("button", { name: "Anmelden", exact: true }).click();
    +}
    +
    +test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
    +  page,
    +}) => {
    +  const name = `${eindeutig()} Deploy`;
    +  await oidcAnmelden(page, "admin");
    +  // Eine Bestaetigung aus einem frueheren Lauf soll nicht mitzaehlen.
    +  await datenbank((client) =>
    +    client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
    +  );
    +  await page.goto("/schluessel/neu");
    +  await page.getByLabel("Zweck").fill(name);
    +  await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
    +  await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
    +  const detail = page.url();
    +  const fingerprint =
    +    (
    +      await page
    +        .getByText(/^SHA256:/)
    +        .first()
    +        .textContent()
    +    )?.trim() ?? "";
    +
    +  // Ohne frische Bestaetigung geht es erst nach /bestaetigen und von dort zurueck in den Dialog.
    +  await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
    +  const dialog = page.getByRole("dialog");
    +  await dialog.getByRole("button", { name: "Anzeigen" }).click();
    +  await oidcBestaetigen(page);
    +  await expect(page).toHaveURL(/privatekey=1/);
    +  await dialog.getByRole("button", { name: "Anzeigen" }).click();
    +  const block = dialog.getByLabel("Private Key", { exact: true });
    +  await expect(block).toContainText("-----BEGIN OPENSSH PRIVATE KEY-----", { timeout: 30_000 });
    +  await expect(dialog.getByText("Wird nicht erneut angezeigt.")).toBeVisible();
    +  await dialog.getByRole("button", { name: "Schließen" }).click();
    +  await expect(block).toHaveCount(0);
    +
    +  // Download: eigene Abholung, Datei , ohne Caching.
    +  await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
    +  const antwort = page.waitForResponse((r) => r.url().includes("/api/private-key/"));
    +  const datei = page.waitForEvent("download");
    +  await dialog.getByRole("button", { name: "Herunterladen" }).click();
    +  const kopf = (await antwort).headers();
    +  expect(kopf["cache-control"]).toContain("no-store");
    +  expect(kopf["content-disposition"]).toMatch(/^attachment; filename="zz-e2e-[a-z0-9]+-deploy"$/);
    +  const download = await datei;
    +  expect(download.suggestedFilename()).toMatch(/^zz-e2e-[a-z0-9]+-deploy$/);
    +  await expect(dialog.getByText(/Download gestartet/)).toBeVisible();
    +  await dialog.getByRole("button", { name: "Schließen" }).click();
    +
    +  // Operator sieht keinen Anzeigen-Knopf und kein Loeschen.
    +  const operator = await page.context().browser()!.newContext();
    +  const operatorSeite = await operator.newPage();
    +  await oidcAnmelden(operatorSeite, "operator");
    +  await operatorSeite.goto(detail);
    +  await expect(operatorSeite.getByText("verschlüsselt gespeichert")).toBeVisible();
    +  await expect(operatorSeite.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
    +  await expect(operatorSeite.getByRole("button", { name: "Private Key löschen" })).toHaveCount(0);
    +  await operator.close();
    +
    +  // Loeschen (Bestaetigung ist noch frisch).
    +  await page.getByRole("button", { name: "Private Key löschen" }).click();
    +  await dialog.getByText("Ich habe verstanden").click();
    +  await dialog.getByRole("button", { name: "Endgültig löschen" }).click();
    +  await expect(page.getByText(/gelöscht am/)).toBeVisible({ timeout: 30_000 });
    +  await expect(page.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
    +
    +  // Im Audit stehen Anzeige, Download und Loeschen mit Fingerprint – ohne den Key.
    +  expect(fingerprint).toMatch(/^SHA256:/);
    +  const { rows } = await datenbank((client) =>
    +    client.query<{ aktion: string; zeile: string }>(
    +      `SELECT "aktion", row_to_json(p)::text AS zeile FROM "Protokoll" p WHERE "fingerprint" = $1 ORDER BY "id"`,
    +      [fingerprint],
    +    ),
    +  );
    +  expect(rows.map((r) => r.aktion)).toEqual(
    +    expect.arrayContaining(["privatekey.angezeigt", "privatekey.heruntergeladen", "privatekey.geloescht"]),
    +  );
    +  expect(rows.some((r) => r.zeile.includes("PRIVATE KEY"))).toBe(false);
    +});
    diff --git a/tests/hilfen/vorgang.ts b/tests/hilfen/vorgang.ts
    new file mode 100644
    index 0000000..f1faf9c
    --- /dev/null
    +++ b/tests/hilfen/vorgang.ts
    @@ -0,0 +1,36 @@
    +import { prisma } from "@/lib/db";
    +import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
    +import { vorgangAusfuehren } from "@/worker/ausfuehren";
    +import { HANDLER } from "@/worker/handler";
    +
    +/**
    + * Fuehrt einen eingereihten Vorgang so aus, wie es der Worker tut (#012): Job aus pg-boss
    + * holen, mit seinem `geheim` ausfuehren, danach loeschen (src/worker/index.ts).
    + * Liefert zusaetzlich die rohen Job-Daten, damit Tests pruefen koennen, was darin stand.
    + */
    +export async function vorgangImWorker(vorgangId: string): Promise<{ jobDaten: VorgangsJob }> {
    +  const boss = await warteschlangeHolen();
    +  // Die Warteschlange ueberlebt das Leeren der Tabellen; deshalb gezielt den eigenen Job suchen.
    +  const jobs = await boss.fetch(WARTESCHLANGE, { batchSize: 100 });
    +  const job = jobs.find((kandidat) => kandidat.data.vorgangId === vorgangId);
    +  if (!job) throw new Error(`Kein Job für Vorgang ${vorgangId}`);
    +  await vorgangAusfuehren(job.data.vorgangId, HANDLER, job.data.geheim);
    +  await boss.complete(WARTESCHLANGE, job.id);
    +  if (job.data.geheim) await boss.deleteJob(WARTESCHLANGE, job.id);
    +  return { jobDaten: job.data };
    +}
    +
    +/** Alle Zeilen aller Tabellen als ein Text – fuer „steht das Geheimnis irgendwo?“. */
    +export async function datenbankAlsText(): Promise {
    +  const teile = await Promise.all([
    +    prisma.vorgang.findMany(),
    +    prisma.protokoll.findMany(),
    +    prisma.$queryRaw`SELECT data FROM pgboss.job`,
    +  ]);
    +  return JSON.stringify(teile, (_k, wert: unknown) => (typeof wert === "bigint" ? wert.toString() : wert));
    +}
    +
    +export async function warteschlangeStoppen(): Promise {
    +  const boss = await warteschlangeHolen();
    +  await boss.stop({ graceful: false });
    +}
    diff --git a/tests/personen.test.ts b/tests/personen.test.ts
    new file mode 100644
    index 0000000..f6e765f
    --- /dev/null
    +++ b/tests/personen.test.ts
    @@ -0,0 +1,213 @@
    +import { readFileSync } from "node:fs";
    +import { afterAll, beforeEach, describe, expect, it } from "vitest";
    +import type { Benutzer } from "@/generated/prisma/client";
    +import { prisma } from "@/lib/db";
    +import { darf } from "@/server/auth/rechte";
    +import { kontenAuswahl, personDetail, personenListe } from "@/server/queries/personen";
    +import { FachFehler } from "@/server/services/fachfehler";
    +import {
    +  eigenePersonSicherstellen,
    +  eigenenPublicKeyHinterlegen,
    +  eigenenSchluesselEntfernen,
    +  eigeneSchluessel,
    +} from "@/server/services/meine-schluessel";
    +import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen";
    +import { publicKeyImportieren } from "@/server/services/schluessel";
    +import { datenbankLeeren } from "./hilfen/db";
    +import { warteschlangeStoppen } from "./hilfen/vorgang";
    +import { ed25519Blob, zeile } from "./ssh/hilfen";
    +
    +/** Personen und Self-Service „Meine Schluessel“ (#013). */
    +
    +let operator: Benutzer;
    +let leser: Benutzer;
    +let andererLeser: Benutzer;
    +
    +beforeEach(async () => {
    +  await datenbankLeeren();
    +  operator = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } });
    +  leser = await prisma.benutzer.create({
    +    data: { name: "Lea Leser", rolle: "LESER", email: "lea@example.org", emailBestaetigt: true },
    +  });
    +  andererLeser = await prisma.benutzer.create({
    +    data: { name: "Max Mitleser", rolle: "LESER", email: "max@example.org", emailBestaetigt: true },
    +  });
    +});
    +
    +afterAll(async () => {
    +  await warteschlangeStoppen();
    +});
    +
    +async function fachfehlerVon(versuch: Promise): Promise {
    +  const fehler = await versuch.then(
    +    () => undefined,
    +    (f: unknown) => f,
    +  );
    +  expect(fehler).toBeInstanceOf(FachFehler);
    +  return fehler as FachFehler;
    +}
    +
    +const akteur = () => ({ id: operator.id, name: operator.name });
    +const neueZeile = (kommentar = "test") => zeile("ssh-ed25519", ed25519Blob(), kommentar);
    +
    +describe("Personen verwalten", () => {
    +  it("legt an, bearbeitet, archiviert und protokolliert", async () => {
    +    const person = await personAnlegen(
    +      { name: "Anna Berger", email: "A.Berger@Example.org", organisation: "Netzwerk GmbH" },
    +      akteur(),
    +    );
    +    const detail = await personDetail(person.id);
    +    expect(detail).toMatchObject({ email: "a.berger@example.org", organisation: "Netzwerk GmbH" });
    +
    +    await personBearbeiten(
    +      person.id,
    +      { name: "Anna Berger", email: "a.berger@example.org", notiz: "privat" },
    +      akteur(),
    +    );
    +    const geaendert = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "person.geaendert" } });
    +    // Die Notiz steht nicht im Protokoll, nur dass sie sich aenderte.
    +    expect(JSON.stringify(geaendert.details)).not.toContain("privat");
    +    expect(geaendert.details).toMatchObject({ notiz: "geändert", organisation: { vorher: "Netzwerk GmbH" } });
    +
    +    await personArchivieren(person.id, akteur());
    +    expect((await personenListe()).map((p) => p.name)).toEqual([]);
    +    expect((await personenListe({ archivierte: true })).map((p) => p.name)).toEqual(["Anna Berger"]);
    +    await fachfehlerVon(personBearbeiten(person.id, { name: "X" }, akteur()));
    +    expect(await prisma.protokoll.count({ where: { aktion: { startsWith: "person." } } })).toBe(3);
    +  });
    +
    +  it("prueft E-Mail und verknuepft ein Konto nur einmal", async () => {
    +    const fehler = await fachfehlerVon(personAnlegen({ name: "X", email: "keine-mail" }, akteur()));
    +    expect(fehler.feldfehler["email"]).toBeDefined();
    +
    +    await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur());
    +    const doppelt = await fachfehlerVon(personAnlegen({ name: "Lea 2", benutzerId: leser.id }, akteur()));
    +    expect(doppelt.feldfehler["benutzerId"]).toBeDefined();
    +    expect((await kontenAuswahl()).map((k) => k.name)).toEqual(["Max Mitleser", "Olga Operator"]);
    +  });
    +
    +  it("loest beim Archivieren die Kontoverknuepfung", async () => {
    +    const person = await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur());
    +    await personArchivieren(person.id, akteur());
    +    expect((await personDetail(person.id))?.benutzerId).toBeNull();
    +    // Der Self-Service landet danach bei einer neuen Person, nicht bei der archivierten.
    +    expect((await eigenePersonSicherstellen(leser)).id).not.toBe(person.id);
    +  });
    +});
    +
    +describe("Eigene Person", () => {
    +  it("wird beim ersten Aufruf angelegt und danach wiederverwendet", async () => {
    +    const [a, b] = await Promise.all([eigenePersonSicherstellen(leser), eigenePersonSicherstellen(leser)]);
    +    expect(a.id).toBe(b.id);
    +    expect(await prisma.person.count()).toBe(1);
    +    expect((await personDetail(a.id))?.benutzerId).toBe(leser.id);
    +  });
    +
    +  it("verknuepft per bestaetigter E-Mail mit einer vorhandenen Person", async () => {
    +    const person = await personAnlegen({ name: "Lea Leser (IT)", email: "LEA@example.org" }, akteur());
    +    expect((await eigenePersonSicherstellen(leser)).id).toBe(person.id);
    +    expect(await prisma.protokoll.count({ where: { aktion: "person.verknuepft" } })).toBe(1);
    +  });
    +
    +  it("verknuepft nicht ueber eine unbestaetigte Adresse", async () => {
    +    const person = await personAnlegen({ name: "Chefin", email: "chefin@example.org" }, akteur());
    +    const angreifer = await prisma.benutzer.create({
    +      data: { name: "Mallory", rolle: "LESER", email: "chefin@example.org", emailBestaetigt: false },
    +    });
    +    expect((await eigenePersonSicherstellen(angreifer)).id).not.toBe(person.id);
    +    expect((await personDetail(person.id))?.benutzerId).toBeNull();
    +  });
    +});
    +
    +describe("Meine Schluessel", () => {
    +  it("hinterlegt eigene Public Keys als Personen-Keys", async () => {
    +    const neu = await eigenenPublicKeyHinterlegen(leser, {
    +      name: "Laptop",
    +      publicKey: neueZeile("lea@laptop"),
    +    });
    +    const schluessel = await prisma.schluessel.findUniqueOrThrow({
    +      where: { id: neu.id },
    +      select: {
    +        kategorie: true,
    +        selbstHinterlegt: true,
    +        personId: true,
    +        erstelltVonId: true,
    +        masterKeyVersion: true,
    +      },
    +    });
    +    const person = await eigenePersonSicherstellen(leser);
    +    expect(schluessel).toEqual({
    +      kategorie: "PERSON",
    +      selbstHinterlegt: true,
    +      personId: person.id,
    +      erstelltVonId: leser.id,
    +      masterKeyVersion: null,
    +    });
    +    const eintrag = await prisma.protokoll.findFirstOrThrow({
    +      where: { aktion: "schluessel.selbst_hinterlegt" },
    +    });
    +    expect(eintrag.akteurId).toBe(leser.id);
    +  });
    +
    +  it("zeigt nur eigene Keys und laesst fremde nicht entfernen", async () => {
    +    const eigener = await eigenenPublicKeyHinterlegen(leser, { name: "Meiner", publicKey: neueZeile() });
    +    const fremder = await eigenenPublicKeyHinterlegen(andererLeser, {
    +      name: "Fremd",
    +      publicKey: neueZeile(),
    +    });
    +
    +    expect((await eigeneSchluessel(leser)).schluessel.map((s) => s.name)).toEqual(["Meiner"]);
    +    expect((await eigeneSchluessel(andererLeser)).schluessel.map((s) => s.name)).toEqual(["Fremd"]);
    +
    +    const fehler = await fachfehlerVon(eigenenSchluesselEntfernen(leser, fremder.id));
    +    expect(fehler.message).toMatch(/unter Ihren Schlüsseln nicht/);
    +    expect(await prisma.schluessel.count({ where: { id: fremder.id } })).toBe(1);
    +
    +    await eigenenSchluesselEntfernen(leser, eigener.id);
    +    expect(await prisma.schluessel.count({ where: { id: eigener.id } })).toBe(0);
    +    expect(await prisma.protokoll.count({ where: { aktion: "schluessel.selbst_entfernt" } })).toBe(1);
    +  });
    +
    +  it("entfernt keine zugewiesenen und keine von Operatoren angelegten Keys", async () => {
    +    const zugewiesen = await eigenenPublicKeyHinterlegen(leser, {
    +      name: "Zugewiesen",
    +      publicKey: neueZeile(),
    +    });
    +    const host = await prisma.host.create({ data: { name: "srv-test01", adresse: "10.0.0.1" } });
    +    await prisma.publicVerwendung.create({
    +      data: { schluesselId: zugewiesen.id, hostId: host.id, zielbenutzer: "lea" },
    +    });
    +    await fachfehlerVon(eigenenSchluesselEntfernen(leser, zugewiesen.id));
    +
    +    const person = await eigenePersonSicherstellen(leser);
    +    const vomOperator = await publicKeyImportieren(
    +      { name: "Vom Operator", kategorie: "PERSON", publicKey: neueZeile(), personId: person.id },
    +      akteur(),
    +    );
    +    await fachfehlerVon(eigenenSchluesselEntfernen(leser, vomOperator.id));
    +    expect(await prisma.schluessel.count()).toBe(2);
    +  });
    +
    +  it("meldet Duplikate auch im Self-Service", async () => {
    +    const z = neueZeile();
    +    await eigenenPublicKeyHinterlegen(leser, { name: "Einmal", publicKey: z });
    +    const fehler = await fachfehlerVon(
    +      eigenenPublicKeyHinterlegen(andererLeser, { name: "Nochmal", publicKey: z }),
    +    );
    +    expect(fehler.message).toMatch(/bereits verwaltet/);
    +  });
    +});
    +
    +describe("Rechte (Freigabe Frage 4)", () => {
    +  it("Leser darf eigene Keys hinterlegen, aber weder verwalten noch zuweisen", () => {
    +    expect(darf("LESER", "eigeneSchluesselHinterlegen")).toBe(true);
    +    expect(darf("LESER", "schluesselVerwalten")).toBe(false);
    +    expect(darf("LESER", "publicKeyAusrollen")).toBe(false);
    +  });
    +
    +  it("die Self-Service-Aktionen verlangen das Self-Service-Recht und reichen nur den eigenen Benutzer weiter", () => {
    +    const text = readFileSync("src/server/actions/meine-schluessel.ts", "utf8");
    +    expect(text.match(/rechtErforderlich\("eigeneSchluesselHinterlegen"\)/g)).toHaveLength(2);
    +    expect(text).not.toMatch(/personId/);
    +  });
    +});
    diff --git a/tests/privatekey.test.ts b/tests/privatekey.test.ts
    new file mode 100644
    index 0000000..f1dcfbe
    --- /dev/null
    +++ b/tests/privatekey.test.ts
    @@ -0,0 +1,249 @@
    +import { randomBytes } from "node:crypto";
    +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
    +import { tmpdir } from "node:os";
    +import path from "node:path";
    +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
    +import { prisma } from "@/lib/db";
    +import { ABHOLFRIST_MS } from "@/lib/schluessel";
    +import { privateKeyLesen } from "@/lib/ssh/openssh";
    +import { BESTAETIGUNG_NOETIG, RECHTE } from "@/server/auth/rechte";
    +import { schluesselDetail } from "@/server/queries/schluessel";
    +import { FachFehler } from "@/server/services/fachfehler";
    +import {
    +  abholungAnfordern,
    +  privateKeyAbholen,
    +  privateKeyLoeschenAnfordern,
    +} from "@/server/services/privatekey";
    +import { erzeugenAnfordern, publicKeyImportieren } from "@/server/services/schluessel";
    +import { vorgangLesen } from "@/server/vorgaenge/vorgaenge";
    +import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
    +import { deploymentKeySicherstellen, masterKeyVergessen, privateKeyLaden } from "@/worker/geheimnisse";
    +import { datenbankLeeren } from "./hilfen/db";
    +import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
    +import { ed25519Blob, zeile } from "./ssh/hilfen";
    +
    +/** Private Key anzeigen, herunterladen, loeschen (#014). */
    +
    +let verzeichnis: string;
    +let admin: { id: string; name: string };
    +let zweiterAdmin: { id: string; name: string };
    +
    +beforeAll(() => {
    +  verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
    +  const datei = path.join(verzeichnis, "master.key");
    +  writeFileSync(datei, randomBytes(32), { mode: 0o600 });
    +  process.env["SSHKV_MASTER_KEY_FILE"] = datei;
    +  masterKeyVergessen();
    +});
    +
    +afterAll(async () => {
    +  rmSync(verzeichnis, { recursive: true, force: true });
    +  await warteschlangeStoppen();
    +});
    +
    +beforeEach(async () => {
    +  await datenbankLeeren();
    +  admin = await prisma.benutzer.create({
    +    data: { name: "Ada Admin", rolle: "ADMIN" },
    +    select: { id: true, name: true },
    +  });
    +  zweiterAdmin = await prisma.benutzer.create({
    +    data: { name: "Zora Zweitadmin", rolle: "ADMIN" },
    +    select: { id: true, name: true },
    +  });
    +});
    +
    +async function fachfehlerVon(versuch: Promise): Promise {
    +  const fehler = await versuch.then(
    +    () => undefined,
    +    (f: unknown) => f,
    +  );
    +  expect(fehler).toBeInstanceOf(FachFehler);
    +  return fehler as FachFehler;
    +}
    +
    +/** Ein Dienst-Key mit gespeichertem Private Key, erzeugt wie in der Oberflaeche. */
    +async function dienstKey(): Promise {
    +  const vorgangId = await erzeugenAnfordern({ name: "Backup" }, undefined, admin);
    +  await vorgangImWorker(vorgangId);
    +  const { ergebnis } = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
    +  return (ergebnis as { schluesselId: string }).schluesselId;
    +}
    +
    +async function bereitgelegt(
    +  schluesselId: string,
    +  akteur = admin,
    +  zweck: "anzeigen" | "herunterladen" = "anzeigen",
    +) {
    +  const vorgangId = await abholungAnfordern(schluesselId, zweck, akteur);
    +  await vorgangImWorker(vorgangId);
    +  return vorgangId;
    +}
    +
    +describe("Abholung", () => {
    +  it("liefert den Key genau einmal und protokolliert ohne ihn", async () => {
    +    const schluesselId = await dienstKey();
    +    const vorgangId = await bereitgelegt(schluesselId);
    +
    +    const roh = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
    +    expect(roh.status).toBe("ERLEDIGT");
    +    const abholung = (roh.ergebnis as { abholung: string }).abholung;
    +    expect(abholung).toMatch(/^v1\./);
    +    expect(abholung).not.toContain("PRIVATE KEY");
    +    // Die Status-Schnittstelle zeigt nur „bereit“, nie das Chiffrat.
    +    expect((await vorgangLesen(vorgangId))?.ergebnis).toEqual({ bereit: true });
    +
    +    const erwartet = (await privateKeyLaden(schluesselId)).toString("utf8");
    +    const geliefert = await privateKeyAbholen(vorgangId, "anzeigen", admin);
    +    expect(geliefert.privateKey).toBe(erwartet);
    +    expect(privateKeyLesen(geliefert.privateKey).fingerprint).toBe(geliefert.fingerprint);
    +
    +    expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).ergebnis).toEqual({
    +      abgeholt: true,
    +    });
    +    await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", admin));
    +
    +    const eintraege = await prisma.protokoll.findMany({
    +      where: { aktion: { startsWith: "privatekey." } },
    +      orderBy: { id: "asc" },
    +    });
    +    expect(eintraege.map((e) => e.aktion)).toEqual(["privatekey.angefordert", "privatekey.angezeigt"]);
    +    expect(eintraege.every((e) => e.fingerprint === geliefert.fingerprint)).toBe(true);
    +
    +    const alles = await datenbankAlsText();
    +    expect(alles).not.toContain("PRIVATE KEY");
    +    expect(alles).not.toContain(erwartet.split("\n")[2]);
    +  });
    +
    +  it("gibt bei gleichzeitigen Abholungen genau eine heraus", async () => {
    +    const vorgangId = await bereitgelegt(await dienstKey(), admin, "herunterladen");
    +    const ergebnisse = await Promise.allSettled(
    +      Array.from({ length: 5 }, () => privateKeyAbholen(vorgangId, "herunterladen", admin)),
    +    );
    +    expect(ergebnisse.filter((e) => e.status === "fulfilled")).toHaveLength(1);
    +    expect(await prisma.protokoll.count({ where: { aktion: "privatekey.heruntergeladen" } })).toBe(1);
    +  });
    +
    +  it("gibt nur dem Anfordernden heraus", async () => {
    +    const vorgangId = await bereitgelegt(await dienstKey());
    +    await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", zweiterAdmin));
    +    await expect(privateKeyAbholen(vorgangId, "anzeigen", admin)).resolves.toBeDefined();
    +  });
    +
    +  it("verweigert nach Ablauf der Frist und ueberschreibt die Abholung trotzdem", async () => {
    +    const vorgangId = await bereitgelegt(await dienstKey());
    +    const { beendetAm } = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
    +    const spaeter = new Date(beendetAm!.getTime() + ABHOLFRIST_MS + 1000);
    +    const fehler = await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", admin, spaeter));
    +    expect(fehler.message).toMatch(/verfallen/);
    +    expect(
    +      JSON.stringify((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).ergebnis),
    +    ).not.toContain("abholung");
    +  });
    +
    +  it("verlangt einen gespeicherten Private Key und gibt keinen Deployment-Key heraus", async () => {
    +    const nurPublic = await publicKeyImportieren(
    +      { name: "Nur Public", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
    +      admin,
    +    );
    +    expect((await fachfehlerVon(abholungAnfordern(nurPublic.id, "anzeigen", admin))).message).toMatch(
    +      /kein Private Key/,
    +    );
    +    const deployment = await deploymentKeySicherstellen();
    +    expect((await fachfehlerVon(abholungAnfordern(deployment, "anzeigen", admin))).message).toMatch(
    +      /Deployment/,
    +    );
    +    await fachfehlerVon(privateKeyLoeschenAnfordern(deployment, admin));
    +    expect(await prisma.vorgang.count({ where: { art: "PRIVATE_KEY_ABHOLEN" } })).toBe(0);
    +  });
    +});
    +
    +describe("Aufraeumen", () => {
    +  it("ueberschreibt nur Abholungen, die laenger als die Frist liegen", async () => {
    +    const jetzt = new Date();
    +    const alt = await prisma.vorgang.create({
    +      data: {
    +        art: "PRIVATE_KEY_ABHOLEN",
    +        status: "ERLEDIGT",
    +        ergebnis: { abholung: "v1.a.b.c" },
    +        beendetAm: new Date(jetzt.getTime() - ABHOLFRIST_MS - 5000),
    +      },
    +    });
    +    const frisch = await prisma.vorgang.create({
    +      data: {
    +        art: "PRIVATE_KEY_ABHOLEN",
    +        status: "ERLEDIGT",
    +        ergebnis: { abholung: "v1.d.e.f" },
    +        beendetAm: new Date(jetzt.getTime() - 30_000),
    +      },
    +    });
    +    const fremd = await prisma.vorgang.create({
    +      data: {
    +        art: "ERREICHBARKEIT",
    +        status: "ERLEDIGT",
    +        ergebnis: { abholung: "kein Key" },
    +        beendetAm: alt.beendetAm,
    +      },
    +    });
    +
    +    expect(await abholungenAufraeumen(jetzt)).toBe(1);
    +    const lesen = async (id: string) => (await prisma.vorgang.findUniqueOrThrow({ where: { id } })).ergebnis;
    +    expect(await lesen(alt.id)).toEqual({ verfallen: true });
    +    expect(await lesen(frisch.id)).toEqual({ abholung: "v1.d.e.f" });
    +    expect(await lesen(fremd.id)).toEqual({ abholung: "kein Key" });
    +    // Spaeter verfaellt auch die zweite.
    +    expect(await abholungenAufraeumen(new Date(jetzt.getTime() + ABHOLFRIST_MS))).toBe(1);
    +  });
    +
    +  it("laeuft vor jeder neuen Abholung im Worker", async () => {
    +    const schluesselId = await dienstKey();
    +    const liegengeblieben = await bereitgelegt(schluesselId);
    +    await prisma.vorgang.update({
    +      where: { id: liegengeblieben },
    +      data: { beendetAm: new Date(Date.now() - ABHOLFRIST_MS - 1000) },
    +    });
    +    await bereitgelegt(schluesselId);
    +    expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: liegengeblieben } })).ergebnis).toEqual({
    +      verfallen: true,
    +    });
    +  });
    +});
    +
    +describe("Private Key loeschen", () => {
    +  it("verwirft Chiffrat, Data-Key und Version im Worker und protokolliert", async () => {
    +    const schluesselId = await dienstKey();
    +    const vorgangId = await privateKeyLoeschenAnfordern(schluesselId, admin);
    +    await vorgangImWorker(vorgangId);
    +    expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).status).toBe("ERLEDIGT");
    +
    +    const detail = await schluesselDetail(schluesselId);
    +    expect(detail?.privatGespeichert).toBe(false);
    +    expect(detail?.privatGeloeschtAm).toBeInstanceOf(Date);
    +    await expect(privateKeyLaden(schluesselId)).rejects.toThrow(/kein Private Key/);
    +
    +    const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "privatekey.geloescht" } });
    +    expect(eintrag).toMatchObject({ akteurId: admin.id, fingerprint: detail?.fingerprint });
    +    await fachfehlerVon(privateKeyLoeschenAnfordern(schluesselId, admin));
    +    await fachfehlerVon(abholungAnfordern(schluesselId, "anzeigen", admin));
    +  });
    +});
    +
    +describe("Rechte", () => {
    +  it("nur Admin, immer mit frischer Bestaetigung", () => {
    +    for (const recht of ["privateKeyAnzeigen", "privateKeyLoeschen"] as const) {
    +      expect(RECHTE[recht]).toEqual(["ADMIN"]);
    +      expect(BESTAETIGUNG_NOETIG.has(recht)).toBe(true);
    +    }
    +  });
    +
    +  it("Action und Route Handler pruefen selbst", () => {
    +    const aktionen = readFileSync("src/server/actions/privatekey.ts", "utf8");
    +    expect(aktionen.match(/rechtErforderlich\("privateKeyAnzeigen"\)/g)).toHaveLength(2);
    +    expect(aktionen).toMatch(/rechtErforderlich\("privateKeyLoeschen"\)/);
    +    const route = readFileSync("src/app/api/private-key/[vorgangId]/route.ts", "utf8");
    +    expect(route).toMatch(/rechtErforderlich\("privateKeyAnzeigen"\)/);
    +    expect(route).toMatch(/no-store/);
    +    expect(route).toMatch(/attachment/);
    +    expect(route).not.toMatch(/export async function GET/);
    +  });
    +});
    diff --git a/tests/schluessel-grenze.test.ts b/tests/schluessel-grenze.test.ts
    new file mode 100644
    index 0000000..e855990
    --- /dev/null
    +++ b/tests/schluessel-grenze.test.ts
    @@ -0,0 +1,78 @@
    +import { readFileSync, readdirSync, statSync } from "node:fs";
    +import path from "node:path";
    +import { describe, expect, it } from "vitest";
    +import { SCHLUESSEL_DETAIL_SELECT, SCHLUESSEL_LISTE_SELECT } from "@/server/queries/schluessel";
    +
    +/**
    + * Strukturell (#012): Jede Prisma-Abfrage auf `Schluessel` ausserhalb des Workers hat ein
    + * festes `select`. Ohne `select` liefert Prisma die ganze Zeile – samt Chiffrat –, ohne dass
    + * der Feldname im Code steht; tests/geheimnisse-grenze.test.ts saehe das nicht.
    + */
    +function dateien(verzeichnis: string): string[] {
    +  return readdirSync(verzeichnis).flatMap((name) => {
    +    const pfad = path.join(verzeichnis, name);
    +    if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
    +    return /\.(ts|tsx)$/.test(name) ? [pfad.split(path.sep).join("/")] : [];
    +  });
    +}
    +
    +/** Der Worker darf (und muss) die ganze Zeile sehen; die Grenze dort zieht geheimnisse.ts. */
    +const WEB = dateien("src").filter((d) => !d.startsWith("src/worker/"));
    +
    +/** Methoden, die Zeilen zurueckgeben – nur die brauchen ein `select`. */
    +const ZEILEN =
    +  /\.schluessel\.(findMany|findFirst|findFirstOrThrow|findUnique|findUniqueOrThrow|create|update|upsert|delete)\(/g;
    +
    +/** Argumenttext eines Aufrufs ab der oeffnenden Klammer, per Klammerzaehlung. */
    +function argumente(text: string, start: number): string {
    +  let tiefe = 0;
    +  for (let i = start; i < text.length; i++) {
    +    if (text[i] === "(") tiefe++;
    +    else if (text[i] === ")" && --tiefe === 0) return text.slice(start, i + 1);
    +  }
    +  return text.slice(start);
    +}
    +
    +describe("Abfragen auf Schluessel im Web", () => {
    +  it("findet Aufrufe", () => {
    +    const anzahl = WEB.reduce((summe, d) => summe + [...readFileSync(d, "utf8").matchAll(ZEILEN)].length, 0);
    +    expect(anzahl).toBeGreaterThan(5);
    +  });
    +
    +  it("haben alle ein select", () => {
    +    const verstoesse: string[] = [];
    +    for (const datei of WEB) {
    +      const text = readFileSync(datei, "utf8");
    +      for (const treffer of text.matchAll(ZEILEN)) {
    +        const start = treffer.index + treffer[0].length - 1;
    +        if (!/\bselect\s*:/.test(argumente(text, start))) {
    +          verstoesse.push(`${datei}: ${treffer[0]}`);
    +        }
    +      }
    +    }
    +    expect(verstoesse).toEqual([]);
    +  });
    +
    +  it("holen Schluessel nie als ganze Relation (include/select: { schluessel: true })", () => {
    +    // Zeilen mit `_count` zaehlen nur und sind erlaubt.
    +    const verstoesse = WEB.filter((d) =>
    +      readFileSync(d, "utf8")
    +        .split("\n")
    +        .some(
    +          (zeile) =>
    +            !zeile.includes("_count") &&
    +            /\b(schluessel|vorgaenger|nachfolger|deploymentSchluessel|erstellteSchluessel)\s*:\s*true/.test(
    +              zeile,
    +            ),
    +        ),
    +    );
    +    expect(verstoesse).toEqual([]);
    +  });
    +
    +  it("die festen select-Listen nennen kein Chiffrat", () => {
    +    for (const auswahl of [SCHLUESSEL_LISTE_SELECT, SCHLUESSEL_DETAIL_SELECT]) {
    +      expect(Object.keys(auswahl)).not.toContain("privatVerschluesselt");
    +      expect(Object.keys(auswahl)).not.toContain("dekVerschluesselt");
    +    }
    +  });
    +});
    diff --git a/tests/schluessel.test.ts b/tests/schluessel.test.ts
    new file mode 100644
    index 0000000..364b750
    --- /dev/null
    +++ b/tests/schluessel.test.ts
    @@ -0,0 +1,431 @@
    +import { randomBytes } from "node:crypto";
    +import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
    +import { tmpdir } from "node:os";
    +import path from "node:path";
    +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
    +import { prisma } from "@/lib/db";
    +import { entschluesseln } from "@/lib/geheimnisse";
    +import {
    +  ablaufZustand,
    +  fingerprintKurz,
    +  freierSlug,
    +  slugAus,
    +  typAnzeige,
    +  erzeugenSchema,
    +} from "@/lib/schluessel";
    +import { privateKeyLesen, schluesselpaarErzeugen } from "@/lib/ssh/openssh";
    +import { schluesselDetail, schluesselListe } from "@/server/queries/schluessel";
    +import { FachFehler } from "@/server/services/fachfehler";
    +import {
    +  erzeugenAnfordern,
    +  paarImportAnfordern,
    +  publicKeyImportieren,
    +  schluesselBearbeiten,
    +} from "@/server/services/schluessel";
    +import { masterKeyVergessen, privateKeyLaden } from "@/worker/geheimnisse";
    +import { datenbankLeeren } from "./hilfen/db";
    +import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
    +import { ed25519Blob, rsaBlob, zeile } from "./ssh/hilfen";
    +
    +/** Schluessel erzeugen, importieren, bearbeiten (#012) – Service, Worker-Ablauf, Lesemodell. */
    +
    +let verzeichnis: string;
    +let akteur: { id: string; name: string };
    +
    +beforeAll(() => {
    +  verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
    +  const datei = path.join(verzeichnis, "master.key");
    +  writeFileSync(datei, randomBytes(32), { mode: 0o600 });
    +  process.env["SSHKV_MASTER_KEY_FILE"] = datei;
    +  masterKeyVergessen();
    +});
    +
    +afterAll(async () => {
    +  rmSync(verzeichnis, { recursive: true, force: true });
    +  await warteschlangeStoppen();
    +});
    +
    +beforeEach(async () => {
    +  await datenbankLeeren();
    +  const benutzer = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } });
    +  akteur = { id: benutzer.id, name: benutzer.name };
    +});
    +
    +async function fachfehlerVon(versuch: Promise): Promise {
    +  const fehler = await versuch.then(
    +    () => undefined,
    +    (f: unknown) => f,
    +  );
    +  expect(fehler).toBeInstanceOf(FachFehler);
    +  return fehler as FachFehler;
    +}
    +
    +/** Kern eines Private Keys (ohne Kopf- und Fusszeile) – das eigentliche Geheimnis. */
    +function kern(privateKey: string): string {
    +  return privateKey.split("\n").slice(1, -2).join("").slice(0, 60);
    +}
    +
    +describe("Regeln (rein)", () => {
    +  it.each([
    +    ["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
    +    ["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],
    +    ["  ---  ", "schluessel"],
    +    ["Café", "cafe"],
    +    ["x".repeat(80), "x".repeat(64)],
    +  ])("slugAus(%j) = %j", (name, slug) => {
    +    expect(slugAus(name)).toBe(slug);
    +  });
    +
    +  it("haengt Nummern an, bis der Slug frei ist", async () => {
    +    const belegt = new Set(["backup", "backup-2"]);
    +    expect(await freierSlug("backup", async (s) => !belegt.has(s))).toBe("backup-3");
    +    const lang = "a".repeat(64);
    +    const ergebnis = await freierSlug(lang, async (s) => s !== lang);
    +    expect(ergebnis).toHaveLength(64);
    +    expect(ergebnis.endsWith("-2")).toBe(true);
    +  });
    +
    +  it("kuerzt Fingerprints und zeigt Typen", () => {
    +    expect(fingerprintKurz("SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k")).toBe(
    +      "SHA256:q3vN8xP0…Qe0k",
    +    );
    +    expect(typAnzeige("ED25519", "ssh-ed25519 AAAA")).toBe("ed25519");
    +    expect(typAnzeige("RSA4096", "ssh-rsa AAAA")).toBe("rsa-4096");
    +    expect(typAnzeige("ANDERER", "ecdsa-sha2-nistp256 AAAA")).toBe("ecdsa-sha2-nistp256");
    +    expect(typAnzeige("ANDERER", "sk-ssh-ed25519@openssh.com AAAA")).toBe("sk-ssh-ed25519");
    +  });
    +
    +  it("bewertet das Ablaufdatum", () => {
    +    const jetzt = new Date("2026-10-12T08:00:00Z");
    +    expect(ablaufZustand(null, jetzt).art).toBe("keiner");
    +    expect(ablaufZustand(new Date("2026-10-30T00:00:00Z"), jetzt)).toEqual({ art: "bald", tage: 18 });
    +    expect(ablaufZustand(new Date("2026-11-12T00:00:00Z"), jetzt).art).toBe("spaeter");
    +    expect(ablaufZustand(new Date("2026-10-11T00:00:00Z"), jetzt).art).toBe("abgelaufen");
    +  });
    +
    +  it("prueft Eingaben", () => {
    +    expect(erzeugenSchema.safeParse({ name: "" }).success).toBe(false);
    +    expect(erzeugenSchema.safeParse({ name: "x", slug: "Mit Leer" }).success).toBe(false);
    +    expect(erzeugenSchema.safeParse({ name: "x", kommentar: "a\nb" }).success).toBe(false);
    +    expect(erzeugenSchema.safeParse({ name: "x", ablaufAm: "2026-02-30" }).success).toBe(false);
    +    expect(erzeugenSchema.safeParse({ name: "x", typ: "DSA" }).success).toBe(false);
    +    const ok = erzeugenSchema.parse({ name: " Backup ", tags: "PVE, pve backup", ablaufAm: "2030-01-31" });
    +    expect(ok).toMatchObject({ name: "Backup", tags: ["pve", "backup"], typ: "ED25519", slug: null });
    +    expect(ok.ablaufAm?.toISOString()).toBe("2030-01-31T00:00:00.000Z");
    +  });
    +});
    +
    +describe("Public Key importieren", () => {
    +  it("legt an, setzt Slug und Kommentar und protokolliert", async () => {
    +    const blob = ed25519Blob();
    +    const neu = await publicKeyImportieren(
    +      {
    +        name: "Laptop Florian",
    +        kategorie: "PERSON",
    +        publicKey: zeile("ssh-ed25519", blob, "florian@laptop"),
    +      },
    +      akteur,
    +    );
    +    const s = await schluesselDetail(neu.id);
    +    expect(s).toMatchObject({
    +      slug: "laptop-florian",
    +      typ: "ED25519",
    +      kategorie: "PERSON",
    +      kommentar: "florian@laptop",
    +      privatGespeichert: false,
    +      selbstHinterlegt: false,
    +    });
    +    expect(s?.publicKey).toBe(zeile("ssh-ed25519", blob, "florian@laptop"));
    +    const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.importiert" } });
    +    expect(eintrag.fingerprint).toBe(s?.fingerprint);
    +    expect(eintrag.akteurName).toBe("Olga Operator");
    +  });
    +
    +  it("meldet einen schon verwalteten Key mit Verweis", async () => {
    +    const z = zeile("ssh-ed25519", ed25519Blob());
    +    const erster = await publicKeyImportieren({ name: "Erster", publicKey: z }, akteur);
    +    const fehler = await fachfehlerVon(publicKeyImportieren({ name: "Zweiter", publicKey: z }, akteur));
    +    expect(fehler.message).toContain("„Erster“");
    +    expect(fehler.message).toContain(`/schluessel/${erster.id}`);
    +    expect(fehler.feldfehler["publicKey"]).toBeDefined();
    +  });
    +
    +  it("nummeriert abgeleitete Slugs und lehnt vergebene Wunsch-Slugs ab", async () => {
    +    const a = await publicKeyImportieren(
    +      { name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
    +      akteur,
    +    );
    +    const b = await publicKeyImportieren(
    +      { name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
    +      akteur,
    +    );
    +    expect([a.slug, b.slug]).toEqual(["backup", "backup-2"]);
    +    const fehler = await fachfehlerVon(
    +      publicKeyImportieren(
    +        { name: "X", slug: "backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
    +        akteur,
    +      ),
    +    );
    +    expect(fehler.feldfehler["slug"]).toMatch(/vergeben/);
    +  });
    +
    +  it("verlangt RSA mit mindestens 2048 Bit und erkennt RSA-4096", async () => {
    +    const fehler = await fachfehlerVon(
    +      publicKeyImportieren({ name: "Alt", publicKey: zeile("ssh-rsa", rsaBlob(1024)) }, akteur),
    +    );
    +    expect(fehler.feldfehler["publicKey"]).toMatch(/2048/);
    +    const neu = await publicKeyImportieren(
    +      { name: "Neu", publicKey: zeile("ssh-rsa", rsaBlob(4096)) },
    +      akteur,
    +    );
    +    expect((await schluesselDetail(neu.id))?.typ).toBe("RSA4096");
    +    const mittel = await publicKeyImportieren(
    +      { name: "Mittel", publicKey: zeile("ssh-rsa", rsaBlob(3072)) },
    +      akteur,
    +    );
    +    expect((await schluesselDetail(mittel.id))?.typ).toBe("ANDERER");
    +  });
    +
    +  it("lehnt Unsinn, Vergangenheit und unbekannte Bezuege ab", async () => {
    +    await fachfehlerVon(publicKeyImportieren({ name: "X", publicKey: "kein key" }, akteur));
    +    const vergangen = await fachfehlerVon(
    +      publicKeyImportieren(
    +        { name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), ablaufAm: "2020-01-01" },
    +        akteur,
    +      ),
    +    );
    +    expect(vergangen.feldfehler["ablaufAm"]).toBeDefined();
    +    const person = await fachfehlerVon(
    +      publicKeyImportieren(
    +        { name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), personId: "gibtsnicht" },
    +        akteur,
    +      ),
    +    );
    +    expect(person.feldfehler["personId"]).toBeDefined();
    +    expect(await prisma.schluessel.count()).toBe(0);
    +  });
    +
    +  it("setzt das Ablaufdatum aus der Laufzeit der Vorlage", async () => {
    +    const vorlage = await prisma.vorlage.create({
    +      data: { name: "Kurz", zielbenutzer: "deploy", standardLaufzeitTage: 10 },
    +    });
    +    const neu = await publicKeyImportieren(
    +      { name: "Mit Vorlage", publicKey: zeile("ssh-ed25519", ed25519Blob()), vorlageId: vorlage.id },
    +      akteur,
    +    );
    +    const s = await schluesselDetail(neu.id);
    +    expect(ablaufZustand(s!.ablaufAm)).toEqual({ art: "bald", tage: 10 });
    +    expect(s?.vorlage?.name).toBe("Kurz");
    +  });
    +});
    +
    +describe("Erzeugen im Worker", () => {
    +  it("erzeugt ed25519, versiegelt und verweist den Vorgang auf den Schluessel", async () => {
    +    const vorgangId = await erzeugenAnfordern(
    +      { name: "Backup srv-backup01", tags: "backup" },
    +      undefined,
    +      akteur,
    +    );
    +    const { jobDaten } = await vorgangImWorker(vorgangId);
    +    expect(jobDaten.geheim).toBeUndefined();
    +
    +    const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
    +    expect(vorgang.status).toBe("ERLEDIGT");
    +    const { schluesselId, fingerprint } = vorgang.ergebnis as { schluesselId: string; fingerprint: string };
    +    expect(vorgang.schluesselId).toBe(schluesselId);
    +
    +    const s = await schluesselDetail(schluesselId);
    +    expect(s).toMatchObject({
    +      slug: "backup-srv-backup01",
    +      kommentar: "sshkv:backup-srv-backup01",
    +      kategorie: "DIENST",
    +      typ: "ED25519",
    +      privatGespeichert: true,
    +      passphraseGeschuetzt: false,
    +      fingerprint,
    +      tags: ["backup"],
    +    });
    +    expect(s?.erstelltVon?.name).toBe("Olga Operator");
    +
    +    const privat = await privateKeyLaden(schluesselId);
    +    expect(privateKeyLesen(privat.toString("utf8")).fingerprint).toBe(fingerprint);
    +    privat.fill(0);
    +
    +    const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.erzeugt" } });
    +    expect(eintrag).toMatchObject({ fingerprint, akteurId: akteur.id, ergebnis: "ERFOLG" });
    +  });
    +
    +  it("speichert mit Passphrase den geschuetzten Key – die Passphrase nirgends", async () => {
    +    const passphrase = "korrekt-pferd-batterie-heftklammer";
    +    const vorgangId = await erzeugenAnfordern({ name: "Geschuetzt", typ: "ED25519" }, passphrase, akteur);
    +    const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
    +    expect(JSON.stringify(vorgang.eingabe)).not.toContain(passphrase);
    +    expect(vorgang.eingabe).toMatchObject({ mitPassphrase: true });
    +
    +    const { jobDaten } = await vorgangImWorker(vorgangId);
    +    expect(jobDaten.geheim).toBeDefined();
    +    expect(jobDaten.geheim).not.toContain(passphrase);
    +    expect(JSON.parse(entschluesseln(jobDaten.geheim!))).toEqual({ passphrase });
    +
    +    const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }))
    +      .ergebnis as { schluesselId: string };
    +    expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true);
    +    const privat = (await privateKeyLaden(schluesselId)).toString("utf8");
    +    expect(() => privateKeyLesen(privat)).toThrow(/Passphrase/);
    +    expect(privateKeyLesen(privat, passphrase).verschluesselt).toBe(true);
    +
    +    const alles = await datenbankAlsText();
    +    expect(alles).not.toContain(passphrase);
    +    expect(alles).not.toContain("PRIVATE KEY");
    +  });
    +
    +  it("erzeugt RSA-4096", { timeout: 30_000 }, async () => {
    +    const vorgangId = await erzeugenAnfordern({ name: "Alt-System", typ: "RSA4096" }, undefined, akteur);
    +    await vorgangImWorker(vorgangId);
    +    const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }))
    +      .ergebnis as { schluesselId: string };
    +    const s = await schluesselDetail(schluesselId);
    +    expect(s?.typ).toBe("RSA4096");
    +    expect(s?.publicKey.startsWith("ssh-rsa ")).toBe(true);
    +  });
    +
    +  it("scheitert verstaendlich, wenn der Slug inzwischen vergeben ist", async () => {
    +    const vorgangId = await erzeugenAnfordern({ name: "Doppelt" }, undefined, akteur);
    +    await publicKeyImportieren(
    +      { name: "Doppelt", slug: "doppelt", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
    +      akteur,
    +    );
    +    await vorgangImWorker(vorgangId);
    +    const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
    +    expect(vorgang.status).toBe("FEHLGESCHLAGEN");
    +    expect(vorgang.fehler).toMatch(/inzwischen vergeben/);
    +  });
    +});
    +
    +describe("Paar importieren im Worker", () => {
    +  it("prueft, versiegelt und laesst weder Vorgang noch Protokoll den Key sehen", async () => {
    +    const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "alt@server" });
    +    const vorgangId = await paarImportAnfordern(
    +      { name: "Altlast" },
    +      paar.privateKeyOpenssh,
    +      undefined,
    +      akteur,
    +    );
    +    const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
    +    expect(JSON.stringify(vorgang)).not.toContain("PRIVATE KEY");
    +
    +    const { jobDaten } = await vorgangImWorker(vorgangId);
    +    expect(jobDaten.geheim).not.toContain(kern(paar.privateKeyOpenssh));
    +    const fertig = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
    +    expect(fertig.status).toBe("ERLEDIGT");
    +    const { schluesselId } = fertig.ergebnis as { schluesselId: string };
    +    const s = await schluesselDetail(schluesselId);
    +    expect(s).toMatchObject({
    +      fingerprint: paar.fingerprint,
    +      kommentar: "alt@server",
    +      privatGespeichert: true,
    +    });
    +    expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh);
    +
    +    const alles = await datenbankAlsText();
    +    expect(alles).not.toContain("PRIVATE KEY");
    +    expect(alles).not.toContain(kern(paar.privateKeyOpenssh));
    +    expect(await prisma.protokoll.count({ where: { aktion: "schluessel.importiert" } })).toBe(1);
    +  });
    +
    +  it("behaelt einen passphrasengeschuetzten Key so, wie er kam", async () => {
    +    const passphrase = "nur-zur-pruefung-1234";
    +    const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x", passphrase });
    +    const ohne = await paarImportAnfordern({ name: "Ohne" }, paar.privateKeyOpenssh, undefined, akteur);
    +    await vorgangImWorker(ohne);
    +    expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: ohne } })).fehler).toMatch(/Passphrase/);
    +
    +    const falsch = await paarImportAnfordern(
    +      { name: "Falsch" },
    +      paar.privateKeyOpenssh,
    +      "Zz9-unpassend",
    +      akteur,
    +    );
    +    await vorgangImWorker(falsch);
    +    const fehler = (await prisma.vorgang.findUniqueOrThrow({ where: { id: falsch } })).fehler ?? "";
    +    expect(fehler).toMatch(/Passphrase/);
    +    expect(fehler).not.toContain("Zz9-unpassend");
    +
    +    const richtig = await paarImportAnfordern(
    +      { name: "Richtig" },
    +      paar.privateKeyOpenssh,
    +      passphrase,
    +      akteur,
    +    );
    +    await vorgangImWorker(richtig);
    +    const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: richtig } }))
    +      .ergebnis as {
    +      schluesselId: string;
    +    };
    +    expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true);
    +    expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh);
    +    expect(await datenbankAlsText()).not.toContain(passphrase);
    +  });
    +
    +  it("lehnt Fremdformate im Web und Duplikate im Worker ab", async () => {
    +    const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----";
    +    const fehler = await fachfehlerVon(paarImportAnfordern({ name: "PEM" }, pem, undefined, akteur));
    +    expect(fehler.message).not.toContain("MIIE");
    +
    +    const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x" });
    +    await publicKeyImportieren({ name: "Schon da", publicKey: paar.publicKeyZeile }, akteur);
    +    const id = await paarImportAnfordern({ name: "Nochmal" }, paar.privateKeyOpenssh, undefined, akteur);
    +    await vorgangImWorker(id);
    +    expect((await prisma.vorgang.findUniqueOrThrow({ where: { id } })).fehler).toMatch(/„Schon da“/);
    +  });
    +});
    +
    +describe("Bearbeiten und Lesemodell", () => {
    +  it("aendert Metadaten, protokolliert nur Geaendertes und schuetzt den Status", async () => {
    +    const neu = await publicKeyImportieren(
    +      { name: "Alt", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
    +      akteur,
    +    );
    +    await schluesselBearbeiten(neu.id, { name: "Neu", tags: "a b", status: "ABGELAUFEN" }, akteur);
    +    const s = await schluesselDetail(neu.id);
    +    expect(s).toMatchObject({ name: "Neu", tags: ["a", "b"], status: "ABGELAUFEN", slug: "alt" });
    +    const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.geaendert" } });
    +    expect(Object.keys(eintrag.details as object).sort()).toEqual(["name", "status", "tags"]);
    +
    +    await prisma.schluessel.update({
    +      where: { id: neu.id },
    +      data: { status: "WIDERRUFEN" },
    +      select: { id: true },
    +    });
    +    await fachfehlerVon(schluesselBearbeiten(neu.id, { name: "Neu", status: "AKTIV" }, akteur));
    +  });
    +
    +  it("filtert die Liste und liefert keine Chiffrat-Felder", async () => {
    +    await publicKeyImportieren(
    +      {
    +        name: "Person A",
    +        kategorie: "PERSON",
    +        publicKey: zeile("ssh-ed25519", ed25519Blob()),
    +        tags: "laptop",
    +      },
    +      akteur,
    +    );
    +    const vorgangId = await erzeugenAnfordern({ name: "Dienst B" }, undefined, akteur);
    +    await vorgangImWorker(vorgangId);
    +
    +    expect((await schluesselListe()).map((s) => s.name)).toEqual(["Dienst B", "Person A"]);
    +    expect((await schluesselListe({ kategorie: "PERSON" })).map((s) => s.name)).toEqual(["Person A"]);
    +    expect((await schluesselListe({ q: "laptop" })).map((s) => s.name)).toEqual(["Person A"]);
    +    expect((await schluesselListe({ status: "WIDERRUFEN" })).length).toBe(0);
    +    expect((await schluesselListe({ kategorie: "QUATSCH" })).length).toBe(2);
    +
    +    const liste = await schluesselListe();
    +    const dienst = liste.find((s) => s.name === "Dienst B")!;
    +    expect(dienst.privatGespeichert).toBe(true);
    +    const detail = await schluesselDetail(dienst.id);
    +    for (const antwort of [liste, detail]) {
    +      const text = JSON.stringify(antwort);
    +      expect(text).not.toMatch(/privatVerschluesselt|dekVerschluesselt|masterKeyVersion/);
    +    }
    +  });
    +});