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
+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: {