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>
169 lines
6.5 KiB
TypeScript
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");
|
|
});
|
|
});
|