feat(schluessel): erzeugen, importieren, exportieren (#012)

Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste
mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem
Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub.

Warum so:
- Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil
  nur er den Master-Key hat. Passphrase und importierter Private Key
  reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt
  im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach.
- Ein reiner Public Key ist kein Geheimnis und wird direkt im Web
  angelegt, mit verstaendlicher Meldung bei einem Duplikat.
- Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist,
  verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein
  struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf
  Schluessel ausserhalb des Workers ein festes select.
- Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das
  Loeschen des Private Keys; CHECKs fuer den Slug (landet in
  Dateinamen) und "geloescht nur ohne Chiffrat".
- Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:29:52 +02:00
co-authored by Claude Opus 5.5
parent f0833aa1db
commit 263143134f
33 changed files with 3322 additions and 287 deletions
+137
View File
@@ -0,0 +1,137 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
erzeugenAnfordern,
paarImportAnfordern,
publicKeyImportieren,
schluesselBearbeiten,
} from "@/server/services/schluessel";
/**
* Aktionen fuer Schluessel (#012). Duenn: Recht pruefen, Felder lesen, Service rufen.
*
* Passphrase und Private Key verlassen diese Funktionen nur verschluesselt im Job (der
* Service verschluesselt sie) – nie in einer Antwort, einem Log oder dem Protokoll.
*/
export type SchluesselAntwort = FormularZustand & {
/** Vorgang im Worker (Erzeugen, Paar-Import) – die Oberflaeche verfolgt ihn. */
readonly vorgangId?: string;
/** Sofort angelegt (Public-Key-Import). */
readonly schluesselId?: string;
};
function metadaten(formular: FormData) {
return {
name: textfeld(formular, "name") ?? "",
slug: textfeld(formular, "slug"),
kommentar: textfeld(formular, "kommentar"),
tags: textfeld(formular, "tags") ?? "",
ablaufAm: textfeld(formular, "ablaufAm"),
personId: textfeld(formular, "personId"),
vorlageId: textfeld(formular, "vorlageId"),
};
}
/** Passphrase mit Wiederholung – die Wiederholung prueft der Server, nicht nur das Formular. */
function passphraseLesen(formular: FormData): { passphrase?: string; fehler?: SchluesselAntwort } {
const passphrase = textfeld(formular, "passphrase");
const wiederholung = textfeld(formular, "passphraseWiederholung");
if (passphrase && passphrase !== wiederholung) {
return {
fehler: {
fehler: "Die Passphrasen stimmen nicht überein.",
feldfehler: { passphraseWiederholung: "Stimmt nicht mit der Passphrase überein." },
},
};
}
return passphrase ? { passphrase } : {};
}
export async function schluesselErzeugenAktion(
_vorher: SchluesselAntwort,
formular: FormData,
): Promise<SchluesselAntwort> {
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
const { passphrase, fehler } = passphraseLesen(formular);
if (fehler) return fehler;
const vorgangId = await erzeugenAnfordern(
{ ...metadaten(formular), typ: textfeld(formular, "typ") as "ED25519" | "RSA4096" | undefined },
passphrase,
akteurVon(benutzer),
);
return { vorgangId };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function paarImportierenAktion(
_vorher: SchluesselAntwort,
formular: FormData,
): Promise<SchluesselAntwort> {
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
const vorgangId = await paarImportAnfordern(
metadaten(formular),
textfeld(formular, "privateKey"),
// Beim Import nur zur Pruefung – keine Wiederholung noetig, falsche merkt der Worker.
textfeld(formular, "passphrase"),
akteurVon(benutzer),
);
return { vorgangId };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function publicKeyImportierenAktion(
_vorher: SchluesselAntwort,
formular: FormData,
): Promise<SchluesselAntwort> {
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
const schluessel = await publicKeyImportieren(
{
...metadaten(formular),
kategorie: textfeld(formular, "kategorie") as "PERSON" | "DIENST" | undefined,
publicKey: textfeld(formular, "publicKey") ?? "",
},
akteurVon(benutzer),
);
revalidatePath("/schluessel");
return { schluesselId: schluessel.id };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function schluesselBearbeitenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
await schluesselBearbeiten(
id,
{
name: textfeld(formular, "name") ?? "",
tags: textfeld(formular, "tags") ?? "",
ablaufAm: textfeld(formular, "ablaufAm"),
personId: textfeld(formular, "personId"),
vorlageId: textfeld(formular, "vorlageId"),
status: textfeld(formular, "status") as "AKTIV" | "ABGELAUFEN" | undefined,
},
akteurVon(benutzer),
);
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/schluessel", "layout");
redirect(`/schluessel/${id}`);
}