Files
sshkeyvault/tests/anmeldung.test.ts
T
florian.wildeandClaude Opus 5.5 0bb62450f3 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>
2026-10-12 10:51:04 +02:00

181 lines
7.1 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 { afterEach, describe, expect, it } from "vitest";
import type { Sitzung } from "@/lib/sitzung";
import { base32Dekodieren, base32Kodieren, totpCode, totpPruefen, totpSchritt } from "@/lib/totp";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { rolleAusGruppen } from "@/server/auth/oidc";
import { BESTAETIGUNG_NOETIG, RECHTE, darf } from "@/server/auth/rechte";
import { BESTAETIGUNG_GUELTIG_MS, bestaetigungFrisch, sitzungBewerten } from "@/server/auth/sitzungspflicht";
import { OEFFENTLICH, istOeffentlich, sicherheitsrichtlinie } from "@/proxy";
/** Entscheidungen rund um Anmeldung und Rechte, die ohne Datenbank pruefbar sind (#004). */
const UMGEBUNG = { ...process.env };
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("TOTP nach RFC 6238", () => {
// Testvektoren aus RFC 6238, Anhang B (SHA-1, Geheimnis "12345678901234567890").
const geheimnis = Buffer.from("12345678901234567890");
it.each([
[59, "94287082"],
[1111111109, "07081804"],
[1111111111, "14050471"],
[1234567890, "89005924"],
[2000000000, "69279037"],
])("Zeitpunkt %i ergibt %s", (sekunden, erwartet) => {
expect(totpCode(geheimnis, totpSchritt(sekunden * 1000), 8)).toBe(erwartet);
expect(totpCode(geheimnis, totpSchritt(sekunden * 1000))).toBe(erwartet.slice(2));
});
it("kodiert Base32 verlustfrei", () => {
expect(base32Kodieren(geheimnis)).toBe("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
expect(base32Dekodieren("gezd gnbv gy3t qojq gezd gnbv gy3t qojq")).toEqual(geheimnis);
});
it("nimmt den Code des Nachbarschritts an, aber keinen zweimal", () => {
const base32 = base32Kodieren(geheimnis);
const jetzt = 1_111_111_111_000;
const vorher = totpCode(geheimnis, totpSchritt(jetzt) - 1);
const schritt = totpPruefen(base32, vorher, jetzt);
expect(schritt).toBe(totpSchritt(jetzt) - 1);
// Derselbe Code nach erfolgreicher Anmeldung: abgelehnt (Wiederholung).
expect(totpPruefen(base32, vorher, jetzt, schritt)).toBeUndefined();
});
it("lehnt alte, kaputte und fremde Codes ab", () => {
const base32 = base32Kodieren(geheimnis);
const jetzt = 1_111_111_111_000;
expect(totpPruefen(base32, totpCode(geheimnis, totpSchritt(jetzt) - 3), jetzt)).toBeUndefined();
expect(totpPruefen(base32, "12345", jetzt)).toBeUndefined();
expect(totpPruefen(base32, "abcdef", jetzt)).toBeUndefined();
});
});
describe("Rechte", () => {
it("laesst Private Keys nur Admins ausrollen und anzeigen (Freigabe Frage 3)", () => {
expect(darf("ADMIN", "privateKeyAusrollen")).toBe(true);
expect(darf("OPERATOR", "privateKeyAusrollen")).toBe(false);
expect(darf("OPERATOR", "privateKeyAnzeigen")).toBe(false);
expect(darf("LESER", "privateKeyAnzeigen")).toBe(false);
});
it("laesst Operatoren Public Keys ausrollen, Leser nicht", () => {
expect(darf("OPERATOR", "publicKeyAusrollen")).toBe(true);
expect(darf("LESER", "publicKeyAusrollen")).toBe(false);
});
it("laesst jeden eigene Public Keys hinterlegen (Freigabe Frage 4)", () => {
for (const rolle of ["ADMIN", "OPERATOR", "LESER"] as const) {
expect(darf(rolle, "eigeneSchluesselHinterlegen")).toBe(true);
}
});
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
[
"deploymentKeyVerwalten",
"massenwiderruf",
"privateKeyAnzeigen",
"privateKeyLoeschen",
"zielkontenVerwalten",
].sort(),
);
for (const recht of BESTAETIGUNG_NOETIG) {
expect(RECHTE[recht]).toEqual(["ADMIN"]);
}
});
});
describe("Rollen aus OIDC-Gruppen", () => {
it("bildet die Standardgruppen ab und nimmt die staerkste", () => {
expect(rolleAusGruppen(["sshkv-readers"])).toBe("LESER");
expect(rolleAusGruppen(["sshkv-operators"])).toBe("OPERATOR");
expect(rolleAusGruppen(["sshkv-readers", "sshkv-admins"])).toBe("ADMIN");
});
it("gibt ohne passende Gruppe gar keine Rolle – kein stiller Lesezugriff", () => {
expect(rolleAusGruppen([])).toBeUndefined();
expect(rolleAusGruppen(["andere", "SSHKV-ADMINS"])).toBeUndefined();
});
it("folgt umbenannten Gruppen aus der Umgebung", () => {
process.env["SSHKV_OIDC_GROUP_ADMIN"] = "it-root";
expect(rolleAusGruppen(["it-root"])).toBe("ADMIN");
expect(rolleAusGruppen(["sshkv-admins"])).toBeUndefined();
});
});
describe("Sitzung bewerten", () => {
const sitzung: Sitzung = {
benutzerId: "b1",
email: "a@b.de",
name: "A",
herkunft: "oidc",
ausgestelltAm: 1_700_000_000,
};
it("traegt fuer ein aktives Konto", () => {
expect(sitzungBewerten(sitzung, { id: "b1", aktiv: true, sitzungenGueltigAb: new Date(0) })).toEqual({
gueltig: true,
});
});
it("verfaellt fuer fremde, deaktivierte und widerrufene Konten", () => {
expect(sitzungBewerten(sitzung, null).gueltig).toBe(false);
expect(sitzungBewerten(sitzung, { id: "b2", aktiv: true, sitzungenGueltigAb: new Date(0) }).gueltig).toBe(
false,
);
expect(
sitzungBewerten(sitzung, { id: "b1", aktiv: false, sitzungenGueltigAb: new Date(0) }).gueltig,
).toBe(false);
const spaeter = new Date((sitzung.ausgestelltAm + 60) * 1000);
expect(sitzungBewerten(sitzung, { id: "b1", aktiv: true, sitzungenGueltigAb: spaeter })).toEqual({
gueltig: false,
grund: "widerrufen",
});
});
it("haelt eine Bestaetigung genau fuenf Minuten fuer frisch", () => {
const jetzt = new Date("2026-10-12T10:00:00Z");
expect(bestaetigungFrisch(null, jetzt)).toBe(false);
expect(bestaetigungFrisch(new Date(jetzt.getTime() - BESTAETIGUNG_GUELTIG_MS), jetzt)).toBe(true);
expect(bestaetigungFrisch(new Date(jetzt.getTime() - BESTAETIGUNG_GUELTIG_MS - 1), jetzt)).toBe(false);
// Aus der Zukunft zaehlt nicht (Uhr verstellt oder Wert manipuliert).
expect(bestaetigungFrisch(new Date(jetzt.getTime() + 60_000), jetzt)).toBe(false);
});
});
describe("Ruecksprungziel", () => {
it("nimmt Pfade der Anwendung an und weist alles andere ab", () => {
expect(sicheresZiel("/schluessel/abc?tab=2")).toBe("/schluessel/abc?tab=2");
for (const boesartig of [
"https://fremd.example",
"//fremd.example",
"/\\fremd.example",
"javascript:alert(1)",
]) {
expect(sicheresZiel(boesartig)).toBe("/");
}
expect(sicheresZiel(null)).toBe("/");
});
});
describe("Proxy", () => {
it("haelt die oeffentlichen Pfade klein und benannt", () => {
expect([...OEFFENTLICH].sort()).toEqual(
["/anmelden", "/api/auth", "/api/gesundheit", "/brand", "/icons", "/manifest.webmanifest"].sort(),
);
expect(istOeffentlich("/api/auth/oidc/start")).toBe(true);
expect(istOeffentlich("/anmeldenX")).toBe(false);
expect(istOeffentlich("/schluessel")).toBe(false);
});
it("liefert im Betrieb eine strenge CSP ohne unsafe-eval", () => {
const richtlinie = sicherheitsrichtlinie("abc", false);
expect(richtlinie).toContain("script-src 'self' 'nonce-abc' 'strict-dynamic'");
expect(richtlinie).not.toContain("unsafe-eval");
expect(richtlinie).toContain("frame-ancestors 'none'");
});
});