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:
2026-10-12 09:37:09 +02:00
co-authored by Claude Opus 5.5
parent ad97c8e9f1
commit 353b751df2
10 changed files with 800 additions and 3 deletions
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { bestaetigungFrisch, rechtErforderlich, RechteFehler } from "@/server/auth/sitzungspflicht";
import { HostKeyEntscheidungFehler, hostKeyEntscheiden } from "@/server/services/hostkeys";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/** Aktionen rund um Host-Keys und Erreichbarkeit (#009). */
export type VorgangsAntwort = { readonly vorgangId?: string; readonly fehler?: string };
export type EntscheidungsAntwort = {
readonly fehler?: string;
readonly bestaetigen?: string;
readonly erledigt?: boolean;
};
const hostIdSchema = z.string().min(1).max(64);
async function einreihen(
art: "HOSTKEY_ABRUFEN" | "ERREICHBARKEIT",
hostId: string,
): Promise<VorgangsAntwort> {
try {
const { benutzer } = await rechtErforderlich("hostsVerwalten");
const id = hostIdSchema.parse(hostId);
return { vorgangId: await vorgangEinreihen({ art, angefordertVonId: benutzer.id, hostId: id }) };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
throw fehler;
}
}
export async function hostKeyAbrufenAktion(hostId: string): Promise<VorgangsAntwort> {
return einreihen("HOSTKEY_ABRUFEN", hostId);
}
export async function erreichbarkeitPruefenAktion(hostId: string): Promise<VorgangsAntwort> {
return einreihen("ERREICHBARKEIT", hostId);
}
const entscheidungSchema = z.object({
hostId: hostIdSchema,
hostSchluesselId: z.string().min(1).max(64),
vertrauen: z.boolean(),
ersetzen: z.boolean().default(false),
});
export async function hostKeyEntscheidenAktion(
eingabe: z.input<typeof entscheidungSchema>,
): Promise<EntscheidungsAntwort> {
try {
const { benutzer } = await rechtErforderlich("hostKeyEntscheiden");
const daten = entscheidungSchema.parse(eingabe);
// Einen vertrauten Key zu ersetzen ist der Moment eines moeglichen Angriffs – nur mit
// frischer Bestaetigung der eigenen Anmeldung.
if (daten.ersetzen && !bestaetigungFrisch(benutzer.bestaetigtAm)) {
return { bestaetigen: `/bestaetigen?ziel=${encodeURIComponent(`/hosts/${daten.hostId}`)}` };
}
await hostKeyEntscheiden(daten, { id: benutzer.id, name: benutzer.name });
revalidatePath(`/hosts/${daten.hostId}`);
return { erledigt: true };
} catch (fehler) {
if (fehler instanceof RechteFehler || fehler instanceof HostKeyEntscheidungFehler)
return { fehler: fehler.message };
throw fehler;
}
}
+90
View File
@@ -0,0 +1,90 @@
import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll";
/**
* Entscheidung eines Menschen ueber einen Host-Key (#009, TOFU).
*
* Einen vertrauten Key zu ersetzen ist der Moment, in dem ein Angreifer mit einer
* abgefangenen Verbindung gewinnen wuerde. Deshalb verlangt das ein ausdrueckliches
* `ersetzen` (die Oberflaeche fragt nach und verlangt eine frische Bestaetigung).
*/
export class HostKeyEntscheidungFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "HostKeyEntscheidungFehler";
}
}
export type Entscheidung = {
readonly hostSchluesselId: string;
readonly vertrauen: boolean;
readonly ersetzen?: boolean;
};
export async function hostKeyEntscheiden(
eingabe: Entscheidung,
akteur: { readonly id: string; readonly name: string },
): Promise<void> {
await prisma.$transaction(async (tx) => {
const key = await tx.hostSchluessel.findUnique({
where: { id: eingabe.hostSchluesselId },
include: { host: true },
});
if (!key) throw new HostKeyEntscheidungFehler("Diesen Host-Key gibt es nicht.");
if (key.status !== "AUSSTEHEND") {
throw new HostKeyEntscheidungFehler("Über diesen Host-Key wurde bereits entschieden.");
}
if (!eingabe.vertrauen) {
await tx.hostSchluessel.update({
where: { id: key.id },
data: { status: "ABGELEHNT", entschiedenVon: akteur.name, entschiedenAm: new Date() },
});
await protokollieren(
{
akteur,
aktion: "hostkey.abgelehnt",
ergebnis: "ERFOLG",
hostName: key.host.name,
fingerprint: key.fingerprint,
},
tx,
);
return;
}
const bisher = await tx.hostSchluessel.findFirst({
where: { hostId: key.hostId, algorithmus: key.algorithmus, status: "VERTRAUT" },
});
if (bisher && !eingabe.ersetzen) {
throw new HostKeyEntscheidungFehler(
`Für ${key.algorithmus} ist bereits ein anderer Host-Key vertraut. Ersetzen muss ausdrücklich bestätigt werden.`,
);
}
if (bisher) {
await tx.hostSchluessel.update({ where: { id: bisher.id }, data: { status: "ERSETZT" } });
}
await tx.hostSchluessel.update({
where: { id: key.id },
data: { status: "VERTRAUT", entschiedenVon: akteur.name, entschiedenAm: new Date() },
});
// Eine offene Abweichung zu genau diesem Key ist damit erledigt.
await tx.abweichung.updateMany({
where: { hostId: key.hostId, art: "HOSTKEY_GEAENDERT", fingerprint: key.fingerprint, behobenAm: null },
data: { behobenAm: new Date() },
});
await protokollieren(
{
akteur,
aktion: bisher ? "hostkey.ersetzt" : "hostkey.bestaetigt",
ergebnis: "ERFOLG",
hostName: key.host.name,
fingerprint: key.fingerprint,
details: bisher
? { bisher: bisher.fingerprint, algorithmus: key.algorithmus }
: { algorithmus: key.algorithmus },
},
tx,
);
});
}