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>
261 lines
9.7 KiB
TypeScript
261 lines
9.7 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { prisma } from "@/lib/db";
|
|
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
|
import { rotationsKette } from "@/server/queries/schluessel";
|
|
import {
|
|
RotationsFehler,
|
|
rotationAltWiderrufen,
|
|
rotationGesperrt,
|
|
rotationStarten,
|
|
rotationUebernehmen,
|
|
rotationsStand,
|
|
} from "@/server/services/rotation";
|
|
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
|
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
|
import { datenbankLeeren } from "./hilfen/db";
|
|
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
|
import { ed25519Blob, zeile } from "./ssh/hilfen";
|
|
|
|
/** Gefuehrte Rotation (#023): Regeln, Schritte, Rechte, Protokoll. */
|
|
|
|
let verzeichnis: string;
|
|
let admin: { id: string; name: string };
|
|
let a: { id: string };
|
|
let b: { id: string };
|
|
|
|
beforeAll(() => {
|
|
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
|
const datei = path.join(verzeichnis, "master.key");
|
|
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
|
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
|
masterKeyVergessen();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
rmSync(verzeichnis, { recursive: true, force: true });
|
|
await warteschlangeStoppen();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await datenbankLeeren();
|
|
admin = await prisma.benutzer.create({
|
|
data: { name: "Ada Admin", rolle: "ADMIN" },
|
|
select: { id: true, name: true },
|
|
});
|
|
a = await prisma.host.create({ data: { name: "srv-a", adresse: "10.0.0.1" }, select: { id: true } });
|
|
b = await prisma.host.create({ data: { name: "srv-b", adresse: "10.0.0.2" }, select: { id: true } });
|
|
});
|
|
|
|
/** Ein ausgerollter Dienst-Key wie nach dem Assistenten: Private auf A (Alias), Public auf B. */
|
|
async function alterSchluessel() {
|
|
const alt = await prisma.schluessel.create({
|
|
data: {
|
|
name: "Backup A → B",
|
|
slug: "backup-a-b",
|
|
typ: "ED25519",
|
|
kategorie: "DIENST",
|
|
publicKey: zeile("ssh-ed25519", ed25519Blob(), "alt"),
|
|
fingerprint: "SHA256:alt",
|
|
kommentar: "alt",
|
|
tags: ["backup"],
|
|
ablaufAm: new Date("2020-01-01T00:00:00Z"),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
const jetzt = new Date();
|
|
await prisma.publicVerwendung.create({
|
|
data: {
|
|
schluesselId: alt.id,
|
|
hostId: b.id,
|
|
zielbenutzer: "backup",
|
|
optionen: 'from="10.0.0.1"',
|
|
zustand: "AUSGEROLLT",
|
|
zuletztAngewendetAm: jetzt,
|
|
},
|
|
});
|
|
await prisma.privateVerwendung.create({
|
|
data: {
|
|
schluesselId: alt.id,
|
|
hostId: a.id,
|
|
zielbenutzer: "root",
|
|
pfad: "~/.ssh/id_backup",
|
|
sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 },
|
|
knownHostsFuer: [b.id],
|
|
beimWiderruf: "UMBENENNEN",
|
|
zustand: "AUSGEROLLT",
|
|
zuletztAngewendetAm: jetzt,
|
|
},
|
|
});
|
|
return alt.id;
|
|
}
|
|
|
|
async function nachfolgerErzeugen(altId: string): Promise<string> {
|
|
const vorgangId = await rotationStarten(altId, admin);
|
|
await vorgangImWorker(vorgangId);
|
|
const neu = await prisma.schluessel.findFirstOrThrow({
|
|
where: { vorgaengerId: altId },
|
|
select: { id: true },
|
|
});
|
|
return neu.id;
|
|
}
|
|
|
|
describe("Namen und Pfade", () => {
|
|
it("ersetzt ein frueheres Rotations-Suffix", () => {
|
|
expect(rotationsName("Backup", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)");
|
|
expect(rotationsName("Backup (rotiert 01.01.2026)", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)");
|
|
expect(rotationsName("x".repeat(200), "12.10.2026", 120)).toHaveLength(120);
|
|
});
|
|
|
|
it("haengt -rJJJJMMTT an und weicht belegten Pfaden aus", () => {
|
|
expect(rotationsPfad("~/.ssh/id_backup", "20261012", new Set())).toBe("~/.ssh/id_backup-r20261012");
|
|
expect(rotationsPfad("~/.ssh/id_backup-r20260101", "20261012", new Set())).toBe(
|
|
"~/.ssh/id_backup-r20261012",
|
|
);
|
|
expect(
|
|
rotationsPfad("~/.ssh/id_backup-r20261012", "20261012", new Set(["~/.ssh/id_backup-r20261012"])),
|
|
).toBe("~/.ssh/id_backup-r20261012-2");
|
|
});
|
|
|
|
it("rechnet das Datum in Berlin", () => {
|
|
expect(rotationsDatum(new Date("2026-10-11T23:30:00Z"))).toEqual({
|
|
anzeige: "12.10.2026",
|
|
kompakt: "20261012",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Was sich rotieren laesst", () => {
|
|
const basis = {
|
|
status: "AKTIV" as const,
|
|
masterKeyVersion: 1,
|
|
widerrufEingeleitetAm: null,
|
|
privatZuweisungen: 0,
|
|
};
|
|
it("verlangt bei Personen-Keys einen neuen Public Key von aussen", () => {
|
|
expect(rotationGesperrt({ ...basis, kategorie: "PERSON", masterKeyVersion: null })).toMatchObject({
|
|
neuerPublicKeyNoetig: true,
|
|
});
|
|
expect(rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null })).toMatchObject({
|
|
neuerPublicKeyNoetig: true,
|
|
});
|
|
// Private Key aus sshkv geloescht, aber ausgerollt: sshkv erzeugt und verteilt den neuen selbst.
|
|
expect(
|
|
rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null, privatZuweisungen: 1 }),
|
|
).toBeNull();
|
|
expect(rotationGesperrt({ ...basis, kategorie: "DEPLOYMENT" })?.grund).toMatch(/eigene Rotation/);
|
|
expect(rotationGesperrt({ ...basis, kategorie: "DIENST", status: "WIDERRUFEN" })).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Rotation Schritt fuer Schritt", () => {
|
|
it("fuehrt vom neuen Schluessel bis ROTIERT und haelt die Reihenfolge ein", async () => {
|
|
const altId = await alterSchluessel();
|
|
expect((await rotationsStand(altId)).schritt).toBe(0);
|
|
|
|
const neuId = await nachfolgerErzeugen(altId);
|
|
const neu = await prisma.schluessel.findUniqueOrThrow({
|
|
where: { id: neuId },
|
|
select: { name: true, typ: true, tags: true, ablaufAm: true, kategorie: true },
|
|
});
|
|
expect(neu.name).toBe(`Backup A → B (rotiert ${rotationsDatum().anzeige})`);
|
|
expect(neu).toMatchObject({ typ: "ED25519", kategorie: "DIENST", tags: ["backup"], ablaufAm: null });
|
|
expect((await rotationsStand(altId)).schritt).toBe(1);
|
|
await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
|
|
|
|
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: false })).rejects.toBeInstanceOf(
|
|
RotationsFehler,
|
|
);
|
|
expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({
|
|
public: 1,
|
|
privat: 1,
|
|
});
|
|
expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({
|
|
public: 0,
|
|
privat: 0,
|
|
});
|
|
|
|
const oeffentlich = await prisma.publicVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } });
|
|
expect(oeffentlich).toMatchObject({ hostId: b.id, zielbenutzer: "backup", optionen: 'from="10.0.0.1"' });
|
|
const privat = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } });
|
|
expect(privat).toMatchObject({
|
|
hostId: a.id,
|
|
zielbenutzer: "root",
|
|
pfad: `~/.ssh/id_backup-r${rotationsDatum().kompakt}`,
|
|
sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 },
|
|
knownHostsFuer: [b.id],
|
|
beimWiderruf: "UMBENENNEN",
|
|
});
|
|
expect((await rotationsStand(altId)).schritt).toBe(2);
|
|
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
|
|
/noch nicht überall/,
|
|
);
|
|
|
|
// Ausgerollt, aber noch nicht getestet.
|
|
const jetzt = new Date();
|
|
await prisma.publicVerwendung.updateMany({
|
|
where: { schluesselId: neuId },
|
|
data: { zustand: "AUSGEROLLT" },
|
|
});
|
|
await prisma.privateVerwendung.updateMany({
|
|
where: { schluesselId: neuId },
|
|
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
|
});
|
|
expect((await rotationsStand(altId)).schritt).toBe(3);
|
|
await prisma.vorgang.create({
|
|
data: { art: "VERBINDUNGSTEST", status: "FEHLGESCHLAGEN", eingabe: { privatVerwendungId: privat.id } },
|
|
});
|
|
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/grün/);
|
|
|
|
await prisma.vorgang.create({
|
|
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
|
|
});
|
|
expect((await rotationsStand(altId)).schritt).toBe(4);
|
|
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
|
|
expect(
|
|
await prisma.publicVerwendung.count({ where: { schluesselId: altId, zustand: "ENTFERNEN" } }),
|
|
).toBe(1);
|
|
// Der Nachfolger bleibt unberuehrt.
|
|
expect(
|
|
await prisma.publicVerwendung.count({ where: { schluesselId: neuId, zustand: "AUSGEROLLT" } }),
|
|
).toBe(1);
|
|
|
|
await prisma.publicVerwendung.updateMany({
|
|
where: { schluesselId: altId },
|
|
data: { zustand: "ENTFERNT" },
|
|
});
|
|
await prisma.privateVerwendung.updateMany({
|
|
where: { schluesselId: altId },
|
|
data: { zustand: "ENTFERNT" },
|
|
});
|
|
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("ROTIERT");
|
|
expect((await rotationsStand(altId)).schritt).toBe(5);
|
|
|
|
expect((await rotationsKette(neuId)).map((g) => [g.id, g.status])).toEqual([
|
|
[altId, "ROTIERT"],
|
|
[neuId, "AKTIV"],
|
|
]);
|
|
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.uebernommen" } })).toBe(1);
|
|
expect(await datenbankAlsText()).not.toContain("PRIVATE KEY");
|
|
});
|
|
|
|
it("verweigert Personen-Keys und den Deployment-Key", async () => {
|
|
const person = await prisma.schluessel.create({
|
|
data: {
|
|
name: "Laptop",
|
|
slug: "laptop",
|
|
typ: "ED25519",
|
|
kategorie: "PERSON",
|
|
publicKey: zeile("ssh-ed25519", ed25519Blob(), "p"),
|
|
fingerprint: "SHA256:p",
|
|
kommentar: "p",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
|
|
});
|
|
});
|