Files
sshkeyvault/tests/schluessel.test.ts
T
florian.wildeandClaude Opus 5.5 263143134f feat(schluessel): erzeugen, importieren, exportieren (#012)
Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste
mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem
Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub.

Warum so:
- Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil
  nur er den Master-Key hat. Passphrase und importierter Private Key
  reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt
  im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach.
- Ein reiner Public Key ist kein Geheimnis und wird direkt im Web
  angelegt, mit verstaendlicher Meldung bei einem Duplikat.
- Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist,
  verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein
  struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf
  Schluessel ausserhalb des Workers ein festes select.
- Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das
  Loeschen des Private Keys; CHECKs fuer den Slug (landet in
  Dateinamen) und "geloescht nur ohne Chiffrat".
- Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:29:52 +02:00

432 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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<unknown>): Promise<FachFehler> {
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.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/);
}
});
});