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, rotationAbbrechen, rotationAltWiderrufen, rotationGesperrt, rotationStarten, rotationUebernehmen, rotationsStand, } from "@/server/services/rotation"; import { widerrufAbschliessen, widerrufEinleiten } 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 { const vorgangId = await rotationStarten(altId, admin); await vorgangImWorker(vorgangId); // Nach einem Abbruch gibt es mehrere Nachfolger; der juengste ist der gerade erzeugte (#033). const neu = await prisma.schluessel.findFirstOrThrow({ where: { vorgaengerId: altId }, orderBy: { angelegtAm: "desc" }, 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/); }); }); describe("Rotation abbrechen (#032)", () => { /** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */ async function laufendeRotation() { const altId = await alterSchluessel(); const neuId = await nachfolgerErzeugen(altId); await rotationUebernehmen(altId, admin, { privatErlaubt: true }); const jetzt = new Date(); await prisma.publicVerwendung.updateMany({ where: { schluesselId: neuId }, data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt }, }); await prisma.privateVerwendung.updateMany({ where: { schluesselId: neuId }, data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt }, }); return { altId, neuId }; } async function verwendungen(schluesselId: string) { const [oeffentlich, privat] = await Promise.all([ prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }), prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }), ]); return [...oeffentlich, ...privat].map((v) => v.zustand).sort(); } it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => { const { altId, neuId } = await laufendeRotation(); const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } }); await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/); await rotationAbbrechen(altId, admin, { privatErlaubt: true }); // Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg. expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]); expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]); expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher); const stand = await rotationsStand(altId); expect(stand.abgebrochen).toBe(true); // Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts. await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/); await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/); await rotationAbbrechen(altId, admin, { privatErlaubt: true }); expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1); const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.rotation.abgebrochen" }, select: { fingerprint: true, details: true }, }); expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } }); // Solange der Abbruch laeuft, keine neue Rotation (#033). await expect(rotationStarten(altId, admin)).rejects.toThrow(/läuft noch/); // Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt. await prisma.publicVerwendung.updateMany({ where: { schluesselId: neuId }, data: { zustand: "ENTFERNT" }, }); await prisma.privateVerwendung.updateMany({ where: { schluesselId: neuId }, data: { zustand: "ENTFERNT" }, }); expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN"); expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([ [altId, "AKTIV"], [neuId, "WIDERRUFEN"], ]); expect( (await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } })) .vorgaengerId, ).toBe(altId); // Danach laesst sich derselbe Schluessel erneut rotieren (#033). expect(await rotationsStand(altId)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false }); const neu2 = await nachfolgerErzeugen(altId); expect(neu2).not.toBe(neuId); expect(await rotationsStand(altId)).toMatchObject({ neu: { id: neu2 }, schritt: 1, abgebrochen: false }); expect((await rotationsKette(altId)).map((g) => g.id)).toEqual([altId, neu2]); await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/); }); it("erlaubt in der Datenbank nur einen offenen Nachfolger je Schluessel (#033)", async () => { const altId = await alterSchluessel(); const nachfolger = (n: string, daten: object = {}) => prisma.schluessel.create({ data: { name: n, slug: n, typ: "ED25519", kategorie: "DIENST", publicKey: zeile("ssh-ed25519", ed25519Blob(), n), fingerprint: `SHA256:${n}`, kommentar: n, vorgaengerId: altId, ...daten, }, select: { id: true }, }); await nachfolger("abgebrochen", { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() }); await nachfolger("im-abbruch", { widerrufEingeleitetAm: new Date() }); await nachfolger("offen"); await expect(nachfolger("zweiter-offen")).rejects.toThrow(); }); it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => { const altId = await alterSchluessel(); const neuId = await nachfolgerErzeugen(altId); await rotationUebernehmen(altId, admin, { privatErlaubt: true }); await rotationAbbrechen(altId, admin, { privatErlaubt: true }); expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]); expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN"); expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]); }); it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => { const { altId } = await laufendeRotation(); const privat = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluessel: { vorgaengerId: altId } }, select: { id: true }, }); await prisma.vorgang.create({ data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } }, }); await rotationAltWiderrufen(altId, admin, { privatErlaubt: true }); await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow( /nicht mehr abbrechen/, ); await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/); }); it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => { const { altId, neuId } = await laufendeRotation(); await rotationAbbrechen(altId, admin, { privatErlaubt: true }); await widerrufEinleiten([altId], admin, { privatErlaubt: true }); await prisma.publicVerwendung.updateMany({ where: { schluesselId: altId }, data: { zustand: "ENTFERNT" }, }); await prisma.privateVerwendung.updateMany({ where: { schluesselId: altId }, data: { zustand: "ENTFERNT" }, }); expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("WIDERRUFEN"); expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV"); }); });