import { randomBytes } from "node:crypto"; import { mkdtempSync, 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 { entschluesseln } from "@/lib/geheimnisse"; import { ablaufZustand, fingerprintKurz, freierSlug, idsAusSuchparameter, slugAus, typAnzeige, erzeugenSchema, } from "@/lib/schluessel"; import { privateKeyLesen, schluesselpaarErzeugen } from "@/lib/ssh/openssh"; import { schluesselDetail, schluesselListe } from "@/server/queries/schluessel"; import { FachFehler } from "@/server/services/fachfehler"; import { erzeugenAnfordern, paarImportAnfordern, publicKeyImportieren, schluesselBearbeiten, } from "@/server/services/schluessel"; import { masterKeyVergessen, privateKeyLaden } from "@/worker/geheimnisse"; import { datenbankLeeren } from "./hilfen/db"; import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang"; import { ed25519Blob, rsaBlob, zeile } from "./ssh/hilfen"; /** Schluessel erzeugen, importieren, bearbeiten (#012) – Service, Worker-Ablauf, Lesemodell. */ let verzeichnis: string; let akteur: { 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(); const benutzer = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } }); akteur = { id: benutzer.id, name: benutzer.name }; }); async function fachfehlerVon(versuch: Promise): Promise { const fehler = await versuch.then( () => undefined, (f: unknown) => f, ); expect(fehler).toBeInstanceOf(FachFehler); return fehler as FachFehler; } /** Kern eines Private Keys (ohne Kopf- und Fusszeile) – das eigentliche Geheimnis. */ function kern(privateKey: string): string { return privateKey.split("\n").slice(1, -2).join("").slice(0, 60); } describe("Regeln (rein)", () => { it("liest IDs aus dem Suchparameter, auch UUIDs vom Worker (#032)", () => { const uuid = "5f703ff1-3877-4af4-af08-84338237fcd8"; expect(idsAusSuchparameter([uuid, "cm1abc"])).toEqual([uuid, "cm1abc"]); expect(idsAusSuchparameter(`a,b,a`)).toEqual(["a", "b"]); expect(idsAusSuchparameter(["x y", "../etc", ""])).toEqual([]); expect(idsAusSuchparameter(undefined)).toEqual([]); expect(idsAusSuchparameter(["a", "b", "c"], 2)).toEqual(["a", "b"]); }); it.each([ ["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"], ["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"], [" --- ", "schluessel"], ["Café", "cafe"], ["x".repeat(80), "x".repeat(64)], ])("slugAus(%j) = %j", (name, slug) => { expect(slugAus(name)).toBe(slug); }); it("haengt Nummern an, bis der Slug frei ist", async () => { const belegt = new Set(["backup", "backup-2"]); expect(await freierSlug("backup", async (s) => !belegt.has(s))).toBe("backup-3"); const lang = "a".repeat(64); const ergebnis = await freierSlug(lang, async (s) => s !== lang); expect(ergebnis).toHaveLength(64); expect(ergebnis.endsWith("-2")).toBe(true); }); it("kuerzt Fingerprints und zeigt Typen", () => { expect(fingerprintKurz("SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k")).toBe( "SHA256:q3vN8xP0…Qe0k", ); expect(typAnzeige("ED25519", "ssh-ed25519 AAAA")).toBe("ed25519"); expect(typAnzeige("RSA4096", "ssh-rsa AAAA")).toBe("rsa-4096"); expect(typAnzeige("ANDERER", "ecdsa-sha2-nistp256 AAAA")).toBe("ecdsa-sha2-nistp256"); expect(typAnzeige("ANDERER", "sk-ssh-ed25519@openssh.com AAAA")).toBe("sk-ssh-ed25519"); }); it("bewertet das Ablaufdatum", () => { const jetzt = new Date("2026-10-12T08:00:00Z"); expect(ablaufZustand(null, jetzt).art).toBe("keiner"); expect(ablaufZustand(new Date("2026-10-30T00:00:00Z"), jetzt)).toEqual({ art: "bald", tage: 18 }); expect(ablaufZustand(new Date("2026-11-12T00:00:00Z"), jetzt).art).toBe("spaeter"); expect(ablaufZustand(new Date("2026-10-11T00:00:00Z"), jetzt).art).toBe("abgelaufen"); }); it("prueft Eingaben", () => { expect(erzeugenSchema.safeParse({ name: "" }).success).toBe(false); expect(erzeugenSchema.safeParse({ name: "x", slug: "Mit Leer" }).success).toBe(false); expect(erzeugenSchema.safeParse({ name: "x", kommentar: "a\nb" }).success).toBe(false); expect(erzeugenSchema.safeParse({ name: "x", ablaufAm: "2026-02-30" }).success).toBe(false); expect(erzeugenSchema.safeParse({ name: "x", typ: "DSA" }).success).toBe(false); const ok = erzeugenSchema.parse({ name: " Backup ", tags: "PVE, pve backup", ablaufAm: "2030-01-31" }); expect(ok).toMatchObject({ name: "Backup", tags: ["pve", "backup"], typ: "ED25519", slug: null }); expect(ok.ablaufAm?.toISOString()).toBe("2030-01-31T00:00:00.000Z"); }); }); describe("Public Key importieren", () => { it("legt an, setzt Slug und Kommentar und protokolliert", async () => { const blob = ed25519Blob(); const neu = await publicKeyImportieren( { name: "Laptop Florian", kategorie: "PERSON", publicKey: zeile("ssh-ed25519", blob, "florian@laptop"), }, akteur, ); const s = await schluesselDetail(neu.id); expect(s).toMatchObject({ slug: "laptop-florian", typ: "ED25519", kategorie: "PERSON", kommentar: "florian@laptop", privatGespeichert: false, selbstHinterlegt: false, }); expect(s?.publicKey).toBe(zeile("ssh-ed25519", blob, "florian@laptop")); const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.importiert" } }); expect(eintrag.fingerprint).toBe(s?.fingerprint); expect(eintrag.akteurName).toBe("Olga Operator"); }); it("meldet einen schon verwalteten Key mit Verweis", async () => { const z = zeile("ssh-ed25519", ed25519Blob()); const erster = await publicKeyImportieren({ name: "Erster", publicKey: z }, akteur); const fehler = await fachfehlerVon(publicKeyImportieren({ name: "Zweiter", publicKey: z }, akteur)); expect(fehler.message).toContain("„Erster“"); expect(fehler.message).toContain(`/schluessel/${erster.id}`); expect(fehler.feldfehler["publicKey"]).toBeDefined(); }); it("nummeriert abgeleitete Slugs und lehnt vergebene Wunsch-Slugs ab", async () => { const a = await publicKeyImportieren( { name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) }, akteur, ); const b = await publicKeyImportieren( { name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) }, akteur, ); expect([a.slug, b.slug]).toEqual(["backup", "backup-2"]); const fehler = await fachfehlerVon( publicKeyImportieren( { name: "X", slug: "backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) }, akteur, ), ); expect(fehler.feldfehler["slug"]).toMatch(/vergeben/); }); it("verlangt RSA mit mindestens 2048 Bit und erkennt RSA-4096", async () => { const fehler = await fachfehlerVon( publicKeyImportieren({ name: "Alt", publicKey: zeile("ssh-rsa", rsaBlob(1024)) }, akteur), ); expect(fehler.feldfehler["publicKey"]).toMatch(/2048/); const neu = await publicKeyImportieren( { name: "Neu", publicKey: zeile("ssh-rsa", rsaBlob(4096)) }, akteur, ); expect((await schluesselDetail(neu.id))?.typ).toBe("RSA4096"); const mittel = await publicKeyImportieren( { name: "Mittel", publicKey: zeile("ssh-rsa", rsaBlob(3072)) }, akteur, ); expect((await schluesselDetail(mittel.id))?.typ).toBe("ANDERER"); }); it("lehnt Unsinn, Vergangenheit und unbekannte Bezuege ab", async () => { await fachfehlerVon(publicKeyImportieren({ name: "X", publicKey: "kein key" }, akteur)); const vergangen = await fachfehlerVon( publicKeyImportieren( { name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), ablaufAm: "2020-01-01" }, akteur, ), ); expect(vergangen.feldfehler["ablaufAm"]).toBeDefined(); const person = await fachfehlerVon( publicKeyImportieren( { name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), personId: "gibtsnicht" }, akteur, ), ); expect(person.feldfehler["personId"]).toBeDefined(); expect(await prisma.schluessel.count()).toBe(0); }); it("setzt das Ablaufdatum aus der Laufzeit der Vorlage", async () => { const vorlage = await prisma.vorlage.create({ data: { name: "Kurz", zielbenutzer: "deploy", standardLaufzeitTage: 10 }, }); const neu = await publicKeyImportieren( { name: "Mit Vorlage", publicKey: zeile("ssh-ed25519", ed25519Blob()), vorlageId: vorlage.id }, akteur, ); const s = await schluesselDetail(neu.id); expect(ablaufZustand(s!.ablaufAm)).toEqual({ art: "bald", tage: 10 }); expect(s?.vorlage?.name).toBe("Kurz"); }); }); describe("Erzeugen im Worker", () => { it("erzeugt ed25519, versiegelt und verweist den Vorgang auf den Schluessel", async () => { const vorgangId = await erzeugenAnfordern( { name: "Backup srv-backup01", tags: "backup" }, undefined, akteur, ); const { jobDaten } = await vorgangImWorker(vorgangId); expect(jobDaten.geheim).toBeUndefined(); const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }); expect(vorgang.status).toBe("ERLEDIGT"); const { schluesselId, fingerprint } = vorgang.ergebnis as { schluesselId: string; fingerprint: string }; expect(vorgang.schluesselId).toBe(schluesselId); const s = await schluesselDetail(schluesselId); expect(s).toMatchObject({ slug: "backup-srv-backup01", kommentar: "sshkv:backup-srv-backup01", kategorie: "DIENST", typ: "ED25519", privatGespeichert: true, passphraseGeschuetzt: false, fingerprint, tags: ["backup"], }); expect(s?.erstelltVon?.name).toBe("Olga Operator"); const privat = await privateKeyLaden(schluesselId); expect(privateKeyLesen(privat.toString("utf8")).fingerprint).toBe(fingerprint); privat.fill(0); const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.erzeugt" } }); expect(eintrag).toMatchObject({ fingerprint, akteurId: akteur.id, ergebnis: "ERFOLG" }); }); it("speichert mit Passphrase den geschuetzten Key – die Passphrase nirgends", async () => { const passphrase = "korrekt-pferd-batterie-heftklammer"; const vorgangId = await erzeugenAnfordern({ name: "Geschuetzt", typ: "ED25519" }, passphrase, akteur); const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }); expect(JSON.stringify(vorgang.eingabe)).not.toContain(passphrase); expect(vorgang.eingabe).toMatchObject({ mitPassphrase: true }); const { jobDaten } = await vorgangImWorker(vorgangId); expect(jobDaten.geheim).toBeDefined(); expect(jobDaten.geheim).not.toContain(passphrase); expect(JSON.parse(entschluesseln(jobDaten.geheim!))).toEqual({ passphrase }); const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })) .ergebnis as { schluesselId: string }; expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true); const privat = (await privateKeyLaden(schluesselId)).toString("utf8"); expect(() => privateKeyLesen(privat)).toThrow(/Passphrase/); expect(privateKeyLesen(privat, passphrase).verschluesselt).toBe(true); const alles = await datenbankAlsText(); expect(alles).not.toContain(passphrase); expect(alles).not.toContain("PRIVATE KEY"); }); it("erzeugt RSA-4096", { timeout: 30_000 }, async () => { const vorgangId = await erzeugenAnfordern({ name: "Alt-System", typ: "RSA4096" }, undefined, akteur); await vorgangImWorker(vorgangId); const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } })) .ergebnis as { schluesselId: string }; const s = await schluesselDetail(schluesselId); expect(s?.typ).toBe("RSA4096"); expect(s?.publicKey.startsWith("ssh-rsa ")).toBe(true); }); it("scheitert verstaendlich, wenn der Slug inzwischen vergeben ist", async () => { const vorgangId = await erzeugenAnfordern({ name: "Doppelt" }, undefined, akteur); await publicKeyImportieren( { name: "Doppelt", slug: "doppelt", publicKey: zeile("ssh-ed25519", ed25519Blob()) }, akteur, ); await vorgangImWorker(vorgangId); const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }); expect(vorgang.status).toBe("FEHLGESCHLAGEN"); expect(vorgang.fehler).toMatch(/inzwischen vergeben/); }); }); describe("Paar importieren im Worker", () => { it("prueft, versiegelt und laesst weder Vorgang noch Protokoll den Key sehen", async () => { const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "alt@server" }); const vorgangId = await paarImportAnfordern( { name: "Altlast" }, paar.privateKeyOpenssh, undefined, akteur, ); const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }); expect(JSON.stringify(vorgang)).not.toContain("PRIVATE KEY"); const { jobDaten } = await vorgangImWorker(vorgangId); expect(jobDaten.geheim).not.toContain(kern(paar.privateKeyOpenssh)); const fertig = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }); expect(fertig.status).toBe("ERLEDIGT"); const { schluesselId } = fertig.ergebnis as { schluesselId: string }; const s = await schluesselDetail(schluesselId); expect(s).toMatchObject({ fingerprint: paar.fingerprint, kommentar: "alt@server", privatGespeichert: true, }); expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh); const alles = await datenbankAlsText(); expect(alles).not.toContain("PRIVATE KEY"); expect(alles).not.toContain(kern(paar.privateKeyOpenssh)); expect(await prisma.protokoll.count({ where: { aktion: "schluessel.importiert" } })).toBe(1); }); it("behaelt einen passphrasengeschuetzten Key so, wie er kam", async () => { const passphrase = "nur-zur-pruefung-1234"; const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x", passphrase }); const ohne = await paarImportAnfordern({ name: "Ohne" }, paar.privateKeyOpenssh, undefined, akteur); await vorgangImWorker(ohne); expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: ohne } })).fehler).toMatch(/Passphrase/); const falsch = await paarImportAnfordern( { name: "Falsch" }, paar.privateKeyOpenssh, "Zz9-unpassend", akteur, ); await vorgangImWorker(falsch); const fehler = (await prisma.vorgang.findUniqueOrThrow({ where: { id: falsch } })).fehler ?? ""; expect(fehler).toMatch(/Passphrase/); expect(fehler).not.toContain("Zz9-unpassend"); const richtig = await paarImportAnfordern( { name: "Richtig" }, paar.privateKeyOpenssh, passphrase, akteur, ); await vorgangImWorker(richtig); const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: richtig } })) .ergebnis as { schluesselId: string; }; expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true); expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh); expect(await datenbankAlsText()).not.toContain(passphrase); }); it("lehnt Fremdformate im Web und Duplikate im Worker ab", async () => { const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----"; const fehler = await fachfehlerVon(paarImportAnfordern({ name: "PEM" }, pem, undefined, akteur)); expect(fehler.message).not.toContain("MIIE"); const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x" }); await publicKeyImportieren({ name: "Schon da", publicKey: paar.publicKeyZeile }, akteur); const id = await paarImportAnfordern({ name: "Nochmal" }, paar.privateKeyOpenssh, undefined, akteur); await vorgangImWorker(id); expect((await prisma.vorgang.findUniqueOrThrow({ where: { id } })).fehler).toMatch(/„Schon da“/); }); }); describe("Bearbeiten und Lesemodell", () => { it("aendert Metadaten, protokolliert nur Geaendertes und schuetzt den Status", async () => { const neu = await publicKeyImportieren( { name: "Alt", publicKey: zeile("ssh-ed25519", ed25519Blob()) }, akteur, ); await schluesselBearbeiten(neu.id, { name: "Neu", tags: "a b", status: "ABGELAUFEN" }, akteur); const s = await schluesselDetail(neu.id); expect(s).toMatchObject({ name: "Neu", tags: ["a", "b"], status: "ABGELAUFEN", slug: "alt" }); const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.geaendert" } }); expect(Object.keys(eintrag.details as object).sort()).toEqual(["name", "status", "tags"]); await prisma.schluessel.update({ where: { id: neu.id }, data: { status: "WIDERRUFEN" }, select: { id: true }, }); await fachfehlerVon(schluesselBearbeiten(neu.id, { name: "Neu", status: "AKTIV" }, akteur)); }); it("filtert die Liste und liefert keine Chiffrat-Felder", async () => { await publicKeyImportieren( { name: "Person A", kategorie: "PERSON", publicKey: zeile("ssh-ed25519", ed25519Blob()), tags: "laptop", }, akteur, ); const vorgangId = await erzeugenAnfordern({ name: "Dienst B" }, undefined, akteur); await vorgangImWorker(vorgangId); expect((await schluesselListe()).map((s) => s.name)).toEqual(["Dienst B", "Person A"]); expect((await schluesselListe({ kategorie: "PERSON" })).map((s) => s.name)).toEqual(["Person A"]); expect((await schluesselListe({ q: "laptop" })).map((s) => s.name)).toEqual(["Person A"]); expect((await schluesselListe({ status: "WIDERRUFEN" })).length).toBe(0); expect((await schluesselListe({ kategorie: "QUATSCH" })).length).toBe(2); const liste = await schluesselListe(); const dienst = liste.find((s) => s.name === "Dienst B")!; expect(dienst.privatGespeichert).toBe(true); const detail = await schluesselDetail(dienst.id); for (const antwort of [liste, detail]) { const text = JSON.stringify(antwort); expect(text).not.toMatch(/privatVerschluesselt|dekVerschluesselt|masterKeyVersion/); } }); });