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,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { vorgangLesen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Status eines Vorgangs fuer die Fortschrittsanzeige (#009). Liefert nur, was
|
||||
* `vorgangLesen` freigibt – keine Job-Interna, keine Geheimnisse.
|
||||
*/
|
||||
export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await rechtErforderlich("lesen");
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||
throw fehler;
|
||||
}
|
||||
const vorgang = await vorgangLesen((await params).id);
|
||||
if (!vorgang) return NextResponse.json({ fehler: "Unbekannter Vorgang." }, { status: 404 });
|
||||
return NextResponse.json(vorgang, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState, useTransition, type ReactNode } from "react";
|
||||
import { Button, type ButtonVariante } from "@/components/ui/Button";
|
||||
|
||||
/**
|
||||
* Startet einen Vorgang im Worker und zeigt seinen Fortschritt (#009). Fragt den Status
|
||||
* jede Sekunde ab und laedt nach dem Ende die Seite neu, damit Server Components die neuen
|
||||
* Daten zeigen.
|
||||
*/
|
||||
type Antwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||
type Status = { status: string; fehler: string | null };
|
||||
|
||||
const LAUFEND = new Set(["WARTEND", "LAEUFT"]);
|
||||
|
||||
export function VorgangsKnopf({
|
||||
aktion,
|
||||
beschriftung,
|
||||
variante = "sekundaer",
|
||||
icon,
|
||||
klein,
|
||||
}: {
|
||||
aktion: () => Promise<Antwort>;
|
||||
beschriftung: string;
|
||||
variante?: ButtonVariante;
|
||||
icon?: ReactNode;
|
||||
klein?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [vorgangId, setVorgangId] = useState<string>();
|
||||
const [status, setStatus] = useState<Status>();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [startet, starten] = useTransition();
|
||||
const abbrechen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vorgangId) return;
|
||||
abbrechen.current = false;
|
||||
const abfragen = async () => {
|
||||
while (!abbrechen.current) {
|
||||
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||
if (!antwort.ok) {
|
||||
setFehler("Status des Vorgangs nicht abrufbar.");
|
||||
return;
|
||||
}
|
||||
const neu = (await antwort.json()) as Status;
|
||||
setStatus(neu);
|
||||
if (!LAUFEND.has(neu.status)) {
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
await new Promise((weiter) => setTimeout(weiter, 1000));
|
||||
}
|
||||
};
|
||||
void abfragen();
|
||||
return () => {
|
||||
abbrechen.current = true;
|
||||
};
|
||||
}, [vorgangId, router]);
|
||||
|
||||
const laeuft =
|
||||
startet || (status !== undefined && LAUFEND.has(status.status)) || (vorgangId !== undefined && !status);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button
|
||||
variante={variante}
|
||||
icon={icon}
|
||||
klein={klein}
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
setStatus(undefined);
|
||||
const antwort = await aktion();
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else setVorgangId(antwort.vorgangId);
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuft ? "Läuft …" : beschriftung}
|
||||
</Button>
|
||||
<p role="status" className="text-[13px]">
|
||||
{fehler && <span className="text-danger">{fehler}</span>}
|
||||
{!fehler && status?.status === "FEHLGESCHLAGEN" && (
|
||||
<span className="text-danger">{status.fehler}</span>
|
||||
)}
|
||||
{!fehler && status?.status === "ERLEDIGT" && <span className="text-ok">Erledigt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||
import { hostKeyFesthalten, hostVerbinden } from "@/worker/ssh/host";
|
||||
import { befehlAusfuehren, hostKeyAbrufen } from "@/worker/ssh/verbindung";
|
||||
|
||||
/** Vorgaenge HOSTKEY_ABRUFEN und ERREICHBARKEIT (#009). */
|
||||
|
||||
export async function hostLaden(hostId: string | null) {
|
||||
if (!hostId) throw new VorgangsFehler("Der Vorgang nennt keinen Host.");
|
||||
const host = await prisma.host.findUnique({ where: { id: hostId } });
|
||||
if (!host || host.archiviertAm) throw new VorgangsFehler("Der Host existiert nicht mehr.");
|
||||
return host;
|
||||
}
|
||||
|
||||
export const hostKeyAbrufenHandler: Handler = async ({ vorgang }) => {
|
||||
const host = await hostLaden(vorgang.hostId);
|
||||
const gesehen = await hostKeyAbrufen(host);
|
||||
const ergebnis = await hostKeyFesthalten(host.id, gesehen);
|
||||
await protokollieren({
|
||||
akteur: "System",
|
||||
aktion: "hostkey.abgerufen",
|
||||
ergebnis: ergebnis === "geaendert" ? "FEHLER" : "ERFOLG",
|
||||
hostName: host.name,
|
||||
fingerprint: gesehen.fingerprint,
|
||||
details: { algorithmus: gesehen.algorithmus, ergebnis },
|
||||
});
|
||||
return { ergebnis: { ...gesehen, ergebnis } };
|
||||
};
|
||||
|
||||
export const erreichbarkeitHandler: Handler = async ({ vorgang }) => {
|
||||
const host = await hostLaden(vorgang.hostId);
|
||||
try {
|
||||
const verbindung = await hostVerbinden(host);
|
||||
try {
|
||||
const antwort = await befehlAusfuehren(verbindung, "id -un");
|
||||
await prisma.host.update({
|
||||
where: { id: host.id },
|
||||
data: { erreichbar: true, zuletztGeprueftAm: new Date() },
|
||||
});
|
||||
return { ergebnis: { erreichbar: true, benutzer: antwort.stdout.trim() } };
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
} catch (fehler) {
|
||||
await prisma.host.update({
|
||||
where: { id: host.id },
|
||||
data: { erreichbar: false, zuletztGeprueftAm: new Date() },
|
||||
});
|
||||
throw fehler;
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||
|
||||
/**
|
||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Host-Key (#009), Bootstrap
|
||||
* (#010), Ausrollen (#016), Abgleich (#021) …
|
||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Bootstrap (#010),
|
||||
* Ausrollen (#016), Abgleich (#021) …
|
||||
*/
|
||||
export const HANDLER: HandlerTabelle = {};
|
||||
export const HANDLER: HandlerTabelle = {
|
||||
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
||||
ERREICHBARKEIT: erreichbarkeitHandler,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { Host } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VorgangsFehler } from "@/worker/ausfuehren";
|
||||
import { privateKeyLaden } from "@/worker/geheimnisse";
|
||||
import {
|
||||
HostKeyGeaendertFehler,
|
||||
verbinden,
|
||||
type Anmeldung,
|
||||
type GesehenerHostKey,
|
||||
} from "@/worker/ssh/verbindung";
|
||||
|
||||
/**
|
||||
* Verbindungen zu verwalteten Hosts (#009): vertraute Host-Keys aus der Datenbank,
|
||||
* Deployment-Key und Anmeldebenutzer nach `anmeldeart`.
|
||||
*/
|
||||
|
||||
/** Dienstbenutzer auf den Hosts (Freigabe Frage 1). */
|
||||
export const DIENSTBENUTZER = "sshkv";
|
||||
|
||||
export function anmeldebenutzer(host: Pick<Host, "anmeldeart">): string {
|
||||
return host.anmeldeart === "ROOT" ? "root" : DIENSTBENUTZER;
|
||||
}
|
||||
|
||||
/** Host-eigener Key, sonst der einer Gruppe, sonst der Instanz-Standard. */
|
||||
export async function deploymentKeyIdFuer(
|
||||
host: Pick<Host, "id" | "deploymentSchluesselId">,
|
||||
): Promise<string> {
|
||||
if (host.deploymentSchluesselId) return host.deploymentSchluesselId;
|
||||
const gruppe = await prisma.hostgruppeMitglied.findFirst({
|
||||
where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } },
|
||||
select: { hostgruppe: { select: { deploymentSchluesselId: true } } },
|
||||
});
|
||||
if (gruppe?.hostgruppe.deploymentSchluesselId) return gruppe.hostgruppe.deploymentSchluesselId;
|
||||
const standard = await prisma.schluessel.findFirst({
|
||||
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!standard)
|
||||
throw new VorgangsFehler("Es gibt noch keinen Deployment-Key. Läuft der Worker vollständig?");
|
||||
return standard.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haelt einen gesehenen Host-Key fest. Ein abweichender Key wird nie vertraut, sondern als
|
||||
* AUSSTEHEND angelegt, und eine Abweichung HOSTKEY_GEAENDERT geht auf.
|
||||
*/
|
||||
export async function hostKeyFesthalten(
|
||||
hostId: string,
|
||||
gesehen: GesehenerHostKey,
|
||||
): Promise<"unveraendert" | "neu" | "geaendert"> {
|
||||
const vertraut = await prisma.hostSchluessel.findFirst({
|
||||
where: { hostId, algorithmus: gesehen.algorithmus, status: "VERTRAUT" },
|
||||
});
|
||||
if (vertraut?.fingerprint === gesehen.fingerprint) {
|
||||
await prisma.hostSchluessel.update({ where: { id: vertraut.id }, data: { zuletztGesehen: new Date() } });
|
||||
return "unveraendert";
|
||||
}
|
||||
await prisma.hostSchluessel.upsert({
|
||||
where: { hostId_fingerprint: { hostId, fingerprint: gesehen.fingerprint } },
|
||||
create: { hostId, ...gesehen, status: "AUSSTEHEND" },
|
||||
update: { zuletztGesehen: new Date() },
|
||||
});
|
||||
if (!vertraut) return "neu";
|
||||
|
||||
const offen = await prisma.abweichung.findFirst({
|
||||
where: { hostId, art: "HOSTKEY_GEAENDERT", behobenAm: null, fingerprint: gesehen.fingerprint },
|
||||
});
|
||||
if (!offen) {
|
||||
await prisma.abweichung.create({
|
||||
data: {
|
||||
hostId,
|
||||
art: "HOSTKEY_GEAENDERT",
|
||||
fingerprint: gesehen.fingerprint,
|
||||
details: { erwartet: vertraut.fingerprint, algorithmus: gesehen.algorithmus },
|
||||
},
|
||||
});
|
||||
await prisma.host.update({ where: { id: hostId }, data: { driftZustand: "DRIFT" } });
|
||||
}
|
||||
return "geaendert";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindet sich mit einem Host. Ein geaenderter Host-Key wird festgehalten und protokolliert,
|
||||
* dann weitergeworfen – der Vorgang scheitert.
|
||||
*/
|
||||
export async function hostVerbinden(host: Host, anmeldung?: Anmeldung): Promise<Client> {
|
||||
const vertraut = await prisma.hostSchluessel.findMany({
|
||||
where: { hostId: host.id, status: "VERTRAUT" },
|
||||
select: { algorithmus: true, fingerprint: true },
|
||||
});
|
||||
let zugang = anmeldung;
|
||||
let privateKey: Buffer | undefined;
|
||||
if (!zugang) {
|
||||
privateKey = await privateKeyLaden(await deploymentKeyIdFuer(host));
|
||||
zugang = { benutzer: anmeldebenutzer(host), privateKey };
|
||||
}
|
||||
try {
|
||||
return await verbinden(host, vertraut, zugang);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof HostKeyGeaendertFehler) {
|
||||
await hostKeyFesthalten(host.id, fehler.gesehen);
|
||||
await protokollieren({
|
||||
akteur: "System",
|
||||
aktion: "hostkey.abweichung",
|
||||
ergebnis: "VERWEIGERT",
|
||||
hostName: host.name,
|
||||
fingerprint: fehler.gesehen.fingerprint,
|
||||
details: { erwartet: fehler.erwartet },
|
||||
});
|
||||
}
|
||||
throw fehler;
|
||||
} finally {
|
||||
privateKey?.fill(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import type { Host, HostSchluessel } from "@/generated/prisma/client";
|
||||
import { fingerprintSha256 } from "@/lib/ssh/openssh";
|
||||
import { VorgangsFehler } from "@/worker/ausfuehren";
|
||||
|
||||
/**
|
||||
* SSH-Verbindungen des Workers (#009) – ausschliesslich mit strikter Host-Key-Pruefung.
|
||||
*
|
||||
* Es gibt genau zwei Arten, einen Host-Key zu behandeln:
|
||||
* - `abrufen`: Key nur ansehen und die Verbindung abbrechen, bevor irgendetwas
|
||||
* uebertragen wird (TOFU, der Mensch entscheidet danach).
|
||||
* - `pruefen`: nur ein vertrauter Key ist gut. Ein unbekannter oder geaenderter Key bricht
|
||||
* die Verbindung ab, bevor Zugangsdaten gesendet werden. Es gibt keinen Codepfad, der
|
||||
* einen Key automatisch annimmt (CLAUDE.md).
|
||||
*/
|
||||
|
||||
/** Wie lange ein Verbindungsaufbau dauern darf. */
|
||||
const VERBINDUNG_TIMEOUT_MS = 15_000;
|
||||
/** Wie lange ein einzelner Befehl laufen darf. */
|
||||
const BEFEHL_TIMEOUT_MS = 120_000;
|
||||
/** Mehr Ausgabe nehmen wir nicht an – authorized_keys-Dateien sind klein. */
|
||||
const AUSGABE_HOECHSTENS = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Bevorzugte Reihenfolge beim Abrufen. ed25519 zuerst: kurz, modern und auf jedem
|
||||
* aktuellen Debian vorhanden.
|
||||
*/
|
||||
const ALGORITHMEN_REIHENFOLGE = [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
] as const;
|
||||
|
||||
type SignaturAlgorithmus = (typeof ALGORITHMEN_REIHENFOLGE)[number];
|
||||
|
||||
/** Der Typ im Key-Blob (`ssh-rsa`) und die Signaturverfahren dazu (`rsa-sha2-*`). */
|
||||
export function signaturAlgorithmenFuer(keyTyp: string): SignaturAlgorithmus[] {
|
||||
if (keyTyp === "ssh-rsa") return ["rsa-sha2-512", "rsa-sha2-256"];
|
||||
return ALGORITHMEN_REIHENFOLGE.filter((algorithmus) => algorithmus === keyTyp);
|
||||
}
|
||||
|
||||
/** Liest den Typ (erstes SSH-String-Feld) aus einem Key-Blob. */
|
||||
export function keyTypAusBlob(blob: Buffer): string {
|
||||
if (blob.length < 4) return "";
|
||||
const laenge = blob.readUInt32BE(0);
|
||||
if (laenge <= 0 || laenge > 64 || blob.length < 4 + laenge) return "";
|
||||
return blob.subarray(4, 4 + laenge).toString("ascii");
|
||||
}
|
||||
|
||||
export type GesehenerHostKey = {
|
||||
readonly algorithmus: string;
|
||||
readonly publicKey: string;
|
||||
readonly fingerprint: string;
|
||||
};
|
||||
|
||||
export function hostKeyBeschreiben(blob: Buffer): GesehenerHostKey {
|
||||
return {
|
||||
algorithmus: keyTypAusBlob(blob),
|
||||
publicKey: blob.toString("base64"),
|
||||
fingerprint: fingerprintSha256(blob),
|
||||
};
|
||||
}
|
||||
|
||||
/** Der Host praesentierte einen anderen Key als den vertrauten. */
|
||||
export class HostKeyGeaendertFehler extends VorgangsFehler {
|
||||
constructor(
|
||||
readonly gesehen: GesehenerHostKey,
|
||||
readonly erwartet: string,
|
||||
) {
|
||||
super(
|
||||
`ACHTUNG: Der Host-Key hat sich geändert (erwartet ${erwartet}, gesehen ${gesehen.fingerprint}). ` +
|
||||
"Die Verbindung wurde abgebrochen. Erst prüfen, ob der Host neu installiert wurde – sonst könnte " +
|
||||
"jemand die Verbindung abfangen.",
|
||||
);
|
||||
this.name = "HostKeyGeaendertFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export class HostKeyUnbekanntFehler extends VorgangsFehler {
|
||||
constructor() {
|
||||
super(
|
||||
"Für diesen Host ist noch kein Host-Key bestätigt. Bitte zuerst den Host-Key abrufen und bestätigen.",
|
||||
);
|
||||
this.name = "HostKeyUnbekanntFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export type Ziel = Pick<Host, "adresse" | "port">;
|
||||
|
||||
export type Anmeldung =
|
||||
| { readonly benutzer: string; readonly privateKey: Buffer }
|
||||
| { readonly benutzer: string; readonly passwort: string };
|
||||
|
||||
/**
|
||||
* Holt den Host-Key, ohne sich anzumelden. Die Verbindung wird im hostVerifier abgebrochen –
|
||||
* vor dem Austausch von Zugangsdaten.
|
||||
*/
|
||||
export function hostKeyAbrufen(ziel: Ziel): Promise<GesehenerHostKey> {
|
||||
return new Promise((erfuellen, ablehnen) => {
|
||||
const client = new Client();
|
||||
let gesehen: GesehenerHostKey | undefined;
|
||||
const fertig = (fehler?: unknown) => {
|
||||
client.end();
|
||||
if (gesehen) erfuellen(gesehen);
|
||||
else ablehnen(fehler instanceof VorgangsFehler ? fehler : verbindungsFehler(fehler, ziel));
|
||||
};
|
||||
client.on("error", fertig);
|
||||
client.on("close", () => fertig());
|
||||
client.connect({
|
||||
host: ziel.adresse,
|
||||
port: ziel.port,
|
||||
username: "sshkv-hostkey-abruf",
|
||||
readyTimeout: VERBINDUNG_TIMEOUT_MS,
|
||||
algorithms: { serverHostKey: [...ALGORITHMEN_REIHENFOLGE] },
|
||||
hostVerifier: (blob: Buffer) => {
|
||||
gesehen = hostKeyBeschreiben(blob);
|
||||
// false bricht ab; mehr als den Key wollten wir nicht.
|
||||
return false;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut eine Verbindung mit strikter Pruefung gegen die vertrauten Host-Keys auf. Nur die
|
||||
* Algorithmen der vertrauten Keys werden angeboten – sonst koennte der Server einen Key eines
|
||||
* anderen Typs zeigen, den wir nie gesehen haben.
|
||||
*/
|
||||
export function verbinden(
|
||||
ziel: Ziel,
|
||||
vertraut: readonly Pick<HostSchluessel, "algorithmus" | "fingerprint">[],
|
||||
anmeldung: Anmeldung,
|
||||
): Promise<Client> {
|
||||
if (vertraut.length === 0) return Promise.reject(new HostKeyUnbekanntFehler());
|
||||
const angeboten = [...new Set(vertraut.flatMap((key) => signaturAlgorithmenFuer(key.algorithmus)))];
|
||||
|
||||
return new Promise((erfuellen, ablehnen) => {
|
||||
const client = new Client();
|
||||
let abweichung: HostKeyGeaendertFehler | undefined;
|
||||
client.on("ready", () => erfuellen(client));
|
||||
client.on("error", (fehler) => {
|
||||
client.end();
|
||||
ablehnen(abweichung ?? verbindungsFehler(fehler, ziel));
|
||||
});
|
||||
|
||||
const konfiguration: ConnectConfig = {
|
||||
host: ziel.adresse,
|
||||
port: ziel.port,
|
||||
username: anmeldung.benutzer,
|
||||
readyTimeout: VERBINDUNG_TIMEOUT_MS,
|
||||
algorithms: { serverHostKey: angeboten },
|
||||
hostVerifier: (blob: Buffer) => {
|
||||
const gesehen = hostKeyBeschreiben(blob);
|
||||
const passend = vertraut.find((key) => key.algorithmus === gesehen.algorithmus);
|
||||
if (passend && passend.fingerprint === gesehen.fingerprint) return true;
|
||||
abweichung = new HostKeyGeaendertFehler(gesehen, passend?.fingerprint ?? vertraut[0]!.fingerprint);
|
||||
return false;
|
||||
},
|
||||
// Nichts ausprobieren, was nicht ausdruecklich gewollt ist (kein Agent, keine Tastatur).
|
||||
tryKeyboard: false,
|
||||
...("privateKey" in anmeldung
|
||||
? { privateKey: anmeldung.privateKey }
|
||||
: { password: anmeldung.passwort }),
|
||||
};
|
||||
client.connect(konfiguration);
|
||||
});
|
||||
}
|
||||
|
||||
function verbindungsFehler(fehler: unknown, ziel: Ziel): VorgangsFehler {
|
||||
const meldung = fehler instanceof Error ? fehler.message : String(fehler);
|
||||
const code = (fehler as { code?: string } | undefined)?.code;
|
||||
if (code === "ECONNREFUSED")
|
||||
return new VorgangsFehler(`${ziel.adresse}:${ziel.port} lehnt die Verbindung ab.`);
|
||||
if (code === "ENOTFOUND" || code === "EAI_AGAIN")
|
||||
return new VorgangsFehler(`${ziel.adresse} ist nicht auflösbar.`);
|
||||
if (code === "EHOSTUNREACH" || code === "ENETUNREACH")
|
||||
return new VorgangsFehler(`${ziel.adresse} ist nicht erreichbar.`);
|
||||
if (/Timed out/i.test(meldung))
|
||||
return new VorgangsFehler(`Zeitüberschreitung beim Verbinden mit ${ziel.adresse}.`);
|
||||
if (/All configured authentication methods failed/i.test(meldung))
|
||||
return new VorgangsFehler("Die Anmeldung wurde abgelehnt (Schlüssel bzw. Passwort nicht akzeptiert).");
|
||||
if (/no matching|handshake failed/i.test(meldung))
|
||||
return new VorgangsFehler(
|
||||
"Kein gemeinsames Verfahren für den Host-Key – der Host bietet den vertrauten Key-Typ nicht an.",
|
||||
);
|
||||
return new VorgangsFehler(`SSH-Verbindung zu ${ziel.adresse} fehlgeschlagen.`, { cause: fehler });
|
||||
}
|
||||
|
||||
export type Befehlsergebnis = { readonly stdout: string; readonly stderr: string; readonly code: number };
|
||||
|
||||
/**
|
||||
* Fuehrt einen Befehl aus. `stdin` wird geschrieben und geschlossen – so reisen Dateiinhalte
|
||||
* (und Private Keys, #017) nur durch die SSH-Verbindung und nie als Datei auf dem sshkv-Server.
|
||||
*/
|
||||
export function befehlAusfuehren(
|
||||
client: Client,
|
||||
befehl: string,
|
||||
stdin?: string | Buffer,
|
||||
): Promise<Befehlsergebnis> {
|
||||
return new Promise((erfuellen, ablehnen) => {
|
||||
client.exec(befehl, (fehler, strom) => {
|
||||
if (fehler)
|
||||
return ablehnen(new VorgangsFehler("Befehl konnte nicht gestartet werden.", { cause: fehler }));
|
||||
const ausgabe: Buffer[] = [];
|
||||
const fehlerAusgabe: Buffer[] = [];
|
||||
let laenge = 0;
|
||||
const zeitgeber = setTimeout(() => {
|
||||
strom.close();
|
||||
ablehnen(new VorgangsFehler("Zeitüberschreitung bei einem Befehl auf dem Host."));
|
||||
}, BEFEHL_TIMEOUT_MS);
|
||||
|
||||
const sammeln = (ziel: Buffer[]) => (daten: Buffer) => {
|
||||
laenge += daten.length;
|
||||
if (laenge > AUSGABE_HOECHSTENS) {
|
||||
strom.close();
|
||||
return;
|
||||
}
|
||||
ziel.push(daten);
|
||||
};
|
||||
strom.on("data", sammeln(ausgabe));
|
||||
strom.stderr.on("data", sammeln(fehlerAusgabe));
|
||||
strom.on("close", (code: number | null) => {
|
||||
clearTimeout(zeitgeber);
|
||||
if (laenge > AUSGABE_HOECHSTENS) {
|
||||
return ablehnen(new VorgangsFehler("Der Host lieferte mehr Ausgabe als erwartet."));
|
||||
}
|
||||
erfuellen({
|
||||
stdout: Buffer.concat(ausgabe).toString("utf8"),
|
||||
stderr: Buffer.concat(fehlerAusgabe).toString("utf8"),
|
||||
code: code ?? -1,
|
||||
});
|
||||
});
|
||||
if (stdin !== undefined) strom.end(stdin);
|
||||
else strom.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/integration/**/*.test.ts"],
|
||||
environment: "node",
|
||||
globalSetup: ["tests/global-setup.ts"],
|
||||
setupFiles: ["tests/setup-env.ts"],
|
||||
fileParallelism: false,
|
||||
testTimeout: 60_000,
|
||||
|
||||
Reference in New Issue
Block a user