diff --git a/docs/issues/015-authorized-keys-parser.md b/docs/issues/015-authorized-keys-parser.md index b1e072c..892ebd0 100644 --- a/docs/issues/015-authorized-keys-parser.md +++ b/docs/issues/015-authorized-keys-parser.md @@ -1,22 +1,29 @@ # #015 authorized_keys: Parser und Merge -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.2.0 **Labels:** backend, test -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0 h 15 min ## Ziel Reine Funktionen zum Lesen und Schreiben von `authorized_keys` mit verwaltetem Block. ## Aufgaben -- [ ] Optionen mit Anführungszeichen -- [ ] CRLF, BOM, Kommentare, Duplikate -- [ ] Block erzeugen/ersetzen, Rest bytegenau -- [ ] Aussperrschutz Deployment-Key +- [x] Optionen mit Anführungszeichen +- [x] CRLF, BOM, Kommentare, Duplikate +- [x] Block erzeugen/ersetzen, Rest bytegenau +- [x] Aussperrschutz Deployment-Key ## Akzeptanzkriterien -- [ ] Umfangreiche Unit-Tests der Randfälle +- [x] Umfangreiche Unit-Tests der Randfälle ## Abhängigkeiten #001 ## Notizen / Entscheidungen +- **Bytegenau:** Zeilen werden an `\n` getrennt, ein `\r` bleibt Teil der Zeile; BOM und fehlende letzte Zeilenschaltung bleiben erhalten. Einzige Änderung außerhalb des Blocks: Fehlt der Block und der letzten Zeile die Zeilenschaltung, bekommt sie eine – sonst stünde der Block-Anfang in derselben Zeile. +- **CRLF-Dateien** (überwiegend `\r\n`) bekommen auch im Block CRLF. +- **Beschädigter Block** (Anfang ohne Ende, mehrere Blöcke, Ende vor Anfang): nie automatisch schreiben, `BlockFehler` mit Bitte um Handarbeit. +- **Erkennung am Fingerprint**, nicht am Kommentar; der Typ im Blob muss zum Zeilentyp passen. +- **Optionen:** Parser wie sshd (Kommas in Anführungszeichen, `\"`). Für Benutzereingaben strenger: nur bekannte Optionen, keine Steuerzeichen. +- **Aussperrschutz:** Ein `pflicht`-Fingerprint (Deployment-Key), der im Block stand, darf nicht verschwinden. +- Diff per LCS, für die Anzeige verdichtet (je 2 Zeilen Kontext). diff --git a/src/lib/ssh/authorized-keys.ts b/src/lib/ssh/authorized-keys.ts new file mode 100644 index 0000000..b033d31 --- /dev/null +++ b/src/lib/ssh/authorized-keys.ts @@ -0,0 +1,266 @@ +import { fingerprintSha256 } from "@/lib/ssh/openssh"; +import { OptionenFehler, optionenAmAnfangLesen } from "@/lib/ssh/optionen"; +import { + BLOCK_ANFANG, + BLOCK_ANFANG_PRAEFIX, + BLOCK_ENDE, + BLOCK_ENDE_PRAEFIX, +} from "@/lib/ssh/verwalteter-block"; +import { zeilenDiff, type DiffZeile } from "@/lib/ssh/zeilen-diff"; + +/** + * authorized_keys lesen und den verwalteten Block setzen (#015). + * + * Zusagen, die die Tests festhalten: + * - Alles ausserhalb des Blocks bleibt **bytegenau** erhalten – auch CRLF, BOM, Kommentare, + * kaputte Zeilen und eine fehlende letzte Zeilenschaltung. + * - Der Block wird deterministisch aus dem Soll erzeugt. + * - Ein beschaedigter Block (Anfang ohne Ende, mehrere Bloecke …) wird nie automatisch + * ueberschrieben: das waere die Stelle, an der sshkv fremde Zeilen verschluckt. + * - Verwaltete Schluessel werden am Fingerprint erkannt, nicht am Kommentar. + */ + +/** Schluesseltypen, an denen sshd den Anfang des Schluessels erkennt (ohne Optionen davor). */ +const SCHLUESSELTYP = + /^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)(-cert-v01@openssh\.com)?$/; + +export type SchluesselZeile = { + readonly art: "schluessel"; + readonly roh: string; + readonly imBlock: boolean; + /** Optionen-Text wie in der Datei, oder null. */ + readonly optionen: string | null; + readonly typ: string; + readonly base64: string; + readonly kommentar: string; + readonly fingerprint: string; +}; + +export type Zeile = + | SchluesselZeile + | { + readonly art: "leer" | "kommentar" | "blockAnfang" | "blockEnde"; + readonly roh: string; + readonly imBlock: boolean; + } + | { readonly art: "ungueltig"; readonly roh: string; readonly imBlock: boolean; readonly grund: string }; + +export type BlockZustand = + | { readonly art: "keiner" } + | { readonly art: "ok"; readonly anfang: number; readonly ende: number } + | { readonly art: "beschaedigt"; readonly grund: string }; + +export type AuthorizedKeys = { + readonly zeilen: readonly Zeile[]; + readonly bom: boolean; + /** Endet die Datei mit einer Zeilenschaltung? */ + readonly endetMitZeilenende: boolean; + /** Ueberwiegend CRLF? Neue Zeilen folgen dann dieser Gewohnheit. */ + readonly crlf: boolean; + readonly block: BlockZustand; +}; + +function ohneCr(roh: string): string { + return roh.endsWith("\r") ? roh.slice(0, -1) : roh; +} + +/** Liest eine Schluesselzeile; wirft mit lesbarem Grund. */ +function schluesselLesen(text: string): Omit { + let rest = text; + let optionen: string | null = null; + const erstesWort = rest.split(/[ \t]/, 1)[0] ?? ""; + if (!SCHLUESSELTYP.test(erstesWort)) { + const { laenge } = optionenAmAnfangLesen(rest); + optionen = rest.slice(0, laenge); + rest = rest.slice(laenge).replace(/^[ \t]+/, ""); + } + const treffer = /^(\S+)[ \t]+(\S+)(?:[ \t]+(.*))?$/.exec(rest); + if (!treffer) throw new Error("Kein Schlüssel nach den Optionen."); + const [, typ, base64, kommentar = ""] = treffer; + if (!SCHLUESSELTYP.test(typ!)) throw new Error(`Unbekannter Schlüsseltyp „${typ}“.`); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64!)) throw new Error("Schlüssel ist nicht Base64-kodiert."); + const blob = Buffer.from(base64!, "base64"); + // Der Typ steht auch im Blob; weicht er ab, ist die Zeile kaputt oder manipuliert. + const laenge = blob.length >= 4 ? blob.readUInt32BE(0) : 0; + if (laenge === 0 || blob.length < 4 + laenge || blob.subarray(4, 4 + laenge).toString("ascii") !== typ) { + throw new Error("Typ im Schlüssel passt nicht zum Zeilentyp."); + } + return { + optionen, + typ: typ!, + base64: base64!, + kommentar: kommentar.trim(), + fingerprint: fingerprintSha256(blob), + }; +} + +export function authorizedKeysLesen(inhalt: string): AuthorizedKeys { + const bom = inhalt.startsWith(""); + const text = bom ? inhalt.slice(1) : inhalt; + const endetMitZeilenende = text.length === 0 || text.endsWith("\n"); + const rohZeilen = text.split("\n"); + if (endetMitZeilenende) rohZeilen.pop(); + + let crlfAnzahl = 0; + const anfaenge: number[] = []; + const enden: number[] = []; + let imBlock = false; + const zeilen: Zeile[] = rohZeilen.map((roh, index) => { + if (roh.endsWith("\r")) crlfAnzahl += 1; + const sauber = ohneCr(roh).trim(); + if (sauber.startsWith(BLOCK_ANFANG_PRAEFIX)) { + anfaenge.push(index); + imBlock = true; + return { art: "blockAnfang", roh, imBlock: true }; + } + if (sauber.startsWith(BLOCK_ENDE_PRAEFIX)) { + enden.push(index); + const war = imBlock; + imBlock = false; + return { art: "blockEnde", roh, imBlock: war }; + } + if (sauber === "") return { art: "leer", roh, imBlock }; + if (sauber.startsWith("#")) return { art: "kommentar", roh, imBlock }; + try { + return { art: "schluessel", roh, imBlock, ...schluesselLesen(sauber) }; + } catch (fehler) { + const grund = fehler instanceof OptionenFehler || fehler instanceof Error ? fehler.message : "unlesbar"; + return { art: "ungueltig", roh, imBlock, grund }; + } + }); + + let block: BlockZustand = { art: "keiner" }; + if (anfaenge.length === 1 && enden.length === 1 && anfaenge[0]! < enden[0]!) { + block = { art: "ok", anfang: anfaenge[0]!, ende: enden[0]! }; + } else if (anfaenge.length > 1 || enden.length > 1) { + block = { art: "beschaedigt", grund: "Die Datei enthält mehr als einen verwalteten Block." }; + } else if (anfaenge.length !== enden.length) { + block = { + art: "beschaedigt", + grund: anfaenge.length + ? "Der verwaltete Block hat kein Ende." + : "Ende eines verwalteten Blocks ohne Anfang.", + }; + } else if (anfaenge.length === 1) { + block = { art: "beschaedigt", grund: "Ende des verwalteten Blocks steht vor dem Anfang." }; + } + + return { + zeilen, + bom, + endetMitZeilenende, + crlf: crlfAnzahl > 0 && crlfAnzahl * 2 >= rohZeilen.length, + block, + }; +} + +/** Ein Eintrag des Solls im verwalteten Block. */ +export type SollEintrag = { + /** Geprueftes Optionen-Feld (siehe optionenPruefen), oder leer. */ + readonly optionen?: string | null; + /** "typ base64 [kommentar]" */ + readonly publicKey: string; + readonly fingerprint: string; +}; + +export class BlockFehler extends Error { + constructor( + nachricht: string, + readonly grund: "beschaedigt" | "aussperrung" | "ungueltig", + ) { + super(nachricht); + this.name = "BlockFehler"; + } +} + +export type BlockErgebnis = { + readonly inhalt: string; + readonly geaendert: boolean; + readonly diff: DiffZeile[]; + /** Hinweise fuer den Dry-Run: doppelte Keys ausserhalb, ungueltige Zeilen … */ + readonly hinweise: string[]; +}; + +function eintragZeile(eintrag: SollEintrag): string { + const key = eintrag.publicKey.trim().replace(/[ \t]+/g, " "); + if (/[\r\n\x00]/.test(key) || (eintrag.optionen && /[\r\n\x00]/.test(eintrag.optionen))) { + throw new BlockFehler("Ein Eintrag enthält einen Zeilenumbruch.", "ungueltig"); + } + return eintrag.optionen ? `${eintrag.optionen} ${key}` : key; +} + +/** + * Setzt den verwalteten Block auf das Soll. `pflicht` sind Fingerprints, die nie aus dem + * Block verschwinden duerfen (Deployment-Key, Aussperrschutz). + */ +export function blockSetzen( + inhalt: string, + soll: readonly SollEintrag[], + pflicht: readonly string[] = [], +): BlockErgebnis { + const datei = authorizedKeysLesen(inhalt); + if (datei.block.art === "beschaedigt") { + throw new BlockFehler(`${datei.block.grund} Bitte die Datei von Hand bereinigen.`, "beschaedigt"); + } + + // Doppelte Soll-Eintraege: der erste gewinnt (deterministisch). + const gesehen = new Set(); + const eintraege = soll.filter((eintrag) => { + if (gesehen.has(eintrag.fingerprint)) return false; + gesehen.add(eintrag.fingerprint); + return true; + }); + + const imBlockVorher = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && z.imBlock); + for (const fingerprint of pflicht) { + const warDa = imBlockVorher.some((z) => z.fingerprint === fingerprint); + if (warDa && !gesehen.has(fingerprint)) { + throw new BlockFehler( + "Diese Änderung würde den Deployment-Key aus dem verwalteten Block entfernen und sshkv aussperren.", + "aussperrung", + ); + } + } + + const ende = datei.crlf ? "\r" : ""; + const neuerBlock = [BLOCK_ANFANG, ...eintraege.map(eintragZeile), BLOCK_ENDE].map((z) => z + ende); + + let rohNeu: string[]; + let endetMitZeilenende = datei.endetMitZeilenende; + const roh = datei.zeilen.map((z) => z.roh); + if (datei.block.art === "ok") { + rohNeu = [...roh.slice(0, datei.block.anfang), ...neuerBlock, ...roh.slice(datei.block.ende + 1)]; + } else { + rohNeu = [...roh, ...neuerBlock]; + // Ein neuer Block beginnt auf einer eigenen Zeile; die bisher letzte Zeile bekommt dafuer + // ihre fehlende Zeilenschaltung – die einzige Aenderung ausserhalb des Blocks. + endetMitZeilenende = true; + } + const neu = (datei.bom ? "" : "") + rohNeu.join("\n") + (endetMitZeilenende && rohNeu.length ? "\n" : ""); + + const hinweise: string[] = []; + const ausserhalb = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && !z.imBlock); + for (const eintrag of eintraege) { + const doppelt = ausserhalb.find((z) => z.fingerprint === eintrag.fingerprint); + if (doppelt) { + hinweise.push( + `${eintrag.fingerprint} steht zusätzlich außerhalb des Blocks${doppelt.optionen ? "" : " – dort ohne Einschränkungen"}.`, + ); + } + } + const ungueltig = datei.zeilen.filter((z) => z.art === "ungueltig").length; + if (ungueltig) + hinweise.push(`${ungueltig} Zeile(n) außerhalb des Blocks sind ungültig und bleiben unverändert.`); + + return { + inhalt: neu, + geaendert: neu !== inhalt, + diff: zeilenDiff(roh.map(ohneCr), rohNeu.map(ohneCr)), + hinweise, + }; +} + +/** Die Schluessel einer Datei mit Lage, fuer Abgleich und Import (#021, #022). */ +export function schluesselZeilen(inhalt: string): SchluesselZeile[] { + return authorizedKeysLesen(inhalt).zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel"); +} diff --git a/src/lib/ssh/optionen.ts b/src/lib/ssh/optionen.ts new file mode 100644 index 0000000..47c5e97 --- /dev/null +++ b/src/lib/ssh/optionen.ts @@ -0,0 +1,125 @@ +/** + * Optionen einer authorized_keys-Zeile (#015): `restrict,from="10.0.0.1",command="…"`. + * + * Geparst wird wie in sshd (auth-options.c): Komma trennt, ausser in doppelten + * Anfuehrungszeichen; dort maskiert `\"` ein Anfuehrungszeichen. Die Optionen stehen am + * Zeilenanfang und enden am ersten Leerraum ausserhalb von Anfuehrungszeichen. + */ +export class OptionenFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "OptionenFehler"; + } +} + +export type Option = { readonly name: string; readonly wert?: string }; + +/** Optionen ohne Wert, die sshd kennt. */ +const SCHALTER = new Set([ + "restrict", + "cert-authority", + "no-port-forwarding", + "no-agent-forwarding", + "no-x11-forwarding", + "no-pty", + "no-user-rc", + "no-touch-required", + "verify-required", + "port-forwarding", + "agent-forwarding", + "x11-forwarding", + "pty", + "user-rc", + "touch-required", +]); + +/** Optionen mit Wert in Anfuehrungszeichen. */ +const MIT_WERT = new Set([ + "from", + "command", + "environment", + "permitopen", + "permitlisten", + "principals", + "tunnel", + "expiry-time", +]); + +/** + * Liest den Optionen-Teil am Anfang einer Zeile. Liefert die Optionen und die Laenge des + * gelesenen Textes (bis zum ersten Leerraum ausserhalb von Anfuehrungszeichen). + */ +export function optionenAmAnfangLesen(text: string): { optionen: Option[]; laenge: number } { + const optionen: Option[] = []; + let index = 0; + while (index < text.length) { + const nameAnfang = index; + while (index < text.length && /[A-Za-z0-9-]/.test(text[index]!)) index += 1; + const name = text.slice(nameAnfang, index).toLowerCase(); + if (!name) throw new OptionenFehler(`Unerwartetes Zeichen in den Optionen an Stelle ${index + 1}.`); + let wert: string | undefined; + if (text[index] === "=") { + index += 1; + if (text[index] !== '"') + throw new OptionenFehler(`Der Wert von „${name}“ muss in Anführungszeichen stehen.`); + index += 1; + let inhalt = ""; + let geschlossen = false; + while (index < text.length) { + const zeichen = text[index]!; + if (zeichen === "\\" && text[index + 1] === '"') { + inhalt += '"'; + index += 2; + continue; + } + if (zeichen === '"') { + geschlossen = true; + index += 1; + break; + } + inhalt += zeichen; + index += 1; + } + if (!geschlossen) throw new OptionenFehler(`Fehlendes schließendes Anführungszeichen bei „${name}“.`); + wert = inhalt; + } + optionen.push(wert === undefined ? { name } : { name, wert }); + if (text[index] === ",") { + index += 1; + continue; + } + break; + } + return { optionen, laenge: index }; +} + +function optionSchreiben(option: Option): string { + return option.wert === undefined ? option.name : `${option.name}="${option.wert.replace(/"/g, '\\"')}"`; +} + +/** + * Prueft vom Benutzer eingegebene Optionen und liefert sie normalisiert zurueck. Strenger + * als sshd: unbekannte Optionen und Steuerzeichen werden abgewiesen – ein Tippfehler soll + * nicht still dazu fuehren, dass eine Einschraenkung fehlt. + */ +export function optionenPruefen(eingabe: string): string { + const text = eingabe.trim(); + if (!text) return ""; + if (/[\x00-\x1f\x7f]/.test(text)) throw new OptionenFehler("Die Optionen enthalten Steuerzeichen."); + const { optionen, laenge } = optionenAmAnfangLesen(text); + if (laenge !== text.length) + throw new OptionenFehler( + "Die Optionen dürfen keine Leerzeichen außerhalb von Anführungszeichen enthalten.", + ); + for (const option of optionen) { + if (SCHALTER.has(option.name)) { + if (option.wert !== undefined) throw new OptionenFehler(`„${option.name}“ hat keinen Wert.`); + } else if (MIT_WERT.has(option.name)) { + if (option.wert === undefined || option.wert === "") + throw new OptionenFehler(`„${option.name}“ braucht einen Wert.`); + } else { + throw new OptionenFehler(`Unbekannte Option „${option.name}“.`); + } + } + return optionen.map(optionSchreiben).join(","); +} diff --git a/src/lib/ssh/zeilen-diff.ts b/src/lib/ssh/zeilen-diff.ts new file mode 100644 index 0000000..80c48da --- /dev/null +++ b/src/lib/ssh/zeilen-diff.ts @@ -0,0 +1,74 @@ +/** + * Zeilen-Diff fuer den Dry-Run (#015). Klassisches LCS – die Dateien sind klein + * (authorized_keys, ~/.ssh/config), und eine ehrliche Vorschau ist wichtiger als Tempo. + */ +export type DiffZeile = { readonly art: "hinzu" | "entfernt" | "kontext"; readonly text: string }; + +/** Ab dieser Groesse kein LCS mehr, sondern „alles alt raus, alles neu rein“. */ +const HOECHSTENS_ZEILEN = 4000; + +export function zeilenDiff(alt: readonly string[], neu: readonly string[]): DiffZeile[] { + if (alt.length > HOECHSTENS_ZEILEN || neu.length > HOECHSTENS_ZEILEN) { + return [ + ...alt.map((text) => ({ art: "entfernt" as const, text })), + ...neu.map((text) => ({ art: "hinzu" as const, text })), + ]; + } + const n = alt.length; + const m = neu.length; + // laenge[i][j] = LCS von alt[i..] und neu[j..] + const laenge: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)); + for (let i = n - 1; i >= 0; i -= 1) { + for (let j = m - 1; j >= 0; j -= 1) { + laenge[i]![j] = + alt[i] === neu[j] ? laenge[i + 1]![j + 1]! + 1 : Math.max(laenge[i + 1]![j]!, laenge[i]![j + 1]!); + } + } + const ergebnis: DiffZeile[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (alt[i] === neu[j]) { + ergebnis.push({ art: "kontext", text: alt[i]! }); + i += 1; + j += 1; + } else if (laenge[i + 1]![j]! >= laenge[i]![j + 1]!) { + ergebnis.push({ art: "entfernt", text: alt[i]! }); + i += 1; + } else { + ergebnis.push({ art: "hinzu", text: neu[j]! }); + j += 1; + } + } + while (i < n) ergebnis.push({ art: "entfernt", text: alt[i++]! }); + while (j < m) ergebnis.push({ art: "hinzu", text: neu[j++]! }); + return ergebnis; +} + +/** + * Kuerzt lange unveraenderte Strecken auf je `rand` Zeilen davor und danach – eine + * authorized_keys mit 200 fremden Zeilen soll im Dry-Run nicht die Aenderung verstecken. + */ +export function diffVerdichten(zeilen: readonly DiffZeile[], rand = 2): DiffZeile[] { + const wichtig = zeilen.map((z) => z.art !== "kontext"); + const behalten = zeilen.map((_, index) => { + for (let abstand = -rand; abstand <= rand; abstand += 1) { + if (wichtig[index + abstand]) return true; + } + return false; + }); + const ergebnis: DiffZeile[] = []; + let ausgelassen = 0; + zeilen.forEach((zeile, index) => { + if (behalten[index]) { + if (ausgelassen > 0) + ergebnis.push({ art: "kontext", text: `… ${ausgelassen} unveränderte Zeile(n) …` }); + ausgelassen = 0; + ergebnis.push(zeile); + } else { + ausgelassen += 1; + } + }); + if (ausgelassen > 0) ergebnis.push({ art: "kontext", text: `… ${ausgelassen} unveränderte Zeile(n) …` }); + return ergebnis; +} diff --git a/tests/ssh/authorized-keys.test.ts b/tests/ssh/authorized-keys.test.ts new file mode 100644 index 0000000..d4adfca --- /dev/null +++ b/tests/ssh/authorized-keys.test.ts @@ -0,0 +1,211 @@ +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/); + }); +});