feat(rotation): gefuehrte Rotation mit Test vor dem Widerruf (#023)

Einen Schlüssel zu tauschen soll keinen Ausfall riskieren: Der Nachfolger
übernimmt alle aktiven Zuweisungen, wird ausgerollt und per
Verbindungstest geprüft – erst wenn alle Tests seit dem letzten Ausrollen
grün sind, entfernt der Widerruf aus #024 den alten Schlüssel, der dann
als ROTIERT endet.

Private Keys bekommen einen neuen Pfad (-rJJJJMMTT) bei gleichem Alias.
Damit ssh den neuen nimmt, schreibt privat.ts je Alias nur noch einen
config-Eintrag; die jüngste Zuweisung gewinnt. Der Worker setzt
vorgaengerId schon beim Erzeugen, so ist die Kette auch nach einem
Abbruch vollständig und die Schrittfolge wiederaufnehmbar.

Personen-Keys ohne Private Key in sshkv bekommen nur einen Hinweis:
dafür braucht es einen neuen Public Key von der Person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:30:39 +02:00
co-authored by Claude Opus 5.5
parent a2d4f8428a
commit f6ed66fc7e
17 changed files with 1368 additions and 11 deletions
+81 -3
View File
@@ -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 && (
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
)}
{rotierbar && (
<Button href={`/schluessel/${s.id}/rotieren`} icon={<RefreshCw className="size-4" />}>
{rotationLaeuft ? "Rotation fortsetzen" : "Rotieren"}
</Button>
)}
{widerrufbar && widerruf && (
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
)}
@@ -282,6 +308,21 @@ export default async function SchluesselSeite({
}
/>
{rotationDesVorgaengers && (
<Karte titel="Rotation läuft" className="border-accent">
<p className="text-muted text-[14px]">
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.{" "}
<Link
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
className="text-accent hover:underline"
>
Rotation fortsetzen
</Link>
</p>
</Karte>
)}
{imWiderruf && widerruf && (
<Karte
titel="Widerruf läuft"
@@ -295,6 +336,7 @@ export default async function SchluesselSeite({
vorauswahl={vorauswahl}
ruecksprung={`/schluessel/${s.id}`}
darfPrivat={rechte.privat}
ohneZielkonten={rotationLaeuft}
/>
</Karte>
)}
@@ -428,6 +470,42 @@ export default async function SchluesselSeite({
</Karte>
)}
{(kette.length > 1 || (rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf)) && (
<Karte
titel="Rotation"
untertitel={kette.length > 1 ? "Vorgänger und Nachfolger dieses Schlüssels" : undefined}
>
{kette.length > 1 && (
<ol className="flex flex-wrap items-center gap-2 text-[14px]" aria-label="Rotationskette">
{kette.map((glied, index) => (
<li key={glied.id} className="flex items-center gap-2">
{index > 0 && (
<span className="text-muted" aria-hidden>
→
</span>
)}
{glied.id === s.id ? (
<span className="font-medium" aria-current="true">
{glied.name}
</span>
) : (
<Link href={`/schluessel/${glied.id}`} className="text-accent hover:underline">
{glied.name}
</Link>
)}
<StatusBadge status={glied.status} />
</li>
))}
</ol>
)}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
{rotationsSperre.grund}
</p>
)}
</Karte>
)}
<Karte
titel="Public Key"
untertitel={
@@ -0,0 +1,82 @@
import { KeyRound } from "lucide-react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Rotation } from "@/components/rotation/Rotation";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { RotationsFehler, rotationsStand } from "@/server/services/rotation";
import { offeneEntfernungen } from "@/server/services/widerruf";
export const metadata: Metadata = { title: "Schlüssel rotieren" };
/** Gefuehrte Rotation (#023): Schrittfolge, wiederaufnehmbar. */
export default async function RotierenSeite({ params }: { params: Promise<{ id: string }> }) {
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 = (
<Seitenkopf
oben={
<Breadcrumb
pfad={[
{ titel: "Schlüssel", href: "/schluessel" },
{ titel: alt.name, href: `/schluessel/${alt.id}` },
{ titel: "Rotieren" },
]}
/>
}
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 (
<div className="flex flex-col gap-6 sm:gap-7">
{kopf}
<Leerzustand
icon={<KeyRound className="size-5" />}
titel={stand.gesperrt.neuerPublicKeyNoetig ? "Neuer Public Key nötig" : "Nicht rotierbar"}
text={stand.gesperrt.grund}
aktion={<Button href={`/schluessel/${alt.id}`}>Zurück zum Schlüssel</Button>}
/>
</div>
);
}
const widerruf =
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
? await offeneEntfernungen([alt.id])
: null;
return (
<div className="flex flex-col gap-6 sm:gap-7">
{kopf}
<Rotation
daten={{
alt: { id: alt.id, name: alt.name },
neu: stand.neu ? { id: stand.neu.id, name: stand.neu.name } : null,
schritt: stand.schritt,
neuOffen: stand.neu ? stand.neuOffen : 0,
tests: stand.neu ? stand.tests : [],
widerruf,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}}
/>
</div>
);
}
+241
View File
@@ -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<string>();
const [fehler, setFehler] = useState<string>();
const { alt, neu, schritt } = daten;
async function kette(arbeit: () => Promise<string | undefined>) {
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 = (
<>
<Formularfehler text={fehler} />
{laeuft && (
<p role="status" className="text-muted text-[14px]">
{laeuft}
</p>
)}
</>
);
return (
<div className="flex flex-col gap-6">
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
{schritt <= 1 && (
<Karte
titel={schritt === 0 ? "Neuen Schlüssel erzeugen" : "Zuweisungen übernehmen"}
untertitel="Noch ändert sich auf keinem Host etwas."
>
<ul className="text-muted mb-4 list-disc space-y-1 pl-5 text-[14px]">
<li>Neues Schlüsselpaar gleichen Typs, Name mit „(rotiert …)“, verknüpft mit dem alten.</li>
<li>Public Key: dieselben Ziele und Einschränkungen wie bisher.</li>
<li>
Private Key: derselbe Host und Benutzer, neue Datei mit Suffix <code>-rJJJJMMTT</code>; der
config-Eintrag behält seinen Alias und zeigt danach auf die neue Datei.
</li>
<li>Der alte Schlüssel bleibt gültig, bis der neue getestet ist.</li>
</ul>
{status}
<Button
variante="primaer"
icon={<KeyRound className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void vorbereiten()}
>
{neu ? "Zuweisungen übernehmen" : "Neuen Schlüssel erzeugen und Zuweisungen übernehmen"}
</Button>
</Karte>
)}
{schritt === 2 && neu && (
<Karte
titel="Neuen Schlüssel ausrollen"
untertitel="Dry-Run prüfen, dann anwenden – der alte Schlüssel bleibt vorerst daneben bestehen."
>
<AusrollenPanel auswahl={{ schluesselId: neu.id }} offen={daten.neuOffen} />
</Karte>
)}
{schritt === 3 && (
<Karte
titel="Verbindungen mit dem neuen Schlüssel testen"
untertitel="Erst wenn alle Tests grün sind, wird der alte Schlüssel entfernt."
>
<ul className="mb-4 flex flex-col gap-2">
{daten.tests.map((t) => (
<li key={t.id} className="flex flex-col gap-0.5 text-[14px]">
<span className="flex items-center gap-2 font-mono text-[13px] break-all">
{t.status === "ERLEDIGT" ? (
<CircleCheck className="text-ok size-4 shrink-0" aria-label="grün" />
) : t.status === "FEHLGESCHLAGEN" ? (
<CircleX className="text-danger size-4 shrink-0" aria-label="fehlgeschlagen" />
) : (
<PlugZap className="text-muted size-4 shrink-0" aria-label="noch nicht getestet" />
)}
{t.ziel}
</span>
{t.fehler && <span className="text-danger pl-6 text-[13px]">{t.fehler}</span>}
</li>
))}
</ul>
{status}
<Button
variante="primaer"
icon={<PlugZap className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void testen()}
>
Verbindungen testen
</Button>
</Karte>
)}
{schritt === 4 && !daten.widerruf && (
<Karte
titel="Alten Schlüssel entfernen"
untertitel={
daten.tests.length > 0
? "Alle Verbindungstests sind grün."
: "Keine Private-Key-Zuweisung mit Alias – es gibt nichts zu testen."
}
>
<p className="text-muted mb-4 text-[14px]">
Der alte Schlüssel „{alt.name}“ wird wie bei einem Widerruf von allen Hosts entfernt (Dry-Run
folgt). Danach gilt er als rotiert.
</p>
{status}
<Button
variante="gefahr"
icon={<Ban className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void altWiderrufen()}
>
Alten Schlüssel widerrufen
</Button>
</Karte>
)}
{schritt === 4 && daten.widerruf && (
<Karte
titel="Alten Schlüssel entfernen"
untertitel="Der Dry-Run zeigt, was vom alten Schlüssel verschwindet."
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={[alt.id]}
offen={daten.widerruf}
zielkonten={[]}
vorauswahl={[]}
ruecksprung={`/schluessel/${alt.id}/rotieren`}
darfPrivat={daten.darfPrivat}
ohneZielkonten
/>
</Karte>
)}
{schritt === 5 && neu && (
<Karte titel="Rotation abgeschlossen">
<p className="text-[14px]">
Der alte Schlüssel ist von allen Hosts entfernt. Weiter mit{" "}
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
{neu.name}
</Link>
.
</p>
</Karte>
)}
</div>
);
}
+37
View File
@@ -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>): 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;
}
+3
View File
@@ -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. */
+71
View File
@@ -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<RotationsAntwort> {
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<RotationsAntwort> {
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<RotationsAntwort> {
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);
}
}
+41
View File
@@ -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<KettenGlied[]> {
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;
}
+313
View File
@@ -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<Alt, "kategorie" | "status" | "masterKeyVersion" | "widerrufEingeleitetAm"> & {
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<Alt> {
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<string> {
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<ReturnType<typeof rotationsStand>>;
/** 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<void> {
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);
}
+6 -3
View File
@@ -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<string, SshConfigEintrag>();
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({
+4
View File
@@ -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 } };