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). ![Widerruf](bilder/widerruf-desktop.png) +**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. + +![Deployment-Key rotieren](bilder/deployment-key-desktop.png) + ## 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) => ( + + ))} +
+ )} +
+ + {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;