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:
@@ -1,20 +1,52 @@
|
||||
# #014 Private Key anzeigen und herunterladen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** sicherheit
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Nur Admin, nur nach Re-Auth, immer protokolliert; Entschlüsselung im Worker.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Einmal-Abholung
|
||||
- [ ] Option „Private Key nach Ausrollen löschen“
|
||||
- [x] Einmal-Abholung
|
||||
- [x] Option „Private Key nach Ausrollen löschen“
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Kein Caching, kein Logging
|
||||
- [x] Kein Caching, kein Logging
|
||||
|
||||
## Abhängigkeiten
|
||||
#012, #004
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Ablauf:** Web legt einen Vorgang `PRIVATE_KEY_ABHOLEN` an (Protokoll `privatekey.angefordert`)
|
||||
→ der Worker öffnet den Umschlag und legt den Key mit `SSHKV_APP_SECRET` verschlüsselt in
|
||||
`Vorgang.ergebnis` ab (`{ abholung: "v1.…" }`) → das Web holt ihn **genau einmal** ab: ein
|
||||
einziges `UPDATE … FROM (SELECT … FOR UPDATE) RETURNING` liest das alte Ergebnis und setzt
|
||||
`{ abgeholt: true }`; fünf gleichzeitige Abholungen ergeben genau eine (Test). Abholen darf
|
||||
nur, wer angefordert hat. Protokoll `privatekey.angezeigt` / `privatekey.heruntergeladen` mit
|
||||
Fingerprint, nie mit Key.
|
||||
- **Die Status-Schnittstelle** `/api/vorgaenge/[id]` zeigt für eine bereitliegende Abholung nur
|
||||
`{ bereit: true }`, nie das Chiffrat.
|
||||
- **Verfall nach 2 Minuten** (`ABHOLFRIST_MS` in `src/lib/schluessel.ts`), dreifach abgesichert:
|
||||
der Worker räumt alle 30 s und vor jeder neuen Abholung auf (`abholungenAufraeumen`, ein
|
||||
einziges UPDATE → `{ verfallen: true }`), und das Web gibt eine ältere Abholung nicht mehr
|
||||
heraus (überschreibt sie aber trotzdem). Ein Worker-Takt statt pg-boss-Zeitplan: dessen Raster
|
||||
ist eine Minute.
|
||||
- **Anzeige** im Dialog (Monospace, Kopier-Knopf, „Wird nicht erneut angezeigt“); der Key lebt
|
||||
nur im Zustand des Dialogs und wird beim Schließen verworfen. **Download** als Datei `<slug>`
|
||||
über `POST /api/private-key/[vorgangId]` (Formular, kein GET – ein Link oder Vorabruf würde die
|
||||
einmalige Abholung verbrauchen), `Content-Disposition: attachment`,
|
||||
`Cache-Control: no-store`. Der Route Handler prüft selbst Recht, Bestätigung und Herkunft:
|
||||
wegen `Referrer-Policy: no-referrer` senden Browser bei Formular-POSTs `Origin: null`,
|
||||
maßgeblich ist deshalb `Sec-Fetch-Site: same-origin` (oder ein passender Origin).
|
||||
- **Re-Auth:** Fehlt die frische Bestätigung, leitet die Oberfläche nach
|
||||
`/bestaetigen?ziel=/schluessel/<id>?privatekey=1` und öffnet danach den Dialog wieder. Der
|
||||
Mock-OIDC liefert `auth_time` nur, wenn man es in die Claims schreibt – die E2E tut das.
|
||||
- **Löschen** („Private Key aus SSHKeyVault löschen“): neues Recht `privateKeyLoeschen` (nur
|
||||
Admin, mit Bestätigung), ausgeführt als Worker-Vorgang `PRIVATE_KEY_LOESCHEN`
|
||||
(`privateKeyVerwerfen` in `src/worker/geheimnisse.ts`), damit nur diese Datei die
|
||||
Chiffrat-Felder anfasst – auch zum Leeren. Setzt Chiffrat, DEK und Version auf `null` und
|
||||
`privatGeloeschtAm`; Protokoll `privatekey.geloescht`. CHECK
|
||||
`schluessel_geloescht_ohne_private_key` (Migration aus #012).
|
||||
- **Deployment-Keys** gibt sshkv weder heraus noch löscht es deren Private Key: der Key ist root
|
||||
auf allen Hosts, und ohne ihn käme der Worker nirgends mehr hin. Im Notfall wird rotiert.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,11 @@ export const KOMMENTAR_MAX = 200;
|
||||
/** Ein OpenSSH-Private-Key mit RSA-16384 bleibt unter 16 KiB; mehr ist kein Schluessel. */
|
||||
export const PRIVATE_KEY_MAX = 16 * 1024;
|
||||
export const PASSPHRASE_MAX = 1024;
|
||||
/**
|
||||
* Wie lange ein vom Worker geoeffneter Private Key zur Abholung bereitliegt (#014). Worker
|
||||
* (Aufraeumen) und Web (Abholen) pruefen dieselbe Frist.
|
||||
*/
|
||||
export const ABHOLFRIST_MS = 2 * 60 * 1000;
|
||||
/** Ab hier wird ein Ablaufdatum in Messing hervorgehoben. */
|
||||
export const ABLAUF_WARNUNG_TAGE = 30;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { HostFehler } from "@/server/services/hosts";
|
||||
import {
|
||||
abholungAnfordern,
|
||||
privateKeyAbholen,
|
||||
privateKeyLoeschenAnfordern,
|
||||
} from "@/server/services/privatekey";
|
||||
|
||||
/**
|
||||
* Private Key anzeigen und loeschen (#014). Jede Aktion prueft selbst Recht **und** frische
|
||||
* Bestaetigung (`rechtErforderlich` mit einem Recht aus BESTAETIGUNG_NOETIG). Fehlt die
|
||||
* Bestaetigung, meldet die Antwort `bestaetigen: true`, und die Oberflaeche leitet nach
|
||||
* /bestaetigen weiter.
|
||||
*
|
||||
* `privateKeyAnzeigenAktion` ist die einzige Stelle, an der ein Private Key als Antwort an den
|
||||
* Browser geht – genau einmal, fuer die Anzeige im Dialog.
|
||||
*/
|
||||
export type PrivateKeyAntwort = {
|
||||
readonly vorgangId?: string;
|
||||
readonly fehler?: string;
|
||||
readonly bestaetigen?: boolean;
|
||||
readonly privateKey?: string;
|
||||
};
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
const zweck = z.enum(["anzeigen", "herunterladen"]);
|
||||
|
||||
function fehlerAntwort(fehler: unknown): PrivateKeyAntwort {
|
||||
if (fehler instanceof BestaetigungFehler) return { fehler: fehler.message, bestaetigen: true };
|
||||
if (fehler instanceof RechteFehler || fehler instanceof HostFehler) return { fehler: fehler.message };
|
||||
if (fehler instanceof z.ZodError) return { fehler: "Ungültige Anfrage." };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
export async function abholungAnfordernAktion(schluesselId: string, art: string): Promise<PrivateKeyAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
|
||||
return {
|
||||
vorgangId: await abholungAnfordern(id.parse(schluesselId), zweck.parse(art), akteurVon(benutzer)),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return fehlerAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function privateKeyAnzeigenAktion(vorgangId: string): Promise<PrivateKeyAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
|
||||
const { privateKey } = await privateKeyAbholen(id.parse(vorgangId), "anzeigen", akteurVon(benutzer));
|
||||
return { privateKey };
|
||||
} catch (fehler) {
|
||||
return fehlerAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function privateKeyLoeschenAktion(schluesselId: string): Promise<PrivateKeyAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyLoeschen");
|
||||
const vorgangId = await privateKeyLoeschenAnfordern(id.parse(schluesselId), akteurVon(benutzer));
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return { vorgangId };
|
||||
} catch (fehler) {
|
||||
return fehlerAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type Recht =
|
||||
| "publicKeyAusrollen"
|
||||
| "privateKeyAusrollen"
|
||||
| "privateKeyAnzeigen"
|
||||
| "privateKeyLoeschen"
|
||||
| "deploymentKeyVerwalten"
|
||||
| "massenwiderruf"
|
||||
| "zielkontenVerwalten"
|
||||
@@ -33,6 +34,8 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||
// Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins.
|
||||
privateKeyAusrollen: ["ADMIN"],
|
||||
privateKeyAnzeigen: ["ADMIN"],
|
||||
// Endgueltig und nicht rueckgaengig zu machen (#014) – wie Anzeigen nur mit Re-Auth.
|
||||
privateKeyLoeschen: ["ADMIN"],
|
||||
deploymentKeyVerwalten: ["ADMIN"],
|
||||
massenwiderruf: ["ADMIN"],
|
||||
zielkontenVerwalten: ["ADMIN"],
|
||||
@@ -42,6 +45,7 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
|
||||
export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
|
||||
"privateKeyAnzeigen",
|
||||
"privateKeyLoeschen",
|
||||
"deploymentKeyVerwalten",
|
||||
"massenwiderruf",
|
||||
"zielkontenVerwalten",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln } from "@/lib/geheimnisse";
|
||||
import { ABHOLFRIST_MS } from "@/lib/schluessel";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import type { Akteur } from "@/server/services/hosts";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Private Key anzeigen, herunterladen, loeschen (#014). Die Rechte (`privateKeyAnzeigen`,
|
||||
* `privateKeyLoeschen`, beide nur Admin mit frischer Bestaetigung) prueft die Action bzw. der
|
||||
* Route Handler; hier stehen die fachlichen Regeln.
|
||||
*
|
||||
* 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, und
|
||||
* `privateKeyAbholen` holt ihn genau einmal ab. Der Key steht nie im Protokoll, in einem Log
|
||||
* oder einer Fehlermeldung – nur Fingerprint und Name.
|
||||
*/
|
||||
export type Zweck = "anzeigen" | "herunterladen";
|
||||
|
||||
/** Felder fuer die Pruefung – ob ein Key da ist, verraet `masterKeyVersion`, nie das Chiffrat. */
|
||||
async function schluesselMitPrivateKey(schluesselId: string) {
|
||||
const schluessel = await prisma.schluessel.findUnique({
|
||||
where: { id: schluesselId },
|
||||
select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
|
||||
});
|
||||
if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
|
||||
if (schluessel.masterKeyVersion === null) {
|
||||
throw new FachFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
|
||||
}
|
||||
// Der Deployment-Key ist root auf allen Hosts; er verlaesst sshkv nie. Im Notfall wird er
|
||||
// rotiert, nicht exportiert.
|
||||
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||
throw new FachFehler(
|
||||
"Den Private Key eines Deployment-Keys gibt sshkv nicht heraus und löscht ihn nicht.",
|
||||
);
|
||||
}
|
||||
return schluessel;
|
||||
}
|
||||
|
||||
/** Bittet den Worker, den Private Key zur einmaligen Abholung bereitzulegen. */
|
||||
export async function abholungAnfordern(schluesselId: string, zweck: Zweck, akteur: Akteur): Promise<string> {
|
||||
const schluessel = await schluesselMitPrivateKey(schluesselId);
|
||||
const vorgangId = await vorgangEinreihen({
|
||||
art: "PRIVATE_KEY_ABHOLEN",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: schluessel.id,
|
||||
eingabe: { zweck },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "privatekey.angefordert",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: schluessel.fingerprint,
|
||||
details: { name: schluessel.name, zweck },
|
||||
});
|
||||
return vorgangId;
|
||||
}
|
||||
|
||||
type Abholung = { ergebnis: unknown; beendetAm: Date | null; schluesselId: string | null };
|
||||
|
||||
/**
|
||||
* Holt den bereitgelegten Private Key genau einmal ab: Lesen und Ueberschreiben geschehen in
|
||||
* einer Anweisung unter Zeilensperre, ein zweiter Aufruf findet nichts mehr. Nur wer den
|
||||
* Vorgang angefordert hat, kann abholen – und nur innerhalb der Abholfrist.
|
||||
*/
|
||||
export async function privateKeyAbholen(
|
||||
vorgangId: string,
|
||||
zweck: Zweck,
|
||||
akteur: Akteur,
|
||||
jetzt: Date = new Date(),
|
||||
): Promise<{ privateKey: string; slug: string; name: string; fingerprint: string }> {
|
||||
const [zeile] = await prisma.$queryRaw<Abholung[]>`
|
||||
WITH alt AS (
|
||||
SELECT "id", "ergebnis", "beendetAm", "schluesselId" FROM "Vorgang"
|
||||
WHERE "id" = ${vorgangId}
|
||||
AND "art" = 'PRIVATE_KEY_ABHOLEN'
|
||||
AND "status" = 'ERLEDIGT'
|
||||
AND "angefordertVonId" = ${akteur.id}
|
||||
AND "ergebnis" ? 'abholung'
|
||||
FOR UPDATE
|
||||
)
|
||||
UPDATE "Vorgang" v SET "ergebnis" = '{"abgeholt": true}'::jsonb
|
||||
FROM alt WHERE v."id" = alt."id"
|
||||
RETURNING alt."ergebnis", alt."beendetAm", alt."schluesselId"`;
|
||||
|
||||
if (!zeile || !zeile.schluesselId) {
|
||||
throw new FachFehler("Nichts abzuholen – der Private Key wurde schon abgeholt oder ist verfallen.");
|
||||
}
|
||||
if (!zeile.beendetAm || jetzt.getTime() - zeile.beendetAm.getTime() > ABHOLFRIST_MS) {
|
||||
throw new FachFehler("Die Abholung ist verfallen. Bitte den Private Key erneut anfordern.");
|
||||
}
|
||||
const chiffrat = (zeile.ergebnis as { abholung?: unknown }).abholung;
|
||||
if (typeof chiffrat !== "string") throw new FachFehler("Die Abholung ist unvollständig.");
|
||||
|
||||
const schluessel = await prisma.schluessel.findUniqueOrThrow({
|
||||
where: { id: zeile.schluesselId },
|
||||
select: { name: true, slug: true, fingerprint: true },
|
||||
});
|
||||
const privateKey = entschluesseln(chiffrat);
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: zweck === "anzeigen" ? "privatekey.angezeigt" : "privatekey.heruntergeladen",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: schluessel.fingerprint,
|
||||
details: { name: schluessel.name },
|
||||
});
|
||||
return { privateKey, ...schluessel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fordert das endgueltige Loeschen des gespeicherten Private Keys an (#014, „Private Key aus
|
||||
* SSHKeyVault loeschen“). Ausgefuehrt im Worker – nur src/worker/geheimnisse.ts fasst die
|
||||
* Chiffrat-Felder an, auch zum Leeren.
|
||||
*/
|
||||
export async function privateKeyLoeschenAnfordern(schluesselId: string, akteur: Akteur): Promise<string> {
|
||||
const schluessel = await schluesselMitPrivateKey(schluesselId);
|
||||
return vorgangEinreihen({
|
||||
art: "PRIVATE_KEY_LOESCHEN",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: schluessel.id,
|
||||
});
|
||||
}
|
||||
@@ -35,8 +35,20 @@ export async function vorgangEinreihen(daten: VorgangAnlegen): Promise<string> {
|
||||
return vorgang.id;
|
||||
}
|
||||
|
||||
/** Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. */
|
||||
/**
|
||||
* Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. Eine bereitliegende
|
||||
* Private-Key-Abholung (#014) wird nie ausgeliefert, auch nicht verschluesselt: abgeholt wird
|
||||
* nur ueber src/server/services/privatekey.ts.
|
||||
*/
|
||||
export async function vorgangLesen(id: string) {
|
||||
const vorgang = await vorgangRoh(id);
|
||||
if (vorgang?.art !== "PRIVATE_KEY_ABHOLEN") return vorgang;
|
||||
const ergebnis = vorgang.ergebnis;
|
||||
const bereit = typeof ergebnis === "object" && ergebnis !== null && "abholung" in ergebnis;
|
||||
return { ...vorgang, ergebnis: bereit ? { bereit: true } : ergebnis };
|
||||
}
|
||||
|
||||
async function vorgangRoh(id: string) {
|
||||
return prisma.vorgang.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { ABHOLFRIST_MS } from "@/lib/schluessel";
|
||||
import { protokollieren, type ProtokollEintrag } from "@/server/services/protokoll";
|
||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||
import { privateKeyLaden, privateKeyVerwerfen } from "@/worker/geheimnisse";
|
||||
|
||||
/**
|
||||
* Private Key abholen und loeschen (#014).
|
||||
*
|
||||
* Abholen: Der Worker oeffnet den Umschlag (nur er hat den Master-Key) und legt den Key mit
|
||||
* SSHKV_APP_SECRET verschluesselt in `Vorgang.ergebnis` ab. Das Web holt ihn genau einmal ab
|
||||
* und ueberschreibt das Ergebnis dabei; was nach ABHOLFRIST noch liegt, raeumt
|
||||
* `abholungenAufraeumen()` weg – der Worker ruft es vor jeder Abholung und regelmaessig auf.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ueberschreibt nicht abgeholte Private Keys, deren Vorgang laenger als ABHOLFRIST fertig ist.
|
||||
* Liefert die Anzahl. Ein einziges UPDATE – kein Zeitfenster zwischen Lesen und Schreiben.
|
||||
*/
|
||||
export async function abholungenAufraeumen(jetzt: Date = new Date()): Promise<number> {
|
||||
const grenze = new Date(jetzt.getTime() - ABHOLFRIST_MS);
|
||||
return prisma.$executeRaw`
|
||||
UPDATE "Vorgang" SET "ergebnis" = '{"verfallen": true}'::jsonb
|
||||
WHERE "art" = 'PRIVATE_KEY_ABHOLEN'
|
||||
AND "ergebnis" ? 'abholung'
|
||||
AND "beendetAm" < ${grenze}`;
|
||||
}
|
||||
|
||||
async function anforderer(angefordertVonId: string | null): Promise<ProtokollEintrag["akteur"]> {
|
||||
if (!angefordertVonId) return "System";
|
||||
const benutzer = await prisma.benutzer.findUnique({
|
||||
where: { id: angefordertVonId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
return benutzer ?? "System";
|
||||
}
|
||||
|
||||
/** Nur Felder, die verraten, ob ein Key da ist – das Chiffrat liest allein geheimnisse.ts. */
|
||||
async function schluesselLaden(schluesselId: string | null) {
|
||||
if (!schluesselId) throw new VorgangsFehler("Der Vorgang nennt keinen Schlüssel.");
|
||||
const schluessel = await prisma.schluessel.findUnique({
|
||||
where: { id: schluesselId },
|
||||
select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
|
||||
});
|
||||
if (!schluessel) throw new VorgangsFehler("Den Schlüssel gibt es nicht mehr.");
|
||||
if (schluessel.masterKeyVersion === null) {
|
||||
throw new VorgangsFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
|
||||
}
|
||||
return schluessel;
|
||||
}
|
||||
|
||||
export const privateKeyAbholenHandler: Handler = async ({ vorgang }) => {
|
||||
await abholungenAufraeumen();
|
||||
const schluessel = await schluesselLaden(vorgang.schluesselId);
|
||||
const klartext = await privateKeyLaden(schluessel.id);
|
||||
try {
|
||||
// JS-Strings lassen sich nicht ueberschreiben; der Puffer wenigstens wird sofort genullt.
|
||||
return {
|
||||
ergebnis: { abholung: verschluesseln(klartext.toString("utf8")), fingerprint: schluessel.fingerprint },
|
||||
};
|
||||
} finally {
|
||||
klartext.fill(0);
|
||||
}
|
||||
};
|
||||
|
||||
export const privateKeyLoeschenHandler: Handler = async ({ vorgang }) => {
|
||||
const schluessel = await schluesselLaden(vorgang.schluesselId);
|
||||
// Ohne Deployment-Key kaeme der Worker auf keinen Host mehr (Aussperrschutz).
|
||||
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||
throw new VorgangsFehler("Den Private Key eines Deployment-Keys löscht sshkv nicht.");
|
||||
}
|
||||
if (!(await privateKeyVerwerfen(schluessel.id))) {
|
||||
throw new VorgangsFehler("Der Private Key war bereits gelöscht.");
|
||||
}
|
||||
await protokollieren({
|
||||
akteur: await anforderer(vorgang.angefordertVonId),
|
||||
aktion: "privatekey.geloescht",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: schluessel.fingerprint,
|
||||
details: { name: schluessel.name, slug: schluessel.slug },
|
||||
});
|
||||
return { ergebnis: { geloescht: true } };
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
||||
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||
|
||||
/**
|
||||
@@ -12,4 +13,6 @@ export const HANDLER: HandlerTabelle = {
|
||||
BOOTSTRAP: bootstrapHandler,
|
||||
SCHLUESSEL_ERZEUGEN: schluesselErzeugenHandler,
|
||||
SCHLUESSEL_IMPORTIEREN: schluesselImportierenHandler,
|
||||
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Job } from "pg-boss";
|
||||
import { VERSION } from "@/lib/version";
|
||||
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
|
||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
||||
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
||||
import { HANDLER } from "@/worker/handler";
|
||||
|
||||
@@ -30,10 +31,25 @@ async function starten(): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// Nicht abgeholte Private Keys (#014) verfallen nach zwei Minuten. Ein Takt im Worker statt
|
||||
// eines pg-boss-Zeitplans: dessen Raster ist eine Minute, und ein Fehler hier soll den
|
||||
// Worker nicht anhalten. Zusaetzlich raeumt jede neue Abholung vorher auf, und das Web
|
||||
// gibt eine abgelaufene Abholung ohnehin nicht mehr heraus.
|
||||
const aufraeumen = () =>
|
||||
abholungenAufraeumen().catch((fehler: unknown) =>
|
||||
console.error(
|
||||
"Aufräumen der Abholungen fehlgeschlagen:",
|
||||
fehler instanceof Error ? fehler.message : fehler,
|
||||
),
|
||||
);
|
||||
await aufraeumen();
|
||||
const takt = setInterval(() => void aufraeumen(), 30_000);
|
||||
|
||||
console.log(`sshkv-worker ${VERSION} bereit.`);
|
||||
|
||||
const beenden = async (signal: string) => {
|
||||
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
|
||||
clearInterval(takt);
|
||||
await boss.stop({ graceful: true, timeout: 30_000 });
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
@@ -73,7 +73,13 @@ describe("Rechte", () => {
|
||||
|
||||
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
|
||||
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
|
||||
["deploymentKeyVerwalten", "massenwiderruf", "privateKeyAnzeigen", "zielkontenVerwalten"].sort(),
|
||||
[
|
||||
"deploymentKeyVerwalten",
|
||||
"massenwiderruf",
|
||||
"privateKeyAnzeigen",
|
||||
"privateKeyLoeschen",
|
||||
"zielkontenVerwalten",
|
||||
].sort(),
|
||||
);
|
||||
for (const recht of BESTAETIGUNG_NOETIG) {
|
||||
expect(RECHTE[recht]).toEqual(["ADMIN"]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
||||
|
||||
@@ -16,6 +17,16 @@ async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader")
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
|
||||
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
|
||||
await client.connect();
|
||||
try {
|
||||
return await arbeit(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
function eindeutig(): string {
|
||||
return `zz-e2e-${Date.now().toString(36)}`;
|
||||
}
|
||||
@@ -92,3 +103,95 @@ test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Entfernen", exact: true }).click();
|
||||
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
|
||||
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
|
||||
*/
|
||||
async function oidcBestaetigen(page: Page) {
|
||||
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
}
|
||||
|
||||
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `${eindeutig()} Deploy`;
|
||||
await oidcAnmelden(page, "admin");
|
||||
// Eine Bestaetigung aus einem frueheren Lauf soll nicht mitzaehlen.
|
||||
await datenbank((client) =>
|
||||
client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
|
||||
);
|
||||
await page.goto("/schluessel/neu");
|
||||
await page.getByLabel("Zweck").fill(name);
|
||||
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||
await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
|
||||
const detail = page.url();
|
||||
const fingerprint =
|
||||
(
|
||||
await page
|
||||
.getByText(/^SHA256:/)
|
||||
.first()
|
||||
.textContent()
|
||||
)?.trim() ?? "";
|
||||
|
||||
// Ohne frische Bestaetigung geht es erst nach /bestaetigen und von dort zurueck in den Dialog.
|
||||
await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Anzeigen" }).click();
|
||||
await oidcBestaetigen(page);
|
||||
await expect(page).toHaveURL(/privatekey=1/);
|
||||
await dialog.getByRole("button", { name: "Anzeigen" }).click();
|
||||
const block = dialog.getByLabel("Private Key", { exact: true });
|
||||
await expect(block).toContainText("-----BEGIN OPENSSH PRIVATE KEY-----", { timeout: 30_000 });
|
||||
await expect(dialog.getByText("Wird nicht erneut angezeigt.")).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Schließen" }).click();
|
||||
await expect(block).toHaveCount(0);
|
||||
|
||||
// Download: eigene Abholung, Datei <slug>, ohne Caching.
|
||||
await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
|
||||
const antwort = page.waitForResponse((r) => r.url().includes("/api/private-key/"));
|
||||
const datei = page.waitForEvent("download");
|
||||
await dialog.getByRole("button", { name: "Herunterladen" }).click();
|
||||
const kopf = (await antwort).headers();
|
||||
expect(kopf["cache-control"]).toContain("no-store");
|
||||
expect(kopf["content-disposition"]).toMatch(/^attachment; filename="zz-e2e-[a-z0-9]+-deploy"$/);
|
||||
const download = await datei;
|
||||
expect(download.suggestedFilename()).toMatch(/^zz-e2e-[a-z0-9]+-deploy$/);
|
||||
await expect(dialog.getByText(/Download gestartet/)).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Schließen" }).click();
|
||||
|
||||
// Operator sieht keinen Anzeigen-Knopf und kein Loeschen.
|
||||
const operator = await page.context().browser()!.newContext();
|
||||
const operatorSeite = await operator.newPage();
|
||||
await oidcAnmelden(operatorSeite, "operator");
|
||||
await operatorSeite.goto(detail);
|
||||
await expect(operatorSeite.getByText("verschlüsselt gespeichert")).toBeVisible();
|
||||
await expect(operatorSeite.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
|
||||
await expect(operatorSeite.getByRole("button", { name: "Private Key löschen" })).toHaveCount(0);
|
||||
await operator.close();
|
||||
|
||||
// Loeschen (Bestaetigung ist noch frisch).
|
||||
await page.getByRole("button", { name: "Private Key löschen" }).click();
|
||||
await dialog.getByText("Ich habe verstanden").click();
|
||||
await dialog.getByRole("button", { name: "Endgültig löschen" }).click();
|
||||
await expect(page.getByText(/gelöscht am/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
|
||||
|
||||
// Im Audit stehen Anzeige, Download und Loeschen mit Fingerprint – ohne den Key.
|
||||
expect(fingerprint).toMatch(/^SHA256:/);
|
||||
const { rows } = await datenbank((client) =>
|
||||
client.query<{ aktion: string; zeile: string }>(
|
||||
`SELECT "aktion", row_to_json(p)::text AS zeile FROM "Protokoll" p WHERE "fingerprint" = $1 ORDER BY "id"`,
|
||||
[fingerprint],
|
||||
),
|
||||
);
|
||||
expect(rows.map((r) => r.aktion)).toEqual(
|
||||
expect.arrayContaining(["privatekey.angezeigt", "privatekey.heruntergeladen", "privatekey.geloescht"]),
|
||||
);
|
||||
expect(rows.some((r) => r.zeile.includes("PRIVATE KEY"))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ABHOLFRIST_MS } from "@/lib/schluessel";
|
||||
import { privateKeyLesen } from "@/lib/ssh/openssh";
|
||||
import { BESTAETIGUNG_NOETIG, RECHTE } from "@/server/auth/rechte";
|
||||
import { schluesselDetail } from "@/server/queries/schluessel";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import {
|
||||
abholungAnfordern,
|
||||
privateKeyAbholen,
|
||||
privateKeyLoeschenAnfordern,
|
||||
} from "@/server/services/privatekey";
|
||||
import { erzeugenAnfordern, publicKeyImportieren } from "@/server/services/schluessel";
|
||||
import { vorgangLesen } from "@/server/vorgaenge/vorgaenge";
|
||||
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
||||
import { deploymentKeySicherstellen, masterKeyVergessen, privateKeyLaden } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
import { ed25519Blob, zeile } from "./ssh/hilfen";
|
||||
|
||||
/** Private Key anzeigen, herunterladen, loeschen (#014). */
|
||||
|
||||
let verzeichnis: string;
|
||||
let admin: { id: string; name: string };
|
||||
let zweiterAdmin: { id: string; name: string };
|
||||
|
||||
beforeAll(() => {
|
||||
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||
const datei = path.join(verzeichnis, "master.key");
|
||||
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||
masterKeyVergessen();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
rmSync(verzeichnis, { recursive: true, force: true });
|
||||
await warteschlangeStoppen();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
admin = await prisma.benutzer.create({
|
||||
data: { name: "Ada Admin", rolle: "ADMIN" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
zweiterAdmin = await prisma.benutzer.create({
|
||||
data: { name: "Zora Zweitadmin", rolle: "ADMIN" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
});
|
||||
|
||||
async function fachfehlerVon(versuch: Promise<unknown>): Promise<FachFehler> {
|
||||
const fehler = await versuch.then(
|
||||
() => undefined,
|
||||
(f: unknown) => f,
|
||||
);
|
||||
expect(fehler).toBeInstanceOf(FachFehler);
|
||||
return fehler as FachFehler;
|
||||
}
|
||||
|
||||
/** Ein Dienst-Key mit gespeichertem Private Key, erzeugt wie in der Oberflaeche. */
|
||||
async function dienstKey(): Promise<string> {
|
||||
const vorgangId = await erzeugenAnfordern({ name: "Backup" }, undefined, admin);
|
||||
await vorgangImWorker(vorgangId);
|
||||
const { ergebnis } = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
return (ergebnis as { schluesselId: string }).schluesselId;
|
||||
}
|
||||
|
||||
async function bereitgelegt(
|
||||
schluesselId: string,
|
||||
akteur = admin,
|
||||
zweck: "anzeigen" | "herunterladen" = "anzeigen",
|
||||
) {
|
||||
const vorgangId = await abholungAnfordern(schluesselId, zweck, akteur);
|
||||
await vorgangImWorker(vorgangId);
|
||||
return vorgangId;
|
||||
}
|
||||
|
||||
describe("Abholung", () => {
|
||||
it("liefert den Key genau einmal und protokolliert ohne ihn", async () => {
|
||||
const schluesselId = await dienstKey();
|
||||
const vorgangId = await bereitgelegt(schluesselId);
|
||||
|
||||
const roh = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
expect(roh.status).toBe("ERLEDIGT");
|
||||
const abholung = (roh.ergebnis as { abholung: string }).abholung;
|
||||
expect(abholung).toMatch(/^v1\./);
|
||||
expect(abholung).not.toContain("PRIVATE KEY");
|
||||
// Die Status-Schnittstelle zeigt nur „bereit“, nie das Chiffrat.
|
||||
expect((await vorgangLesen(vorgangId))?.ergebnis).toEqual({ bereit: true });
|
||||
|
||||
const erwartet = (await privateKeyLaden(schluesselId)).toString("utf8");
|
||||
const geliefert = await privateKeyAbholen(vorgangId, "anzeigen", admin);
|
||||
expect(geliefert.privateKey).toBe(erwartet);
|
||||
expect(privateKeyLesen(geliefert.privateKey).fingerprint).toBe(geliefert.fingerprint);
|
||||
|
||||
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).ergebnis).toEqual({
|
||||
abgeholt: true,
|
||||
});
|
||||
await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", admin));
|
||||
|
||||
const eintraege = await prisma.protokoll.findMany({
|
||||
where: { aktion: { startsWith: "privatekey." } },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
expect(eintraege.map((e) => e.aktion)).toEqual(["privatekey.angefordert", "privatekey.angezeigt"]);
|
||||
expect(eintraege.every((e) => e.fingerprint === geliefert.fingerprint)).toBe(true);
|
||||
|
||||
const alles = await datenbankAlsText();
|
||||
expect(alles).not.toContain("PRIVATE KEY");
|
||||
expect(alles).not.toContain(erwartet.split("\n")[2]);
|
||||
});
|
||||
|
||||
it("gibt bei gleichzeitigen Abholungen genau eine heraus", async () => {
|
||||
const vorgangId = await bereitgelegt(await dienstKey(), admin, "herunterladen");
|
||||
const ergebnisse = await Promise.allSettled(
|
||||
Array.from({ length: 5 }, () => privateKeyAbholen(vorgangId, "herunterladen", admin)),
|
||||
);
|
||||
expect(ergebnisse.filter((e) => e.status === "fulfilled")).toHaveLength(1);
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "privatekey.heruntergeladen" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("gibt nur dem Anfordernden heraus", async () => {
|
||||
const vorgangId = await bereitgelegt(await dienstKey());
|
||||
await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", zweiterAdmin));
|
||||
await expect(privateKeyAbholen(vorgangId, "anzeigen", admin)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("verweigert nach Ablauf der Frist und ueberschreibt die Abholung trotzdem", async () => {
|
||||
const vorgangId = await bereitgelegt(await dienstKey());
|
||||
const { beendetAm } = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||
const spaeter = new Date(beendetAm!.getTime() + ABHOLFRIST_MS + 1000);
|
||||
const fehler = await fachfehlerVon(privateKeyAbholen(vorgangId, "anzeigen", admin, spaeter));
|
||||
expect(fehler.message).toMatch(/verfallen/);
|
||||
expect(
|
||||
JSON.stringify((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).ergebnis),
|
||||
).not.toContain("abholung");
|
||||
});
|
||||
|
||||
it("verlangt einen gespeicherten Private Key und gibt keinen Deployment-Key heraus", async () => {
|
||||
const nurPublic = await publicKeyImportieren(
|
||||
{ name: "Nur Public", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||
admin,
|
||||
);
|
||||
expect((await fachfehlerVon(abholungAnfordern(nurPublic.id, "anzeigen", admin))).message).toMatch(
|
||||
/kein Private Key/,
|
||||
);
|
||||
const deployment = await deploymentKeySicherstellen();
|
||||
expect((await fachfehlerVon(abholungAnfordern(deployment, "anzeigen", admin))).message).toMatch(
|
||||
/Deployment/,
|
||||
);
|
||||
await fachfehlerVon(privateKeyLoeschenAnfordern(deployment, admin));
|
||||
expect(await prisma.vorgang.count({ where: { art: "PRIVATE_KEY_ABHOLEN" } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Aufraeumen", () => {
|
||||
it("ueberschreibt nur Abholungen, die laenger als die Frist liegen", async () => {
|
||||
const jetzt = new Date();
|
||||
const alt = await prisma.vorgang.create({
|
||||
data: {
|
||||
art: "PRIVATE_KEY_ABHOLEN",
|
||||
status: "ERLEDIGT",
|
||||
ergebnis: { abholung: "v1.a.b.c" },
|
||||
beendetAm: new Date(jetzt.getTime() - ABHOLFRIST_MS - 5000),
|
||||
},
|
||||
});
|
||||
const frisch = await prisma.vorgang.create({
|
||||
data: {
|
||||
art: "PRIVATE_KEY_ABHOLEN",
|
||||
status: "ERLEDIGT",
|
||||
ergebnis: { abholung: "v1.d.e.f" },
|
||||
beendetAm: new Date(jetzt.getTime() - 30_000),
|
||||
},
|
||||
});
|
||||
const fremd = await prisma.vorgang.create({
|
||||
data: {
|
||||
art: "ERREICHBARKEIT",
|
||||
status: "ERLEDIGT",
|
||||
ergebnis: { abholung: "kein Key" },
|
||||
beendetAm: alt.beendetAm,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await abholungenAufraeumen(jetzt)).toBe(1);
|
||||
const lesen = async (id: string) => (await prisma.vorgang.findUniqueOrThrow({ where: { id } })).ergebnis;
|
||||
expect(await lesen(alt.id)).toEqual({ verfallen: true });
|
||||
expect(await lesen(frisch.id)).toEqual({ abholung: "v1.d.e.f" });
|
||||
expect(await lesen(fremd.id)).toEqual({ abholung: "kein Key" });
|
||||
// Spaeter verfaellt auch die zweite.
|
||||
expect(await abholungenAufraeumen(new Date(jetzt.getTime() + ABHOLFRIST_MS))).toBe(1);
|
||||
});
|
||||
|
||||
it("laeuft vor jeder neuen Abholung im Worker", async () => {
|
||||
const schluesselId = await dienstKey();
|
||||
const liegengeblieben = await bereitgelegt(schluesselId);
|
||||
await prisma.vorgang.update({
|
||||
where: { id: liegengeblieben },
|
||||
data: { beendetAm: new Date(Date.now() - ABHOLFRIST_MS - 1000) },
|
||||
});
|
||||
await bereitgelegt(schluesselId);
|
||||
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: liegengeblieben } })).ergebnis).toEqual({
|
||||
verfallen: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Private Key loeschen", () => {
|
||||
it("verwirft Chiffrat, Data-Key und Version im Worker und protokolliert", async () => {
|
||||
const schluesselId = await dienstKey();
|
||||
const vorgangId = await privateKeyLoeschenAnfordern(schluesselId, admin);
|
||||
await vorgangImWorker(vorgangId);
|
||||
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })).status).toBe("ERLEDIGT");
|
||||
|
||||
const detail = await schluesselDetail(schluesselId);
|
||||
expect(detail?.privatGespeichert).toBe(false);
|
||||
expect(detail?.privatGeloeschtAm).toBeInstanceOf(Date);
|
||||
await expect(privateKeyLaden(schluesselId)).rejects.toThrow(/kein Private Key/);
|
||||
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "privatekey.geloescht" } });
|
||||
expect(eintrag).toMatchObject({ akteurId: admin.id, fingerprint: detail?.fingerprint });
|
||||
await fachfehlerVon(privateKeyLoeschenAnfordern(schluesselId, admin));
|
||||
await fachfehlerVon(abholungAnfordern(schluesselId, "anzeigen", admin));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rechte", () => {
|
||||
it("nur Admin, immer mit frischer Bestaetigung", () => {
|
||||
for (const recht of ["privateKeyAnzeigen", "privateKeyLoeschen"] as const) {
|
||||
expect(RECHTE[recht]).toEqual(["ADMIN"]);
|
||||
expect(BESTAETIGUNG_NOETIG.has(recht)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("Action und Route Handler pruefen selbst", () => {
|
||||
const aktionen = readFileSync("src/server/actions/privatekey.ts", "utf8");
|
||||
expect(aktionen.match(/rechtErforderlich\("privateKeyAnzeigen"\)/g)).toHaveLength(2);
|
||||
expect(aktionen).toMatch(/rechtErforderlich\("privateKeyLoeschen"\)/);
|
||||
const route = readFileSync("src/app/api/private-key/[vorgangId]/route.ts", "utf8");
|
||||
expect(route).toMatch(/rechtErforderlich\("privateKeyAnzeigen"\)/);
|
||||
expect(route).toMatch(/no-store/);
|
||||
expect(route).toMatch(/attachment/);
|
||||
expect(route).not.toMatch(/export async function GET/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user