Files
sshkeyvault/tests/rotation.test.ts
T
florian.wildeandClaude Opus 5.5 c06c051637 feat(rotation): nach Abbruch erneut rotieren (#033)
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>
2026-10-12 20:09:42 +02:00

412 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { 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,
rotationAbbrechen,
rotationAltWiderrufen,
rotationGesperrt,
rotationStarten,
rotationUebernehmen,
rotationsStand,
} from "@/server/services/rotation";
import { widerrufAbschliessen, widerrufEinleiten } 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);
// Nach einem Abbruch gibt es mehrere Nachfolger; der juengste ist der gerade erzeugte (#033).
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: altId },
orderBy: { angelegtAm: "desc" },
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/);
});
});
describe("Rotation abbrechen (#032)", () => {
/** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */
async function laufendeRotation() {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
const jetzt = new Date();
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
return { altId, neuId };
}
async function verwendungen(schluesselId: string) {
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
]);
return [...oeffentlich, ...privat].map((v) => v.zustand).sort();
}
it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => {
const { altId, neuId } = await laufendeRotation();
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
// Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg.
expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]);
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher);
const stand = await rotationsStand(altId);
expect(stand.abgebrochen).toBe(true);
// Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts.
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.rotation.abgebrochen" },
select: { fingerprint: true, details: true },
});
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
// Solange der Abbruch laeuft, keine neue Rotation (#033).
await expect(rotationStarten(altId, admin)).rejects.toThrow(/läuft noch/);
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([
[altId, "AKTIV"],
[neuId, "WIDERRUFEN"],
]);
expect(
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
.vorgaengerId,
).toBe(altId);
// Danach laesst sich derselbe Schluessel erneut rotieren (#033).
expect(await rotationsStand(altId)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
const neu2 = await nachfolgerErzeugen(altId);
expect(neu2).not.toBe(neuId);
expect(await rotationsStand(altId)).toMatchObject({ neu: { id: neu2 }, schritt: 1, abgebrochen: false });
expect((await rotationsKette(altId)).map((g) => g.id)).toEqual([altId, neu2]);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
});
it("erlaubt in der Datenbank nur einen offenen Nachfolger je Schluessel (#033)", async () => {
const altId = await alterSchluessel();
const nachfolger = (n: string, daten: object = {}) =>
prisma.schluessel.create({
data: {
name: n,
slug: n,
typ: "ED25519",
kategorie: "DIENST",
publicKey: zeile("ssh-ed25519", ed25519Blob(), n),
fingerprint: `SHA256:${n}`,
kommentar: n,
vorgaengerId: altId,
...daten,
},
select: { id: true },
});
await nachfolger("abgebrochen", { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() });
await nachfolger("im-abbruch", { widerrufEingeleitetAm: new Date() });
await nachfolger("offen");
await expect(nachfolger("zweiter-offen")).rejects.toThrow();
});
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]);
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
});
it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => {
const { altId } = await laufendeRotation();
const privat = await prisma.privateVerwendung.findFirstOrThrow({
where: { schluessel: { vorgaengerId: altId } },
select: { id: true },
});
await prisma.vorgang.create({
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
});
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
/nicht mehr abbrechen/,
);
await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/);
});
it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => {
const { altId, neuId } = await laufendeRotation();
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
await widerrufEinleiten([altId], admin, { privatErlaubt: true });
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("WIDERRUFEN");
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV");
});
});