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:
@@ -0,0 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { HostFehler } from "@/server/services/hosts";
|
||||
import {
|
||||
abholungAnfordern,
|
||||
privateKeyAbholen,
|
||||
privateKeyLoeschenAnfordern,
|
||||
} from "@/server/services/privatekey";
|
||||
|
||||
/**
|
||||
* Private Key anzeigen und loeschen (#014). Jede Aktion prueft selbst Recht **und** frische
|
||||
* Bestaetigung (`rechtErforderlich` mit einem Recht aus BESTAETIGUNG_NOETIG). Fehlt die
|
||||
* Bestaetigung, meldet die Antwort `bestaetigen: true`, und die Oberflaeche leitet nach
|
||||
* /bestaetigen weiter.
|
||||
*
|
||||
* `privateKeyAnzeigenAktion` ist die einzige Stelle, an der ein Private Key als Antwort an den
|
||||
* Browser geht – genau einmal, fuer die Anzeige im Dialog.
|
||||
*/
|
||||
export type PrivateKeyAntwort = {
|
||||
readonly vorgangId?: string;
|
||||
readonly fehler?: string;
|
||||
readonly bestaetigen?: boolean;
|
||||
readonly privateKey?: string;
|
||||
};
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
const zweck = z.enum(["anzeigen", "herunterladen"]);
|
||||
|
||||
function fehlerAntwort(fehler: unknown): PrivateKeyAntwort {
|
||||
if (fehler instanceof BestaetigungFehler) return { fehler: fehler.message, bestaetigen: true };
|
||||
if (fehler instanceof RechteFehler || fehler instanceof HostFehler) return { fehler: fehler.message };
|
||||
if (fehler instanceof z.ZodError) return { fehler: "Ungültige Anfrage." };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
export async function abholungAnfordernAktion(schluesselId: string, art: string): Promise<PrivateKeyAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
|
||||
return {
|
||||
vorgangId: await abholungAnfordern(id.parse(schluesselId), zweck.parse(art), akteurVon(benutzer)),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return fehlerAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function privateKeyAnzeigenAktion(vorgangId: string): Promise<PrivateKeyAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
|
||||
const { privateKey } = await privateKeyAbholen(id.parse(vorgangId), "anzeigen", akteurVon(benutzer));
|
||||
return { privateKey };
|
||||
} catch (fehler) {
|
||||
return fehlerAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function privateKeyLoeschenAktion(schluesselId: string): Promise<PrivateKeyAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyLoeschen");
|
||||
const vorgangId = await privateKeyLoeschenAnfordern(id.parse(schluesselId), akteurVon(benutzer));
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return { vorgangId };
|
||||
} catch (fehler) {
|
||||
return fehlerAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type Recht =
|
||||
| "publicKeyAusrollen"
|
||||
| "privateKeyAusrollen"
|
||||
| "privateKeyAnzeigen"
|
||||
| "privateKeyLoeschen"
|
||||
| "deploymentKeyVerwalten"
|
||||
| "massenwiderruf"
|
||||
| "zielkontenVerwalten"
|
||||
@@ -33,6 +34,8 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||
// Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins.
|
||||
privateKeyAusrollen: ["ADMIN"],
|
||||
privateKeyAnzeigen: ["ADMIN"],
|
||||
// Endgueltig und nicht rueckgaengig zu machen (#014) – wie Anzeigen nur mit Re-Auth.
|
||||
privateKeyLoeschen: ["ADMIN"],
|
||||
deploymentKeyVerwalten: ["ADMIN"],
|
||||
massenwiderruf: ["ADMIN"],
|
||||
zielkontenVerwalten: ["ADMIN"],
|
||||
@@ -42,6 +45,7 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
|
||||
export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
|
||||
"privateKeyAnzeigen",
|
||||
"privateKeyLoeschen",
|
||||
"deploymentKeyVerwalten",
|
||||
"massenwiderruf",
|
||||
"zielkontenVerwalten",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln } from "@/lib/geheimnisse";
|
||||
import { ABHOLFRIST_MS } from "@/lib/schluessel";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import type { Akteur } from "@/server/services/hosts";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Private Key anzeigen, herunterladen, loeschen (#014). Die Rechte (`privateKeyAnzeigen`,
|
||||
* `privateKeyLoeschen`, beide nur Admin mit frischer Bestaetigung) prueft die Action bzw. der
|
||||
* Route Handler; hier stehen die fachlichen Regeln.
|
||||
*
|
||||
* Der Web-Prozess hat keinen Master-Key. Er bittet den Worker per Vorgang, den Key zu oeffnen;
|
||||
* der legt ihn mit SSHKV_APP_SECRET verschluesselt in `Vorgang.ergebnis` ab, und
|
||||
* `privateKeyAbholen` holt ihn genau einmal ab. Der Key steht nie im Protokoll, in einem Log
|
||||
* oder einer Fehlermeldung – nur Fingerprint und Name.
|
||||
*/
|
||||
export type Zweck = "anzeigen" | "herunterladen";
|
||||
|
||||
/** Felder fuer die Pruefung – ob ein Key da ist, verraet `masterKeyVersion`, nie das Chiffrat. */
|
||||
async function schluesselMitPrivateKey(schluesselId: string) {
|
||||
const schluessel = await prisma.schluessel.findUnique({
|
||||
where: { id: schluesselId },
|
||||
select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
|
||||
});
|
||||
if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
|
||||
if (schluessel.masterKeyVersion === null) {
|
||||
throw new FachFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
|
||||
}
|
||||
// Der Deployment-Key ist root auf allen Hosts; er verlaesst sshkv nie. Im Notfall wird er
|
||||
// rotiert, nicht exportiert.
|
||||
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||
throw new FachFehler(
|
||||
"Den Private Key eines Deployment-Keys gibt sshkv nicht heraus und löscht ihn nicht.",
|
||||
);
|
||||
}
|
||||
return schluessel;
|
||||
}
|
||||
|
||||
/** Bittet den Worker, den Private Key zur einmaligen Abholung bereitzulegen. */
|
||||
export async function abholungAnfordern(schluesselId: string, zweck: Zweck, akteur: Akteur): Promise<string> {
|
||||
const schluessel = await schluesselMitPrivateKey(schluesselId);
|
||||
const vorgangId = await vorgangEinreihen({
|
||||
art: "PRIVATE_KEY_ABHOLEN",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: schluessel.id,
|
||||
eingabe: { zweck },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "privatekey.angefordert",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: schluessel.fingerprint,
|
||||
details: { name: schluessel.name, zweck },
|
||||
});
|
||||
return vorgangId;
|
||||
}
|
||||
|
||||
type Abholung = { ergebnis: unknown; beendetAm: Date | null; schluesselId: string | null };
|
||||
|
||||
/**
|
||||
* Holt den bereitgelegten Private Key genau einmal ab: Lesen und Ueberschreiben geschehen in
|
||||
* einer Anweisung unter Zeilensperre, ein zweiter Aufruf findet nichts mehr. Nur wer den
|
||||
* Vorgang angefordert hat, kann abholen – und nur innerhalb der Abholfrist.
|
||||
*/
|
||||
export async function privateKeyAbholen(
|
||||
vorgangId: string,
|
||||
zweck: Zweck,
|
||||
akteur: Akteur,
|
||||
jetzt: Date = new Date(),
|
||||
): Promise<{ privateKey: string; slug: string; name: string; fingerprint: string }> {
|
||||
const [zeile] = await prisma.$queryRaw<Abholung[]>`
|
||||
WITH alt AS (
|
||||
SELECT "id", "ergebnis", "beendetAm", "schluesselId" FROM "Vorgang"
|
||||
WHERE "id" = ${vorgangId}
|
||||
AND "art" = 'PRIVATE_KEY_ABHOLEN'
|
||||
AND "status" = 'ERLEDIGT'
|
||||
AND "angefordertVonId" = ${akteur.id}
|
||||
AND "ergebnis" ? 'abholung'
|
||||
FOR UPDATE
|
||||
)
|
||||
UPDATE "Vorgang" v SET "ergebnis" = '{"abgeholt": true}'::jsonb
|
||||
FROM alt WHERE v."id" = alt."id"
|
||||
RETURNING alt."ergebnis", alt."beendetAm", alt."schluesselId"`;
|
||||
|
||||
if (!zeile || !zeile.schluesselId) {
|
||||
throw new FachFehler("Nichts abzuholen – der Private Key wurde schon abgeholt oder ist verfallen.");
|
||||
}
|
||||
if (!zeile.beendetAm || jetzt.getTime() - zeile.beendetAm.getTime() > ABHOLFRIST_MS) {
|
||||
throw new FachFehler("Die Abholung ist verfallen. Bitte den Private Key erneut anfordern.");
|
||||
}
|
||||
const chiffrat = (zeile.ergebnis as { abholung?: unknown }).abholung;
|
||||
if (typeof chiffrat !== "string") throw new FachFehler("Die Abholung ist unvollständig.");
|
||||
|
||||
const schluessel = await prisma.schluessel.findUniqueOrThrow({
|
||||
where: { id: zeile.schluesselId },
|
||||
select: { name: true, slug: true, fingerprint: true },
|
||||
});
|
||||
const privateKey = entschluesseln(chiffrat);
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: zweck === "anzeigen" ? "privatekey.angezeigt" : "privatekey.heruntergeladen",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: schluessel.fingerprint,
|
||||
details: { name: schluessel.name },
|
||||
});
|
||||
return { privateKey, ...schluessel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fordert das endgueltige Loeschen des gespeicherten Private Keys an (#014, „Private Key aus
|
||||
* SSHKeyVault loeschen“). Ausgefuehrt im Worker – nur src/worker/geheimnisse.ts fasst die
|
||||
* Chiffrat-Felder an, auch zum Leeren.
|
||||
*/
|
||||
export async function privateKeyLoeschenAnfordern(schluesselId: string, akteur: Akteur): Promise<string> {
|
||||
const schluessel = await schluesselMitPrivateKey(schluesselId);
|
||||
return vorgangEinreihen({
|
||||
art: "PRIVATE_KEY_LOESCHEN",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: schluessel.id,
|
||||
});
|
||||
}
|
||||
@@ -35,8 +35,20 @@ export async function vorgangEinreihen(daten: VorgangAnlegen): Promise<string> {
|
||||
return vorgang.id;
|
||||
}
|
||||
|
||||
/** Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. */
|
||||
/**
|
||||
* Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. Eine bereitliegende
|
||||
* Private-Key-Abholung (#014) wird nie ausgeliefert, auch nicht verschluesselt: abgeholt wird
|
||||
* nur ueber src/server/services/privatekey.ts.
|
||||
*/
|
||||
export async function vorgangLesen(id: string) {
|
||||
const vorgang = await vorgangRoh(id);
|
||||
if (vorgang?.art !== "PRIVATE_KEY_ABHOLEN") return vorgang;
|
||||
const ergebnis = vorgang.ergebnis;
|
||||
const bereit = typeof ergebnis === "object" && ergebnis !== null && "abholung" in ergebnis;
|
||||
return { ...vorgang, ergebnis: bereit ? { bereit: true } : ergebnis };
|
||||
}
|
||||
|
||||
async function vorgangRoh(id: string) {
|
||||
return prisma.vorgang.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
|
||||
Reference in New Issue
Block a user