feat(schluessel): erzeugen, importieren, exportieren (#012)

Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste
mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem
Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub.

Warum so:
- Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil
  nur er den Master-Key hat. Passphrase und importierter Private Key
  reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt
  im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach.
- Ein reiner Public Key ist kein Geheimnis und wird direkt im Web
  angelegt, mit verstaendlicher Meldung bei einem Duplikat.
- Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist,
  verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein
  struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf
  Schluessel ausserhalb des Workers ein festes select.
- Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das
  Loeschen des Private Keys; CHECKs fuer den Slug (landet in
  Dateinamen) und "geloescht nur ohne Chiffrat".
- Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:29:52 +02:00
co-authored by Claude Opus 5.5
parent f0833aa1db
commit 263143134f
33 changed files with 3322 additions and 287 deletions
@@ -0,0 +1,19 @@
-- Schluessel erzeugen, importieren, Private Key loeschen (#012, #014).
--
-- Neue Vorgangsarten: Erzeugen und Import eines Paars laufen im Worker, weil nur er den
-- Master-Key hat. Das Loeschen eines gespeicherten Private Keys laeuft ebenfalls dort, damit
-- die Grenze „nur src/worker/geheimnisse.ts fasst das Chiffrat an“ einfach bleibt.
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_ERZEUGEN';
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_IMPORTIEREN';
ALTER TYPE "VorgangsArt" ADD VALUE 'PRIVATE_KEY_LOESCHEN';
-- Der Slug landet in Dateinamen (<slug>.pub, ~/.ssh/id_<slug>) und Kommentaren. Was hier
-- nicht passt, kommt nie bis in einen Pfad oder ein Shell-Skript.
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_slug_gueltig" CHECK (
"slug" ~ '^[a-z0-9-]{1,64}$'
);
-- „Private Key geloescht“ und ein gespeichertes Chiffrat schliessen sich aus (#014).
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_geloescht_ohne_private_key" CHECK (
"privatGeloeschtAm" IS NULL OR "privatVerschluesselt" IS NULL
);
+6
View File
@@ -104,6 +104,12 @@ enum VorgangsArt {
ROTATION
WIDERRUF
PRIVATE_KEY_ABHOLEN
/// Schluesselpaar im Worker erzeugen und versiegeln (#012).
SCHLUESSEL_ERZEUGEN
/// Importiertes Paar im Worker pruefen und versiegeln (#012).
SCHLUESSEL_IMPORTIEREN
/// Gespeicherten Private Key endgueltig verwerfen (#014).
PRIVATE_KEY_LOESCHEN
}
enum VorgangsStatus {
+89
View File
@@ -124,10 +124,99 @@ async function demoDaten(): Promise<void> {
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
}
/**
* Demo-Personen und -Schluessel (#012, #013). Nur Public Keys: einen Private Key kann nur
* der Worker versiegeln. Die Paare werden hier frisch erzeugt und der Private Key sofort
* verworfen – es sind Attrappen, die nirgends Zugang geben.
*/
const DEMO_PERSONEN = [
{ name: "Florian Wilde", email: "florian@example.org", organisation: "IT" },
{ name: "Anna Berger", email: "a.berger@netzwerk-gmbh.example", organisation: "Netzwerk GmbH" },
{ name: "Mehmet Yılmaz", email: "mehmet@example.org", organisation: "IT" },
] as const;
const TAG_MS = 24 * 60 * 60 * 1000;
const DEMO_SCHLUESSEL: {
name: string;
slug: string;
kategorie: "PERSON" | "DIENST";
person?: string;
tags: string[];
ablaufInTagen?: number;
kommentar: string;
}[] = [
{
name: "Laptop Florian",
slug: "laptop-florian",
kategorie: "PERSON",
person: "Florian Wilde",
tags: ["laptop"],
kommentar: "florian@laptop",
},
{
name: "Wartungszugang Netzwerk GmbH",
slug: "wartung-netzwerk-gmbh",
kategorie: "PERSON",
person: "Anna Berger",
tags: ["dienstleister"],
ablaufInTagen: 18,
kommentar: "anna@netzwerk-gmbh",
},
{
name: "Workstation Mehmet",
slug: "workstation-mehmet",
kategorie: "PERSON",
person: "Mehmet Yılmaz",
tags: [],
ablaufInTagen: 200,
kommentar: "mehmet@ws-07",
},
{
name: "Monitoring zabbix → alle Hosts",
slug: "monitoring-zabbix",
kategorie: "DIENST",
tags: ["monitoring"],
kommentar: "zabbix@srv-mon01",
},
];
async function demoSchluessel(): Promise<void> {
if (process.env["SSHKV_SEED_DEMO"] !== "1") return;
const { schluesselpaarErzeugen } = await import("../src/lib/ssh/openssh");
const personen = new Map<string, string>();
for (const p of DEMO_PERSONEN) {
const vorhanden = await prisma.person.findFirst({ where: { name: p.name }, select: { id: true } });
const person = vorhanden ?? (await prisma.person.create({ data: p, select: { id: true } }));
personen.set(p.name, person.id);
}
for (const s of DEMO_SCHLUESSEL) {
if (await prisma.schluessel.findUnique({ where: { slug: s.slug }, select: { id: true } })) continue;
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: s.kommentar });
await prisma.schluessel.create({
data: {
name: s.name,
slug: s.slug,
typ: "ED25519",
kategorie: s.kategorie,
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: s.kommentar,
tags: s.tags,
personId: s.person ? (personen.get(s.person) ?? null) : null,
ablaufAm: s.ablaufInTagen ? new Date(Date.now() + s.ablaufInTagen * TAG_MS) : null,
},
select: { id: true },
});
}
console.log(`Demo-Daten geprüft: ${DEMO_PERSONEN.length} Personen, ${DEMO_SCHLUESSEL.length} Schlüssel.`);
}
try {
await notfallAdmin();
await vorlagen();
await demoDaten();
await demoSchluessel();
} finally {
await prisma.$disconnect();
}