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>
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user