diff --git a/docs/anleitung/bilder/rotation-desktop.png b/docs/anleitung/bilder/rotation-desktop.png new file mode 100644 index 0000000..4b3c4bd Binary files /dev/null and b/docs/anleitung/bilder/rotation-desktop.png differ diff --git a/docs/anleitung/bilder/widerruf-desktop.png b/docs/anleitung/bilder/widerruf-desktop.png index b44abab..5b83932 100644 Binary files a/docs/anleitung/bilder/widerruf-desktop.png and b/docs/anleitung/bilder/widerruf-desktop.png differ diff --git a/docs/anleitung/bilder/widerruf-dry-run-desktop.png b/docs/anleitung/bilder/widerruf-dry-run-desktop.png index 9824d18..0f13e66 100644 Binary files a/docs/anleitung/bilder/widerruf-dry-run-desktop.png and b/docs/anleitung/bilder/widerruf-dry-run-desktop.png differ diff --git a/docs/issues/023-rotation.md b/docs/issues/023-rotation.md index 6abca80..fb1b7e5 100644 --- a/docs/issues/023-rotation.md +++ b/docs/issues/023-rotation.md @@ -1,19 +1,46 @@ # #023 Geführte Rotation -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.4.0 **Labels:** backend, ui -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min ## Ziel Neuer Schlüssel an allen Verwendungen, Test, alter entfernt. ## Aufgaben -- [ ] wird zu Beginn der Phase verfeinert +- [x] „Rotieren“ auf der Schlüsselseite, Schrittfolge `/schluessel/[id]/rotieren` (wiederaufnehmbar) +- [x] Neuer Schlüssel gleichen Typs, Name „… (rotiert TT.MM.JJJJ)“, `vorgaengerId` = alter +- [x] Zuweisungen übernehmen: Public gleiche Ziele/Optionen, Private gleicher Host/Benutzer/Alias mit neuem Pfad +- [x] Dry-Run + Ausrollen, Verbindungstests, erst dann den alten widerrufen (#024) → `ROTIERT` +- [x] Personen-Key ohne Private Key: nur Hinweis „neuer Public Key nötig“ +- [x] Rotationskette auf der Schlüsselseite (Vorgänger/Nachfolger verlinkt) ## Akzeptanzkriterien -- [ ] wird zu Beginn der Phase verfeinert +- [x] DB-Tests: Schritte, Reihenfolge, Rechte, Namen/Pfade (`tests/rotation.test.ts`) +- [x] Integrationstest: neuer Schlüssel funktioniert, alter von beiden Hosts weg, `ssh ` geht weiter +- [x] E2E im Browser (`tests/e2e/rotation.spec.ts`), Screenshot `rotation-desktop.png` ## Abhängigkeiten -#020 +#020, #024 ## Notizen / Entscheidungen +- **Fünf Schritte:** Neuer Schlüssel → Zuweisungen übernehmen → Dry-Run & Ausrollen → Verbindungstests → + Alten widerrufen. Der aktuelle Schritt wird aus der Datenbank errechnet (`rotationsStand`), deshalb + lässt sich die Rotation jederzeit neu laden und fortsetzen; jeder Schritt ist idempotent. +- **Kette sofort:** Der Worker setzt `vorgaengerId` schon beim Erzeugen (neues Feld in der + Vorgangs-Eingabe) – auch ein abgebrochener Browser hinterlässt keinen verwaisten Schlüssel. + `vorgaengerId` ist eindeutig: zwei gleichzeitige Rotationen scheitern verständlich. +- **Gleicher Alias, zwei Zuweisungen:** `privat.ts` schreibt je Alias genau einen config-Eintrag, die + jüngste Zuweisung gewinnt (an der Position der ersten). So zeigt `ssh ` nach dem Ausrollen auf + die neue Datei, während die alte bis zum Widerruf daneben liegt. +- **Neuer Pfad:** `-rJJJJMMTT`, bei Kollision mit laufender Nummer – + auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index über alle Zustände). +- **Grün heißt aktuell:** Der alte Schlüssel wird nur widerrufen, wenn für jede Private-Zuweisung mit + Alias ein erfolgreicher `VERBINDUNGSTEST` nach dem letzten Ausrollen vorliegt (Server prüft das). + Ohne Private-Zuweisung mit Alias entfällt der Test. +- **Abschluss:** Widerruf aus #024 ohne Zielkonten (der Nachfolger nutzt sie weiter); ein aktiver + Nachfolger macht aus `WIDERRUFEN` den Status `ROTIERT`. +- Ablaufdatum: Der Nachfolger erbt es nur, wenn es in der Zukunft liegt. Eine Passphrase wird nicht + übernommen (sshkv erzeugt ohne). +- Rechte: `schluesselVerwalten` + `publicKeyAusrollen`, bei Private-Zuweisungen `privateKeyAusrollen`. + Deployment-Key: eigene Rotation (später). diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx index 4c15fc4..836e6f7 100644 --- a/src/app/(app)/schluessel/[id]/page.tsx +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -1,4 +1,4 @@ -import { Download, Lock, LockOpen, Pencil } from "lucide-react"; +import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -15,7 +15,7 @@ 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 { rotationsKette, schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; import { EintragAktionen } from "@/components/ausrollen/EintragAktionen"; import { zielAuswahl } from "@/server/queries/ziele"; @@ -23,6 +23,7 @@ import { widerrufUebersicht } from "@/server/queries/widerruf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf"; import { ablaufZustand } from "@/lib/schluessel"; +import { rotationGesperrt } from "@/server/services/rotation"; import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; @@ -214,13 +215,33 @@ export default async function SchluesselSeite({ }; const jetzt = new Date(); const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen"; + const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten"); const widerrufbar = !imWiderruf && + !(s.nachfolger?.status === "AKTIV") && s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN") && rechte.public && (s.privateVerwendungen.length === 0 || rechte.privat); const widerruf = widerrufbar || imWiderruf ? await widerrufUebersicht([s.id]) : undefined; + // Rotation (#023): Kette, Knopf bzw. Hinweis fuer Schluessel ohne Private Key in sshkv. + const kette = await rotationsKette(s.id); + const aktivePrivat = s.privateVerwendungen.filter((v) => v.zustand !== "ENTFERNEN").length; + const rotationsSperre = rotationGesperrt({ + kategorie: s.kategorie, + status: s.status, + masterKeyVersion: s.privatGespeichert ? 1 : null, + widerrufEingeleitetAm: s.widerrufEingeleitetAm, + privatZuweisungen: aktivePrivat, + }); + const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT"; + const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat); + const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf)); + // Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger. + const rotationDesVorgaengers = + s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN") + ? s.vorgaenger + : null; const vorauswahl = ( Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "") ) @@ -267,13 +288,18 @@ export default async function SchluesselSeite({ } aktionen={ - (verwalten || (widerrufbar && widerruf)) && ( + (verwalten || rotierbar || (widerrufbar && widerruf)) && ( <> {verwalten && ( )} + {rotierbar && ( + + )} {widerrufbar && widerruf && ( )} @@ -282,6 +308,21 @@ export default async function SchluesselSeite({ } /> + {rotationDesVorgaengers && ( + +

+ Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der + neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "} + + Rotation fortsetzen + +

+
+ )} + {imWiderruf && widerruf && ( )} @@ -428,6 +470,42 @@ export default async function SchluesselSeite({ )} + {(kette.length > 1 || (rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf)) && ( + 1 ? "Vorgänger und Nachfolger dieses Schlüssels" : undefined} + > + {kette.length > 1 && ( +
    + {kette.map((glied, index) => ( +
  1. + {index > 0 && ( + + → + + )} + {glied.id === s.id ? ( + + {glied.name} + + ) : ( + + {glied.name} + + )} + +
  2. + ))} +
+ )} + {rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && ( +

1 ? "mt-3" : ""}`}> + {rotationsSperre.grund} +

+ )} +
+ )} + }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/rotieren`); + if (!darf(benutzer.rolle, "publicKeyAusrollen")) notFound(); + let stand; + try { + stand = await rotationsStand(id); + } catch (fehler) { + if (fehler instanceof RotationsFehler) notFound(); + throw fehler; + } + const { alt } = stand; + + const kopf = ( + + } + titel={`„${alt.name}“ rotieren`} + untertitel="Neuer Schlüssel an allen Stellen, testen, erst dann den alten entfernen." + /> + ); + + // Ohne Nachfolger und nicht rotierbar: nur der Hinweis, keine halbe Rotation. + if (!stand.neu && stand.gesperrt) { + return ( +
+ {kopf} + } + titel={stand.gesperrt.neuerPublicKeyNoetig ? "Neuer Public Key nötig" : "Nicht rotierbar"} + text={stand.gesperrt.grund} + aktion={} + /> +
+ ); + } + + const widerruf = + stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT" + ? await offeneEntfernungen([alt.id]) + : null; + + return ( +
+ {kopf} + +
+ ); +} diff --git a/src/components/rotation/Rotation.tsx b/src/components/rotation/Rotation.tsx new file mode 100644 index 0000000..41c82b4 --- /dev/null +++ b/src/components/rotation/Rotation.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { Ban, CircleCheck, CircleX, KeyRound, PlugZap } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; +import { Button, Formularfehler, Karte } from "@/components/ui"; +import { Schrittleiste } from "@/components/ui/Schrittleiste"; +import { warten } from "@/components/vorgang/warten"; +import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; +import { + rotationAltWiderrufenAktion, + rotationStartenAktion, + rotationUebernehmenAktion, +} from "@/server/actions/rotation"; +import { verbindungTestenAktion } from "@/server/actions/verwendungen"; +import type { OffeneEntfernung } from "@/server/services/widerruf"; + +/** + * Gefuehrte Rotation (#023) als Schrittfolge wie der Assistent (#020). Der Schritt kommt vom + * Server (aus der Datenbank errechnet) – nach Neuladen, Abbruch oder Fehlschlag geht es an + * derselben Stelle weiter. + */ +const SCHRITTE = [ + "Neuer Schlüssel", + "Zuweisungen übernehmen", + "Dry-Run & Ausrollen", + "Verbindungstests", + "Alten widerrufen", +] as const; + +export type RotationsTest = { + id: string; + ziel: string; + ausgerollt: boolean; + status: string | null; + fehler: string | null; +}; + +export type RotationsDaten = { + alt: { id: string; name: string }; + neu: { id: string; name: string } | null; + schritt: 0 | 1 | 2 | 3 | 4 | 5; + neuOffen: number; + tests: RotationsTest[]; + /** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */ + widerruf: OffeneEntfernung[] | null; + darfPrivat: boolean; +}; + +export function Rotation({ daten }: { daten: RotationsDaten }) { + const router = useRouter(); + const [laeuft, setLaeuft] = useState(); + const [fehler, setFehler] = useState(); + const { alt, neu, schritt } = daten; + + async function kette(arbeit: () => Promise) { + setFehler(undefined); + try { + const meldung = await arbeit(); + if (meldung) setFehler(meldung); + } catch (f) { + setFehler((f as Error).message); + } finally { + setLaeuft(undefined); + router.refresh(); + } + } + + const vorbereiten = () => + kette(async () => { + if (!neu) { + setLaeuft("Neues Schlüsselpaar wird erzeugt …"); + const antwort = await rotationStartenAktion(alt.id); + if (antwort.fehler || !antwort.vorgangId) return antwort.fehler; + const ende = await warten(antwort.vorgangId); + if (ende.status !== "ERLEDIGT") return ende.fehler ?? "Erzeugen fehlgeschlagen."; + } + setLaeuft("Zuweisungen werden übernommen …"); + return (await rotationUebernehmenAktion(alt.id)).fehler; + }); + + const testen = () => + kette(async () => { + for (const test of daten.tests) { + setLaeuft(`Teste ${test.ziel} …`); + const antwort = await verbindungTestenAktion(test.id); + if (antwort.fehler || !antwort.vorgangId) return antwort.fehler; + await warten(antwort.vorgangId); + } + return undefined; + }); + + const altWiderrufen = () => + kette(async () => { + setLaeuft("Alter Schlüssel wird zum Entfernen vorgemerkt …"); + return (await rotationAltWiderrufenAktion(alt.id)).fehler; + }); + + const status = ( + <> + + {laeuft && ( +

+ {laeuft} +

+ )} + + ); + + return ( +
+ + + {schritt <= 1 && ( + +
    +
  • Neues Schlüsselpaar gleichen Typs, Name mit „(rotiert …)“, verknüpft mit dem alten.
  • +
  • Public Key: dieselben Ziele und Einschränkungen wie bisher.
  • +
  • + Private Key: derselbe Host und Benutzer, neue Datei mit Suffix -rJJJJMMTT; der + config-Eintrag behält seinen Alias und zeigt danach auf die neue Datei. +
  • +
  • Der alte Schlüssel bleibt gültig, bis der neue getestet ist.
  • +
+ {status} + +
+ )} + + {schritt === 2 && neu && ( + + + + )} + + {schritt === 3 && ( + +
    + {daten.tests.map((t) => ( +
  • + + {t.status === "ERLEDIGT" ? ( + + ) : t.status === "FEHLGESCHLAGEN" ? ( + + ) : ( + + )} + {t.ziel} + + {t.fehler && {t.fehler}} +
  • + ))} +
+ {status} + +
+ )} + + {schritt === 4 && !daten.widerruf && ( + 0 + ? "Alle Verbindungstests sind grün." + : "Keine Private-Key-Zuweisung mit Alias – es gibt nichts zu testen." + } + > +

+ Der alte Schlüssel „{alt.name}“ wird wie bei einem Widerruf von allen Hosts entfernt (Dry-Run + folgt). Danach gilt er als rotiert. +

+ {status} + +
+ )} + + {schritt === 4 && daten.widerruf && ( + + + + )} + + {schritt === 5 && neu && ( + +

+ Der alte Schlüssel ist von allen Hosts entfernt. Weiter mit{" "} + + {neu.name} + + . +

+
+ )} +
+ ); +} diff --git a/src/lib/rotation.ts b/src/lib/rotation.ts new file mode 100644 index 0000000..3665525 --- /dev/null +++ b/src/lib/rotation.ts @@ -0,0 +1,37 @@ +/** + * Namens- und Pfadregeln der gefuehrten Rotation (#023). Rein, damit testbar. + */ + +const NAME_SUFFIX = /\s*\(rotiert \d{2}\.\d{2}\.\d{4}\)$/; +const PFAD_SUFFIX = /-r\d{8}(?:-\d+)?$/; + +/** TT.MM.JJJJ und JJJJMMTT nach dem Kalender in Berlin. */ +export function rotationsDatum(jetzt: Date = new Date()): { anzeige: string; kompakt: string } { + const [jahr, monat, tag] = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" }) + .format(jetzt) + .split("-"); + return { anzeige: `${tag}.${monat}.${jahr}`, kompakt: `${jahr}${monat}${tag}` }; +} + +/** + * „Backup A → B (rotiert 12.10.2026)“ – ein frueheres Rotations-Suffix wird ersetzt, damit + * die Namen bei jeder Rotation nicht laenger werden. + */ +export function rotationsName(name: string, datum: string, maxLaenge = 200): string { + const suffix = ` (rotiert ${datum})`; + const basis = name.replace(NAME_SUFFIX, "").trim(); + return `${basis.slice(0, Math.max(1, maxLaenge - suffix.length))}${suffix}`; +} + +/** + * Neuer Pfad fuer den Private Key des Nachfolgers: gleicher Name mit `-rJJJJMMTT`. Ein alter + * Suffix wird ersetzt; ist der Pfad (auch durch eine fruehere, entfernte Zuweisung) belegt, + * kommt eine laufende Nummer dazu. Zwei Dateien nebeneinander sind gewollt: bis der Nachfolger + * getestet ist, bleibt der alte Schluessel benutzbar. + */ +export function rotationsPfad(pfad: string, datumKompakt: string, belegt: ReadonlySet): string { + const basis = pfad.replace(PFAD_SUFFIX, ""); + let kandidat = `${basis}-r${datumKompakt}`; + for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`; + return kandidat; +} diff --git a/src/lib/schluessel.ts b/src/lib/schluessel.ts index 31c6c5d..dfcb0ae 100644 --- a/src/lib/schluessel.ts +++ b/src/lib/schluessel.ts @@ -200,6 +200,8 @@ export type SchluesselVorgangEingabe = { readonly typ?: "ED25519" | "RSA4096"; /** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */ readonly mitPassphrase?: boolean; + /** Rotation (#023): der abgeloeste Schluessel – so ist die Kette sofort da, auch ohne Browser. */ + readonly vorgaengerId?: string | null; }; /** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */ @@ -217,6 +219,7 @@ export const vorgangEingabeSchema = z.object({ .nullable(), typ: z.enum(["ED25519", "RSA4096"]).optional(), mitPassphrase: z.boolean().optional(), + vorgaengerId: z.string().max(64).nullish(), }); /** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */ diff --git a/src/server/actions/rotation.ts b/src/server/actions/rotation.ts new file mode 100644 index 0000000..72ba2f8 --- /dev/null +++ b/src/server/actions/rotation.ts @@ -0,0 +1,71 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "@/lib/validierung"; +import { akteurVon } from "@/server/actions/formular"; +import { darf } from "@/server/auth/rechte"; +import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { FachFehler } from "@/server/services/fachfehler"; +import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation"; +import { VerwendungsFehler } from "@/server/services/verwendungen"; + +/** + * Gefuehrte Rotation (#023): je Schritt eine duenne Aktion, die Oberflaeche verkettet sie. + * Rotieren heisst einen Schluessel erzeugen und ausrollen: `schluesselVerwalten` und + * `publicKeyAusrollen`; fuer Private-Key-Zuweisungen zusaetzlich `privateKeyAusrollen` (Service). + */ +export type RotationsAntwort = { readonly vorgangId?: string; readonly fehler?: string }; + +function alsAntwort(fehler: unknown): RotationsAntwort { + if ( + fehler instanceof RechteFehler || + fehler instanceof BestaetigungFehler || + fehler instanceof VerwendungsFehler || + fehler instanceof FachFehler + ) { + return { fehler: fehler.message }; + } + throw fehler; +} + +const id = z.string().min(1).max(64); + +async function berechtigt() { + await rechtErforderlich("schluesselVerwalten"); + return rechtErforderlich("publicKeyAusrollen"); +} + +export async function rotationStartenAktion(schluesselId: string): Promise { + try { + const { benutzer } = await berechtigt(); + return { vorgangId: await rotationStarten(id.parse(schluesselId), akteurVon(benutzer)) }; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +export async function rotationUebernehmenAktion(schluesselId: string): Promise { + try { + const { benutzer } = await berechtigt(); + await rotationUebernehmen(id.parse(schluesselId), akteurVon(benutzer), { + privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"), + }); + revalidatePath(`/schluessel/${schluesselId}`); + return {}; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +export async function rotationAltWiderrufenAktion(schluesselId: string): Promise { + try { + const { benutzer } = await berechtigt(); + await rotationAltWiderrufen(id.parse(schluesselId), akteurVon(benutzer), { + privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"), + }); + revalidatePath(`/schluessel/${schluesselId}`); + return {}; + } catch (fehler) { + return alsAntwort(fehler); + } +} diff --git a/src/server/queries/schluessel.ts b/src/server/queries/schluessel.ts index 5c87d4e..d4cdb97 100644 --- a/src/server/queries/schluessel.ts +++ b/src/server/queries/schluessel.ts @@ -191,3 +191,44 @@ export async function vorlagenAuswahl() { select: { id: true, name: true, standardLaufzeitTage: true }, }); } + +// ---------------------------------------------------------------- Rotationskette (#023) + +export type KettenGlied = { readonly id: string; readonly name: string; readonly status: SchluesselStatus }; + +/** + * Die ganze Rotationskette eines Schluessels, aeltester zuerst. Begrenzt, damit eine (durch + * Fehler entstandene) Schleife die Seite nicht aufhaengt. + */ +export async function rotationsKette(id: string, maximal = 25): Promise { + const glied = (schluesselId: string) => + prisma.schluessel.findUnique({ + where: { id: schluesselId }, + select: { + id: true, + name: true, + status: true, + vorgaengerId: true, + nachfolger: { select: { id: true } }, + }, + }); + const start = await glied(id); + if (!start) return []; + const kette: KettenGlied[] = [{ id: start.id, name: start.name, status: start.status }]; + const gesehen = new Set([start.id]); + for (let davor = start.vorgaengerId; davor && kette.length < maximal && !gesehen.has(davor);) { + const g = await glied(davor); + if (!g) break; + gesehen.add(g.id); + kette.unshift({ id: g.id, name: g.name, status: g.status }); + davor = g.vorgaengerId; + } + for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) { + const g = await glied(danach); + if (!g) break; + gesehen.add(g.id); + kette.push({ id: g.id, name: g.name, status: g.status }); + danach = g.nachfolger?.id; + } + return kette; +} diff --git a/src/server/services/rotation.ts b/src/server/services/rotation.ts new file mode 100644 index 0000000..a490ea7 --- /dev/null +++ b/src/server/services/rotation.ts @@ -0,0 +1,313 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation"; +import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel"; +import { eindeutigerSlug } from "@/server/services/schluessel"; +import { protokollieren } from "@/server/services/protokoll"; +import { + VerwendungsFehler, + privateVerwendungAnlegen, + publicVerwendungAnlegen, +} from "@/server/services/verwendungen"; +import { widerrufEinleiten } from "@/server/services/widerruf"; +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; + +/** + * Gefuehrte Rotation (#023): neuer Schluessel gleichen Typs, alle aktiven Zuweisungen + * uebernehmen, ausrollen, Verbindungen testen – und erst dann den alten Schluessel ueber den + * Widerruf (#024) entfernen. Der alte endet als ROTIERT, der neue zeigt per `vorgaengerId` auf ihn. + * + * Jeder Schritt ist idempotent und liest seinen Stand aus der Datenbank: die Oberflaeche kann + * jederzeit neu laden und an derselben Stelle weitermachen. + */ +export class RotationsFehler extends VerwendungsFehler { + constructor(nachricht: string) { + super(nachricht); + this.name = "RotationsFehler"; + } +} + +type Akteur = { readonly id: string; readonly name: string }; + +/** Zustaende, in denen eine Verwendung mitgenommen wird. */ +const AKTIVE = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const; + +const ALT_SELECT = { + id: true, + name: true, + typ: true, + kategorie: true, + status: true, + fingerprint: true, + tags: true, + ablaufAm: true, + personId: true, + vorlageId: true, + masterKeyVersion: true, + widerrufEingeleitetAm: true, + nachfolger: { select: { id: true, name: true, status: true } }, + _count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } }, +} satisfies Prisma.SchluesselSelect; + +type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>; + +/** + * Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder + * ein Dienst-Key, dessen Private Key woanders liegt) braucht einen neuen Public Key von aussen. + */ +export function rotationGesperrt( + s: Pick & { + readonly privatZuweisungen: number; + }, +): { grund: string; neuerPublicKeyNoetig?: boolean } | null { + if (s.kategorie === "DEPLOYMENT") + return { grund: "Der Deployment-Key wird über eine eigene Rotation ersetzt." }; + if (s.status !== "AKTIV" && s.status !== "ABGELAUFEN") + return { grund: "Nur aktive oder abgelaufene Schlüssel lassen sich rotieren." }; + if (s.kategorie === "PERSON" || (s.masterKeyVersion === null && s.privatZuweisungen === 0)) { + return { + grund: + "Den Private Key hat sshkv nicht – rotieren heißt hier: Die Person erzeugt ein neues Schlüsselpaar und liefert den neuen Public Key. Diesen importieren, genauso zuweisen und danach diesen Schlüssel widerrufen.", + neuerPublicKeyNoetig: true, + }; + } + return null; +} + +async function altLaden(id: string): Promise { + const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT }); + if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht."); + return alt; +} + +function gesperrtPruefen(alt: Alt) { + // Ein laufender Widerruf gehoert zur Rotation, wenn es schon einen Nachfolger gibt. + if (alt.widerrufEingeleitetAm && !alt.nachfolger) + throw new RotationsFehler("Dieser Schlüssel wird gerade widerrufen."); + const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen }); + if (gesperrt) throw new RotationsFehler(gesperrt.grund); +} + +/** Schritt 1: Nachfolger im Worker erzeugen lassen. Liefert die Vorgangs-ID. */ +export async function rotationStarten(altId: string, akteur: Akteur): Promise { + const alt = await altLaden(altId); + gesperrtPruefen(alt); + if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger."); + const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX); + const slug = await eindeutigerSlug(null, name); + // Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen. + const ablaufAm = alt.ablaufAm && alt.ablaufAm > new Date() ? alt.ablaufAm.toISOString() : null; + const eingabe: SchluesselVorgangEingabe = { + name, + slug, + tags: alt.tags, + ablaufAm, + personId: alt.personId, + vorlageId: alt.vorlageId, + kommentar: null, + typ: alt.typ === "RSA4096" ? "RSA4096" : "ED25519", + vorgaengerId: alt.id, + }; + return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe }); +} + +async function nachfolgerVon(alt: Alt) { + if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen."); + if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv."); + return alt.nachfolger; +} + +/** + * Schritt 2: aktive Zuweisungen fuer den Nachfolger uebernehmen. Public: gleiche Ziele und + * Optionen. Private: gleicher Host/Benutzer und Alias, neuer Pfad – der config-Block zeigt nach + * dem Ausrollen auf die neue Datei (juengste Zuweisung gewinnt je Alias, src/worker/ablaeufe/privat.ts). + */ +export async function rotationUebernehmen( + altId: string, + akteur: Akteur, + optionen: { readonly privatErlaubt: boolean }, +): Promise<{ public: number; privat: number }> { + const alt = await altLaden(altId); + gesperrtPruefen(alt); + const neu = await nachfolgerVon(alt); + const [altPublic, altPrivat, neuPublic, neuPrivat] = await Promise.all([ + prisma.publicVerwendung.findMany({ + where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } }, + orderBy: { angelegtAm: "asc" }, + }), + prisma.privateVerwendung.findMany({ + where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } }, + orderBy: { angelegtAm: "asc" }, + }), + prisma.publicVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }), + prisma.privateVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }), + ]); + if (altPrivat.length > 0 && !optionen.privatErlaubt) + throw new RotationsFehler("Private Keys rollt nur ein Admin aus."); + + let anzahlPublic = 0; + for (const v of altPublic) { + const schon = neuPublic.some( + (n) => n.hostId === v.hostId && n.hostgruppeId === v.hostgruppeId && n.zielbenutzer === v.zielbenutzer, + ); + if (schon) continue; + await publicVerwendungAnlegen( + { + schluesselId: neu.id, + hostId: v.hostId, + hostgruppeId: v.hostgruppeId, + zielbenutzer: v.zielbenutzer, + optionen: v.optionen, + }, + akteur, + ); + anzahlPublic += 1; + } + + const datum = rotationsDatum().kompakt; + let anzahlPrivat = 0; + for (const v of altPrivat) { + const config = v.sshConfig as { alias: string; hostName: string; benutzer: string; port: number } | null; + const schon = neuPrivat.some( + (n) => + n.hostId === v.hostId && + n.zielbenutzer === v.zielbenutzer && + ((n.sshConfig as { alias?: string } | null)?.alias ?? null) === (config?.alias ?? null), + ); + if (schon) continue; + // Auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index ueber alle Zustaende). + const belegt = new Set( + ( + await prisma.privateVerwendung.findMany({ + where: { hostId: v.hostId, zielbenutzer: v.zielbenutzer }, + select: { pfad: true }, + }) + ).map((p) => p.pfad), + ); + await privateVerwendungAnlegen( + { + schluesselId: neu.id, + hostId: v.hostId, + zielbenutzer: v.zielbenutzer, + pfad: rotationsPfad(v.pfad, datum, belegt), + sshConfig: config, + knownHostsFuer: v.knownHostsFuer, + beimWiderruf: v.beimWiderruf, + }, + akteur, + ); + anzahlPrivat += 1; + } + if (anzahlPublic + anzahlPrivat > 0) { + await protokollieren({ + akteur, + aktion: "schluessel.rotation.uebernommen", + ergebnis: "ERFOLG", + fingerprint: alt.fingerprint, + details: { nachfolgerId: neu.id, public: anzahlPublic, privat: anzahlPrivat }, + }); + } + return { public: anzahlPublic, privat: anzahlPrivat }; +} + +/** Verbindungstests, die der Nachfolger bestehen muss: jede Private-Zuweisung mit Alias. */ +async function testZiele(neuId: string) { + const verwendungen = await prisma.privateVerwendung.findMany({ + where: { schluesselId: neuId, zustand: { not: "ENTFERNT" } }, + select: { + id: true, + zielbenutzer: true, + sshConfig: true, + zustand: true, + zuletztAngewendetAm: true, + host: { select: { name: true } }, + }, + orderBy: { angelegtAm: "asc" }, + }); + const mitAlias = verwendungen.filter((v) => (v.sshConfig as { alias?: string } | null)?.alias); + return Promise.all( + mitAlias.map(async (v) => { + // Gruen ist nur ein Test nach dem letzten Ausrollen – ein aelterer bewiese nichts. + const test = await prisma.vorgang.findFirst({ + where: { + art: "VERBINDUNGSTEST", + eingabe: { equals: { privatVerwendungId: v.id } }, + ...(v.zuletztAngewendetAm ? { angelegtAm: { gte: v.zuletztAngewendetAm } } : {}), + }, + orderBy: { angelegtAm: "desc" }, + select: { status: true, fehler: true }, + }); + return { + id: v.id, + ziel: `${v.zielbenutzer}@${v.host.name} → ${(v.sshConfig as { alias: string }).alias}`, + ausgerollt: v.zustand === "AUSGEROLLT", + status: test?.status ?? null, + fehler: test?.fehler ?? null, + }; + }), + ); +} + +export type RotationsStand = Awaited>; + +/** Wo steht die Rotation? Fuer die Schrittanzeige – alles aus der Datenbank. */ +export async function rotationsStand(altId: string) { + const alt = await altLaden(altId); + const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen }); + const neu = alt.nachfolger; + if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const }; + + const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([ + prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }), + prisma.privateVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }), + prisma.publicVerwendung.findMany({ + where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } }, + select: { zustand: true }, + }), + prisma.privateVerwendung.findMany({ + where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } }, + select: { zustand: true }, + }), + testZiele(neu.id), + ]); + const neuOffen = [...neuPublic, ...neuPrivat].filter((v) => v.zustand !== "AUSGEROLLT").length; + // Uebernommen, sobald der Neue mindestens so viele Zuweisungen hat wie der Alte (noch) aktive. + const uebernommen = + alt.widerrufEingeleitetAm !== null || (neuPublic.length >= altPublic && neuPrivat.length >= altPrivat); + const testsGruen = tests.every((t) => t.ausgerollt && t.status === "ERLEDIGT"); + const schritt = + alt.status === "ROTIERT" + ? (5 as const) + : !uebernommen + ? (1 as const) + : alt.widerrufEingeleitetAm + ? (4 as const) + : neuOffen > 0 + ? (2 as const) + : !testsGruen + ? (3 as const) + : (4 as const); + return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen }; +} + +/** + * Schritt 5: den alten Schluessel widerrufen – nur wenn der Nachfolger ueberall ausgerollt ist + * und jeder Verbindungstest seit dem letzten Ausrollen gruen war. + */ +export async function rotationAltWiderrufen( + altId: string, + akteur: Akteur, + optionen: { readonly privatErlaubt: boolean }, +): Promise { + const stand = await rotationsStand(altId); + if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen."); + if (stand.schritt === 5) return; + if (stand.schritt < 4) { + throw new RotationsFehler( + stand.schritt === 3 + ? "Erst wenn alle Verbindungstests mit dem neuen Schlüssel grün sind, wird der alte entfernt." + : "Der neue Schlüssel ist noch nicht überall ausgerollt.", + ); + } + await widerrufEinleiten([altId], akteur, optionen); +} diff --git a/src/worker/ablaeufe/privat.ts b/src/worker/ablaeufe/privat.ts index 8219f26..61ede27 100644 --- a/src/worker/ablaeufe/privat.ts +++ b/src/worker/ablaeufe/privat.ts @@ -60,7 +60,10 @@ async function gewollteDateien( }); const dateien: Gewollt[] = []; - const config: SshConfigEintrag[] = []; + // Je Alias genau ein Eintrag: bei einer Rotation (#023) tragen alte und neue Zuweisung denselben + // Alias. Die juengere gewinnt (Reihenfolge nach angelegtAm) – ssh nimmt sonst den ersten Treffer, + // und der Alias zeigte weiter auf den alten Schluessel. Die Position bleibt die des ersten. + const config = new Map(); const knownHosts: string[] = []; for (const verwendung of verwendungen) { const pfad = relativenPfadPruefen(verwendung.pfad); @@ -100,7 +103,7 @@ async function gewollteDateien( port?: number; } | null; if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) { - config.push({ + config.set(sshConfig.alias, { alias: sshConfig.alias, hostName: sshConfig.hostName, benutzer: sshConfig.benutzer, @@ -127,7 +130,7 @@ async function gewollteDateien( dateien.push({ art: "config", pfad: ".ssh/config", - inhalt: Buffer.from(sshConfigZeilen(config).join("\n")), + inhalt: Buffer.from(sshConfigZeilen([...config.values()]).join("\n")), modus: "0600", }); dateien.push({ diff --git a/src/worker/ablaeufe/schluessel.ts b/src/worker/ablaeufe/schluessel.ts index 6b5dd57..0ebf100 100644 --- a/src/worker/ablaeufe/schluessel.ts +++ b/src/worker/ablaeufe/schluessel.ts @@ -103,12 +103,15 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) => vorlageId: eingabe.vorlageId, erstelltVonId: akteur === "System" ? null : akteur.id, passphraseGeschuetzt: Boolean(passphrase), + vorgaengerId: eingabe.vorgaengerId ?? null, ...neu.versiegelt, }, select: { id: true }, }); } catch (fehler) { if (istEindeutigkeitsFehler(fehler)) { + // vorgaengerId ist eindeutig: zwei Rotationen desselben Schluessels gleichzeitig (#023). + if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation."); throw new VorgangsFehler( `Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`, ); @@ -127,6 +130,7 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) => typ, kategorie: "DIENST", passphraseGeschuetzt: Boolean(passphrase), + ...(eingabe.vorgaengerId ? { vorgaengerId: eingabe.vorgaengerId } : {}), }, }); return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } }; diff --git a/tests/e2e/rotation.spec.ts b/tests/e2e/rotation.spec.ts new file mode 100644 index 0000000..43a35dd --- /dev/null +++ b/tests/e2e/rotation.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; +import { + anmelden, + archivieren, + aufZiel, + hostEinrichten, + kernAufbauen, + PORT, + zieleZuruecksetzen, +} from "./hilfen"; + +/** + * Gefuehrte Rotation im Browser (#023), gebauter Stand + Worker: Ausgangslage ist der + * Kern-Anwendungsfall. Neuer Schluessel → Zuweisungen uebernehmen → ausrollen → Verbindung + * testen → alten widerrufen. `ssh zu-b` von A funktioniert vorher und nachher. + */ +test.setTimeout(300_000); + +test.beforeAll(() => zieleZuruecksetzen()); + +test("Rotation mit Schrittanzeige", async ({ page }) => { + const kennung = Date.now().toString(36); + const hostA = `zz-e2e-a-${kennung}`; + const hostB = `zz-e2e-b-${kennung}`; + const zweck = `zz-e2e Rotation ${kennung}`; + + await anmelden(page); + await hostEinrichten(page, hostA, PORT.a); + await hostEinrichten(page, hostB, PORT.b); + await kernAufbauen(page, zweck, hostA, hostB); + const altDateien = aufZiel("ziel-a", "ls /root/.ssh"); + await page.reload(); + + await page.getByRole("link", { name: "Rotieren" }).click(); + await expect(page.getByRole("heading", { name: `„${zweck}“ rotieren` })).toBeVisible(); + await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click(); + + // Schritt 3: neuen Schluessel ausrollen. + await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 60_000 }); + await page.getByRole("button", { name: "Dry-Run starten" }).click(); + // Derselbe Alias, neue Datei: nur die IdentityFile-Zeile aendert sich. + await expect(page.getByText(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/)).toBeVisible({ timeout: 30_000 }); + await page.screenshot({ path: "docs/anleitung/bilder/rotation-desktop.png", fullPage: true }); + await page.getByRole("button", { name: /Änderung anwenden/ }).click(); + + // Schritt 4: Verbindungstest mit dem neuen Schluessel. + await page.getByRole("button", { name: "Verbindungen testen" }).click({ timeout: 60_000 }); + + // Schritt 5: alten Schluessel widerrufen. + await page.getByRole("button", { name: "Alten Schlüssel widerrufen" }).click({ timeout: 60_000 }); + await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 30_000 }); + await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Rotation abgeschlossen" })).toBeVisible({ + timeout: 60_000, + }); + + expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root"); + const neueDateien = aufZiel("ziel-a", "ls /root/.ssh"); + const alterKey = altDateien.split("\n").find((d) => d.startsWith("id_ed25519") && !d.endsWith(".pub"))!; + expect(neueDateien.split("\n")).not.toContain(alterKey); + expect(neueDateien).toMatch(/-r\d{8}\n/); + + // Kette auf der Seite des neuen Schluessels. + await page.getByRole("link", { name: /\(rotiert / }).click(); + const kette = page.getByRole("list", { name: "Rotationskette" }); + await expect(kette.getByRole("link", { name: zweck })).toBeVisible(); + await expect(kette.getByText("rotiert", { exact: true })).toBeVisible(); + + await archivieren(page, hostA); + await archivieren(page, hostB); +}); diff --git a/tests/integration/rotation.test.ts b/tests/integration/rotation.test.ts new file mode 100644 index 0000000..2b368f9 --- /dev/null +++ b/tests/integration/rotation.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { rotationsDatum } from "@/lib/rotation"; +import { + rotationAltWiderrufen, + rotationStarten, + rotationUebernehmen, + rotationsStand, +} from "@/server/services/rotation"; +import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll"; +import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen"; +import { widerrufAbschliessen } from "@/server/services/widerruf"; +import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse"; +import { datenbankLeeren } from "../hilfen/db"; +import { vorgangImWorker, warteschlangeStoppen } from "../hilfen/vorgang"; +import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host"; +import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele"; + +/** + * Rotation gegen echte Hosts (#023): der neue Schluessel funktioniert (Verbindungstest gruen), + * der alte ist danach von beiden Hosts verschwunden, und `ssh ` von A geht weiter – + * derselbe Alias zeigt jetzt auf die neue Datei. + */ +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; +}); + +async function trockenlaufUndAusrollen(schluesselId: string) { + const ziele = [ + ...(await zieleVonVerwendungen({ schluesselId })), + ...(await zieleVonPrivatVerwendungen({ schluesselId })), + ]; + const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id }); + expect(trockenlauf.status).toBe("ERLEDIGT"); + const ausrollen = await vorgangLaufen("AUSROLLEN", { + eingabe: { trockenlaufId: trockenlauf.id }, + angefordertVonId: AKTEUR.id, + }); + expect(ausrollen.fehler).toBeNull(); +} + +describe("Rotation", () => { + it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => { + const a = await eingerichteterHost(zielA, "ziel-a"); + const b = await eingerichteterHost(zielB, "ziel-b"); + const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup"); + const alt = await prisma.schluessel.create({ + data: { + id: paar.id, + name: "Backup", + slug: "backup", + typ: "ED25519", + kategorie: "DIENST", + publicKey: paar.publicKeyZeile, + fingerprint: paar.fingerprint, + kommentar: "sshkv:backup", + ...paar.versiegelt, + }, + select: { id: true, publicKey: true }, + }); + await privateVerwendungAnlegen( + { + schluesselId: alt.id, + hostId: a.id, + zielbenutzer: "root", + pfad: "~/.ssh/id_backup", + sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, + knownHostsFuer: [b.id], + }, + AKTEUR, + ); + await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR); + await trockenlaufUndAusrollen(alt.id); + + // 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen. + await vorgangImWorker(await rotationStarten(alt.id, AKTEUR)); + await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true }); + const neu = await prisma.schluessel.findFirstOrThrow({ + where: { vorgaengerId: alt.id }, + select: { id: true, publicKey: true }, + }); + + // 3: ausrollen – beide Schluessel liegen jetzt nebeneinander, der Alias zeigt auf den neuen. + await trockenlaufUndAusrollen(neu.id); + const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`; + const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout; + expect(config.match(/^Host zu-b$/gm)).toHaveLength(1); + expect(config).toContain(`IdentityFile ~/.ssh/${neuerPfad}`); + + // 4: Verbindungstest mit dem neuen Schluessel. + const verwendung = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neu.id } }); + const test = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } }); + expect(test.fehler).toBeNull(); + expect((await rotationsStand(alt.id)).schritt).toBe(4); + + // 5: alten widerrufen. + await rotationAltWiderrufen(alt.id, AKTEUR, { privatErlaubt: true }); + await trockenlaufUndAusrollen(alt.id); + expect((await widerrufAbschliessen([alt.id], AKTEUR))[0]?.status).toBe("ROTIERT"); + + const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout; + expect(authorized).not.toContain(alt.publicKey.split(" ")[1]!); + expect(authorized).toContain(neu.publicKey.split(" ")[1]!); + const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n"); + expect(dateien).not.toContain("id_backup"); + expect(dateien).toContain(neuerPfad); + const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); + expect(ssh.stdout.trim()).toBe("root"); + }); +}); diff --git a/tests/rotation.test.ts b/tests/rotation.test.ts new file mode 100644 index 0000000..9ee8d34 --- /dev/null +++ b/tests/rotation.test.ts @@ -0,0 +1,260 @@ +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 { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation"; +import { rotationsKette } from "@/server/queries/schluessel"; +import { + RotationsFehler, + rotationAltWiderrufen, + rotationGesperrt, + rotationStarten, + rotationUebernehmen, + rotationsStand, +} from "@/server/services/rotation"; +import { widerrufAbschliessen } from "@/server/services/widerruf"; +import { masterKeyVergessen } from "@/worker/geheimnisse"; +import { datenbankLeeren } from "./hilfen/db"; +import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang"; +import { ed25519Blob, zeile } from "./ssh/hilfen"; + +/** Gefuehrte Rotation (#023): Regeln, Schritte, Rechte, Protokoll. */ + +let verzeichnis: string; +let admin: { id: string; name: string }; +let a: { id: string }; +let b: { id: 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 }, + }); + a = await prisma.host.create({ data: { name: "srv-a", adresse: "10.0.0.1" }, select: { id: true } }); + b = await prisma.host.create({ data: { name: "srv-b", adresse: "10.0.0.2" }, select: { id: true } }); +}); + +/** Ein ausgerollter Dienst-Key wie nach dem Assistenten: Private auf A (Alias), Public auf B. */ +async function alterSchluessel() { + const alt = await prisma.schluessel.create({ + data: { + name: "Backup A → B", + slug: "backup-a-b", + typ: "ED25519", + kategorie: "DIENST", + publicKey: zeile("ssh-ed25519", ed25519Blob(), "alt"), + fingerprint: "SHA256:alt", + kommentar: "alt", + tags: ["backup"], + ablaufAm: new Date("2020-01-01T00:00:00Z"), + }, + select: { id: true }, + }); + const jetzt = new Date(); + await prisma.publicVerwendung.create({ + data: { + schluesselId: alt.id, + hostId: b.id, + zielbenutzer: "backup", + optionen: 'from="10.0.0.1"', + zustand: "AUSGEROLLT", + zuletztAngewendetAm: jetzt, + }, + }); + await prisma.privateVerwendung.create({ + data: { + schluesselId: alt.id, + hostId: a.id, + zielbenutzer: "root", + pfad: "~/.ssh/id_backup", + sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 }, + knownHostsFuer: [b.id], + beimWiderruf: "UMBENENNEN", + zustand: "AUSGEROLLT", + zuletztAngewendetAm: jetzt, + }, + }); + return alt.id; +} + +async function nachfolgerErzeugen(altId: string): Promise { + const vorgangId = await rotationStarten(altId, admin); + await vorgangImWorker(vorgangId); + const neu = await prisma.schluessel.findFirstOrThrow({ + where: { vorgaengerId: altId }, + select: { id: true }, + }); + return neu.id; +} + +describe("Namen und Pfade", () => { + it("ersetzt ein frueheres Rotations-Suffix", () => { + expect(rotationsName("Backup", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)"); + expect(rotationsName("Backup (rotiert 01.01.2026)", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)"); + expect(rotationsName("x".repeat(200), "12.10.2026", 120)).toHaveLength(120); + }); + + it("haengt -rJJJJMMTT an und weicht belegten Pfaden aus", () => { + expect(rotationsPfad("~/.ssh/id_backup", "20261012", new Set())).toBe("~/.ssh/id_backup-r20261012"); + expect(rotationsPfad("~/.ssh/id_backup-r20260101", "20261012", new Set())).toBe( + "~/.ssh/id_backup-r20261012", + ); + expect( + rotationsPfad("~/.ssh/id_backup-r20261012", "20261012", new Set(["~/.ssh/id_backup-r20261012"])), + ).toBe("~/.ssh/id_backup-r20261012-2"); + }); + + it("rechnet das Datum in Berlin", () => { + expect(rotationsDatum(new Date("2026-10-11T23:30:00Z"))).toEqual({ + anzeige: "12.10.2026", + kompakt: "20261012", + }); + }); +}); + +describe("Was sich rotieren laesst", () => { + const basis = { + status: "AKTIV" as const, + masterKeyVersion: 1, + widerrufEingeleitetAm: null, + privatZuweisungen: 0, + }; + it("verlangt bei Personen-Keys einen neuen Public Key von aussen", () => { + expect(rotationGesperrt({ ...basis, kategorie: "PERSON", masterKeyVersion: null })).toMatchObject({ + neuerPublicKeyNoetig: true, + }); + expect(rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null })).toMatchObject({ + neuerPublicKeyNoetig: true, + }); + // Private Key aus sshkv geloescht, aber ausgerollt: sshkv erzeugt und verteilt den neuen selbst. + expect( + rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null, privatZuweisungen: 1 }), + ).toBeNull(); + expect(rotationGesperrt({ ...basis, kategorie: "DEPLOYMENT" })?.grund).toMatch(/eigene Rotation/); + expect(rotationGesperrt({ ...basis, kategorie: "DIENST", status: "WIDERRUFEN" })).not.toBeNull(); + }); +}); + +describe("Rotation Schritt fuer Schritt", () => { + it("fuehrt vom neuen Schluessel bis ROTIERT und haelt die Reihenfolge ein", async () => { + const altId = await alterSchluessel(); + expect((await rotationsStand(altId)).schritt).toBe(0); + + const neuId = await nachfolgerErzeugen(altId); + const neu = await prisma.schluessel.findUniqueOrThrow({ + where: { id: neuId }, + select: { name: true, typ: true, tags: true, ablaufAm: true, kategorie: true }, + }); + expect(neu.name).toBe(`Backup A → B (rotiert ${rotationsDatum().anzeige})`); + expect(neu).toMatchObject({ typ: "ED25519", kategorie: "DIENST", tags: ["backup"], ablaufAm: null }); + expect((await rotationsStand(altId)).schritt).toBe(1); + await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/); + + await expect(rotationUebernehmen(altId, admin, { privatErlaubt: false })).rejects.toBeInstanceOf( + RotationsFehler, + ); + expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({ + public: 1, + privat: 1, + }); + expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({ + public: 0, + privat: 0, + }); + + const oeffentlich = await prisma.publicVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } }); + expect(oeffentlich).toMatchObject({ hostId: b.id, zielbenutzer: "backup", optionen: 'from="10.0.0.1"' }); + const privat = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } }); + expect(privat).toMatchObject({ + hostId: a.id, + zielbenutzer: "root", + pfad: `~/.ssh/id_backup-r${rotationsDatum().kompakt}`, + sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 }, + knownHostsFuer: [b.id], + beimWiderruf: "UMBENENNEN", + }); + expect((await rotationsStand(altId)).schritt).toBe(2); + await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow( + /noch nicht überall/, + ); + + // Ausgerollt, aber noch nicht getestet. + const jetzt = new Date(); + await prisma.publicVerwendung.updateMany({ + where: { schluesselId: neuId }, + data: { zustand: "AUSGEROLLT" }, + }); + await prisma.privateVerwendung.updateMany({ + where: { schluesselId: neuId }, + data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt }, + }); + expect((await rotationsStand(altId)).schritt).toBe(3); + await prisma.vorgang.create({ + data: { art: "VERBINDUNGSTEST", status: "FEHLGESCHLAGEN", eingabe: { privatVerwendungId: privat.id } }, + }); + await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/grün/); + + await prisma.vorgang.create({ + data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } }, + }); + expect((await rotationsStand(altId)).schritt).toBe(4); + await rotationAltWiderrufen(altId, admin, { privatErlaubt: true }); + expect( + await prisma.publicVerwendung.count({ where: { schluesselId: altId, zustand: "ENTFERNEN" } }), + ).toBe(1); + // Der Nachfolger bleibt unberuehrt. + expect( + await prisma.publicVerwendung.count({ where: { schluesselId: neuId, zustand: "AUSGEROLLT" } }), + ).toBe(1); + + await prisma.publicVerwendung.updateMany({ + where: { schluesselId: altId }, + data: { zustand: "ENTFERNT" }, + }); + await prisma.privateVerwendung.updateMany({ + where: { schluesselId: altId }, + data: { zustand: "ENTFERNT" }, + }); + expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("ROTIERT"); + expect((await rotationsStand(altId)).schritt).toBe(5); + + expect((await rotationsKette(neuId)).map((g) => [g.id, g.status])).toEqual([ + [altId, "ROTIERT"], + [neuId, "AKTIV"], + ]); + expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.uebernommen" } })).toBe(1); + expect(await datenbankAlsText()).not.toContain("PRIVATE KEY"); + }); + + it("verweigert Personen-Keys und den Deployment-Key", async () => { + const person = await prisma.schluessel.create({ + data: { + name: "Laptop", + slug: "laptop", + typ: "ED25519", + kategorie: "PERSON", + publicKey: zeile("ssh-ed25519", ed25519Blob(), "p"), + fingerprint: "SHA256:p", + kommentar: "p", + }, + select: { id: true }, + }); + await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/); + }); +});