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
+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);
}
}