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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user