import { randomBytes } from "node:crypto"; import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { EntschluesselungsFehler, MasterKeyFehler, masterKeyLaden, oeffnen, UNSICHER_ERLAUBT_VARIABLE, versiegeln, } from "@/lib/krypto/umschlag"; const ZUSATZ = "schluessel:k_8f3a:SHA256:abc"; const KLARTEXT = "-----BEGIN OPENSSH PRIVATE KEY-----\nnur ein Platzhalter\n-----END OPENSSH PRIVATE KEY-----\n"; describe("versiegeln und oeffnen", () => { const masterKey = randomBytes(32); it("Rundreise liefert den Klartext", () => { const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); expect(umschlag.masterKeyVersion).toBe(1); expect(oeffnen(umschlag, masterKey, ZUSATZ).toString()).toBe(KLARTEXT); }); it("hält das dokumentierte Format ein", () => { const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ, 3); expect(umschlag.chiffrat[0]).toBe(1); expect(umschlag.chiffrat.length).toBe(1 + 12 + 16 + Buffer.byteLength(KLARTEXT)); expect(umschlag.dekVerschluesselt.length).toBe(1 + 12 + 16 + 32); expect(umschlag.masterKeyVersion).toBe(3); expect(umschlag.chiffrat.toString("latin1")).not.toContain("Platzhalter"); }); it("liefert bei jedem Versiegeln ein anderes Chiffrat", () => { const a = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); const b = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); expect(a.chiffrat.equals(b.chiffrat)).toBe(false); expect(a.dekVerschluesselt.equals(b.dekVerschluesselt)).toBe(false); }); it("lehnt einen falschen Master-Key ab", () => { const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); expect(() => oeffnen(umschlag, randomBytes(32), ZUSATZ)).toThrow(EntschluesselungsFehler); }); it("lehnt ein falsches AAD ab (vertauschte Datensätze)", () => { const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); expect(() => oeffnen(umschlag, masterKey, "schluessel:k_anders:SHA256:abc")).toThrow( EntschluesselungsFehler, ); }); it("lehnt eine falsche Master-Key-Version ab", () => { const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ, 1); expect(() => oeffnen({ ...umschlag, masterKeyVersion: 2 }, masterKey, ZUSATZ)).toThrow( EntschluesselungsFehler, ); }); it.each(["chiffrat", "dekVerschluesselt"] as const)("erkennt jeden Bit-Flip in %s", (feld) => { const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); // Version, IV, Tag und Daten: jede Stelle muss auffallen. for (const stelle of [0, 1, 13, 29, umschlag[feld].length - 1]) { const kaputt = Buffer.from(umschlag[feld]); kaputt[stelle]! ^= 0x01; expect(() => oeffnen({ ...umschlag, [feld]: kaputt }, masterKey, ZUSATZ)).toThrow( EntschluesselungsFehler, ); } }); it("lehnt abgeschnittene Puffer ab", () => { const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); expect(() => oeffnen({ ...umschlag, chiffrat: umschlag.chiffrat.subarray(0, 20) }, masterKey, ZUSATZ), ).toThrow(EntschluesselungsFehler); }); it("verlangt einen 32-Byte-Master-Key", () => { expect(() => versiegeln(Buffer.from("x"), randomBytes(16), ZUSATZ)).toThrow(MasterKeyFehler); }); it("nennt in Fehlermeldungen weder Klartext noch Schlüssel", () => { const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); try { oeffnen(umschlag, randomBytes(32), ZUSATZ); expect.unreachable(); } catch (fehler) { const text = String((fehler as Error).message) + String((fehler as Error).stack); expect(text).not.toContain("Platzhalter"); expect(text).not.toContain(masterKey.toString("hex")); expect(text).not.toContain(masterKey.toString("base64")); } }); }); describe("masterKeyLaden", () => { let verzeichnis: string; const schluessel = randomBytes(32); beforeAll(async () => { verzeichnis = await mkdtemp(join(tmpdir(), "sshkv-masterkey-")); }); afterAll(async () => { await rm(verzeichnis, { recursive: true, force: true }); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); async function datei(name: string, inhalt: Buffer | string, modus = 0o600): Promise { const pfad = join(verzeichnis, name); await writeFile(pfad, inhalt); await chmod(pfad, modus); return pfad; } it.each([ ["roh", schluessel], ["base64", schluessel.toString("base64")], ["base64 mit Zeilenende", schluessel.toString("base64") + "\n"], ["hex", schluessel.toString("hex")], ["hex mit CRLF", schluessel.toString("hex") + "\r\n"], ])("liest %s", async (name, inhalt) => { const geladen = await masterKeyLaden(await datei(name, inhalt)); expect(geladen.equals(schluessel)).toBe(true); }); it("lehnt falsche Längen ab", async () => { await expect(masterKeyLaden(await datei("kurz", randomBytes(31)))).rejects.toThrow(MasterKeyFehler); await expect(masterKeyLaden(await datei("lang", randomBytes(33)))).rejects.toThrow(MasterKeyFehler); await expect(masterKeyLaden(await datei("hex-kurz", randomBytes(31).toString("hex")))).rejects.toThrow( "32-Byte-Schlüssel", ); }); it("lehnt einen Schlüssel aus lauter Nullbytes ab", async () => { await expect(masterKeyLaden(await datei("null", Buffer.alloc(32)))).rejects.toThrow("Nullbytes"); }); it("lehnt eine fehlende Datei ab", async () => { await expect(masterKeyLaden(join(verzeichnis, "gibt-es-nicht"))).rejects.toThrow(MasterKeyFehler); }); it("verweigert Dateien, die Gruppe oder Welt lesen dürfen", async () => { for (const modus of [0o640, 0o604, 0o644]) { const pfad = await datei(`offen-${modus.toString(8)}`, schluessel, modus); await expect(masterKeyLaden(pfad)).rejects.toThrow("chmod 600"); } expect((await masterKeyLaden(await datei("nur-lesen", schluessel, 0o400))).equals(schluessel)).toBe(true); }); it("lädt offene Dateien nur mit ausdrücklicher Erlaubnis und warnt dann", async () => { vi.stubEnv(UNSICHER_ERLAUBT_VARIABLE, "1"); const warnung = vi.spyOn(console, "warn").mockImplementation(() => {}); const geladen = await masterKeyLaden(await datei("offen-erlaubt", schluessel.toString("hex"), 0o644)); expect(geladen.equals(schluessel)).toBe(true); expect(warnung).toHaveBeenCalledOnce(); const ausgabe = String(warnung.mock.calls[0]?.[0]); expect(ausgabe).not.toContain(schluessel.toString("hex")); expect(ausgabe).not.toContain(schluessel.toString("base64")); }); });