Files
florian.wildeandClaude Opus 5.5 353b751df2 feat(ssh): strikte Host-Key-Pruefung und TOFU im Worker (#009)
Jede Verbindung prueft den Host-Key gegen die vertrauten Keys des
Hosts und bietet nur deren Algorithmen an - sonst koennte der Server
einen Key eines anderen Typs zeigen, den nie jemand gesehen hat. Ein
abweichender Key bricht vor dem Senden von Zugangsdaten ab, wird als
ausstehend festgehalten und oeffnet eine Abweichung.

Abrufen bricht die Verbindung im hostVerifier ab: der Mensch
entscheidet, nie der Code. Einen vertrauten Key zu ersetzen verlangt
ein ausdrueckliches ersetzen und eine frische Bestaetigung.

Integrationstest gegen ein echtes sshd inklusive Host-Key-Wechsel.

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

114 lines
4.6 KiB
TypeScript

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/);
});
});