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>
250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
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/);
|
|
});
|
|
});
|