Merge branch 'phase/015-authorized-keys-parser'

This commit is contained in:
2026-10-12 10:10:58 +02:00
5 changed files with 690 additions and 7 deletions
+14 -7
View File
@@ -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).
+266
View File
@@ -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<SchluesselZeile, "art" | "roh" | "imBlock"> {
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<string>();
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");
}
+125
View File
@@ -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(",");
}
+74
View File
@@ -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;
}
+211
View File
@@ -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/);
});
});