Files
sshkeyvault/tests/geheimnisse.test.ts
T
florian.wildeandClaude Opus 5.5 5ef90310e1 feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004)
Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf
sshkv angepasst. Abweichungen mit Absicht:

- Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen
  Lesezugriff: schon die Hostliste verraet, wer wohin darf.
- Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges
  Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt
  einmal, fuenf Fehlversuche sperren 15 Minuten.
- Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf
  Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung
  von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis.
- Sitzungen laufen acht statt zwoelf Stunden.

Die Seiten folgen, sobald das Design-System (#002) steht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:17:44 +02:00

169 lines
6.5 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { GeheimnisFehler, entschluesseln, verschluesseln } from "@/lib/geheimnisse";
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen
// ihn danach wieder her, damit die Reihenfolge der Tests nichts aendert.
const APP_SECRET_VORHER = process.env["SSHKV_APP_SECRET"];
function appSecretSetzen(wert: string): void {
process.env["SSHKV_APP_SECRET"] = wert;
}
afterEach(() => {
if (APP_SECRET_VORHER === undefined) {
delete process.env["SSHKV_APP_SECRET"];
} else {
process.env["SSHKV_APP_SECRET"] = APP_SECRET_VORHER;
}
});
/** Aendert ein Byte im angegebenen Teil des Geheimtexts, ohne das Format zu verletzen. */
function teilVerfaelschen(geheimtext: string, index: number): string {
const teile = geheimtext.split(".");
const teil = teile[index];
if (teil === undefined || teil.length === 0) {
throw new Error(`Teil ${index} fehlt im Geheimtext.`);
}
// Das erste base64-Zeichen tragt die oberen Bits des ersten Bytes, eine
// Aenderung dort wirkt also sicher -- anders als am gepolsterten Ende.
const ersetzt = teil.startsWith("A") ? "B" : "A";
teile[index] = ersetzt + teil.slice(1);
return teile.join(".");
}
describe("Geheimnisse verschluesseln und entschluesseln", () => {
it("ist verlustfrei fuer einfachen Text", () => {
const klartext = "bind-passwort-123";
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
});
it.each([
["Umlaute und Eszett", "Grueße aus Bad Münstereifel: ÄÖÜäöüß"],
["Emoji", "Schlüssel 🔐 im Tresor 🗄️ mit Häkchen ✅"],
["leerer Text", ""],
["nur Leerzeichen", " "],
["Zeilenumbrueche", "erste Zeile\nzweite Zeile\r\ndritte"],
["Punkte im Klartext", "v1.das.sieht.aus.wie.ein.geheimtext"],
["base64-artiger Text", "YWJjZGVmZ2g="],
])("ist verlustfrei fuer %s", (_fall, klartext) => {
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
});
it("ist verlustfrei fuer lange Texte", () => {
const lang = "Zertifikat-".repeat(10_000);
const geheim = verschluesseln(lang);
expect(geheim.length).toBeGreaterThan(lang.length / 2);
expect(entschluesseln(geheim)).toBe(lang);
});
it("traegt das Versionspraefix und vier durch Punkt getrennte Teile", () => {
const teile = verschluesseln("etwas").split(".");
expect(teile).toHaveLength(4);
expect(teile[0]).toBe("v1");
// 12 Byte IV und 16 Byte Auth-Tag, base64 kodiert.
expect(Buffer.from(teile[1] ?? "", "base64")).toHaveLength(12);
expect(Buffer.from(teile[2] ?? "", "base64")).toHaveLength(16);
});
it("liefert fuer denselben Klartext zwei verschiedene Geheimtexte", () => {
// Jeder Aufruf zieht einen eigenen IV; sonst waere an gleichen Geheimtexten
// ablesbar, welche Konten dasselbe Passwort benutzen.
const klartext = "immer-dasselbe-passwort";
const erster = verschluesseln(klartext);
const zweiter = verschluesseln(klartext);
expect(erster).not.toBe(zweiter);
expect(erster.split(".")[1]).not.toBe(zweiter.split(".")[1]);
expect(entschluesseln(erster)).toBe(klartext);
expect(entschluesseln(zweiter)).toBe(klartext);
});
it("enthaelt den Klartext nicht im Ergebnis", () => {
const klartext = "geheimes-bind-passwort";
expect(verschluesseln(klartext)).not.toContain(klartext);
});
});
describe("Geheimnisse gegen Verfaelschung", () => {
it("wirft, wenn ein Byte im Geheimtext veraendert wurde", () => {
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 3);
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
expect(() => entschluesseln(verfaelscht)).toThrow(/Auth-Tag passt nicht/);
});
it("wirft, wenn das Auth-Tag veraendert wurde", () => {
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 2);
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
});
it("wirft, wenn der IV veraendert wurde", () => {
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 1);
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
});
it("wirft, wenn der Geheimtext abgeschnitten wurde", () => {
const geheim = verschluesseln("bind-passwort-123");
const ohneLetztenTeil = geheim.split(".").slice(0, 3).join(".");
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(GeheimnisFehler);
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(/unbekanntes Format/);
});
it.each([
["leerer Wert", ""],
["Klartext ohne Trenner", "einfach-nur-text"],
["zu wenige Teile", "v1.abc"],
["zu viele Teile", "v1.a.b.c.d"],
["unbekannte Version", "v9.AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
["IV zu kurz", "v1.AAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
["Auth-Tag zu kurz", "v1.AAAAAAAAAAAAAAAA.AAAA.AAAA"],
])("wirft mit verstaendlicher Meldung bei %s", (_fall, geheimtext) => {
let gefangen: unknown;
try {
entschluesseln(geheimtext);
} catch (fehler) {
gefangen = fehler;
}
expect(gefangen).toBeInstanceOf(GeheimnisFehler);
expect((gefangen as GeheimnisFehler).message).toMatch(/v1\.<iv>\.<authTag>\.<geheimtext>/);
});
it("wirft, wenn SSHKV_APP_SECRET zwischen Verschluesseln und Entschluesseln wechselt", () => {
const geheim = verschluesseln("bind-passwort-123");
appSecretSetzen("ein-voellig-anderes-geheimnis-mit-genug-zeichen");
expect(() => entschluesseln(geheim)).toThrow(GeheimnisFehler);
});
});
describe("SSHKV_APP_SECRET pruefen", () => {
it.each([
["fehlendes", ""],
["viel zu kurzes", "kurz"],
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
])("wirft bei %s SSHKV_APP_SECRET mit Hinweis auf openssl", (_fall, wert) => {
appSecretSetzen(wert);
expect(() => verschluesseln("etwas")).toThrow(GeheimnisFehler);
expect(() => verschluesseln("etwas")).toThrow(/openssl rand -base64 48/);
expect(() => verschluesseln("etwas")).toThrow(/mindestens 32 Zeichen/);
});
it("nimmt genau 32 Zeichen an", () => {
appSecretSetzen("b".repeat(32));
expect(entschluesseln(verschluesseln("grenzfall"))).toBe("grenzfall");
});
it("stellt den Standardwert aus setup-env wieder her", () => {
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die
// Tests je nach Reihenfolge unterschiedlich ausgehen.
expect(process.env["SSHKV_APP_SECRET"]).toBe(APP_SECRET_VORHER);
expect(entschluesseln(verschluesseln("nach dem Aufraeumen"))).toBe("nach dem Aufraeumen");
});
});