Merge branch 'phase/015-authorized-keys-parser'
This commit is contained in:
@@ -1,22 +1,29 @@
|
|||||||
# #015 authorized_keys: Parser und Merge
|
# #015 authorized_keys: Parser und Merge
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, test
|
**Labels:** backend, test
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 15 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Reine Funktionen zum Lesen und Schreiben von `authorized_keys` mit verwaltetem Block.
|
Reine Funktionen zum Lesen und Schreiben von `authorized_keys` mit verwaltetem Block.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Optionen mit Anführungszeichen
|
- [x] Optionen mit Anführungszeichen
|
||||||
- [ ] CRLF, BOM, Kommentare, Duplikate
|
- [x] CRLF, BOM, Kommentare, Duplikate
|
||||||
- [ ] Block erzeugen/ersetzen, Rest bytegenau
|
- [x] Block erzeugen/ersetzen, Rest bytegenau
|
||||||
- [ ] Aussperrschutz Deployment-Key
|
- [x] Aussperrschutz Deployment-Key
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Umfangreiche Unit-Tests der Randfälle
|
- [x] Umfangreiche Unit-Tests der Randfälle
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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).
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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(",");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user