feat(schluessel): Private Key anzeigen, herunterladen, loeschen (#014)

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>
This commit is contained in:
2026-10-12 10:51:04 +02:00
co-authored by Claude Opus 5.5
parent b5b43f41f6
commit 0bb62450f3
15 changed files with 1029 additions and 8 deletions
@@ -0,0 +1,224 @@
"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>
</>
);
}
+28 -1
View File
@@ -17,6 +17,7 @@ import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
export const metadata: Metadata = { title: "Schlüssel" };
@@ -160,6 +161,10 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
const aktiv = s.status === "AKTIV";
const personenKey = s.kategorie === "PERSON";
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
const jetzt = new Date();
return (
@@ -216,7 +221,17 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</span>
</InfoFeld>
<InfoFeld titel="Private Key">
<PrivatStatus s={s} />
<PrivatStatus
s={s}
anzeigen={
darfAnzeigen && (
<>
<span aria-hidden>·</span>
<PrivateKeyAnzeigen schluesselId={s.id} name={s.name} />
</>
)
}
/>
</InfoFeld>
</dl>
</Karte>
@@ -280,6 +295,18 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
>
<MonoBlock wert={s.publicKey} beschriftung="Public Key" umbrechen />
</Karte>
{darfLoeschen && (
<Karte titel="Private Key löschen" untertitel="Nur noch auf den Hosts, nicht mehr in SSHKeyVault">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted max-w-xl text-[14px]">
Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann
ihn niemand mehr aus SSHKeyVault holen – auch kein Admin.
</p>
<PrivateKeyLoeschen schluesselId={s.id} name={s.name} />
</div>
</Karte>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { akteurVon } from "@/server/actions/formular";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { HostFehler } from "@/server/services/hosts";
import { privateKeyAbholen } from "@/server/services/privatekey";
/**
* Download des Private Keys als Datei `<slug>` (#014). Nur POST aus einem Formular dieser
* Anwendung: ein GET liesse sich per Link oder Vorabruf ausloesen und verbraeuchte die
* einmalige Abholung. Fremde Seiten haelt die Herkunftspruefung fern.
*
* Prueft selbst Recht und frische Bestaetigung. Antwort ohne Caching; Fehlertexte enthalten
* nie Schluesselmaterial.
*/
const KEIN_CACHE = { "Cache-Control": "no-store, max-age=0", Pragma: "no-cache" } as const;
function text(meldung: string, status: number): NextResponse {
return new NextResponse(`${meldung}\n`, {
status,
headers: { "Content-Type": "text/plain; charset=utf-8", ...KEIN_CACHE },
});
}
/**
* Kommt die Anfrage von dieser Anwendung? Wegen `Referrer-Policy: no-referrer` schicken
* Browser bei Formular-POSTs `Origin: null`; massgeblich ist deshalb `Sec-Fetch-Site`, das
* Seiten nicht faelschen koennen. Ein passender Origin genuegt ebenso.
*/
function vonHier(anfrage: NextRequest): boolean {
if (anfrage.headers.get("sec-fetch-site") === "same-origin") return true;
return anfrage.headers.get("origin") === oeffentlicheAdresse("/", anfrage.nextUrl.origin).origin;
}
export async function POST(anfrage: NextRequest, { params }: { params: Promise<{ vorgangId: string }> }) {
if (!vonHier(anfrage)) return text("Anfrage von fremder Herkunft.", 403);
let benutzer;
try {
({ benutzer } = await rechtErforderlich("privateKeyAnzeigen"));
} catch (fehler) {
if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler)
return text(fehler.message, 403);
throw fehler;
}
const { vorgangId } = await params;
try {
const abholung = await privateKeyAbholen(vorgangId.slice(0, 64), "herunterladen", akteurVon(benutzer));
return new NextResponse(abholung.privateKey, {
headers: {
"Content-Type": "application/octet-stream",
// Der Slug passt per DB-CHECK in einen Dateinamen; so heisst die Datei wie in ~/.ssh.
"Content-Disposition": `attachment; filename="${abholung.slug}"`,
"X-Content-Type-Options": "nosniff",
...KEIN_CACHE,
},
});
} catch (fehler) {
if (fehler instanceof HostFehler) return text(fehler.message, 410);
throw fehler;
}
}