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

Admins koennen einen gespeicherten Private Key nach frischer
Bestaetigung einmalig anzeigen oder als Datei <slug> herunterladen und
ihn endgueltig aus SSHKeyVault loeschen.

Warum so:
- Der Web-Prozess hat keinen Master-Key. Er bittet den Worker per
  Vorgang, den Key zu oeffnen; der legt ihn mit SSHKV_APP_SECRET
  verschluesselt in Vorgang.ergebnis ab. Das Web holt ihn genau einmal
  ab: ein einziges UPDATE unter Zeilensperre liest und ueberschreibt.
- Nicht abgeholte Keys verfallen nach zwei Minuten: Worker-Takt alle
  30 s, Aufraeumen vor jeder Abholung, und das Web gibt aeltere nicht
  mehr heraus. Die Status-Schnittstelle zeigt nur "bereit".
- Download nur per POST (ein GET-Link oder Vorabruf wuerde die
  Abholung verbrauchen), no-store, Herkunft per Sec-Fetch-Site, weil
  no-referrer bei Formular-POSTs Origin: null sendet.
- Loeschen laeuft als Worker-Vorgang, damit nur geheimnisse.ts die
  Chiffrat-Felder anfasst; eigenes Recht privateKeyLoeschen mit
  Bestaetigung. Deployment-Keys gibt sshkv weder heraus noch loescht es
  sie.
- Protokoll: angefordert, angezeigt, heruntergeladen, geloescht - mit
  Fingerprint, nie mit Key.

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