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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:51:04 +02:00
co-authored by Claude Opus 5.5
parent b5b43f41f6
commit 0bb62450f3
15 changed files with 1029 additions and 8 deletions
@@ -0,0 +1,224 @@
"use client";
import { Download, Eye, Trash2, TriangleAlert } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState, useTransition } from "react";
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
import { MonoBlock } from "@/components/ui/MonoBlock";
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
import {
abholungAnfordernAktion,
privateKeyAnzeigenAktion,
privateKeyLoeschenAktion,
type PrivateKeyAntwort,
} from "@/server/actions/privatekey";
/**
* Private Key anzeigen, herunterladen und loeschen (#014). Alles verlangt frische
* Bestaetigung; fehlt sie, geht es nach /bestaetigen und von dort mit `?privatekey=1` zurueck,
* damit der Dialog gleich wieder offen ist.
*
* Der Key lebt nur im Zustand dieses Dialogs und wird beim Schliessen verworfen.
*/
const DIALOG_KLASSEN =
"border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-2xl border p-5 backdrop:bg-black/70 sm:p-6";
function useBestaetigungUmleitung() {
const router = useRouter();
const pfad = usePathname();
return (antwort: PrivateKeyAntwort): boolean => {
if (!antwort.bestaetigen) return false;
router.push(`/bestaetigen?ziel=${encodeURIComponent(`${pfad}?privatekey=1`)}`);
return true;
};
}
export function PrivateKeyAnzeigen({ schluesselId, name }: { schluesselId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const download = useRef<HTMLFormElement>(null);
const suche = useSearchParams();
const umleiten = useBestaetigungUmleitung();
const [zweck, setZweck] = useState<"anzeigen" | "herunterladen">();
const [vorgangId, setVorgangId] = useState<string>();
const [privateKey, setPrivateKey] = useState<string>();
const [fehler, setFehler] = useState<string>();
const [startet, starten] = useTransition();
const { stand, abfrageFehler } = useVorgang(vorgangId);
const abgeholt = useRef<string | undefined>(undefined);
// Zurueck von /bestaetigen: Dialog gleich oeffnen.
useEffect(() => {
if (suche.get("privatekey") === "1") dialog.current?.showModal();
}, [suche]);
// Bereit? Dann genau einmal abholen – als Anzeige oder als Download-Formular.
useEffect(() => {
if (!vorgangId || stand?.status !== "ERLEDIGT" || abgeholt.current === vorgangId) return;
abgeholt.current = vorgangId;
if (zweck === "herunterladen") {
download.current?.submit();
return;
}
void privateKeyAnzeigenAktion(vorgangId).then((antwort) => {
if (umleiten(antwort)) return;
if (antwort.fehler) setFehler(antwort.fehler);
else setPrivateKey(antwort.privateKey);
});
}, [vorgangId, stand?.status, zweck, umleiten]);
function anfordern(art: "anzeigen" | "herunterladen") {
starten(async () => {
setFehler(undefined);
setPrivateKey(undefined);
setZweck(art);
const antwort = await abholungAnfordernAktion(schluesselId, art);
if (umleiten(antwort)) return;
if (antwort.fehler) setFehler(antwort.fehler);
else setVorgangId(antwort.vorgangId);
});
}
function schliessen() {
// Nichts vom Key bleibt im Zustand stehen.
setPrivateKey(undefined);
setVorgangId(undefined);
setFehler(undefined);
dialog.current?.close();
}
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
const downloadGestartet =
zweck === "herunterladen" && vorgangId !== undefined && stand?.status === "ERLEDIGT";
return (
<>
<button
type="button"
onClick={() => dialog.current?.showModal()}
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
>
anzeigen (Re-Auth)
</button>
<dialog ref={dialog} aria-labelledby="privatekey-titel" className={DIALOG_KLASSEN} onClose={schliessen}>
<div className="flex flex-col gap-4">
<h2 id="privatekey-titel" className="text-[17px] font-semibold">
Private Key von „{name}“
</h2>
{privateKey ? (
<>
<MonoBlock wert={privateKey} beschriftung="Private Key" hoehe="22rem" />
<p className="text-accent flex items-start gap-2 text-[14px]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
Wird nicht erneut angezeigt. Kopieren Sie ihn jetzt an einen sicheren Ort.
</p>
</>
) : downloadGestartet ? (
<p role="status" className="text-ok text-[14.5px]">
Download gestartet. Der Private Key wird nicht erneut angeboten.
</p>
) : (
<>
<p className="text-muted text-[14.5px]">
Der Private Key wird im Worker entschlüsselt und genau einmal ausgeliefert. Jede Anzeige und
jeder Download steht im Audit-Log.
</p>
<div className="flex flex-wrap gap-2.5">
<Button
variante="primaer"
icon={<Eye className="size-4" />}
disabled={laeuft}
onClick={() => anfordern("anzeigen")}
>
{laeuft && zweck === "anzeigen" ? "Entschlüssele …" : "Anzeigen"}
</Button>
<Button
icon={<Download className="size-4" />}
disabled={laeuft}
onClick={() => anfordern("herunterladen")}
>
{laeuft && zweck === "herunterladen" ? "Entschlüssele …" : "Herunterladen"}
</Button>
</div>
</>
)}
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
<div className="flex justify-end">
<Button onClick={schliessen}>Schließen</Button>
</div>
</div>
{vorgangId && <form ref={download} method="post" action={`/api/private-key/${vorgangId}`} hidden />}
</dialog>
</>
);
}
/** „Private Key aus SSHKeyVault loeschen“ (#014) – endgueltig, mit Rueckfrage. */
export function PrivateKeyLoeschen({ schluesselId, name }: { schluesselId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const router = useRouter();
const umleiten = useBestaetigungUmleitung();
const [verstanden, setVerstanden] = useState(false);
const [vorgangId, setVorgangId] = useState<string>();
const [fehler, setFehler] = useState<string>();
const [startet, starten] = useTransition();
const { stand, abfrageFehler } = useVorgang(vorgangId);
useEffect(() => {
if (stand?.status === "ERLEDIGT") {
dialog.current?.close();
router.refresh();
}
}, [stand?.status, router]);
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
return (
<>
<Button
variante="gefahr"
icon={<Trash2 className="size-4" />}
onClick={() => dialog.current?.showModal()}
>
Private Key löschen
</Button>
<dialog ref={dialog} aria-labelledby="privatekey-loeschen-titel" className={DIALOG_KLASSEN}>
<div className="flex flex-col gap-4">
<h2 id="privatekey-loeschen-titel" className="text-[17px] font-semibold">
Private Key von „{name}“ aus SSHKeyVault löschen?
</h2>
<p className="text-muted text-[14.5px]">
sshkv verwirft den verschlüsselten Private Key endgültig. Der Public Key und alle Zuweisungen
bleiben; auf den Hosts ändert sich nichts. Danach kann sshkv den Private Key weder anzeigen noch
auf weitere Hosts ausrollen.
</p>
<Ankreuzfeld
checked={verstanden}
onChange={(e) => setVerstanden(e.target.checked)}
beschriftung="Ich habe verstanden, dass sich das nicht rückgängig machen lässt."
/>
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
<Button
variante="gefahr"
disabled={!verstanden || laeuft}
onClick={() =>
starten(async () => {
setFehler(undefined);
const antwort = await privateKeyLoeschenAktion(schluesselId);
if (umleiten(antwort)) return;
if (antwort.fehler) setFehler(antwort.fehler);
else setVorgangId(antwort.vorgangId);
})
}
>
{laeuft ? "Lösche …" : "Endgültig löschen"}
</Button>
</div>
</div>
</dialog>
</>
);
}
+28 -1
View File
@@ -17,6 +17,7 @@ import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
export const metadata: Metadata = { title: "Schlüssel" };
@@ -160,6 +161,10 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
const aktiv = s.status === "AKTIV";
const personenKey = s.kategorie === "PERSON";
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
const jetzt = new Date();
return (
@@ -216,7 +221,17 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</span>
</InfoFeld>
<InfoFeld titel="Private Key">
<PrivatStatus s={s} />
<PrivatStatus
s={s}
anzeigen={
darfAnzeigen && (
<>
<span aria-hidden>·</span>
<PrivateKeyAnzeigen schluesselId={s.id} name={s.name} />
</>
)
}
/>
</InfoFeld>
</dl>
</Karte>
@@ -280,6 +295,18 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
>
<MonoBlock wert={s.publicKey} beschriftung="Public Key" umbrechen />
</Karte>
{darfLoeschen && (
<Karte titel="Private Key löschen" untertitel="Nur noch auf den Hosts, nicht mehr in SSHKeyVault">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted max-w-xl text-[14px]">
Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann
ihn niemand mehr aus SSHKeyVault holen – auch kein Admin.
</p>
<PrivateKeyLoeschen schluesselId={s.id} name={s.name} />
</div>
</Karte>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { akteurVon } from "@/server/actions/formular";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { HostFehler } from "@/server/services/hosts";
import { privateKeyAbholen } from "@/server/services/privatekey";
/**
* Download des Private Keys als Datei `<slug>` (#014). Nur POST aus einem Formular dieser
* Anwendung: ein GET liesse sich per Link oder Vorabruf ausloesen und verbraeuchte die
* einmalige Abholung. Fremde Seiten haelt die Herkunftspruefung fern.
*
* Prueft selbst Recht und frische Bestaetigung. Antwort ohne Caching; Fehlertexte enthalten
* nie Schluesselmaterial.
*/
const KEIN_CACHE = { "Cache-Control": "no-store, max-age=0", Pragma: "no-cache" } as const;
function text(meldung: string, status: number): NextResponse {
return new NextResponse(`${meldung}\n`, {
status,
headers: { "Content-Type": "text/plain; charset=utf-8", ...KEIN_CACHE },
});
}
/**
* Kommt die Anfrage von dieser Anwendung? Wegen `Referrer-Policy: no-referrer` schicken
* Browser bei Formular-POSTs `Origin: null`; massgeblich ist deshalb `Sec-Fetch-Site`, das
* Seiten nicht faelschen koennen. Ein passender Origin genuegt ebenso.
*/
function vonHier(anfrage: NextRequest): boolean {
if (anfrage.headers.get("sec-fetch-site") === "same-origin") return true;
return anfrage.headers.get("origin") === oeffentlicheAdresse("/", anfrage.nextUrl.origin).origin;
}
export async function POST(anfrage: NextRequest, { params }: { params: Promise<{ vorgangId: string }> }) {
if (!vonHier(anfrage)) return text("Anfrage von fremder Herkunft.", 403);
let benutzer;
try {
({ benutzer } = await rechtErforderlich("privateKeyAnzeigen"));
} catch (fehler) {
if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler)
return text(fehler.message, 403);
throw fehler;
}
const { vorgangId } = await params;
try {
const abholung = await privateKeyAbholen(vorgangId.slice(0, 64), "herunterladen", akteurVon(benutzer));
return new NextResponse(abholung.privateKey, {
headers: {
"Content-Type": "application/octet-stream",
// Der Slug passt per DB-CHECK in einen Dateinamen; so heisst die Datei wie in ~/.ssh.
"Content-Disposition": `attachment; filename="${abholung.slug}"`,
"X-Content-Type-Options": "nosniff",
...KEIN_CACHE,
},
});
} catch (fehler) {
if (fehler instanceof HostFehler) return text(fehler.message, 410);
throw fehler;
}
}
+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);
};