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 @@
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);
});
+126
View File
@@ -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");
});
});
+260
View File
@@ -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/);
});
});