Einen Schlüssel zu tauschen soll keinen Ausfall riskieren: Der Nachfolger übernimmt alle aktiven Zuweisungen, wird ausgerollt und per Verbindungstest geprüft – erst wenn alle Tests seit dem letzten Ausrollen grün sind, entfernt der Widerruf aus #024 den alten Schlüssel, der dann als ROTIERT endet. Private Keys bekommen einen neuen Pfad (-rJJJJMMTT) bei gleichem Alias. Damit ssh den neuen nimmt, schreibt privat.ts je Alias nur noch einen config-Eintrag; die jüngste Zuweisung gewinnt. Der Worker setzt vorgaengerId schon beim Erzeugen, so ist die Kette auch nach einem Abbruch vollständig und die Schrittfolge wiederaufnehmbar. Personen-Keys ohne Private Key in sshkv bekommen nur einen Hinweis: dafür braucht es einen neuen Public Key von der Person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
5.4 KiB
TypeScript
127 lines
5.4 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||
import { prisma } from "@/lib/db";
|
||
import { rotationsDatum } from "@/lib/rotation";
|
||
import {
|
||
rotationAltWiderrufen,
|
||
rotationStarten,
|
||
rotationUebernehmen,
|
||
rotationsStand,
|
||
} from "@/server/services/rotation";
|
||
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
|
||
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
|
||
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
||
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||
import { datenbankLeeren } from "../hilfen/db";
|
||
import { vorgangImWorker, warteschlangeStoppen } from "../hilfen/vorgang";
|
||
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
|
||
|
||
/**
|
||
* Rotation gegen echte Hosts (#023): der neue Schluessel funktioniert (Verbindungstest gruen),
|
||
* der alte ist danach von beiden Hosts verschwunden, und `ssh <alias>` von A geht weiter –
|
||
* derselbe Alias zeigt jetzt auf die neue Datei.
|
||
*/
|
||
const AKTEUR = { id: "", name: "Test-Admin" };
|
||
let aufraeumen: () => void;
|
||
|
||
beforeAll(async () => {
|
||
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
|
||
aufraeumen = masterKeyAnlegen();
|
||
});
|
||
afterAll(async () => {
|
||
aufraeumen();
|
||
await warteschlangeStoppen();
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
await deploymentKeySicherstellen();
|
||
const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
|
||
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
|
||
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
|
||
});
|
||
|
||
async function trockenlaufUndAusrollen(schluesselId: string) {
|
||
const ziele = [
|
||
...(await zieleVonVerwendungen({ schluesselId })),
|
||
...(await zieleVonPrivatVerwendungen({ schluesselId })),
|
||
];
|
||
const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id });
|
||
expect(trockenlauf.status).toBe("ERLEDIGT");
|
||
const ausrollen = await vorgangLaufen("AUSROLLEN", {
|
||
eingabe: { trockenlaufId: trockenlauf.id },
|
||
angefordertVonId: AKTEUR.id,
|
||
});
|
||
expect(ausrollen.fehler).toBeNull();
|
||
}
|
||
|
||
describe("Rotation", () => {
|
||
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
|
||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
|
||
const alt = await prisma.schluessel.create({
|
||
data: {
|
||
id: paar.id,
|
||
name: "Backup",
|
||
slug: "backup",
|
||
typ: "ED25519",
|
||
kategorie: "DIENST",
|
||
publicKey: paar.publicKeyZeile,
|
||
fingerprint: paar.fingerprint,
|
||
kommentar: "sshkv:backup",
|
||
...paar.versiegelt,
|
||
},
|
||
select: { id: true, publicKey: true },
|
||
});
|
||
await privateVerwendungAnlegen(
|
||
{
|
||
schluesselId: alt.id,
|
||
hostId: a.id,
|
||
zielbenutzer: "root",
|
||
pfad: "~/.ssh/id_backup",
|
||
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
|
||
knownHostsFuer: [b.id],
|
||
},
|
||
AKTEUR,
|
||
);
|
||
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
|
||
await trockenlaufUndAusrollen(alt.id);
|
||
|
||
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
|
||
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
|
||
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
|
||
const neu = await prisma.schluessel.findFirstOrThrow({
|
||
where: { vorgaengerId: alt.id },
|
||
select: { id: true, publicKey: true },
|
||
});
|
||
|
||
// 3: ausrollen – beide Schluessel liegen jetzt nebeneinander, der Alias zeigt auf den neuen.
|
||
await trockenlaufUndAusrollen(neu.id);
|
||
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
|
||
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
|
||
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
|
||
expect(config).toContain(`IdentityFile ~/.ssh/${neuerPfad}`);
|
||
|
||
// 4: Verbindungstest mit dem neuen Schluessel.
|
||
const verwendung = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neu.id } });
|
||
const test = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } });
|
||
expect(test.fehler).toBeNull();
|
||
expect((await rotationsStand(alt.id)).schritt).toBe(4);
|
||
|
||
// 5: alten widerrufen.
|
||
await rotationAltWiderrufen(alt.id, AKTEUR, { privatErlaubt: true });
|
||
await trockenlaufUndAusrollen(alt.id);
|
||
expect((await widerrufAbschliessen([alt.id], AKTEUR))[0]?.status).toBe("ROTIERT");
|
||
|
||
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
|
||
expect(authorized).not.toContain(alt.publicKey.split(" ")[1]!);
|
||
expect(authorized).toContain(neu.publicKey.split(" ")[1]!);
|
||
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
|
||
expect(dateien).not.toContain("id_backup");
|
||
expect(dateien).toContain(neuerPfad);
|
||
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||
expect(ssh.stdout.trim()).toBe("root");
|
||
});
|
||
});
|