Merge branch 'phase/009-host-key-pruefung'

This commit is contained in:
2026-10-12 09:51:59 +02:00
10 changed files with 800 additions and 3 deletions
+113
View File
@@ -0,0 +1,113 @@
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/);
});
});