Bisher war vorgaengerId eindeutig: nach einer abgebrochenen Rotation passte kein zweiter Nachfolger in die Kette, und der Schlüssel ließ sich nur über Neuanlage, Zuweisen und Widerruf ersetzen. Jetzt ist die Relation 1:n. Ein partieller Index erlaubt je Vorgänger nur einen Nachfolger, der weder widerrufen ist noch gerade widerrufen wird. Maßgeblich ist der jüngste. Erst wenn der Abbruch abgeschlossen ist, beginnt die Rotation wieder bei Schritt 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
8.2 KiB
TypeScript
181 lines
8.2 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||
import { prisma } from "@/lib/db";
|
||
import { rotationsDatum } from "@/lib/rotation";
|
||
import {
|
||
rotationAbbrechen,
|
||
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();
|
||
}
|
||
|
||
/** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */
|
||
async function alterSchluesselAusgerollt(aId: string, bId: string) {
|
||
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: aId,
|
||
zielbenutzer: "root",
|
||
pfad: "~/.ssh/id_backup",
|
||
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
|
||
knownHostsFuer: [bId],
|
||
},
|
||
AKTEUR,
|
||
);
|
||
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR);
|
||
await trockenlaufUndAusrollen(alt.id);
|
||
return alt;
|
||
}
|
||
|
||
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 alt = await alterSchluesselAusgerollt(a.id, b.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");
|
||
});
|
||
|
||
it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => {
|
||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||
const alt = await alterSchluesselAusgerollt(a.id, b.id);
|
||
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
|
||
|
||
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 },
|
||
});
|
||
await trockenlaufUndAusrollen(neu.id);
|
||
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
|
||
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain(
|
||
`IdentityFile ~/.ssh/${neuerPfad}`,
|
||
);
|
||
|
||
// Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden.
|
||
await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true });
|
||
await trockenlaufUndAusrollen(neu.id);
|
||
expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN");
|
||
|
||
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
|
||
expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!);
|
||
expect(authorized).toContain(alt.publicKey.split(" ")[1]!);
|
||
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
|
||
expect(dateien).toContain("id_backup");
|
||
expect(dateien).not.toContain(neuerPfad);
|
||
// Der Alias zeigt wieder auf die alte Datei – und funktioniert.
|
||
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
|
||
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
|
||
expect(config).toContain("IdentityFile ~/.ssh/id_backup\n");
|
||
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||
expect(ssh.stdout.trim()).toBe("root");
|
||
|
||
// Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt.
|
||
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher);
|
||
const zustaende = [
|
||
...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })),
|
||
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
|
||
].map((v) => v.zustand);
|
||
expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
|
||
// Abbruch abgeschlossen: die Rotation steht wieder am Anfang (#033).
|
||
expect(await rotationsStand(alt.id)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
|
||
});
|
||
});
|