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:
@@ -73,7 +73,13 @@ describe("Rechte", () => {
|
||||
|
||||
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
|
||||
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
|
||||
["deploymentKeyVerwalten", "massenwiderruf", "privateKeyAnzeigen", "zielkontenVerwalten"].sort(),
|
||||
[
|
||||
"deploymentKeyVerwalten",
|
||||
"massenwiderruf",
|
||||
"privateKeyAnzeigen",
|
||||
"privateKeyLoeschen",
|
||||
"zielkontenVerwalten",
|
||||
].sort(),
|
||||
);
|
||||
for (const recht of BESTAETIGUNG_NOETIG) {
|
||||
expect(RECHTE[recht]).toEqual(["ADMIN"]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
||||
|
||||
@@ -16,6 +17,16 @@ async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader")
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
|
||||
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
|
||||
await client.connect();
|
||||
try {
|
||||
return await arbeit(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
function eindeutig(): string {
|
||||
return `zz-e2e-${Date.now().toString(36)}`;
|
||||
}
|
||||
@@ -92,3 +103,95 @@ test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Entfernen", exact: true }).click();
|
||||
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
|
||||
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
|
||||
*/
|
||||
async function oidcBestaetigen(page: Page) {
|
||||
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
}
|
||||
|
||||
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `${eindeutig()} Deploy`;
|
||||
await oidcAnmelden(page, "admin");
|
||||
// Eine Bestaetigung aus einem frueheren Lauf soll nicht mitzaehlen.
|
||||
await datenbank((client) =>
|
||||
client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
|
||||
);
|
||||
await page.goto("/schluessel/neu");
|
||||
await page.getByLabel("Zweck").fill(name);
|
||||
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||
await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
|
||||
const detail = page.url();
|
||||
const fingerprint =
|
||||
(
|
||||
await page
|
||||
.getByText(/^SHA256:/)
|
||||
.first()
|
||||
.textContent()
|
||||
)?.trim() ?? "";
|
||||
|
||||
// Ohne frische Bestaetigung geht es erst nach /bestaetigen und von dort zurueck in den Dialog.
|
||||
await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Anzeigen" }).click();
|
||||
await oidcBestaetigen(page);
|
||||
await expect(page).toHaveURL(/privatekey=1/);
|
||||
await dialog.getByRole("button", { name: "Anzeigen" }).click();
|
||||
const block = dialog.getByLabel("Private Key", { exact: true });
|
||||
await expect(block).toContainText("-----BEGIN OPENSSH PRIVATE KEY-----", { timeout: 30_000 });
|
||||
await expect(dialog.getByText("Wird nicht erneut angezeigt.")).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Schließen" }).click();
|
||||
await expect(block).toHaveCount(0);
|
||||
|
||||
// Download: eigene Abholung, Datei <slug>, ohne Caching.
|
||||
await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click();
|
||||
const antwort = page.waitForResponse((r) => r.url().includes("/api/private-key/"));
|
||||
const datei = page.waitForEvent("download");
|
||||
await dialog.getByRole("button", { name: "Herunterladen" }).click();
|
||||
const kopf = (await antwort).headers();
|
||||
expect(kopf["cache-control"]).toContain("no-store");
|
||||
expect(kopf["content-disposition"]).toMatch(/^attachment; filename="zz-e2e-[a-z0-9]+-deploy"$/);
|
||||
const download = await datei;
|
||||
expect(download.suggestedFilename()).toMatch(/^zz-e2e-[a-z0-9]+-deploy$/);
|
||||
await expect(dialog.getByText(/Download gestartet/)).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Schließen" }).click();
|
||||
|
||||
// Operator sieht keinen Anzeigen-Knopf und kein Loeschen.
|
||||
const operator = await page.context().browser()!.newContext();
|
||||
const operatorSeite = await operator.newPage();
|
||||
await oidcAnmelden(operatorSeite, "operator");
|
||||
await operatorSeite.goto(detail);
|
||||
await expect(operatorSeite.getByText("verschlüsselt gespeichert")).toBeVisible();
|
||||
await expect(operatorSeite.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
|
||||
await expect(operatorSeite.getByRole("button", { name: "Private Key löschen" })).toHaveCount(0);
|
||||
await operator.close();
|
||||
|
||||
// Loeschen (Bestaetigung ist noch frisch).
|
||||
await page.getByRole("button", { name: "Private Key löschen" }).click();
|
||||
await dialog.getByText("Ich habe verstanden").click();
|
||||
await dialog.getByRole("button", { name: "Endgültig löschen" }).click();
|
||||
await expect(page.getByText(/gelöscht am/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0);
|
||||
|
||||
// Im Audit stehen Anzeige, Download und Loeschen mit Fingerprint – ohne den Key.
|
||||
expect(fingerprint).toMatch(/^SHA256:/);
|
||||
const { rows } = await datenbank((client) =>
|
||||
client.query<{ aktion: string; zeile: string }>(
|
||||
`SELECT "aktion", row_to_json(p)::text AS zeile FROM "Protokoll" p WHERE "fingerprint" = $1 ORDER BY "id"`,
|
||||
[fingerprint],
|
||||
),
|
||||
);
|
||||
expect(rows.map((r) => r.aktion)).toEqual(
|
||||
expect.arrayContaining(["privatekey.angezeigt", "privatekey.heruntergeladen", "privatekey.geloescht"]),
|
||||
);
|
||||
expect(rows.some((r) => r.zeile.includes("PRIVATE KEY"))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user