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): Promise { 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 { 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/); }); });