From 19edd20d7243006c95adbed1c45233345d403901 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 10:10:54 +0200 Subject: [PATCH] feat(ssh): authorized_keys lesen und verwalteten Block setzen (#015) Reine Funktionen fuer Dry-Run und Ausrollen. Alles ausserhalb des Blocks bleibt bytegenau stehen, auch CRLF, BOM und kaputte Zeilen; der Block entsteht deterministisch aus dem Soll. Ein beschaedigter Block wird nie automatisch ueberschrieben, weil genau dort fremde Zeilen verschwinden koennten. Verwaltete Keys erkennt sshkv am Fingerprint, nicht am Kommentar. Der Deployment-Key kann nicht aus dem Block fallen (Aussperrschutz). Co-Authored-By: Claude Opus 5.5 --- docs/issues/015-authorized-keys-parser.md | 21 +- src/lib/ssh/authorized-keys.ts | 266 ++++++++++++++++++++++ src/lib/ssh/optionen.ts | 125 ++++++++++ src/lib/ssh/zeilen-diff.ts | 74 ++++++ tests/ssh/authorized-keys.test.ts | 211 +++++++++++++++++ 5 files changed, 690 insertions(+), 7 deletions(-) create mode 100644 src/lib/ssh/authorized-keys.ts create mode 100644 src/lib/ssh/optionen.ts create mode 100644 src/lib/ssh/zeilen-diff.ts create mode 100644 tests/ssh/authorized-keys.test.ts 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/); + }); +});