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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user