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:
2026-10-12 10:51:04 +02:00
co-authored by Claude Opus 5.5
parent b5b43f41f6
commit 0bb62450f3
15 changed files with 1029 additions and 8 deletions
+103
View File
@@ -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);
});