Files
sshkeyvault/tests/integration/widerruf.test.ts
T
florian.wildeandClaude Opus 5.5 c6361ff3c6 feat(widerruf): Widerruf und Massenwiderruf mit Dry-Run (#024)
Ein Schlüssel soll nach Projektende mit einem Klick von allen Hosts
verschwinden – aber nie als „widerrufen“ gelten, solange er irgendwo noch
liegt. Deshalb ein Zwischenzustand (widerrufEingeleitetAm): Verwendungen
werden zum Entfernen vorgemerkt, der bestehende Dry-Run mit Hash-Sperre
zeigt und schreibt die Änderungen, und erst wenn keine Verwendung mehr
offen ist, setzt der Abschluss WIDERRUFEN (bzw. ROTIERT für #023).
Scheitert ein Host, bleibt der Schlüssel im Zwischenzustand und die Seite
nennt die offenen Ziele.

Zielkonten lassen sich optional mit entfernen, aber erst nach den
Schlüsseln und nie, wenn ein anderer aktiver Schlüssel sie noch nutzt.
Massenwiderruf (Admin, frische Bestätigung) nutzt einen gemeinsamen
Dry-Run und protokolliert je Schlüssel. Abgelaufene Schlüssel erscheinen
im Filter „abgelaufen“ und bieten den Widerruf an – entfernt wird nie
automatisch.

Migration 20261012130000_widerruf_eingeleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:14:01 +02:00

172 lines
7.5 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
import {
widerrufAbschliessen,
widerrufEinleiten,
widerrufZielkontenEntfernen,
} from "@/server/services/widerruf";
import type { Plan } from "@/worker/ablaeufe/ausrollen";
import { vorgangAusfuehren } from "@/worker/ausfuehren";
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { HANDLER } from "@/worker/handler";
import { datenbankLeeren } from "../hilfen/db";
import { warteschlangeStoppen } from "../hilfen/vorgang";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
/**
* Widerruf gegen echte Hosts (#024): Public Key aus authorized_keys von B, Private Key samt
* config- und known_hosts-Eintrag von A, optional das Zielkonto deploy auf B. Danach scheitert
* `ssh <alias>` von A. Scheitert ein Host, bleibt der Schluessel im Zwischenzustand.
*/
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; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
const admin = await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } });
AKTEUR.id = admin.id;
});
async function dienstSchluessel(name: string) {
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", `sshkv:${name}`);
return prisma.schluessel.create({
data: {
id: neu.id,
name,
slug: name,
typ: "ED25519",
kategorie: "DIENST",
publicKey: neu.publicKeyZeile,
fingerprint: neu.fingerprint,
kommentar: `sshkv:${name}`,
...neu.versiegelt,
},
select: { id: true, fingerprint: true, publicKey: true },
});
}
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,
});
return { plan: trockenlauf.plan as unknown as Plan, ausrollen };
}
/** Deployment-Zugang wie im Assistenten: deploy auf B, Private Key auf A, Alias zu-b. */
async function deploymentZugang() {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const konto = await vorgangLaufen("ZIELKONTO_ANLEGEN", {
hostId: b.id,
eingabe: { benutzer: "deploy", modus: "KEINER", befehle: [] },
});
expect(konto.fehler).toBeNull();
const schluessel = await dienstSchluessel("deploy-a-b");
await privateVerwendungAnlegen(
{
schluesselId: schluessel.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_ed25519_deploy",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "deploy", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: b.id, zielbenutzer: "deploy" },
AKTEUR,
);
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(ausrollen.fehler).toBeNull();
const vorher = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(vorher.stdout.trim()).toBe("deploy");
return { a, b, schluessel };
}
describe("Widerruf", () => {
it("entfernt Public Key, Private Key, config, known_hosts und auf Wunsch das Zielkonto", async () => {
const { schluessel } = await deploymentZugang();
const blob = schluessel.publicKey.split(" ")[1]!;
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(plan.ziele.every((z) => !z.fehler)).toBe(true);
expect(ausrollen.fehler).toBeNull();
expect((await befehlAlsRoot(zielB, "cat /home/deploy/.ssh/authorized_keys")).stdout).not.toContain(blob);
const ssh = await befehlAlsRoot(zielA, "ls -a /root/.ssh; cat /root/.ssh/config /root/.ssh/known_hosts");
expect(ssh.stdout).not.toContain("id_ed25519_deploy");
expect(ssh.stdout).not.toContain("zu-b");
expect(ssh.stdout).not.toContain("ssh-ed25519");
const danach = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b id -un");
expect(danach.code).not.toBe(0);
// Zielkonto: erst nach den Schluesseln, dann Abschluss.
const konto = await prisma.zielkonto.findFirstOrThrow({ where: { benutzername: "deploy" } });
const vorgaenge = await widerrufZielkontenEntfernen([schluessel.id], [konto.id], AKTEUR);
for (const v of vorgaenge) await vorgangAusfuehren(v.id, HANDLER);
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } })).fehler).toBeNull();
expect((await befehlAlsRoot(zielB, "id deploy")).code).not.toBe(0);
const [ergebnis] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(ergebnis?.status).toBe("WIDERRUFEN");
const protokoll = await prisma.protokoll.findMany({ where: { fingerprint: schluessel.fingerprint } });
expect(protokoll.map((p) => p.aktion)).toEqual(
expect.arrayContaining(["schluessel.widerruf.eingeleitet", "schluessel.widerrufen"]),
);
expect(JSON.stringify(protokoll.map((p) => [p.details, p.diff]))).not.toContain("PRIVATE KEY");
});
it("bleibt im Zwischenzustand, wenn ein Host nicht erreichbar ist", async () => {
const { b, schluessel } = await deploymentZugang();
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
// B „faellt aus“: ein Port, auf dem niemand lauscht.
await prisma.host.update({ where: { id: b.id }, data: { port: 1 } });
const { plan } = await trockenlaufUndAusrollen(schluessel.id);
expect(plan.ziele.find((z) => z.hostName === "ziel-b")?.fehler).toBeTruthy();
expect(plan.ziele.find((z) => z.hostName === "ziel-a")?.fehler).toBeUndefined();
const [offen] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(offen?.status).toBe("OFFEN");
expect(offen?.offen.map((o) => o.ziel)).toEqual(["deploy@ziel-b:~/.ssh/authorized_keys"]);
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).status).toBe(
"AKTIV",
);
// Was ging, ist trotzdem weg: der Private Key auf A.
expect((await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout).not.toContain("id_ed25519_deploy");
// B ist zurueck: ein neuer Dry-Run erledigt den Rest.
await prisma.host.update({ where: { id: b.id }, data: { port: zielB.port } });
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(ausrollen.fehler).toBeNull();
const [fertig] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(fertig?.status).toBe("WIDERRUFEN");
});
});