Files
sshkeyvault/tests/ssh/authorized-keys.test.ts
T
florian.wildeandClaude Opus 5.5 e518e75022 feat(abgleich): Drift-Erkennung, Zeitplan und Uebernahme (#021, #022)
Ein Abgleich liest je Host alle authorized_keys in einem SSH-Aufruf,
als jeweiliger Benutzer, und vergleicht sie mit dem Soll: fehlende,
von Hand geaenderte und unbekannte Keys, falsche Rechte, beschaedigte
Bloecke und veraenderte Private Keys. Befunde werden geoeffnet und
wieder geschlossen, Zustaende von Verwendungen und Host nachgezogen.
Auf dem Host aendert der Abgleich nichts.

Der Zeitplan liegt in pg-boss und feuert auch bei mehreren Workern
nur einmal. Auf der Hostseite stehen Abweichungen und der Ist-Zustand
je Benutzer; unbekannte Keys lassen sich uebernehmen - nur bekannt
machen oder mit Dry-Run in den verwalteten Block holen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:02:11 +02:00

223 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/);
});
});
describe("uebernommene Keys (#022)", () => {
it("entfernt nur ausdruecklich genannte Zeilen ausserhalb des Blocks", () => {
const vorher = `# Kommentar\n${FREMD.zeile}\n${BACKUP.zeile}\n`;
const ergebnis = blockSetzen(vorher, soll(FREMD), [], [FREMD.fingerprint]);
expect(ergebnis.inhalt).toBe(
`# Kommentar\n${BACKUP.zeile}\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\n${FREMD.zeile}\n# END SSHKV MANAGED\n`,
);
expect(ergebnis.hinweise).toEqual([]);
});
});