import { generateKeyPairSync } from "node:crypto"; import { describe, expect, it } from "vitest"; import { BlockFehler, authorizedKeysLesen, blockSetzen, schluesselZeilen, type SollEintrag, } from "@/lib/ssh/authorized-keys"; import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen"; import { fingerprintSha256 } from "@/lib/ssh/openssh"; import { diffVerdichten, zeilenDiff } from "@/lib/ssh/zeilen-diff"; /** authorized_keys-Parser und Merge (#015) – die Randfaelle aus der Vorgabe. */ function ed25519(kommentar: string): { zeile: string; fingerprint: string; base64: string } { const { publicKey } = generateKeyPairSync("ed25519"); const roh = publicKey.export({ format: "der", type: "spki" }).subarray(-32); const typ = Buffer.from("ssh-ed25519"); const blob = Buffer.concat([Buffer.from([0, 0, 0, typ.length]), typ, Buffer.from([0, 0, 0, 32]), roh]); const base64 = blob.toString("base64"); return { zeile: `ssh-ed25519 ${base64} ${kommentar}`, fingerprint: fingerprintSha256(blob), base64 }; } const DEPLOY = ed25519("sshkv-deployment@sshkv"); const BACKUP = ed25519("sshkv:backup"); const FREMD = ed25519("admin@laptop"); const soll = (...keys: { zeile: string; fingerprint: string }[]): SollEintrag[] => keys.map((k) => ({ publicKey: k.zeile, fingerprint: k.fingerprint })); describe("Lesen", () => { it("erkennt Schluessel, Kommentare, Leerzeilen und den Block", () => { const inhalt = [ "# eigener Kommentar", "", FREMD.zeile, "# BEGIN SSHKV MANAGED – nicht manuell bearbeiten", DEPLOY.zeile, "# END SSHKV MANAGED", "", ].join("\n"); const datei = authorizedKeysLesen(inhalt); expect(datei.block).toEqual({ art: "ok", anfang: 3, ende: 5 }); expect(datei.zeilen.map((z) => z.art)).toEqual([ "kommentar", "leer", "schluessel", "blockAnfang", "schluessel", "blockEnde", ]); const keys = schluesselZeilen(inhalt); expect(keys.map((k) => [k.fingerprint, k.imBlock])).toEqual([ [FREMD.fingerprint, false], [DEPLOY.fingerprint, true], ]); }); it("liest Optionen mit Anfuehrungszeichen, Kommas, Leerzeichen und Escapes", () => { const zeile = `restrict,from="10.0.0.1,10.0.0.2",command="echo \\"hallo welt\\"" ${BACKUP.zeile}`; const [key] = schluesselZeilen(zeile); expect(key?.optionen).toBe(`restrict,from="10.0.0.1,10.0.0.2",command="echo \\"hallo welt\\""`); expect(key?.fingerprint).toBe(BACKUP.fingerprint); expect(key?.kommentar).toBe("sshkv:backup"); }); it("markiert kaputte Zeilen als ungueltig statt abzubrechen", () => { const datei = authorizedKeysLesen( [ "ssh-ed25519 kein-base64!!", `ssh-rsa ${DEPLOY.base64} typ-passt-nicht`, 'from="offen ssh-ed25519 AAAA', "irgendwas", ].join("\n"), ); expect(datei.zeilen.every((z) => z.art === "ungueltig")).toBe(true); }); it("erkennt beschaedigte Bloecke", () => { expect(authorizedKeysLesen("# BEGIN SSHKV MANAGED\nx\n").block.art).toBe("beschaedigt"); expect(authorizedKeysLesen("# END SSHKV MANAGED\n").block.art).toBe("beschaedigt"); expect( authorizedKeysLesen( "# BEGIN SSHKV MANAGED\n# END SSHKV MANAGED\n# BEGIN SSHKV MANAGED\n# END SSHKV MANAGED\n", ).block.art, ).toBe("beschaedigt"); expect(authorizedKeysLesen("# END SSHKV MANAGED\n# BEGIN SSHKV MANAGED\n").block.art).toBe("beschaedigt"); }); }); describe("Block setzen", () => { it("legt den Block in einer leeren Datei an", () => { const ergebnis = blockSetzen("", soll(DEPLOY)); expect(ergebnis.inhalt).toBe( `# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\n${DEPLOY.zeile}\n# END SSHKV MANAGED\n`, ); expect(ergebnis.geaendert).toBe(true); }); it("haengt den Block an und laesst alles andere bytegenau stehen", () => { const vorher = `# Florian\n${FREMD.zeile}\n\n`; const ergebnis = blockSetzen(vorher, soll(DEPLOY, BACKUP)); expect(ergebnis.inhalt.startsWith(vorher)).toBe(true); expect(ergebnis.inhalt).toContain(`${DEPLOY.zeile}\n${BACKUP.zeile}\n# END SSHKV MANAGED\n`); }); it("ersetzt nur den Block, Zeilen davor und danach bleiben unberuehrt", () => { const davor = `# davor\n${FREMD.zeile}\n`; const danach = "# danach\n"; const vorher = `${davor}# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\n${DEPLOY.zeile}\n# END SSHKV MANAGED\n${danach}`; const ergebnis = blockSetzen(vorher, soll(DEPLOY, BACKUP)); expect(ergebnis.inhalt.startsWith(davor)).toBe(true); expect(ergebnis.inhalt.endsWith(`# END SSHKV MANAGED\n${danach}`)).toBe(true); expect(ergebnis.diff.filter((z) => z.art !== "kontext")).toEqual([{ art: "hinzu", text: BACKUP.zeile }]); }); it("ist idempotent", () => { const einmal = blockSetzen(FREMD.zeile, soll(DEPLOY)).inhalt; const zweimal = blockSetzen(einmal, soll(DEPLOY)); expect(zweimal.inhalt).toBe(einmal); expect(zweimal.geaendert).toBe(false); }); it("behaelt CRLF, BOM und die fehlende letzte Zeilenschaltung fremder Zeilen", () => { const vorher = `# Windows\r\n${FREMD.zeile}\r\n# BEGIN SSHKV MANAGED\r\n# END SSHKV MANAGED\r\n# ohne Ende`; const ergebnis = blockSetzen(vorher, soll(DEPLOY)); expect(ergebnis.inhalt).toBe( `# Windows\r\n${FREMD.zeile}\r\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\r\n${DEPLOY.zeile}\r\n# END SSHKV MANAGED\r\n# ohne Ende`, ); }); it("setzt Optionen vor den Schluessel", () => { const ergebnis = blockSetzen("", [{ ...soll(BACKUP)[0]!, optionen: 'restrict,from="10.0.0.12"' }]); expect(ergebnis.inhalt).toContain(`restrict,from="10.0.0.12" ${BACKUP.zeile}\n`); }); it("nimmt doppelte Soll-Eintraege nur einmal", () => { const ergebnis = blockSetzen("", soll(DEPLOY, DEPLOY)); expect(ergebnis.inhalt.match(/sshkv-deployment/g)).toHaveLength(1); }); it("meldet einen Schluessel, der zusaetzlich ausserhalb des Blocks steht", () => { const ergebnis = blockSetzen(`${BACKUP.zeile}\n`, soll(BACKUP)); expect(ergebnis.hinweise.join(" ")).toMatch(/außerhalb des Blocks – dort ohne Einschränkungen/); }); it("ueberschreibt einen beschaedigten Block nie", () => { expect(() => blockSetzen(`# BEGIN SSHKV MANAGED\n${FREMD.zeile}\n`, soll(DEPLOY))).toThrow(BlockFehler); }); it("schuetzt den Deployment-Key vor dem Entfernen (Aussperrschutz)", () => { const vorher = blockSetzen("", soll(DEPLOY, BACKUP)).inhalt; expect(() => blockSetzen(vorher, soll(BACKUP), [DEPLOY.fingerprint])).toThrow(/aussperren/); // Ohne den Deployment-Key im Block (z. B. root-Konto bei Dienstbenutzer-Anmeldung) greift nichts. expect(() => blockSetzen(blockSetzen("", soll(BACKUP)).inhalt, [], [DEPLOY.fingerprint])).not.toThrow(); }); it("verweigert Zeilenumbrueche in Eintraegen", () => { expect(() => blockSetzen("", [{ publicKey: `${BACKUP.zeile}\n${FREMD.zeile}`, fingerprint: "x" }]), ).toThrow(BlockFehler); }); it("entfernt fremde Zeilen innerhalb des Blocks – und zeigt das im Diff", () => { const vorher = `# BEGIN SSHKV MANAGED\n${FREMD.zeile}\n${DEPLOY.zeile}\n# END SSHKV MANAGED\n`; const ergebnis = blockSetzen(vorher, soll(DEPLOY)); expect(ergebnis.diff).toContainEqual({ art: "entfernt", text: FREMD.zeile }); }); }); describe("Optionen pruefen", () => { it("normalisiert gueltige Optionen", () => { expect(optionenPruefen(' restrict,FROM="10.0.0.1",command="ls -l" ')).toBe( 'restrict,from="10.0.0.1",command="ls -l"', ); expect(optionenPruefen("")).toBe(""); }); it.each([ ["unbekannte Option", "restriktiv"], ["Wert ohne Anfuehrungszeichen", "from=10.0.0.1"], ["fehlendes Anfuehrungszeichen", 'from="10.0.0.1'], ["Leerzeichen ausserhalb", "restrict no-pty"], ["Zeilenumbruch", 'command="ls\nrm -rf /"'], ["Schalter mit Wert", 'no-pty="ja"'], ["leerer Wert", 'from=""'], ])("weist ab: %s", (_, eingabe) => { expect(() => optionenPruefen(eingabe)).toThrow(OptionenFehler); }); }); describe("Diff", () => { it("findet Einfuegungen und Entfernungen", () => { expect(zeilenDiff(["a", "b", "c"], ["a", "x", "c"])).toEqual([ { art: "kontext", text: "a" }, { art: "entfernt", text: "b" }, { art: "hinzu", text: "x" }, { art: "kontext", text: "c" }, ]); }); it("verdichtet lange unveraenderte Strecken", () => { const alt = Array.from({ length: 20 }, (_, i) => `z${i}`); const neu = [...alt.slice(0, 10), "neu", ...alt.slice(10)]; const kurz = diffVerdichten(zeilenDiff(alt, neu)); expect(kurz.length).toBeLessThan(10); expect(kurz).toContainEqual({ art: "hinzu", text: "neu" }); expect(kurz[0]?.text).toMatch(/8 unveränderte Zeile/); }); });