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
+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,
);
});
}