diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87b0e58..beb7be1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
### Hinzugefügt
+- Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031)
### Geändert
diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md
index 340b783..f7d2729 100644
--- a/docs/anleitung/README.md
+++ b/docs/anleitung/README.md
@@ -170,6 +170,14 @@ auswählen (nur Admin).

+**Deployment-Key rotieren** (Einstellungen → Instanz, nur Admin): fünf Schritte – neuen Key
+**erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung
+mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert
+die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach
+einem neuen Bootstrap.
+
+
+
## 8. Personen, Self-Service und Zuweisungsmatrix
- **Personen:** Admins, Mitarbeiter, Dienstleister mit ihren Schlüsseln. Private Keys von Personen
diff --git a/docs/anleitung/bilder/deployment-key-desktop.png b/docs/anleitung/bilder/deployment-key-desktop.png
new file mode 100644
index 0000000..80583cc
Binary files /dev/null and b/docs/anleitung/bilder/deployment-key-desktop.png differ
diff --git a/docs/betrieb/sicherheit.md b/docs/betrieb/sicherheit.md
index d00f7e9..4d612ba 100644
--- a/docs/betrieb/sicherheit.md
+++ b/docs/betrieb/sicherheit.md
@@ -67,8 +67,12 @@ Private Key steht. Bootstrap-Passwörter werden nie gespeichert.
5. **Protokoll auswerten:** Syslog-Kopie (unveränderlich außerhalb von sshkv) mit dem Protokoll
vergleichen; `protokoll_kette_pruefen()` zeigt manipulierte Zeilen.
-> Eine eingebaute **Rotation des Deployment-Keys** gibt es noch nicht (siehe `docs/issues`). Bis dahin
-> ist Schritt 2 Handarbeit.
+> **Rotation des Deployment-Keys** (Einstellungen → Instanz → „Deployment-Key rotieren“, nur Admin mit
+> frischer Bestätigung): regelmäßig, oder wenn nur der Key abgeflossen sein kann – etwa ein verlorenes
+> Backup samt Master-Key. Der neue Key kommt erst neben den alten, wird auf jedem Host geprüft und löst
+> den alten dann ab; zuletzt wird der alte aus allen Blöcken entfernt und sein Private Key verworfen.
+> Ist sshkv **selbst** kompromittiert, hilft das nicht – der Instanz ist dann nicht mehr zu trauen,
+> Schritt 2 bleibt Handarbeit.
## Betriebsregeln
diff --git a/docs/issues/031-deploymentkey-rotation.md b/docs/issues/031-deploymentkey-rotation.md
index 598d20a..3420ecf 100644
--- a/docs/issues/031-deploymentkey-rotation.md
+++ b/docs/issues/031-deploymentkey-rotation.md
@@ -1,25 +1,30 @@
# #031 Rotation des Deployment-Keys
-**Status:** offen
+**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** sicherheit, backend
-**Geschätzt:** 3 h **Tatsächlich:** –
+**Geschätzt:** 3 h **Tatsächlich:** 1 h 10 min
## Ziel
Den Deployment-Key der Instanz geführt tauschen – regelmäßig oder im Notfall (sshkv kompromittiert, siehe `docs/betrieb/sicherheit.md`).
## Aufgaben
-- [ ] Neuen Deployment-Key erzeugen, ohne den alten abzulösen (zweiter Instanz-Key im Übergang)
-- [ ] Auf jedem eingerichteten Host den neuen Key neben den alten in den verwalteten Block des Anmeldebenutzers schreiben (Dry-Run, Ausrollen)
-- [ ] Verbindung mit dem neuen Key je Host prüfen
-- [ ] Erst wenn alle Hosts den neuen Key akzeptieren: alten aus allen Blöcken entfernen, alten Key verwerfen
-- [ ] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen)
-- [ ] Nur Admin mit frischer Bestätigung, alles protokolliert
+- [x] Neuen Deployment-Key erzeugen, ohne den alten abzulösen (zweiter Instanz-Key im Übergang)
+- [x] Auf jedem eingerichteten Host den neuen Key neben den alten in den verwalteten Block des Anmeldebenutzers schreiben (Dry-Run, Ausrollen)
+- [x] Verbindung mit dem neuen Key je Host prüfen
+- [x] Erst wenn alle Hosts den neuen Key akzeptieren: alten aus allen Blöcken entfernen, alten Key verwerfen
+- [x] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen)
+- [x] Nur Admin mit frischer Bestätigung, alles protokolliert
## Akzeptanzkriterien
-- [ ] Integrationstest: nach der Rotation meldet sich sshkv nur noch mit dem neuen Key an, der alte steht auf keinem Host mehr
-- [ ] Aussperrschutz: zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da
+- [x] Integrationstest: nach der Rotation meldet sich sshkv nur noch mit dem neuen Key an, der alte steht auf keinem Host mehr
+- [x] Aussperrschutz: zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da
## Abhängigkeiten
#016, #021
## Notizen / Entscheidungen
+- Der Nachfolger ist ein eigener Schlüssel (`kategorie DEPLOYMENT`, `vorgaengerId` = alter, noch nicht `instanzStandard`). `sollFuer()` nimmt ihn für alle Hosts mit dem Instanz-Key zusätzlich in Block **und** Pflichtliste – der normale Dry-Run verteilt ihn also, und der Aussperrschutz schützt beide.
+- Hosts mit eigenem oder Gruppen-Deployment-Key nehmen nicht teil.
+- Umstellen ist eine Transaktion (alter ROTIERT, neuer Standard) und verlangt eine Prüfung **nach** dem Erzeugen, in der jeder Host den neuen Key angenommen hat – oder bewusst übergangen wird (protokolliert).
+- Abschließen prüft je Host, dass der alte Fingerprint aus dem Block verschwunden ist; erst dann wird sein Private Key verworfen. Unerreichbare Hosts blockieren den Abschluss nicht (sshkv benutzt den alten Key ohnehin nicht mehr), werden aber im Ergebnis genannt.
+- Tests: Integrationstest gegen beide Testziele (DIENSTBENUTZER und ROOT, alte Anmeldung scheitert danach), E2E im Browser.
diff --git a/src/app/(app)/deployment-key/page.tsx b/src/app/(app)/deployment-key/page.tsx
new file mode 100644
index 0000000..bc57396
--- /dev/null
+++ b/src/app/(app)/deployment-key/page.tsx
@@ -0,0 +1,165 @@
+import { CheckCheck, KeyRound, ShieldCheck } from "lucide-react";
+import type { Metadata } from "next";
+import Link from "next/link";
+import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
+import { Umstellen } from "@/components/deploymentkey/Umstellen";
+import { Badge } from "@/components/ui/Badge";
+import { Breadcrumb } from "@/components/ui/Breadcrumb";
+import { Karte } from "@/components/ui/Karte";
+import { MonoKopieren } from "@/components/ui/MonoKopieren";
+import { Schrittleiste } from "@/components/ui/Schrittleiste";
+import { Seitenkopf } from "@/components/ui/Seitenkopf";
+import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
+import { rotationsSchrittAktion } from "@/server/actions/deploymentkey";
+import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { rotationsStand } from "@/server/services/deploymentkey";
+
+export const metadata: Metadata = { title: "Deployment-Key rotieren" };
+
+const SCHRITTE = ["Erzeugen", "Verteilen", "Prüfen", "Umstellen", "Aufräumen"] as const;
+
+/**
+ * Rotation des Instanz-Deployment-Keys (#031). Der neue Key kommt erst neben den alten,
+ * wird geprueft und erst dann zum Standard; der alte verschwindet zuletzt.
+ */
+export default async function DeploymentKeySeite() {
+ const { benutzer } = await sitzungErforderlich("deploymentKeyVerwalten", "/deployment-key");
+ const stand = await rotationsStand();
+ const fehlgeschlagen = (stand.pruefung ?? []).filter((p) => !p.ok);
+ const aktuell =
+ stand.phase === "bereit"
+ ? 0
+ : stand.phase === "aufraeumen"
+ ? 4
+ : !stand.pruefung
+ ? 1
+ : fehlgeschlagen.length
+ ? 2
+ : 3;
+
+ return (
+
+
+ }
+ titel="Deployment-Key rotieren"
+ untertitel="Ohne Unterbrechung: der neue Key steht erst neben dem alten, wird geprüft und löst ihn dann ab."
+ />
+
+
+ {!bestaetigungFrisch(benutzer.bestaetigtAm) && (
+
+ Jeder Schritt verlangt eine frische Bestätigung Ihrer Anmeldung.{" "}
+
+ Jetzt bestätigen
+
+
+ )}
+
+
+ {stand.aktuell ? (
+
+ ) : (
+ Noch kein Deployment-Key – läuft der Worker?
+ )}
+ {stand.nachfolger && (
+
+
Neuer Key (noch nicht Standard)
+
+
+ )}
+
+
+ {stand.phase === "bereit" && stand.aktuell && (
+
+ }
+ />
+
+ )}
+
+ {stand.phase === "verteilen" && (
+ <>
+
+
+
+
+ }
+ />
+ {stand.pruefung && (
+
+ {stand.pruefung.map((p) => (
+
+ {p.hostName}
+ {p.ok ? (
+ angemeldet
+ ) : (
+ fehlgeschlagen
+ )}
+ {p.fehler && {p.fehler} }
+
+ ))}
+
+ )}
+
+ {stand.pruefung && (
+
+
+
+ )}
+ >
+ )}
+
+ {stand.phase === "aufraeumen" && stand.vorgaenger && (
+
+
+
+ Alter Key: {stand.vorgaenger.fingerprint}
+
+
+
+ }
+ />
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx
index 75d92cd..d5cb40e 100644
--- a/src/app/(app)/einstellungen/page.tsx
+++ b/src/app/(app)/einstellungen/page.tsx
@@ -1,5 +1,6 @@
import { Inbox } from "lucide-react";
import type { Metadata } from "next";
+import Link from "next/link";
import type { ReactNode } from "react";
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
import { Badge } from "@/components/ui/Badge";
@@ -161,11 +162,21 @@ export default async function Einstellungen() {
{instanz.deploymentKey ? (
-
+ <>
+
+ {darf(benutzer.rolle, "deploymentKeyVerwalten") && (
+
+ Deployment-Key rotieren
+
+ )}
+ >
) : (
noch nicht erzeugt – läuft der Worker?
)}
diff --git a/src/components/deploymentkey/Umstellen.tsx b/src/components/deploymentkey/Umstellen.tsx
new file mode 100644
index 0000000..3cc4351
--- /dev/null
+++ b/src/components/deploymentkey/Umstellen.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { Repeat } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState, useTransition } from "react";
+import { Button } from "@/components/ui/Button";
+import { umstellenAktion } from "@/server/actions/deploymentkey";
+
+/**
+ * Schritt 4 der Rotation (#031). Hosts, auf denen die Pruefung scheiterte, lassen sich bewusst
+ * uebergehen – sshkv kommt danach nicht mehr auf sie und sie brauchen einen neuen Bootstrap.
+ */
+export function Umstellen({
+ fehlgeschlagen,
+}: {
+ fehlgeschlagen: readonly { hostId: string; hostName: string }[];
+}) {
+ const router = useRouter();
+ const [uebergehen, setUebergehen] = useState([]);
+ const [fehler, setFehler] = useState();
+ const [laeuft, starten] = useTransition();
+ const bereit = fehlgeschlagen.every((h) => uebergehen.includes(h.hostId));
+
+ return (
+
+ {fehlgeschlagen.length > 0 && (
+
+
+ Auf diesen Hosts hat der neue Key nicht funktioniert. Übergangene Hosts erreicht sshkv nach dem
+ Umstellen nicht mehr – sie brauchen einen neuen Bootstrap.
+
+ {fehlgeschlagen.map((h) => (
+
+
+ setUebergehen((alt) =>
+ e.target.checked ? [...alt, h.hostId] : alt.filter((id) => id !== h.hostId),
+ )
+ }
+ />
+
+ {h.hostName} übergehen
+
+
+ ))}
+
+ )}
+
+
}
+ disabled={!bereit || laeuft}
+ onClick={() =>
+ starten(async () => {
+ setFehler(undefined);
+ const antwort = await umstellenAktion(uebergehen);
+ if (antwort.fehler) setFehler(antwort.fehler);
+ else router.refresh();
+ })
+ }
+ >
+ Auf neuen Key umstellen
+
+ {fehler && (
+
+ {fehler}
+
+ )}
+
+
+ );
+}
diff --git a/src/lib/protokoll-aktionen.ts b/src/lib/protokoll-aktionen.ts
index 3cc6a6e..7998b41 100644
--- a/src/lib/protokoll-aktionen.ts
+++ b/src/lib/protokoll-aktionen.ts
@@ -77,6 +77,9 @@ export const AKTIONEN: Readonly> = {
"privatekey.heruntergeladen": a("Private Key heruntergeladen", "schluessel", false),
"privatekey.geloescht": a("Private Key gelöscht", "schluessel"),
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
+ "deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false),
+ "deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"),
+ "deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"),
// Widerruf und Rotation (#023, #024)
"schluessel.widerruf.eingeleitet": a("Widerruf eingeleitet", "widerruf"),
diff --git a/src/server/actions/deploymentkey.ts b/src/server/actions/deploymentkey.ts
new file mode 100644
index 0000000..bf9bf76
--- /dev/null
+++ b/src/server/actions/deploymentkey.ts
@@ -0,0 +1,44 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { z } from "@/lib/validierung";
+import { akteurVon } from "@/server/actions/formular";
+import type { VorgangsAntwort } from "@/server/actions/verwendungen";
+import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
+import { DeploymentKeyFehler, rotationsSchrittAnfordern, umstellen } from "@/server/services/deploymentkey";
+
+/** Rotation des Deployment-Keys (#031). Nur Admins mit frischer Bestaetigung. */
+
+function alsAntwort(fehler: unknown): { fehler: string } {
+ if (
+ fehler instanceof DeploymentKeyFehler ||
+ fehler instanceof RechteFehler ||
+ fehler instanceof BestaetigungFehler
+ ) {
+ return { fehler: fehler.message };
+ }
+ throw fehler;
+}
+
+export async function rotationsSchrittAktion(
+ schritt: "erzeugen" | "pruefen" | "abschliessen",
+): Promise {
+ try {
+ const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
+ const geprueft = z.enum(["erzeugen", "pruefen", "abschliessen"]).parse(schritt);
+ return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) };
+ } catch (fehler) {
+ return alsAntwort(fehler);
+ }
+}
+
+export async function umstellenAktion(uebergehen: readonly string[]): Promise<{ fehler?: string }> {
+ try {
+ const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
+ await umstellen(akteurVon(benutzer), z.array(z.string().min(1).max(64)).max(500).parse(uebergehen));
+ revalidatePath("/deployment-key");
+ return {};
+ } catch (fehler) {
+ return alsAntwort(fehler);
+ }
+}
diff --git a/src/server/actions/verwendungen.ts b/src/server/actions/verwendungen.ts
index fa10776..8d22983 100644
--- a/src/server/actions/verwendungen.ts
+++ b/src/server/actions/verwendungen.ts
@@ -14,6 +14,7 @@ import {
publicVerwendungEntfernen,
trockenlaufStarten,
} from "@/server/services/verwendungen";
+import { instanzZiele } from "@/server/services/deploymentkey";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
@@ -78,10 +79,16 @@ export type AusrollAuswahl =
| { readonly schluesselId: string }
| { readonly hostId: string }
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
- | { readonly schluesselIds: readonly string[] };
+ | { readonly schluesselIds: readonly string[] }
+ /** Alle Hosts mit dem Instanz-Deployment-Key – fuer dessen Rotation (#031). */
+ | { readonly deploymentKey: true };
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise {
try {
+ if ("deploymentKey" in auswahl) {
+ const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
+ return { vorgangId: await trockenlaufStarten({ ziele: await instanzZiele() }, akteurVon(benutzer)) };
+ }
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
const id = z.string().min(1).max(64);
return {
diff --git a/src/server/queries/uebersicht.ts b/src/server/queries/uebersicht.ts
index 34f3fc0..56c3329 100644
--- a/src/server/queries/uebersicht.ts
+++ b/src/server/queries/uebersicht.ts
@@ -49,7 +49,7 @@ export function aktionEinordnen(aktion: string, ergebnis: string): AktionsArt {
if (aktion.startsWith("authorized_keys.") || aktion.startsWith("privatekey.ausgerollt"))
return "ausgerollt";
if (aktion.includes("widerruf")) return "widerrufen";
- if (aktion.includes("rotation")) return "rotiert";
+ if (aktion.includes("rotation") || aktion === "deploymentkey.umgestellt") return "rotiert";
if (aktion === "abgleich") return "abgleich";
if (aktion.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung";
if (
diff --git a/src/server/services/deploymentkey.ts b/src/server/services/deploymentkey.ts
new file mode 100644
index 0000000..a9e08ac
--- /dev/null
+++ b/src/server/services/deploymentkey.ts
@@ -0,0 +1,144 @@
+import { prisma } from "@/lib/db";
+import { protokollieren } from "@/server/services/protokoll";
+import { anmeldebenutzerVon, deploymentNachfolger, type Ziel } from "@/server/services/soll";
+import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
+
+/**
+ * Rotation des Instanz-Deployment-Keys (#031) – geführt in fünf Schritten:
+ *
+ * 1. erzeugen: Nachfolger anlegen (DEPLOYMENT, nicht Standard, vorgaengerId = alter).
+ * 2. verteilen: Dry-Run + Ausrollen auf alle Hosts mit Instanz-Key – der Nachfolger kommt
+ * **neben** den alten in den Block (soll.ts).
+ * 3. pruefen: je Host eine Anmeldung mit dem neuen Key.
+ * 4. umstellen: Nachfolger wird Standard, alter ROTIERT; danach Dry-Run + Ausrollen entfernt den
+ * alten aus allen Blöcken.
+ * 5. abschliessen: auf jedem Host nachsehen, dass der alte weg ist, dann seinen Private Key
+ * verwerfen.
+ *
+ * Zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da: umgestellt wird
+ * erst, wenn der neue Key überall geprüft ist (oder ein Admin einen Host bewusst übergeht –
+ * der braucht danach einen neuen Bootstrap).
+ */
+export class DeploymentKeyFehler extends Error {
+ constructor(nachricht: string) {
+ super(nachricht);
+ this.name = "DeploymentKeyFehler";
+ }
+}
+
+type Akteur = { readonly id: string; readonly name: string };
+
+export type PruefErgebnis = { hostId: string; hostName: string; ok: boolean; fehler?: string };
+
+/** Hosts, die den Instanz-Key benutzen (kein eigener, keiner über eine Gruppe). */
+export async function instanzHosts() {
+ return prisma.host.findMany({
+ where: {
+ archiviertAm: null,
+ bootstrapAm: { not: null },
+ deploymentSchluesselId: null,
+ gruppen: { none: { hostgruppe: { deploymentSchluesselId: { not: null } } } },
+ },
+ select: { id: true, name: true, anmeldeart: true },
+ orderBy: { name: "asc" },
+ });
+}
+
+export async function instanzZiele(): Promise {
+ return (await instanzHosts()).map((h) => ({
+ hostId: h.id,
+ zielbenutzer: anmeldebenutzerVon(h),
+ art: "public",
+ }));
+}
+
+export async function rotationsStand() {
+ const aktuell = await prisma.schluessel.findFirst({
+ where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
+ select: { id: true, fingerprint: true, angelegtAm: true, vorgaengerId: true },
+ });
+ const nachfolger = await deploymentNachfolger();
+ // Nach dem Umstellen: der alte ist ROTIERT, sein Private Key noch nicht verworfen.
+ const vorgaenger = aktuell?.vorgaengerId
+ ? await prisma.schluessel.findFirst({
+ where: {
+ id: aktuell.vorgaengerId,
+ kategorie: "DEPLOYMENT",
+ status: "ROTIERT",
+ privatGeloeschtAm: null,
+ },
+ select: { id: true, fingerprint: true },
+ })
+ : null;
+ const letztePruefung = await prisma.vorgang.findFirst({
+ where: { art: "ROTATION", eingabe: { path: ["schritt"], equals: "pruefen" } },
+ orderBy: { angelegtAm: "desc" },
+ select: { id: true, status: true, ergebnis: true, beendetAm: true, angelegtAm: true },
+ });
+ const hosts = await instanzHosts();
+ const pruefung =
+ letztePruefung && nachfolger && letztePruefung.angelegtAm > nachfolger.angelegtAm
+ ? ((letztePruefung.ergebnis as { hosts?: PruefErgebnis[] } | null)?.hosts ?? null)
+ : null;
+ const phase: "bereit" | "verteilen" | "aufraeumen" = nachfolger
+ ? "verteilen"
+ : vorgaenger
+ ? "aufraeumen"
+ : "bereit";
+ return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung };
+}
+
+export async function rotationsSchrittAnfordern(
+ schritt: "erzeugen" | "pruefen" | "abschliessen",
+ akteur: Akteur,
+): Promise {
+ const stand = await rotationsStand();
+ if (schritt === "erzeugen" && stand.phase !== "bereit") {
+ throw new DeploymentKeyFehler("Es läuft bereits eine Rotation des Deployment-Keys.");
+ }
+ if (schritt === "pruefen" && !stand.nachfolger)
+ throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
+ if (schritt === "abschliessen" && !stand.vorgaenger)
+ throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
+ return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
+}
+
+/**
+ * Schritt 4: Nachfolger wird Standard. Verlangt eine Prüfung nach dem Erzeugen, in der jeder
+ * Host den neuen Key angenommen hat – oder ausdrücklich übergangen wird.
+ */
+export async function umstellen(akteur: Akteur, uebergehen: readonly string[] = []): Promise {
+ const stand = await rotationsStand();
+ if (!stand.nachfolger || !stand.aktuell)
+ throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key.");
+ if (!stand.pruefung) throw new DeploymentKeyFehler("Erst den neuen Key auf allen Hosts prüfen.");
+ const geprueft = new Map(stand.pruefung.map((p) => [p.hostId, p]));
+ const offen = stand.hosts.filter((h) => !geprueft.get(h.id)?.ok && !uebergehen.includes(h.id));
+ if (offen.length) {
+ throw new DeploymentKeyFehler(
+ `Der neue Key ist noch nicht auf allen Hosts geprüft: ${offen.map((h) => h.name).join(", ")}.`,
+ );
+ }
+ const alt = stand.aktuell;
+ const neu = stand.nachfolger;
+ await prisma.$transaction([
+ // Erst den alten abloesen, dann den neuen setzen – der partielle Index erlaubt nur einen Standard.
+ prisma.schluessel.update({
+ where: { id: alt.id },
+ data: { instanzStandard: false, status: "ROTIERT" },
+ select: { id: true },
+ }),
+ prisma.schluessel.update({
+ where: { id: neu.id },
+ data: { instanzStandard: true },
+ select: { id: true },
+ }),
+ ]);
+ await protokollieren({
+ akteur,
+ aktion: "deploymentkey.umgestellt",
+ ergebnis: "ERFOLG",
+ fingerprint: neu.fingerprint,
+ details: { alt: alt.fingerprint, uebergangen: [...uebergehen] },
+ });
+}
diff --git a/src/server/services/soll.ts b/src/server/services/soll.ts
index 727904c..0ddff50 100644
--- a/src/server/services/soll.ts
+++ b/src/server/services/soll.ts
@@ -46,6 +46,23 @@ async function deploymentKeyFuer(host: Pick {
const host = await prisma.host.findUniqueOrThrow({
where: { id: hostId },
@@ -77,6 +94,18 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise h.ok) ? "ERFOLG" : "FEHLER",
+ fingerprint: stand.nachfolger.fingerprint,
+ details: {
+ ok: hosts.filter((h) => h.ok).length,
+ fehler: hosts.filter((h) => !h.ok).map((h) => h.hostName),
+ },
+ });
+ return { ergebnis: { hosts } };
+}
+
+/**
+ * Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft
+ * erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie
+ * melden sich mit dem alten Key ohnehin nicht mehr an, weil sshkv ihn nicht mehr benutzt –,
+ * werden aber im Ergebnis genannt.
+ */
+async function abschliessen(angefordertVonId: string | null) {
+ const stand = await rotationsStand();
+ if (!stand.vorgaenger) throw new VorgangsFehler("Es gibt nichts abzuschließen.");
+ const nochDa: string[] = [];
+ const nichtErreicht: string[] = [];
+ for (const kurz of await instanzHosts()) {
+ const host = await prisma.host.findUniqueOrThrow({ where: { id: kurz.id } });
+ try {
+ const verbindung = await hostVerbinden(host);
+ try {
+ const stand2 = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
+ const zeilen = authorizedKeysLesen(stand2.inhalt).zeilen.filter(
+ (z): z is SchluesselZeile => z.art === "schluessel",
+ );
+ if (zeilen.some((z) => z.fingerprint === stand.vorgaenger!.fingerprint)) nochDa.push(host.name);
+ } finally {
+ verbindung.end();
+ }
+ } catch {
+ nichtErreicht.push(host.name);
+ }
+ }
+ if (nochDa.length) {
+ throw new VorgangsFehler(
+ `Der alte Deployment-Key steht noch auf: ${nochDa.join(", ")}. Bitte zuerst Dry-Run und Ausrollen.`,
+ );
+ }
+ await privateKeyVerwerfen(stand.vorgaenger.id);
+ await protokollieren({
+ akteur: await akteurVon(angefordertVonId),
+ aktion: "deploymentkey.abgeschlossen",
+ ergebnis: "ERFOLG",
+ fingerprint: stand.vorgaenger.fingerprint,
+ details: { nichtErreicht },
+ });
+ return { ergebnis: { nichtErreicht } };
+}
+
+export const deploymentRotationHandler: Handler = async ({ vorgang }) => {
+ const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt;
+ if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId);
+ if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId);
+ if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId);
+ throw new VorgangsFehler("Unbekannter Schritt der Rotation.");
+};
diff --git a/src/worker/handler.ts b/src/worker/handler.ts
index 45bac31..de9fe29 100644
--- a/src/worker/handler.ts
+++ b/src/worker/handler.ts
@@ -3,6 +3,7 @@ import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
import { benachrichtigungTestHandler } from "@/worker/ablaeufe/benachrichtigung";
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
+import { deploymentRotationHandler } from "@/worker/ablaeufe/deploymentkey";
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
@@ -24,6 +25,7 @@ export const HANDLER: HandlerTabelle = {
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
VERBINDUNGSTEST: verbindungstestHandler,
ABGLEICH: abgleichHandler,
+ ROTATION: deploymentRotationHandler,
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
BENACHRICHTIGUNG_TEST: benachrichtigungTestHandler,
diff --git a/tests/e2e/deploymentkey.spec.ts b/tests/e2e/deploymentkey.spec.ts
new file mode 100644
index 0000000..a2bf4e5
--- /dev/null
+++ b/tests/e2e/deploymentkey.spec.ts
@@ -0,0 +1,78 @@
+import { expect, test, type Page } from "@playwright/test";
+import { anmelden, archivieren, aufZiel, hostEinrichten, PORT, zieleZuruecksetzen } from "./hilfen";
+
+/**
+ * Rotation des Deployment-Keys im Browser (#031), gebauter Stand + Worker: zwei frisch
+ * eingerichtete Hosts, fuenf Schritte, danach meldet sich sshkv nur noch mit dem neuen Key an.
+ */
+test.setTimeout(300_000);
+
+test.beforeAll(() => zieleZuruecksetzen());
+
+async function bestaetigen(page: Page) {
+ await page.goto("/bestaetigen?ziel=%2Fdeployment-key");
+ 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();
+ await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible();
+}
+
+test("Deployment-Key rotieren", async ({ page }) => {
+ const kennung = Date.now().toString(36);
+ const hostA = `zz-e2e-a-${kennung}`;
+ const hostB = `zz-e2e-b-${kennung}`;
+
+ await anmelden(page);
+ await hostEinrichten(page, hostA, PORT.a);
+ await hostEinrichten(page, hostB, PORT.b);
+ const vorher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
+
+ await page.goto("/einstellungen");
+ await page.getByRole("link", { name: "Deployment-Key rotieren" }).click();
+ await bestaetigen(page);
+
+ await page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" }).click();
+ await expect(page.getByText("Neuer Key (noch nicht Standard)")).toBeVisible({ timeout: 30_000 });
+
+ // Schritt 2: verteilen – der neue Key kommt neben den alten.
+ await page.getByRole("button", { name: "Dry-Run starten" }).click();
+ await expect(page.getByRole("button", { name: /Änderung anwenden/ })).toBeVisible({ timeout: 30_000 });
+ await page.screenshot({ path: "docs/anleitung/bilder/deployment-key-desktop.png", fullPage: true });
+ await page.getByRole("button", { name: /Änderung anwenden/ }).click();
+ await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
+ expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("2");
+
+ // Schritt 3 und 4: pruefen, umstellen.
+ await page.getByRole("button", { name: "Neuen Key prüfen" }).click();
+ const ergebnis = page.getByRole("list", { name: "Ergebnis der Prüfung" });
+ await expect(ergebnis.getByText("angemeldet")).toHaveCount(2, { timeout: 60_000 });
+ await page.getByRole("button", { name: "Auf neuen Key umstellen" }).click();
+
+ // Schritt 5: alten Key entfernen und abschliessen.
+ await expect(page.getByRole("heading", { name: "5 · Alten Key entfernen" })).toBeVisible({
+ timeout: 30_000,
+ });
+ await page.getByRole("button", { name: "Dry-Run starten" }).click();
+ await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
+ await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
+ await page.getByRole("button", { name: "Rotation abschließen" }).click();
+ await expect(page.getByRole("heading", { name: "1 · Neuen Key erzeugen" })).toBeVisible({
+ timeout: 60_000,
+ });
+
+ const nachher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
+ expect(nachher.match(/sshkv-deployment/g)).toHaveLength(1);
+ expect(nachher).not.toBe(vorher);
+
+ // Der Alltag geht weiter: Verbindung pruefen klappt mit dem neuen Key.
+ await page.goto("/hosts");
+ await page.getByRole("link", { name: hostA }).first().click();
+ const pruefen = page.getByRole("button", { name: "Verbindung prüfen" });
+ await pruefen.click();
+ await expect(pruefen.locator("..").getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
+
+ await archivieren(page, hostA);
+ await archivieren(page, hostB);
+});
diff --git a/tests/integration/deploymentkey.test.ts b/tests/integration/deploymentkey.test.ts
new file mode 100644
index 0000000..6fa12f8
--- /dev/null
+++ b/tests/integration/deploymentkey.test.ts
@@ -0,0 +1,147 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { prisma } from "@/lib/db";
+import { authorizedKeysLesen } from "@/lib/ssh/authorized-keys";
+import {
+ DeploymentKeyFehler,
+ instanzZiele,
+ rotationsStand,
+ umstellen,
+} from "@/server/services/deploymentkey";
+import { anmeldebenutzerVon } from "@/server/services/soll";
+import { deploymentKeySicherstellen, privateKeyLaden } from "@/worker/geheimnisse";
+import { hostVerbinden } from "@/worker/ssh/host";
+import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien";
+import { datenbankLeeren } from "../hilfen/db";
+import { warteschlangeStoppen } from "../hilfen/vorgang";
+import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
+import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
+
+/**
+ * Rotation des Deployment-Keys gegen echte Hosts (#031): danach meldet sich sshkv nur noch mit
+ * dem neuen Key an, der alte steht auf keinem Host mehr, und zu keinem Zeitpunkt war ein Host
+ * ohne funktionierenden Key.
+ */
+const AKTEUR = { id: "", name: "Test-Admin" };
+let aufraeumen: () => void;
+
+beforeAll(async () => {
+ await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
+ aufraeumen = masterKeyAnlegen();
+});
+afterAll(async () => {
+ aufraeumen();
+ await warteschlangeStoppen();
+});
+
+beforeEach(async () => {
+ await datenbankLeeren();
+ await deploymentKeySicherstellen();
+ const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
+ await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
+ AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
+});
+
+const schritt = (s: "erzeugen" | "pruefen" | "abschliessen") =>
+ vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
+
+async function trockenlaufUndAusrollen() {
+ const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
+ eingabe: { ziele: await instanzZiele() },
+ angefordertVonId: AKTEUR.id,
+ });
+ expect(trockenlauf.status).toBe("ERLEDIGT");
+ const ausrollen = await vorgangLaufen("AUSROLLEN", {
+ eingabe: { trockenlaufId: trockenlauf.id },
+ angefordertVonId: AKTEUR.id,
+ });
+ expect(ausrollen.fehler).toBeNull();
+}
+
+type Host = Awaited>;
+
+async function fingerprintsAuf(host: Host): Promise {
+ const verbindung = await hostVerbinden(host);
+ try {
+ const datei = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
+ return authorizedKeysLesen(datei.inhalt).zeilen.flatMap((z) =>
+ z.art === "schluessel" ? [z.fingerprint] : [],
+ );
+ } finally {
+ verbindung.end();
+ }
+}
+
+async function anmeldungKlappt(host: Host, schluesselId: string): Promise {
+ const privateKey = await privateKeyLaden(schluesselId);
+ try {
+ const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey });
+ verbindung.end();
+ return true;
+ } catch {
+ return false;
+ } finally {
+ privateKey.fill(0);
+ }
+}
+
+describe("Rotation des Deployment-Keys", () => {
+ it("tauscht den Key auf allen Hosts ohne Unterbrechung aus", async () => {
+ const a = await eingerichteterHost(zielA, "ziel-a");
+ const b = await eingerichteterHost(zielB, "ziel-b", "ROOT");
+ const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
+
+ expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
+ const neu = (await rotationsStand()).nachfolger!;
+ expect(neu.vorgaengerId).toBe(alt.id);
+ // Ein zweites Erzeugen waehrend der Rotation wird abgelehnt.
+ expect((await schritt("erzeugen")).status).toBe("FEHLGESCHLAGEN");
+
+ // Vor dem Verteilen scheitert die Pruefung – und ohne erfolgreiche Pruefung kein Umstellen.
+ await schritt("pruefen");
+ expect((await rotationsStand()).pruefung?.every((p) => !p.ok)).toBe(true);
+ await expect(umstellen(AKTEUR)).rejects.toThrow(DeploymentKeyFehler);
+
+ await trockenlaufUndAusrollen();
+ for (const host of [a, b]) {
+ expect(await fingerprintsAuf(host)).toEqual(expect.arrayContaining([alt.fingerprint, neu.fingerprint]));
+ }
+ await schritt("pruefen");
+ expect((await rotationsStand()).pruefung?.map((p) => p.ok)).toEqual([true, true]);
+
+ await umstellen(AKTEUR);
+ const umgestellt = await rotationsStand();
+ expect(umgestellt.aktuell?.id).toBe(neu.id);
+ expect(umgestellt.phase).toBe("aufraeumen");
+ // Abschliessen geht erst, wenn der alte Key ueberall entfernt ist.
+ expect((await schritt("abschliessen")).fehler).toMatch(/steht noch auf/);
+
+ await trockenlaufUndAusrollen();
+ for (const host of [a, b]) {
+ const fingerprints = await fingerprintsAuf(host);
+ expect(fingerprints).toContain(neu.fingerprint);
+ expect(fingerprints).not.toContain(alt.fingerprint);
+ expect(await anmeldungKlappt(host, alt.id)).toBe(false);
+ expect(await anmeldungKlappt(host, neu.id)).toBe(true);
+ }
+
+ expect((await schritt("abschliessen")).status).toBe("ERLEDIGT");
+ const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
+ expect(verworfen.status).toBe("ROTIERT");
+ expect(verworfen.privatGeloeschtAm).not.toBeNull();
+ expect((await rotationsStand()).phase).toBe("bereit");
+
+ // Der Alltag geht mit dem neuen Key weiter.
+ const abgleich = await vorgangLaufen("ABGLEICH", { hostId: a.id });
+ expect(abgleich.status).toBe("ERLEDIGT");
+ expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
+ });
+
+ it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
+ const a = await eingerichteterHost(zielA, "ziel-a");
+ const b = await eingerichteterHost(zielB, "ziel-b");
+ const eigener = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
+ // b bekommt (formal) einen eigenen Key – derselbe genuegt, es zaehlt die Zuordnung.
+ await prisma.host.update({ where: { id: b.id }, data: { deploymentSchluesselId: eigener.id } });
+ expect((await instanzZiele()).map((z) => z.hostId)).toEqual([a.id]);
+ });
+});
diff --git a/tests/integration/hilfen/host.ts b/tests/integration/hilfen/host.ts
index 9dc3c5c..95b8e02 100644
--- a/tests/integration/hilfen/host.ts
+++ b/tests/integration/hilfen/host.ts
@@ -29,7 +29,8 @@ export async function vorgangLaufen(
| "VERBINDUNGSTEST"
| "ZIELKONTO_ANLEGEN"
| "ZIELKONTO_ENTFERNEN"
- | "ABGLEICH",
+ | "ABGLEICH"
+ | "ROTATION",
daten: {
hostId?: string;
eingabe?: object;