diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx index 46cb372..d1cb64e 100644 --- a/src/app/(app)/schluessel/[id]/page.tsx +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -23,7 +23,8 @@ 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 { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen"; +import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation"; import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; @@ -216,9 +217,17 @@ 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"); + // 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 widerrufbar = !imWiderruf && !(s.nachfolger?.status === "AKTIV") && + // Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) – + // so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand. + !rotationDesVorgaengers && s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN") && rechte.public && @@ -235,13 +244,19 @@ export default async function SchluesselSeite({ privatZuweisungen: aktivePrivat, }); const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT"; + const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger); 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; + // Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter + // passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr. + const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger)); + // Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht. + const darfAbbrechen = + Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) && + !imWiderruf && + s.status === "AKTIV" && + verwaltenRecht && + rechte.public && + (s.privateVerwendungen.length === 0 || rechte.privat); const vorauswahl = ( Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "") ) @@ -308,20 +323,41 @@ 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 - -

-
- )} + {rotationDesVorgaengers && + (imWiderruf || s.status === "WIDERRUFEN" ? ( + +

+ Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen. + {imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "} + + {rotationDesVorgaengers.name} + {" "} + bleibt aktiv. +

+
+ ) : ( + +

+ 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 + +

+ {darfAbbrechen && ( +
+ +
+ )} +
+ ))} {imWiderruf && widerruf && ( )} @@ -367,7 +403,8 @@ export default async function SchluesselSeite({

Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen. - {s.nachfolger && ( + {/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */} + {s.nachfolger && s.status === "ROTIERT" && ( <> {" "} Ersetzt durch{" "} @@ -502,6 +539,14 @@ export default async function SchluesselSeite({ ))} )} + {abgebrocheneRotation && s.nachfolger && ( +

+ Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen + {s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"}; + dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue + Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen. +

+ )} {rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (

1 ? "mt-3" : ""}`}> {rotationsSperre.grund} diff --git a/src/app/(app)/schluessel/[id]/rotieren/page.tsx b/src/app/(app)/schluessel/[id]/rotieren/page.tsx index c8d7df7..961f1ac 100644 --- a/src/app/(app)/schluessel/[id]/rotieren/page.tsx +++ b/src/app/(app)/schluessel/[id]/rotieren/page.tsx @@ -59,9 +59,14 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id: } const widerruf = - stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT" + stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT" ? await offeneEntfernungen([alt.id]) : null; + // Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter. + const abbruch = + stand.neu && stand.abgebrochen + ? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) } + : null; return (

@@ -74,6 +79,7 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id: neuOffen: stand.neu ? stand.neuOffen : 0, tests: stand.neu ? stand.tests : [], widerruf, + abbruch, darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"), }} /> diff --git a/src/components/rotation/Rotation.tsx b/src/components/rotation/Rotation.tsx index 41c82b4..5ec4600 100644 --- a/src/components/rotation/Rotation.tsx +++ b/src/components/rotation/Rotation.tsx @@ -9,6 +9,7 @@ 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 { RotationAbbrechen } from "./RotationAbbrechen"; import { rotationAltWiderrufenAktion, rotationStartenAktion, @@ -47,6 +48,11 @@ export type RotationsDaten = { /** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */ widerruf: OffeneEntfernung[] | null; darfPrivat: boolean; + /** + * Abgebrochene Rotation (#032): `offen` sind die offenen Entfernungen des neuen Schluessels, + * solange sein Widerruf laeuft; null, wenn er abgeschlossen ist. + */ + abbruch: { offen: OffeneEntfernung[] | null } | null; }; export function Rotation({ daten }: { daten: RotationsDaten }) { @@ -109,6 +115,40 @@ export function Rotation({ daten }: { daten: RotationsDaten }) { ); + if (daten.abbruch && neu) { + return daten.abbruch.offen ? ( + + + + ) : ( + +

+ Der neue Schlüssel{" "} + + {neu.name} + {" "} + ist widerrufen und von allen Hosts entfernt.{" "} + + {alt.name} + {" "} + bleibt aktiv. +

+
+ ); + } + return (
@@ -225,6 +265,11 @@ export function Rotation({ daten }: { daten: RotationsDaten }) { )} + {/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */} + {neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && ( + + )} + {schritt === 5 && neu && (

diff --git a/src/components/rotation/RotationAbbrechen.tsx b/src/components/rotation/RotationAbbrechen.tsx new file mode 100644 index 0000000..ecf6185 --- /dev/null +++ b/src/components/rotation/RotationAbbrechen.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Undo2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Button, Formularfehler } from "@/components/ui"; +import { rotationAbbrechenAktion } from "@/server/actions/rotation"; + +/** + * „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der + * Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und + * „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt. + */ +export function RotationAbbrechen({ + altId, + altName, + neuName, +}: { + altId: string; + altName: string; + neuName: string; +}) { + const router = useRouter(); + const [offen, setOffen] = useState(false); + const [fehler, setFehler] = useState(); + const [laeuft, starten] = useTransition(); + + if (!offen) { + return ( +

+ +
+ ); + } + + return ( +
+

Rotation abbrechen?

+
    +
  • + Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was + entfernt wird. +
  • +
  • „{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.
  • +
  • Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.
  • +
+ +
+ + +
+
+ ); +} diff --git a/src/lib/protokoll-aktionen.ts b/src/lib/protokoll-aktionen.ts index 3cc6a6e..274a2ba 100644 --- a/src/lib/protokoll-aktionen.ts +++ b/src/lib/protokoll-aktionen.ts @@ -83,6 +83,7 @@ export const AKTIONEN: Readonly> = { "schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"), "schluessel.rotiert": a("Schlüssel rotiert", "widerruf"), "schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"), + "schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"), // Zielkonten (#018) "zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"), diff --git a/src/server/actions/rotation.ts b/src/server/actions/rotation.ts index 72ba2f8..ac81103 100644 --- a/src/server/actions/rotation.ts +++ b/src/server/actions/rotation.ts @@ -6,7 +6,12 @@ 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 { + rotationAbbrechen, + rotationAltWiderrufen, + rotationStarten, + rotationUebernehmen, +} from "@/server/services/rotation"; import { VerwendungsFehler } from "@/server/services/verwendungen"; /** @@ -69,3 +74,22 @@ export async function rotationAltWiderrufenAktion(schluesselId: string): Promise return alsAntwort(fehler); } } + +/** + * Laufende Rotation abbrechen (#032): merkt den neuen Schluessel zum Widerruf vor. Gleiche Rechte + * wie die Rotation selbst; Private Keys entfernt nur, wer sie ausrollen darf (Service). + */ +export async function rotationAbbrechenAktion(schluesselId: string): Promise { + try { + const { benutzer } = await berechtigt(); + const altId = id.parse(schluesselId); + await rotationAbbrechen(altId, akteurVon(benutzer), { + privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"), + }); + revalidatePath(`/schluessel/${altId}`); + revalidatePath(`/schluessel/${altId}/rotieren`); + return {}; + } catch (fehler) { + return alsAntwort(fehler); + } +} diff --git a/src/server/queries/schluessel.ts b/src/server/queries/schluessel.ts index d4cdb97..2817f39 100644 --- a/src/server/queries/schluessel.ts +++ b/src/server/queries/schluessel.ts @@ -58,8 +58,9 @@ export const SCHLUESSEL_DETAIL_SELECT = { person: { select: { id: true, name: true } }, vorlage: { select: { id: true, name: true } }, erstelltVon: { select: { name: true } }, - vorgaenger: { select: { id: true, name: true, status: true } }, - nachfolger: { select: { id: true, name: true, status: true } }, + vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, + // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032). + nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } }, orderBy: { angelegtAm: "asc" }, diff --git a/src/server/services/rotation.ts b/src/server/services/rotation.ts index a490ea7..a7e2532 100644 --- a/src/server/services/rotation.ts +++ b/src/server/services/rotation.ts @@ -45,7 +45,7 @@ const ALT_SELECT = { vorlageId: true, masterKeyVersion: true, widerrufEingeleitetAm: true, - nachfolger: { select: { id: true, name: true, status: true } }, + nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, _count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } }, } satisfies Prisma.SchluesselSelect; @@ -92,6 +92,13 @@ function gesperrtPruefen(alt: Alt) { export async function rotationStarten(altId: string, akteur: Akteur): Promise { const alt = await altLaden(altId); gesperrtPruefen(alt); + if (alt.nachfolger && rotationAbgebrochen(alt.nachfolger)) { + // vorgaengerId ist eindeutig und bleibt fuer die Historie stehen (#032): ein zweiter + // Nachfolger passt nicht in die Kette. + throw new RotationsFehler( + "Die Rotation dieses Schlüssels wurde abgebrochen; ein zweiter Nachfolger ist nicht vorgesehen. Bitte einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.", + ); + } 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); @@ -111,8 +118,19 @@ export async function rotationStarten(altId: string, akteur: Akteur): Promise { + const alt = await altLaden(altId); + const neu = alt.nachfolger; + if (!neu) throw new RotationsFehler("Für diesen Schlüssel läuft keine Rotation."); + if (alt.status === "ROTIERT") throw new RotationsFehler("Die Rotation ist bereits abgeschlossen."); + // Idempotent: ein zweiter Klick (oder ein Neuladen) setzt den laufenden Abbruch nur fort. + if (rotationAbgebrochen(neu)) return; + if (alt.widerrufEingeleitetAm) { + throw new RotationsFehler( + "Der alte Schlüssel wird bereits entfernt – die Rotation lässt sich nicht mehr abbrechen.", + ); + } + await widerrufEinleiten([neu.id], akteur, { + privatErlaubt: optionen.privatErlaubt, + zusaetzlich: async (tx) => { + await protokollieren( + { + akteur, + aktion: "schluessel.rotation.abgebrochen", + ergebnis: "ERFOLG", + fingerprint: alt.fingerprint, + details: { name: alt.name, nachfolgerId: neu.id, nachfolger: neu.name }, + }, + tx, + ); + }, + }); +} diff --git a/src/server/services/widerruf.ts b/src/server/services/widerruf.ts index 53bb957..11160d5 100644 --- a/src/server/services/widerruf.ts +++ b/src/server/services/widerruf.ts @@ -37,7 +37,7 @@ const SCHLUESSEL_KOPF = { status: true, fingerprint: true, widerrufEingeleitetAm: true, - nachfolger: { select: { id: true, status: true } }, + nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } }, } satisfies Prisma.SchluesselSelect; type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>; @@ -167,6 +167,11 @@ export async function zielkontoKandidaten(schluesselIds: readonly string[]): Pro export type EinleitenOptionen = { /** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */ readonly privatErlaubt: boolean; + /** + * Laeuft in derselben Transaktion wie das Vormerken – etwa der Protokolleintrag eines + * Rotationsabbruchs (#032), damit es keinen Abbruch ohne Eintrag gibt. + */ + readonly zusaetzlich?: (tx: Prisma.TransactionClient) => Promise; }; /** @@ -264,6 +269,7 @@ export async function widerrufEinleiten( tx, ); } + await optionen.zusaetzlich?.(tx); }); } @@ -406,7 +412,9 @@ export async function widerrufAbschliessen( continue; } // Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023). - const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN"; + // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032) – ersetzt ist dann nichts. + const status = + s.nachfolger?.status === "AKTIV" && !s.nachfolger.widerrufEingeleitetAm ? "ROTIERT" : "WIDERRUFEN"; await prisma.$transaction(async (tx) => { const geaendert = await tx.schluessel.updateMany({ where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } }, diff --git a/tests/e2e/rotation.spec.ts b/tests/e2e/rotation.spec.ts index 43a35dd..108f443 100644 --- a/tests/e2e/rotation.spec.ts +++ b/tests/e2e/rotation.spec.ts @@ -69,3 +69,49 @@ test("Rotation mit Schrittanzeige", async ({ page }) => { await archivieren(page, hostA); await archivieren(page, hostB); }); + +test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", async ({ page }) => { + const kennung = Date.now().toString(36); + const hostA = `zz-e2e-a-${kennung}`; + const hostB = `zz-e2e-b-${kennung}`; + const zweck = `zz-e2e Abbruch ${kennung}`; + + zieleZuruecksetzen(); + await anmelden(page); + await hostEinrichten(page, hostA, PORT.a); + await hostEinrichten(page, hostB, PORT.b); + const schluesselSeite = await kernAufbauen(page, zweck, hostA, hostB); + await page.reload(); + + await page.getByRole("link", { name: "Rotieren" }).click(); + await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click(); + await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 60_000 }); + await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Verbindungen testen" })).toBeVisible({ timeout: 60_000 }); + expect(aufZiel("ziel-a", "cat /root/.ssh/config")).toMatch(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/m); + + // Abbrechen: Rueckfrage, dann Widerruf des neuen Schluessels mit Dry-Run. + await page.getByRole("button", { name: "Rotation abbrechen" }).click(); + const rueckfrage = page.getByRole("group", { name: "Rotation abbrechen?" }); + await expect(rueckfrage.getByText(`„${zweck}“ bleibt aktiv und unverändert`)).toBeVisible(); + await rueckfrage.getByRole("button", { name: "Neuen Schlüssel widerrufen" }).click(); + await expect(page.getByRole("heading", { name: "Rotation wird abgebrochen" })).toBeVisible(); + await page.getByRole("button", { name: "Dry-Run starten" }).click(); + await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Rotation abgebrochen" })).toBeVisible({ timeout: 60_000 }); + + // Auf den Hosts: wie vorher. Der Alias geht wieder mit dem alten Schluessel. + expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toMatch(/-r\d{8}/); + expect(aufZiel("ziel-a", "cat /root/.ssh/config")).not.toMatch(/-r\d{8}/); + expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root"); + + // Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger, kein „Rotieren“ mehr. + await page.goto(schluesselSeite); + const kette = page.getByRole("list", { name: "Rotationskette" }); + await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible(); + await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen/)).toBeVisible(); + await expect(page.getByRole("link", { name: "Rotieren" })).toHaveCount(0); + + await archivieren(page, hostA); + await archivieren(page, hostB); +}); diff --git a/tests/integration/rotation.test.ts b/tests/integration/rotation.test.ts index 2b368f9..27e141b 100644 --- a/tests/integration/rotation.test.ts +++ b/tests/integration/rotation.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; import { rotationsDatum } from "@/lib/rotation"; import { + rotationAbbrechen, rotationAltWiderrufen, rotationStarten, rotationUebernehmen, @@ -55,38 +56,44 @@ async function trockenlaufUndAusrollen(schluesselId: string) { expect(ausrollen.fehler).toBeNull(); } +/** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */ +async function alterSchluesselAusgerollt(aId: string, bId: string) { + 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: aId, + zielbenutzer: "root", + pfad: "~/.ssh/id_backup", + sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, + knownHostsFuer: [bId], + }, + AKTEUR, + ); + await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR); + await trockenlaufUndAusrollen(alt.id); + return alt; +} + 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); + const alt = await alterSchluesselAusgerollt(a.id, b.id); // 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen. await vorgangImWorker(await rotationStarten(alt.id, AKTEUR)); @@ -123,4 +130,50 @@ describe("Rotation", () => { const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); expect(ssh.stdout.trim()).toBe("root"); }); + + it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => { + const a = await eingerichteterHost(zielA, "ziel-a"); + const b = await eingerichteterHost(zielB, "ziel-b"); + const alt = await alterSchluesselAusgerollt(a.id, b.id); + const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } }); + + 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 }, + }); + await trockenlaufUndAusrollen(neu.id); + const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`; + expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain( + `IdentityFile ~/.ssh/${neuerPfad}`, + ); + + // Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden. + await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true }); + await trockenlaufUndAusrollen(neu.id); + expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN"); + + const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout; + expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!); + expect(authorized).toContain(alt.publicKey.split(" ")[1]!); + const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n"); + expect(dateien).toContain("id_backup"); + expect(dateien).not.toContain(neuerPfad); + // Der Alias zeigt wieder auf die alte Datei – und funktioniert. + const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout; + expect(config.match(/^Host zu-b$/gm)).toHaveLength(1); + expect(config).toContain("IdentityFile ~/.ssh/id_backup\n"); + const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); + expect(ssh.stdout.trim()).toBe("root"); + + // Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt. + expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher); + const zustaende = [ + ...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })), + ...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })), + ].map((v) => v.zustand); + expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]); + expect((await rotationsStand(alt.id)).abgebrochen).toBe(true); + }); }); diff --git a/tests/rotation.test.ts b/tests/rotation.test.ts index 9ee8d34..4658893 100644 --- a/tests/rotation.test.ts +++ b/tests/rotation.test.ts @@ -8,13 +8,14 @@ import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation"; import { rotationsKette } from "@/server/queries/schluessel"; import { RotationsFehler, + rotationAbbrechen, rotationAltWiderrufen, rotationGesperrt, rotationStarten, rotationUebernehmen, rotationsStand, } from "@/server/services/rotation"; -import { widerrufAbschliessen } from "@/server/services/widerruf"; +import { widerrufAbschliessen, widerrufEinleiten } from "@/server/services/widerruf"; import { masterKeyVergessen } from "@/worker/geheimnisse"; import { datenbankLeeren } from "./hilfen/db"; import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang"; @@ -258,3 +259,118 @@ describe("Rotation Schritt fuer Schritt", () => { await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/); }); }); + +describe("Rotation abbrechen (#032)", () => { + /** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */ + async function laufendeRotation() { + const altId = await alterSchluessel(); + const neuId = await nachfolgerErzeugen(altId); + await rotationUebernehmen(altId, admin, { privatErlaubt: true }); + const jetzt = new Date(); + await prisma.publicVerwendung.updateMany({ + where: { schluesselId: neuId }, + data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt }, + }); + await prisma.privateVerwendung.updateMany({ + where: { schluesselId: neuId }, + data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt }, + }); + return { altId, neuId }; + } + + async function verwendungen(schluesselId: string) { + const [oeffentlich, privat] = await Promise.all([ + prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }), + prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }), + ]); + return [...oeffentlich, ...privat].map((v) => v.zustand).sort(); + } + + it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => { + const { altId, neuId } = await laufendeRotation(); + const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } }); + + await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/); + await rotationAbbrechen(altId, admin, { privatErlaubt: true }); + + // Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg. + expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]); + expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]); + expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher); + const stand = await rotationsStand(altId); + expect(stand.abgebrochen).toBe(true); + + // Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts. + await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/); + await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/); + await rotationAbbrechen(altId, admin, { privatErlaubt: true }); + expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1); + const eintrag = await prisma.protokoll.findFirstOrThrow({ + where: { aktion: "schluessel.rotation.abgebrochen" }, + select: { fingerprint: true, details: true }, + }); + expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } }); + + // Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt. + await prisma.publicVerwendung.updateMany({ + where: { schluesselId: neuId }, + data: { zustand: "ENTFERNT" }, + }); + await prisma.privateVerwendung.updateMany({ + where: { schluesselId: neuId }, + data: { zustand: "ENTFERNT" }, + }); + expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN"); + expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([ + [altId, "AKTIV"], + [neuId, "WIDERRUFEN"], + ]); + expect( + (await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } })) + .vorgaengerId, + ).toBe(altId); + await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/); + }); + + it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => { + const altId = await alterSchluessel(); + const neuId = await nachfolgerErzeugen(altId); + await rotationUebernehmen(altId, admin, { privatErlaubt: true }); + await rotationAbbrechen(altId, admin, { privatErlaubt: true }); + expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]); + expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN"); + expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]); + }); + + it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => { + const { altId } = await laufendeRotation(); + const privat = await prisma.privateVerwendung.findFirstOrThrow({ + where: { schluessel: { vorgaengerId: altId } }, + select: { id: true }, + }); + await prisma.vorgang.create({ + data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } }, + }); + await rotationAltWiderrufen(altId, admin, { privatErlaubt: true }); + await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow( + /nicht mehr abbrechen/, + ); + await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/); + }); + + it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => { + const { altId, neuId } = await laufendeRotation(); + await rotationAbbrechen(altId, admin, { privatErlaubt: true }); + await widerrufEinleiten([altId], admin, { privatErlaubt: true }); + 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("WIDERRUFEN"); + expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV"); + }); +});