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:
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 292 KiB |
@@ -1,19 +1,46 @@
|
|||||||
# #023 Geführte Rotation
|
# #023 Geführte Rotation
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
|
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
|
||||||
|
|
||||||
## Aufgaben
|
## 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
|
## 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 <alias>` geht weiter
|
||||||
|
- [x] E2E im Browser (`tests/e2e/rotation.spec.ts`), Screenshot `rotation-desktop.png`
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#020
|
#020, #024
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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 <alias>` nach dem Ausrollen auf
|
||||||
|
die neue Datei, während die alte bis zum Widerruf daneben liegt.
|
||||||
|
- **Neuer Pfad:** `<alter Pfad ohne -rJJJJMMTT>-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).
|
||||||
|
|||||||
@@ -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 type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -15,7 +15,7 @@ import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
|
|||||||
import { datum, datumZeit } from "@/lib/format";
|
import { datum, datumZeit } from "@/lib/format";
|
||||||
import { darf } from "@/server/auth/rechte";
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
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 { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||||
import { zielAuswahl } from "@/server/queries/ziele";
|
import { zielAuswahl } from "@/server/queries/ziele";
|
||||||
@@ -23,6 +23,7 @@ import { widerrufUebersicht } from "@/server/queries/widerruf";
|
|||||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
||||||
import { ablaufZustand } from "@/lib/schluessel";
|
import { ablaufZustand } from "@/lib/schluessel";
|
||||||
|
import { rotationGesperrt } from "@/server/services/rotation";
|
||||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||||
@@ -214,13 +215,33 @@ export default async function SchluesselSeite({
|
|||||||
};
|
};
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
|
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
|
||||||
|
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
const widerrufbar =
|
const widerrufbar =
|
||||||
!imWiderruf &&
|
!imWiderruf &&
|
||||||
|
!(s.nachfolger?.status === "AKTIV") &&
|
||||||
s.kategorie !== "DEPLOYMENT" &&
|
s.kategorie !== "DEPLOYMENT" &&
|
||||||
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
|
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
|
||||||
rechte.public &&
|
rechte.public &&
|
||||||
(s.privateVerwendungen.length === 0 || rechte.privat);
|
(s.privateVerwendungen.length === 0 || rechte.privat);
|
||||||
const widerruf = widerrufbar || imWiderruf ? await widerrufUebersicht([s.id]) : undefined;
|
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 = (
|
const vorauswahl = (
|
||||||
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
|
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
|
||||||
)
|
)
|
||||||
@@ -267,13 +288,18 @@ export default async function SchluesselSeite({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
aktionen={
|
aktionen={
|
||||||
(verwalten || (widerrufbar && widerruf)) && (
|
(verwalten || rotierbar || (widerrufbar && widerruf)) && (
|
||||||
<>
|
<>
|
||||||
{verwalten && (
|
{verwalten && (
|
||||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{rotierbar && (
|
||||||
|
<Button href={`/schluessel/${s.id}/rotieren`} icon={<RefreshCw className="size-4" />}>
|
||||||
|
{rotationLaeuft ? "Rotation fortsetzen" : "Rotieren"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{widerrufbar && widerruf && (
|
{widerrufbar && widerruf && (
|
||||||
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
|
<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 && (
|
{imWiderruf && widerruf && (
|
||||||
<Karte
|
<Karte
|
||||||
titel="Widerruf läuft"
|
titel="Widerruf läuft"
|
||||||
@@ -295,6 +336,7 @@ export default async function SchluesselSeite({
|
|||||||
vorauswahl={vorauswahl}
|
vorauswahl={vorauswahl}
|
||||||
ruecksprung={`/schluessel/${s.id}`}
|
ruecksprung={`/schluessel/${s.id}`}
|
||||||
darfPrivat={rechte.privat}
|
darfPrivat={rechte.privat}
|
||||||
|
ohneZielkonten={rotationLaeuft}
|
||||||
/>
|
/>
|
||||||
</Karte>
|
</Karte>
|
||||||
)}
|
)}
|
||||||
@@ -428,6 +470,42 @@ export default async function SchluesselSeite({
|
|||||||
</Karte>
|
</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
|
<Karte
|
||||||
titel="Public Key"
|
titel="Public Key"
|
||||||
untertitel={
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -200,6 +200,8 @@ export type SchluesselVorgangEingabe = {
|
|||||||
readonly typ?: "ED25519" | "RSA4096";
|
readonly typ?: "ED25519" | "RSA4096";
|
||||||
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
|
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
|
||||||
readonly mitPassphrase?: boolean;
|
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. */
|
/** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */
|
||||||
@@ -217,6 +219,7 @@ export const vorgangEingabeSchema = z.object({
|
|||||||
.nullable(),
|
.nullable(),
|
||||||
typ: z.enum(["ED25519", "RSA4096"]).optional(),
|
typ: z.enum(["ED25519", "RSA4096"]).optional(),
|
||||||
mitPassphrase: z.boolean().optional(),
|
mitPassphrase: z.boolean().optional(),
|
||||||
|
vorgaengerId: z.string().max(64).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
|
/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,3 +191,44 @@ export async function vorlagenAuswahl() {
|
|||||||
select: { id: true, name: true, standardLaufzeitTage: true },
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -60,7 +60,10 @@ async function gewollteDateien(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dateien: Gewollt[] = [];
|
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[] = [];
|
const knownHosts: string[] = [];
|
||||||
for (const verwendung of verwendungen) {
|
for (const verwendung of verwendungen) {
|
||||||
const pfad = relativenPfadPruefen(verwendung.pfad);
|
const pfad = relativenPfadPruefen(verwendung.pfad);
|
||||||
@@ -100,7 +103,7 @@ async function gewollteDateien(
|
|||||||
port?: number;
|
port?: number;
|
||||||
} | null;
|
} | null;
|
||||||
if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) {
|
if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) {
|
||||||
config.push({
|
config.set(sshConfig.alias, {
|
||||||
alias: sshConfig.alias,
|
alias: sshConfig.alias,
|
||||||
hostName: sshConfig.hostName,
|
hostName: sshConfig.hostName,
|
||||||
benutzer: sshConfig.benutzer,
|
benutzer: sshConfig.benutzer,
|
||||||
@@ -127,7 +130,7 @@ async function gewollteDateien(
|
|||||||
dateien.push({
|
dateien.push({
|
||||||
art: "config",
|
art: "config",
|
||||||
pfad: ".ssh/config",
|
pfad: ".ssh/config",
|
||||||
inhalt: Buffer.from(sshConfigZeilen(config).join("\n")),
|
inhalt: Buffer.from(sshConfigZeilen([...config.values()]).join("\n")),
|
||||||
modus: "0600",
|
modus: "0600",
|
||||||
});
|
});
|
||||||
dateien.push({
|
dateien.push({
|
||||||
|
|||||||
@@ -103,12 +103,15 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
|
|||||||
vorlageId: eingabe.vorlageId,
|
vorlageId: eingabe.vorlageId,
|
||||||
erstelltVonId: akteur === "System" ? null : akteur.id,
|
erstelltVonId: akteur === "System" ? null : akteur.id,
|
||||||
passphraseGeschuetzt: Boolean(passphrase),
|
passphraseGeschuetzt: Boolean(passphrase),
|
||||||
|
vorgaengerId: eingabe.vorgaengerId ?? null,
|
||||||
...neu.versiegelt,
|
...neu.versiegelt,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
if (istEindeutigkeitsFehler(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(
|
throw new VorgangsFehler(
|
||||||
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
|
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
|
||||||
);
|
);
|
||||||
@@ -127,6 +130,7 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
|
|||||||
typ,
|
typ,
|
||||||
kategorie: "DIENST",
|
kategorie: "DIENST",
|
||||||
passphraseGeschuetzt: Boolean(passphrase),
|
passphraseGeschuetzt: Boolean(passphrase),
|
||||||
|
...(eingabe.vorgaengerId ? { vorgaengerId: eingabe.vorgaengerId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
|
return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 <alias>` 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user