import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; 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 { hostKeyEntscheiden } from "@/server/services/hostkeys"; import { vorgangAusfuehren } from "@/worker/ausfuehren"; import { masterKeyVergessen } from "@/worker/geheimnisse"; import { HANDLER } from "@/worker/handler"; import { hostVerbinden } from "@/worker/ssh/host"; import { HostKeyGeaendertFehler, befehlAusfuehren } from "@/worker/ssh/verbindung"; import { datenbankLeeren } from "../hilfen/db"; import { hostKeyFingerprint, hostKeysTauschen, rootPasswort, zielA, zielBereit } from "./hilfen/ziele"; /** Host-Key-Pruefung gegen ein echtes sshd (#009). */ const AKTEUR = { id: "test", name: "Test" }; let verzeichnis: string; beforeAll(async () => { await zielBereit(zielA); 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(() => rmSync(verzeichnis, { recursive: true, force: true })); beforeEach(async () => { await datenbankLeeren(); }); async function hostAnlegen() { return prisma.host.create({ data: { name: "ziel-a", adresse: zielA.host, port: zielA.port, anmeldeart: "ROOT" }, }); } async function abrufen(hostId: string) { const vorgang = await prisma.vorgang.create({ data: { art: "HOSTKEY_ABRUFEN", hostId } }); await vorgangAusfuehren(vorgang.id, HANDLER); return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } }); } const passwort = () => ({ benutzer: "root", passwort: rootPasswort() }); describe("Host-Key (TOFU)", () => { it("ruft den Key ab, ohne ihm zu vertrauen", async () => { const host = await hostAnlegen(); const vorgang = await abrufen(host.id); expect(vorgang.status).toBe("ERLEDIGT"); const keys = await prisma.hostSchluessel.findMany({ where: { hostId: host.id } }); expect(keys).toHaveLength(1); expect(keys[0]).toMatchObject({ status: "AUSSTEHEND", algorithmus: "ssh-ed25519" }); expect(keys[0]?.fingerprint).toBe(await hostKeyFingerprint(zielA)); // Ohne Bestaetigung keine Verbindung. await expect(hostVerbinden(host, passwort())).rejects.toThrow(/noch kein Host-Key bestätigt/); }); it("verbindet nach Bestaetigung und verweigert nach einem Host-Key-Wechsel", async () => { const host = await hostAnlegen(); await abrufen(host.id); const [key] = await prisma.hostSchluessel.findMany({ where: { hostId: host.id } }); await hostKeyEntscheiden({ hostSchluesselId: key!.id, vertrauen: true }, AKTEUR); const verbindung = await hostVerbinden(host, passwort()); expect((await befehlAusfuehren(verbindung, "id -un")).stdout.trim()).toBe("root"); verbindung.end(); await hostKeysTauschen(zielA); await expect(hostVerbinden(host, passwort())).rejects.toBeInstanceOf(HostKeyGeaendertFehler); const abweichung = await prisma.abweichung.findFirst({ where: { hostId: host.id, art: "HOSTKEY_GEAENDERT" }, }); expect(abweichung?.behobenAm).toBeNull(); const neu = await prisma.hostSchluessel.findFirst({ where: { hostId: host.id, status: "AUSSTEHEND" } }); expect(neu?.fingerprint).toBe(await hostKeyFingerprint(zielA)); expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).driftZustand).toBe("DRIFT"); // Ersetzen nur ausdruecklich. await expect(hostKeyEntscheiden({ hostSchluesselId: neu!.id, vertrauen: true }, AKTEUR)).rejects.toThrow( /ausdrücklich/, ); await hostKeyEntscheiden({ hostSchluesselId: neu!.id, vertrauen: true, ersetzen: true }, AKTEUR); const wieder = await hostVerbinden(host, passwort()); wieder.end(); expect( await prisma.abweichung.count({ where: { hostId: host.id, art: "HOSTKEY_GEAENDERT", behobenAm: null }, }), ).toBe(0); const aktionen = (await prisma.protokoll.findMany()).map((e) => e.aktion); expect(aktionen).toEqual( expect.arrayContaining([ "hostkey.abgerufen", "hostkey.bestaetigt", "hostkey.abweichung", "hostkey.ersetzt", ]), ); }); it("meldet einen nicht erreichbaren Host verstaendlich", async () => { const host = await prisma.host.create({ data: { name: "weg", adresse: "127.0.0.1", port: 1 } }); const vorgang = await abrufen(host.id); expect(vorgang.status).toBe("FEHLGESCHLAGEN"); expect(vorgang.fehler).toMatch(/lehnt die Verbindung ab/); }); });