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
+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/);
});
});