Admins koennen einen gespeicherten Private Key nach frischer Bestaetigung einmalig anzeigen oder als Datei <slug> herunterladen und ihn endgueltig aus SSHKeyVault loeschen. Warum so: - Der Web-Prozess hat keinen Master-Key. Er bittet den Worker per Vorgang, den Key zu oeffnen; der legt ihn mit SSHKV_APP_SECRET verschluesselt in Vorgang.ergebnis ab. Das Web holt ihn genau einmal ab: ein einziges UPDATE unter Zeilensperre liest und ueberschreibt. - Nicht abgeholte Keys verfallen nach zwei Minuten: Worker-Takt alle 30 s, Aufraeumen vor jeder Abholung, und das Web gibt aeltere nicht mehr heraus. Die Status-Schnittstelle zeigt nur "bereit". - Download nur per POST (ein GET-Link oder Vorabruf wuerde die Abholung verbrauchen), no-store, Herkunft per Sec-Fetch-Site, weil no-referrer bei Formular-POSTs Origin: null sendet. - Loeschen laeuft als Worker-Vorgang, damit nur geheimnisse.ts die Chiffrat-Felder anfasst; eigenes Recht privateKeyLoeschen mit Bestaetigung. Deployment-Keys gibt sshkv weder heraus noch loescht es sie. - Protokoll: angefordert, angezeigt, heruntergeladen, geloescht - mit Fingerprint, nie mit Key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
225 lines
8.9 KiB
TypeScript
225 lines
8.9 KiB
TypeScript
"use client";
|
||
|
||
import { Download, Eye, Trash2, TriangleAlert } from "lucide-react";
|
||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||
import { useEffect, useRef, useState, useTransition } from "react";
|
||
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
|
||
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
|
||
import {
|
||
abholungAnfordernAktion,
|
||
privateKeyAnzeigenAktion,
|
||
privateKeyLoeschenAktion,
|
||
type PrivateKeyAntwort,
|
||
} from "@/server/actions/privatekey";
|
||
|
||
/**
|
||
* Private Key anzeigen, herunterladen und loeschen (#014). Alles verlangt frische
|
||
* Bestaetigung; fehlt sie, geht es nach /bestaetigen und von dort mit `?privatekey=1` zurueck,
|
||
* damit der Dialog gleich wieder offen ist.
|
||
*
|
||
* Der Key lebt nur im Zustand dieses Dialogs und wird beim Schliessen verworfen.
|
||
*/
|
||
const DIALOG_KLASSEN =
|
||
"border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-2xl border p-5 backdrop:bg-black/70 sm:p-6";
|
||
|
||
function useBestaetigungUmleitung() {
|
||
const router = useRouter();
|
||
const pfad = usePathname();
|
||
return (antwort: PrivateKeyAntwort): boolean => {
|
||
if (!antwort.bestaetigen) return false;
|
||
router.push(`/bestaetigen?ziel=${encodeURIComponent(`${pfad}?privatekey=1`)}`);
|
||
return true;
|
||
};
|
||
}
|
||
|
||
export function PrivateKeyAnzeigen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||
const dialog = useRef<HTMLDialogElement>(null);
|
||
const download = useRef<HTMLFormElement>(null);
|
||
const suche = useSearchParams();
|
||
const umleiten = useBestaetigungUmleitung();
|
||
const [zweck, setZweck] = useState<"anzeigen" | "herunterladen">();
|
||
const [vorgangId, setVorgangId] = useState<string>();
|
||
const [privateKey, setPrivateKey] = useState<string>();
|
||
const [fehler, setFehler] = useState<string>();
|
||
const [startet, starten] = useTransition();
|
||
const { stand, abfrageFehler } = useVorgang(vorgangId);
|
||
const abgeholt = useRef<string | undefined>(undefined);
|
||
|
||
// Zurueck von /bestaetigen: Dialog gleich oeffnen.
|
||
useEffect(() => {
|
||
if (suche.get("privatekey") === "1") dialog.current?.showModal();
|
||
}, [suche]);
|
||
|
||
// Bereit? Dann genau einmal abholen – als Anzeige oder als Download-Formular.
|
||
useEffect(() => {
|
||
if (!vorgangId || stand?.status !== "ERLEDIGT" || abgeholt.current === vorgangId) return;
|
||
abgeholt.current = vorgangId;
|
||
if (zweck === "herunterladen") {
|
||
download.current?.submit();
|
||
return;
|
||
}
|
||
void privateKeyAnzeigenAktion(vorgangId).then((antwort) => {
|
||
if (umleiten(antwort)) return;
|
||
if (antwort.fehler) setFehler(antwort.fehler);
|
||
else setPrivateKey(antwort.privateKey);
|
||
});
|
||
}, [vorgangId, stand?.status, zweck, umleiten]);
|
||
|
||
function anfordern(art: "anzeigen" | "herunterladen") {
|
||
starten(async () => {
|
||
setFehler(undefined);
|
||
setPrivateKey(undefined);
|
||
setZweck(art);
|
||
const antwort = await abholungAnfordernAktion(schluesselId, art);
|
||
if (umleiten(antwort)) return;
|
||
if (antwort.fehler) setFehler(antwort.fehler);
|
||
else setVorgangId(antwort.vorgangId);
|
||
});
|
||
}
|
||
|
||
function schliessen() {
|
||
// Nichts vom Key bleibt im Zustand stehen.
|
||
setPrivateKey(undefined);
|
||
setVorgangId(undefined);
|
||
setFehler(undefined);
|
||
dialog.current?.close();
|
||
}
|
||
|
||
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
|
||
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
|
||
const downloadGestartet =
|
||
zweck === "herunterladen" && vorgangId !== undefined && stand?.status === "ERLEDIGT";
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => dialog.current?.showModal()}
|
||
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
||
>
|
||
anzeigen (Re-Auth)
|
||
</button>
|
||
<dialog ref={dialog} aria-labelledby="privatekey-titel" className={DIALOG_KLASSEN} onClose={schliessen}>
|
||
<div className="flex flex-col gap-4">
|
||
<h2 id="privatekey-titel" className="text-[17px] font-semibold">
|
||
Private Key von „{name}“
|
||
</h2>
|
||
{privateKey ? (
|
||
<>
|
||
<MonoBlock wert={privateKey} beschriftung="Private Key" hoehe="22rem" />
|
||
<p className="text-accent flex items-start gap-2 text-[14px]">
|
||
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||
Wird nicht erneut angezeigt. Kopieren Sie ihn jetzt an einen sicheren Ort.
|
||
</p>
|
||
</>
|
||
) : downloadGestartet ? (
|
||
<p role="status" className="text-ok text-[14.5px]">
|
||
Download gestartet. Der Private Key wird nicht erneut angeboten.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<p className="text-muted text-[14.5px]">
|
||
Der Private Key wird im Worker entschlüsselt und genau einmal ausgeliefert. Jede Anzeige und
|
||
jeder Download steht im Audit-Log.
|
||
</p>
|
||
<div className="flex flex-wrap gap-2.5">
|
||
<Button
|
||
variante="primaer"
|
||
icon={<Eye className="size-4" />}
|
||
disabled={laeuft}
|
||
onClick={() => anfordern("anzeigen")}
|
||
>
|
||
{laeuft && zweck === "anzeigen" ? "Entschlüssele …" : "Anzeigen"}
|
||
</Button>
|
||
<Button
|
||
icon={<Download className="size-4" />}
|
||
disabled={laeuft}
|
||
onClick={() => anfordern("herunterladen")}
|
||
>
|
||
{laeuft && zweck === "herunterladen" ? "Entschlüssele …" : "Herunterladen"}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
|
||
<div className="flex justify-end">
|
||
<Button onClick={schliessen}>Schließen</Button>
|
||
</div>
|
||
</div>
|
||
{vorgangId && <form ref={download} method="post" action={`/api/private-key/${vorgangId}`} hidden />}
|
||
</dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** „Private Key aus SSHKeyVault loeschen“ (#014) – endgueltig, mit Rueckfrage. */
|
||
export function PrivateKeyLoeschen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||
const dialog = useRef<HTMLDialogElement>(null);
|
||
const router = useRouter();
|
||
const umleiten = useBestaetigungUmleitung();
|
||
const [verstanden, setVerstanden] = useState(false);
|
||
const [vorgangId, setVorgangId] = useState<string>();
|
||
const [fehler, setFehler] = useState<string>();
|
||
const [startet, starten] = useTransition();
|
||
const { stand, abfrageFehler } = useVorgang(vorgangId);
|
||
|
||
useEffect(() => {
|
||
if (stand?.status === "ERLEDIGT") {
|
||
dialog.current?.close();
|
||
router.refresh();
|
||
}
|
||
}, [stand?.status, router]);
|
||
|
||
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
|
||
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
variante="gefahr"
|
||
icon={<Trash2 className="size-4" />}
|
||
onClick={() => dialog.current?.showModal()}
|
||
>
|
||
Private Key löschen
|
||
</Button>
|
||
<dialog ref={dialog} aria-labelledby="privatekey-loeschen-titel" className={DIALOG_KLASSEN}>
|
||
<div className="flex flex-col gap-4">
|
||
<h2 id="privatekey-loeschen-titel" className="text-[17px] font-semibold">
|
||
Private Key von „{name}“ aus SSHKeyVault löschen?
|
||
</h2>
|
||
<p className="text-muted text-[14.5px]">
|
||
sshkv verwirft den verschlüsselten Private Key endgültig. Der Public Key und alle Zuweisungen
|
||
bleiben; auf den Hosts ändert sich nichts. Danach kann sshkv den Private Key weder anzeigen noch
|
||
auf weitere Hosts ausrollen.
|
||
</p>
|
||
<Ankreuzfeld
|
||
checked={verstanden}
|
||
onChange={(e) => setVerstanden(e.target.checked)}
|
||
beschriftung="Ich habe verstanden, dass sich das nicht rückgängig machen lässt."
|
||
/>
|
||
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
|
||
<div className="flex flex-wrap justify-end gap-2.5">
|
||
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||
<Button
|
||
variante="gefahr"
|
||
disabled={!verstanden || laeuft}
|
||
onClick={() =>
|
||
starten(async () => {
|
||
setFehler(undefined);
|
||
const antwort = await privateKeyLoeschenAktion(schluesselId);
|
||
if (umleiten(antwort)) return;
|
||
if (antwort.fehler) setFehler(antwort.fehler);
|
||
else setVorgangId(antwort.vorgangId);
|
||
})
|
||
}
|
||
>
|
||
{laeuft ? "Lösche …" : "Endgültig löschen"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
</>
|
||
);
|
||
}
|