diff --git a/docs/issues/014-private-key-anzeigen.md b/docs/issues/014-private-key-anzeigen.md index f2c6ffa..5dd6f27 100644 --- a/docs/issues/014-private-key-anzeigen.md +++ b/docs/issues/014-private-key-anzeigen.md @@ -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 `` + ü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/?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. diff --git a/src/app/(app)/schluessel/[id]/PrivateKeyAktionen.tsx b/src/app/(app)/schluessel/[id]/PrivateKeyAktionen.tsx new file mode 100644 index 0000000..6c4ab32 --- /dev/null +++ b/src/app/(app)/schluessel/[id]/PrivateKeyAktionen.tsx @@ -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(null); + const download = useRef(null); + const suche = useSearchParams(); + const umleiten = useBestaetigungUmleitung(); + const [zweck, setZweck] = useState<"anzeigen" | "herunterladen">(); + const [vorgangId, setVorgangId] = useState(); + const [privateKey, setPrivateKey] = useState(); + const [fehler, setFehler] = useState(); + const [startet, starten] = useTransition(); + const { stand, abfrageFehler } = useVorgang(vorgangId); + const abgeholt = useRef(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 ( + <> + + +
+

+ Private Key von „{name}“ +

+ {privateKey ? ( + <> + +

+ + Wird nicht erneut angezeigt. Kopieren Sie ihn jetzt an einen sicheren Ort. +

+ + ) : downloadGestartet ? ( +

+ Download gestartet. Der Private Key wird nicht erneut angeboten. +

+ ) : ( + <> +

+ Der Private Key wird im Worker entschlüsselt und genau einmal ausgeliefert. Jede Anzeige und + jeder Download steht im Audit-Log. +

+
+ + +
+ + )} + +
+ +
+
+ {vorgangId &&
+ + ); +} + +/** „Private Key aus SSHKeyVault loeschen“ (#014) – endgueltig, mit Rueckfrage. */ +export function PrivateKeyLoeschen({ schluesselId, name }: { schluesselId: string; name: string }) { + const dialog = useRef(null); + const router = useRouter(); + const umleiten = useBestaetigungUmleitung(); + const [verstanden, setVerstanden] = useState(false); + const [vorgangId, setVorgangId] = useState(); + const [fehler, setFehler] = useState(); + 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 ( + <> + + +
+

+ Private Key von „{name}“ aus SSHKeyVault löschen? +

+

+ 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. +

+ setVerstanden(e.target.checked)} + beschriftung="Ich habe verstanden, dass sich das nicht rückgängig machen lässt." + /> + +
+ + +
+
+
+ + ); +} diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx index 9b95ac7..e6ec5ca 100644 --- a/src/app/(app)/schluessel/[id]/page.tsx +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -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 - + + · + + + ) + } + /> @@ -280,6 +295,18 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id > + + {darfLoeschen && ( + +
+

+ Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann + ihn niemand mehr aus SSHKeyVault holen – auch kein Admin. +

+ +
+
+ )} ); } diff --git a/src/app/api/private-key/[vorgangId]/route.ts b/src/app/api/private-key/[vorgangId]/route.ts new file mode 100644 index 0000000..1d22615 --- /dev/null +++ b/src/app/api/private-key/[vorgangId]/route.ts @@ -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 `` (#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; + } +} diff --git a/src/lib/schluessel.ts b/src/lib/schluessel.ts index cd22966..31c6c5d 100644 --- a/src/lib/schluessel.ts +++ b/src/lib/schluessel.ts @@ -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; diff --git a/src/server/actions/privatekey.ts b/src/server/actions/privatekey.ts new file mode 100644 index 0000000..cac2f03 --- /dev/null +++ b/src/server/actions/privatekey.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/src/server/auth/rechte.ts b/src/server/auth/rechte.ts index d822225..4e618b9 100644 --- a/src/server/auth/rechte.ts +++ b/src/server/auth/rechte.ts @@ -16,6 +16,7 @@ export type Recht = | "publicKeyAusrollen" | "privateKeyAusrollen" | "privateKeyAnzeigen" + | "privateKeyLoeschen" | "deploymentKeyVerwalten" | "massenwiderruf" | "zielkontenVerwalten" @@ -33,6 +34,8 @@ export const RECHTE: Readonly> = { // 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> = { /** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */ export const BESTAETIGUNG_NOETIG: ReadonlySet = new Set([ "privateKeyAnzeigen", + "privateKeyLoeschen", "deploymentKeyVerwalten", "massenwiderruf", "zielkontenVerwalten", diff --git a/src/server/services/privatekey.ts b/src/server/services/privatekey.ts new file mode 100644 index 0000000..2638a9c --- /dev/null +++ b/src/server/services/privatekey.ts @@ -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 { + 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` + 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 { + const schluessel = await schluesselMitPrivateKey(schluesselId); + return vorgangEinreihen({ + art: "PRIVATE_KEY_LOESCHEN", + angefordertVonId: akteur.id, + schluesselId: schluessel.id, + }); +} diff --git a/src/server/vorgaenge/vorgaenge.ts b/src/server/vorgaenge/vorgaenge.ts index a44f2ac..2e0f65b 100644 --- a/src/server/vorgaenge/vorgaenge.ts +++ b/src/server/vorgaenge/vorgaenge.ts @@ -35,8 +35,20 @@ export async function vorgangEinreihen(daten: VorgangAnlegen): Promise { 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: { diff --git a/src/worker/ablaeufe/privatekey.ts b/src/worker/ablaeufe/privatekey.ts new file mode 100644 index 0000000..39f6f5b --- /dev/null +++ b/src/worker/ablaeufe/privatekey.ts @@ -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 { + 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 { + 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 } }; +}; diff --git a/src/worker/handler.ts b/src/worker/handler.ts index eac2c9e..fa17645 100644 --- a/src/worker/handler.ts +++ b/src/worker/handler.ts @@ -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, }; diff --git a/src/worker/index.ts b/src/worker/index.ts index ca0b997..219e5b3 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -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 { }, ); + // 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); }; diff --git a/tests/anmeldung.test.ts b/tests/anmeldung.test.ts index 5b35b7f..b51624b 100644 --- a/tests/anmeldung.test.ts +++ b/tests/anmeldung.test.ts @@ -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"]); diff --git a/tests/e2e/schluessel.spec.ts b/tests/e2e/schluessel.spec.ts index 6277f86..330747d 100644 --- a/tests/e2e/schluessel.spec.ts +++ b/tests/e2e/schluessel.spec.ts @@ -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(arbeit: (client: pg.Client) => Promise): Promise { + 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; + 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 , 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); +}); diff --git a/tests/privatekey.test.ts b/tests/privatekey.test.ts new file mode 100644 index 0000000..f1dcfbe --- /dev/null +++ b/tests/privatekey.test.ts @@ -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): Promise { + 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 { + 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/); + }); +});