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
+66
View File
@@ -0,0 +1,66 @@
import { Badge } from "@/components/ui/Badge";
import { datum } from "@/lib/format";
import {
ablaufZustand,
KATEGORIE_TEXT,
STATUS_TEXT,
typAnzeige,
type Kategorie,
type Status,
} from "@/lib/schluessel";
/** Kleine Anzeigebausteine fuer Schluessel (#012) – reine Darstellung. */
export function TypBadge({ typ, publicKey }: { typ: "ED25519" | "RSA4096" | "ANDERER"; publicKey: string }) {
return <Badge mono>{typAnzeige(typ, publicKey)}</Badge>;
}
export function KategorieBadge({ kategorie }: { kategorie: Kategorie }) {
return <Badge>{KATEGORIE_TEXT[kategorie]}</Badge>;
}
/** AKTIV zeigen wir nicht eigens – nur Abweichungen vom Normalfall. */
export function StatusBadge({ status }: { status: Status }) {
if (status === "AKTIV") return null;
return <Badge variante={status === "ROTIERT" ? "neutral" : "gefahr"}>{STATUS_TEXT[status]}</Badge>;
}
/**
* Ablauf: in Messing, sobald er in hoechstens 30 Tagen ansteht (Design #002), in Koralle,
* wenn er vorbei ist. `lang` zeigt zusaetzlich das Datum (Detailseite).
*/
export function AblaufBadge({
ablaufAm,
jetzt,
lang = false,
}: {
ablaufAm: Date | null;
jetzt: Date;
lang?: boolean;
}) {
const zustand = ablaufZustand(ablaufAm, jetzt);
if (zustand.art === "keiner" || !ablaufAm) return null;
const zeit = <time dateTime={ablaufAm.toISOString()}>{datum(ablaufAm)}</time>;
if (zustand.art === "abgelaufen") {
return <Badge variante="gefahr">{lang ? <span>abgelaufen am {zeit}</span> : "abgelaufen"}</Badge>;
}
const in_ = zustand.tage === 0 ? "heute" : zustand.tage === 1 ? "morgen" : `in ${zustand.tage} Tagen`;
if (zustand.art === "bald") {
return (
<Badge variante="messing">
{lang ? (
<span>
läuft ab am {zeit} · {in_}
</span>
) : (
<span>läuft ab {in_}</span>
)}
</Badge>
);
}
return lang ? (
<Badge variante="neutral" className="text-muted">
<span>läuft ab am {zeit}</span>
</Badge>
) : null;
}
@@ -0,0 +1,99 @@
"use client";
import { Auswahlliste, Button, Feld, Formularfehler, Karte } from "@/components/ui";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { schluesselBearbeitenAktion } from "@/server/actions/schluessel";
import type { Auswahl } from "./SchluesselNeu";
export type SchluesselFormularWerte = {
id: string;
name: string;
tags: string[];
/** JJJJ-MM-TT oder leer */
ablaufAm: string;
personId: string;
vorlageId: string;
status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
statusAenderbar: boolean;
};
/**
* Metadaten bearbeiten (#012). Slug, Typ und Kommentar bleiben – sie stehen schon in
* Dateinamen und authorized_keys-Zeilen auf den Hosts.
*/
export function SchluesselBearbeiten({
werte,
personen,
vorlagen,
}: {
werte: SchluesselFormularWerte;
personen: Auswahl[];
vorlagen: Auswahl[];
}) {
const { zustand, absenden, laeuft } = useFormularAktion(schluesselBearbeitenAktion);
const f = zustand.feldfehler ?? {};
return (
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
<input type="hidden" name="id" value={werte.id} />
<Formularfehler text={zustand.fehler} />
<Karte titel="Metadaten">
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Zweck"
name="name"
defaultValue={werte.name}
required
maxLength={120}
fehler={f["name"]}
className="sm:col-span-2"
/>
<Auswahlliste beschriftung="Person" name="personId" defaultValue={werte.personId}>
<option value="">keine</option>
{personen.map((p) => (
<option key={p.id} value={p.id}>
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Vorlage" name="vorlageId" defaultValue={werte.vorlageId}>
<option value="">keine</option>
{vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</Auswahlliste>
<Feld
beschriftung="Ablaufdatum"
name="ablaufAm"
type="date"
defaultValue={werte.ablaufAm}
fehler={f["ablaufAm"]}
/>
<Feld
beschriftung="Tags"
name="tags"
mono
defaultValue={werte.tags.join(", ")}
autoComplete="off"
spellCheck={false}
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
fehler={f["tags"]}
/>
{werte.statusAenderbar && (
<Auswahlliste beschriftung="Status" name="status" defaultValue={werte.status}>
<option value="AKTIV">aktiv</option>
<option value="ABGELAUFEN">abgelaufen</option>
</Auswahlliste>
)}
</div>
</Karte>
<div className="flex flex-wrap gap-2.5">
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : "Änderungen speichern"}
</Button>
<Button href={`/schluessel/${werte.id}`}>Abbrechen</Button>
</div>
</form>
);
}
+400
View File
@@ -0,0 +1,400 @@
"use client";
import { Download, KeyRound, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition, type FormEvent, type ReactNode } from "react";
import { clsx } from "clsx";
import {
Ankreuzfeld,
Auswahlliste,
Button,
Feld,
Feldgruppe,
Formularfehler,
Karte,
Textbereich,
} from "@/components/ui";
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
import {
paarImportierenAktion,
publicKeyImportierenAktion,
schluesselErzeugenAktion,
type SchluesselAntwort,
} from "@/server/actions/schluessel";
/**
* Neuer Schluessel (#012): erzeugen oder importieren (nur Public Key oder Paar).
*
* Erzeugen und Paar-Import laufen als Vorgang im Worker; das Formular verfolgt ihn und
* springt nach Erfolg zum Schluessel. Passphrase und Private Key stehen nur so lange im
* Formular, bis sie abgeschickt sind.
*/
type Modus = "erzeugen" | "public" | "paar";
export type Auswahl = { id: string; name: string; zusatz?: string | null };
const HINWEIS_PASSPHRASE = "Für automatische Anmeldungen (Host-zu-Host) ungeeignet.";
function Umschalter({
wert,
setzen,
optionen,
beschriftung,
}: {
wert: string;
setzen: (wert: string) => void;
optionen: { wert: string; text: string; icon?: ReactNode }[];
beschriftung: string;
}) {
return (
<div
role="radiogroup"
aria-label={beschriftung}
className="border-border-strong bg-sidebar rounded-box inline-flex w-full max-w-md gap-1 border p-1 sm:w-auto sm:self-start"
>
{optionen.map((o) => (
<button
key={o.wert}
type="button"
role="radio"
aria-checked={wert === o.wert}
onClick={() => setzen(o.wert)}
className={clsx(
"rounded-feld inline-flex min-h-10 flex-1 items-center justify-center gap-2 px-4 text-[14px] font-medium transition-colors sm:flex-none",
wert === o.wert ? "bg-raised text-text" : "text-muted hover:text-text",
)}
>
{o.icon}
{o.text}
</button>
))}
</div>
);
}
export function SchluesselNeu({
personen,
vorlagen,
vorgabePersonId,
}: {
personen: Auswahl[];
vorlagen: Auswahl[];
vorgabePersonId?: string;
}) {
const router = useRouter();
const [art, setArt] = useState<"erzeugen" | "importieren">("erzeugen");
const [importArt, setImportArt] = useState<"public" | "paar">("public");
const modus: Modus = art === "erzeugen" ? "erzeugen" : importArt;
const [antwort, setAntwort] = useState<SchluesselAntwort>({});
const [sendet, senden] = useTransition();
const { stand, abfrageFehler } = useVorgang(antwort.vorgangId);
// Geheimnisse kontrolliert, damit sie nach dem Absenden sofort aus dem Formular verschwinden.
const [privateKey, setPrivateKey] = useState("");
const [passphrase, setPassphrase] = useState("");
const [wiederholung, setWiederholung] = useState("");
const ergebnisId =
stand?.status === "ERLEDIGT" && stand.ergebnis && typeof stand.ergebnis === "object"
? (stand.ergebnis as { schluesselId?: unknown }).schluesselId
: undefined;
useEffect(() => {
if (typeof ergebnisId === "string") router.push(`/schluessel/${ergebnisId}`);
}, [ergebnisId, router]);
const vorgangLaeuft = antwort.vorgangId !== undefined && istLaufend(stand) && !abfrageFehler;
const beschaeftigt = sendet || vorgangLaeuft || typeof ergebnisId === "string";
const f = antwort.feldfehler ?? {};
const fehler =
antwort.fehler ??
abfrageFehler ??
(stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined);
function absenden(ereignis: FormEvent<HTMLFormElement>) {
ereignis.preventDefault();
const daten = new FormData(ereignis.currentTarget);
const aktion =
modus === "erzeugen"
? schluesselErzeugenAktion
: modus === "paar"
? paarImportierenAktion
: publicKeyImportierenAktion;
senden(async () => {
setAntwort({});
const neu = await aktion({}, daten);
if (neu.vorgangId || neu.schluesselId) {
setPrivateKey("");
setPassphrase("");
setWiederholung("");
}
if (neu.schluesselId) router.push(`/schluessel/${neu.schluesselId}`);
setAntwort(neu);
});
}
const knopfText = beschaeftigt
? modus === "erzeugen"
? "Erzeuge …"
: "Importiere …"
: modus === "erzeugen"
? "Schlüssel erzeugen"
: "Schlüssel importieren";
return (
<form onSubmit={absenden} noValidate className="flex flex-col gap-6" autoComplete="off">
<div className="flex flex-col gap-3">
<Umschalter
beschriftung="Art"
wert={art}
setzen={(w) => {
setArt(w as typeof art);
setAntwort({});
}}
optionen={[
{ wert: "erzeugen", text: "Erzeugen", icon: <Sparkles className="size-4" aria-hidden /> },
{ wert: "importieren", text: "Importieren", icon: <Download className="size-4" aria-hidden /> },
]}
/>
{art === "importieren" && (
<Umschalter
beschriftung="Was importieren?"
wert={importArt}
setzen={(w) => {
setImportArt(w as typeof importArt);
setAntwort({});
}}
optionen={[
{ wert: "public", text: "Nur Public Key" },
{ wert: "paar", text: "Schlüsselpaar" },
]}
/>
)}
</div>
<Formularfehler text={fehler} />
<Karte titel="Zweck">
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Zweck"
name="name"
required
maxLength={120}
placeholder="Backup srv-backup01 → srv-pve01"
fehler={f["name"]}
className="sm:col-span-2"
/>
<Feld
beschriftung="Kurzname (optional)"
name="slug"
mono
maxLength={64}
spellCheck={false}
placeholder="wird aus dem Zweck gebildet"
hinweis="Für Dateinamen und Kommentare: a–z, 0–9, Bindestrich."
fehler={f["slug"]}
/>
<Feld
beschriftung="Kommentar (optional)"
name="kommentar"
mono
maxLength={200}
spellCheck={false}
placeholder={modus === "erzeugen" ? "sshkv:<kurzname>" : "aus dem Schlüssel"}
hinweis="Steht hinter dem Public Key in authorized_keys."
fehler={f["kommentar"]}
/>
</div>
</Karte>
{modus === "erzeugen" && (
<Karte
titel="Schlüssel"
untertitel="sshkv erzeugt das Paar im Worker und speichert den Private Key verschlüsselt"
>
<div className="flex flex-col gap-5">
<Feldgruppe legende="Typ" fehler={f["typ"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="typ"
value="ED25519"
defaultChecked
beschriftung={<span className="font-mono">ed25519</span>}
erklaerung="Empfohlen – kurz, schnell, sicher."
/>
<Ankreuzfeld
type="radio"
name="typ"
value="RSA4096"
beschriftung={<span className="font-mono">rsa-4096</span>}
erklaerung="Nur für alte Gegenstellen ohne ed25519."
/>
</div>
</Feldgruppe>
<p className="text-muted text-[13.5px]">
Kategorie: <span className="text-text">Dienst</span> – Personen hinterlegen ihre Public Keys
selbst unter „Meine Schlüssel“, deren Private Keys speichert sshkv nie.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Passphrase (optional)"
name="passphrase"
type="password"
autoComplete="new-password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
hinweis={`Wird nicht gespeichert. ${HINWEIS_PASSPHRASE}`}
fehler={f["passphrase"]}
/>
<Feld
beschriftung="Passphrase wiederholen"
name="passphraseWiederholung"
type="password"
autoComplete="new-password"
value={wiederholung}
onChange={(e) => setWiederholung(e.target.value)}
disabled={!passphrase}
fehler={f["passphraseWiederholung"]}
/>
</div>
</div>
</Karte>
)}
{modus === "public" && (
<Karte titel="Public Key" untertitel="Eine Zeile im OpenSSH-Format, z. B. aus id_ed25519.pub">
<div className="flex flex-col gap-5">
<Textbereich
beschriftung="Public Key"
name="publicKey"
required
rows={4}
spellCheck={false}
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
fehler={f["publicKey"]}
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
/>
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="kategorie"
value="DIENST"
defaultChecked={!vorgabePersonId}
beschriftung="Dienst"
erklaerung="Ein Programm oder Host meldet sich damit an."
/>
<Ankreuzfeld
type="radio"
name="kategorie"
value="PERSON"
defaultChecked={Boolean(vorgabePersonId)}
beschriftung="Person"
erklaerung="Ein Mensch meldet sich damit an."
/>
</div>
</Feldgruppe>
</div>
</Karte>
)}
{modus === "paar" && (
<Karte
titel="Schlüsselpaar"
untertitel="Private Key im OpenSSH-Format – sshkv prüft ihn im Worker und speichert ihn verschlüsselt"
>
<div className="flex flex-col gap-5">
<Textbereich
beschriftung="Private Key"
name="privateKey"
required
rows={7}
spellCheck={false}
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder={"-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----"}
fehler={f["privateKey"]}
className="[&_textarea]:font-mono [&_textarea]:text-[12.5px]"
/>
<Feld
beschriftung="Passphrase (nur falls der Key eine hat)"
name="passphrase"
type="password"
autoComplete="off"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
hinweis={`Nur zur Prüfung, wird verworfen. Der Key bleibt passphrasengeschützt gespeichert – ${HINWEIS_PASSPHRASE.toLowerCase()}`}
fehler={f["passphrase"]}
className="sm:max-w-md"
/>
<p className="text-muted text-[13.5px]">
Kategorie: <span className="text-text">Dienst</span> – Private Keys von Personen speichert sshkv
nie.
</p>
</div>
</Karte>
)}
<Karte titel="Einordnung">
<div className="grid gap-4 sm:grid-cols-2">
<Auswahlliste
beschriftung={modus === "public" ? "Person (optional)" : "Verantwortliche Person (optional)"}
name="personId"
defaultValue={vorgabePersonId ?? ""}
>
<option value="">keine</option>
{personen.map((p) => (
<option key={p.id} value={p.id}>
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Vorlage (optional)" name="vorlageId" defaultValue="">
<option value="">keine</option>
{vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.zusatz ? `${v.name} (${v.zusatz})` : v.name}
</option>
))}
</Auswahlliste>
<Feld
beschriftung="Ablaufdatum (optional)"
name="ablaufAm"
type="date"
hinweis="Ohne Angabe gilt die Laufzeit der Vorlage, sonst keine."
fehler={f["ablaufAm"]}
/>
<Feld
beschriftung="Tags"
name="tags"
mono
autoComplete="off"
spellCheck={false}
placeholder="backup, pve"
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
fehler={f["tags"]}
/>
</div>
</Karte>
<div className="flex flex-wrap items-center gap-2.5">
<Button
type="submit"
variante="primaer"
disabled={beschaeftigt}
icon={<KeyRound className="size-4" />}
>
{knopfText}
</Button>
<Button href="/schluessel">Abbrechen</Button>
<p role="status" className="text-muted text-[13px]">
{vorgangLaeuft && "Der Worker arbeitet …"}
</p>
</div>
</form>
);
}
+8 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { clsx } from "clsx";
import { Check, Copy } from "lucide-react";
import { useState } from "react";
@@ -8,10 +9,13 @@ export function MonoBlock({
wert,
beschriftung,
hoehe,
umbrechen = false,
}: {
wert: string;
beschriftung: string;
hoehe?: string;
/** Lange Einzeiler (Public Keys) umbrechen statt seitlich scrollen (#012). */
umbrechen?: boolean;
}) {
const [kopiert, setKopiert] = useState(false);
return (
@@ -33,7 +37,10 @@ export function MonoBlock({
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
</button>
<pre
className="text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]"
className={clsx(
"text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]",
umbrechen && "break-all whitespace-pre-wrap",
)}
style={hoehe ? { maxHeight: hoehe } : undefined}
tabIndex={0}
aria-label={beschriftung}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
/**
* Verfolgt einen Vorgang bis zum Ende (#012) – wie VorgangsKnopf, aber fuer Formulare, die
* nach dem Ende etwas mit dem Ergebnis tun (z. B. zum neuen Schluessel springen).
*/
export type VorgangsStand = {
readonly status: string;
readonly fehler: string | null;
readonly ergebnis: unknown;
};
const LAUFEND = new Set(["WARTEND", "LAEUFT"]);
export function istLaufend(stand: VorgangsStand | undefined): boolean {
return stand === undefined || LAUFEND.has(stand.status);
}
type Gemerkt = { readonly id: string; readonly stand?: VorgangsStand; readonly fehler?: string };
export function useVorgang(vorgangId: string | undefined): {
stand: VorgangsStand | undefined;
abfrageFehler: string | undefined;
} {
// Nach der ID gemerkt: ein neuer Vorgang zeigt so nie den Stand des vorigen.
const [gemerkt, setGemerkt] = useState<Gemerkt>();
useEffect(() => {
if (!vorgangId) return;
let abbrechen = false;
const abfragen = async () => {
while (!abbrechen) {
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
if (abbrechen) return;
if (!antwort.ok) {
setGemerkt({ id: vorgangId, fehler: "Status des Vorgangs nicht abrufbar." });
return;
}
const neu = (await antwort.json()) as VorgangsStand;
if (abbrechen) return;
setGemerkt({ id: vorgangId, stand: neu });
if (!LAUFEND.has(neu.status)) return;
await new Promise((weiter) => setTimeout(weiter, 800));
}
};
void abfragen();
return () => {
abbrechen = true;
};
}, [vorgangId]);
const aktuell = gemerkt && gemerkt.id === vorgangId ? gemerkt : undefined;
return { stand: aktuell?.stand, abfrageFehler: aktuell?.fehler };
}