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
+287
View File
@@ -0,0 +1,287 @@
import { MAX_TAGS, TAG } from "@/lib/hosts";
import { tageBis } from "@/lib/format";
import { z } from "@/lib/validierung";
/**
* Eingaben und Anzeigeregeln fuer Schluessel (#012).
*
* Framework-frei: Web-Service, Server Actions, Worker-Ablaeufe und Tests teilen dieselben
* Regeln. Der Worker prueft die Metadaten aus `Vorgang.eingabe` noch einmal, weil zwischen
* Anfrage und Ausfuehrung Zeit vergeht und die Eingabe in der Datenbank liegt.
*/
/** Wie der DB-CHECK `schluessel_slug_gueltig`. */
export const SLUG = /^[a-z0-9-]{1,64}$/;
export const NAME_MAX = 120;
export const KOMMENTAR_MAX = 200;
/** Ein OpenSSH-Private-Key mit RSA-16384 bleibt unter 16 KiB; mehr ist kein Schluessel. */
export const PRIVATE_KEY_MAX = 16 * 1024;
export const PASSPHRASE_MAX = 1024;
/** Ab hier wird ein Ablaufdatum in Messing hervorgehoben. */
export const ABLAUF_WARNUNG_TAGE = 30;
const UMLAUTE: Record<string, string> = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" };
/**
* Slug aus dem Zweck: „Backup srv-backup01 → srv-pve01“ wird zu
* `backup-srv-backup01-srv-pve01`. Ohne verwertbare Zeichen bleibt „schluessel“.
*/
export function slugAus(name: string): string {
const slug = name
.toLowerCase()
.replace(/[äöüß]/g, (z) => UMLAUTE[z] ?? z)
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64)
.replace(/-+$/, "");
return slug || "schluessel";
}
/** Haengt `-2`, `-3` … an, bis `istFrei` zustimmt; kuerzt vorher, damit 64 Zeichen reichen. */
export async function freierSlug(
basis: string,
istFrei: (slug: string) => Promise<boolean>,
): Promise<string> {
if (await istFrei(basis)) return basis;
for (let n = 2; n < 1000; n++) {
const anhang = `-${n}`;
const kandidat = `${basis.slice(0, 64 - anhang.length).replace(/-+$/, "")}${anhang}`;
if (await istFrei(kandidat)) return kandidat;
}
throw new Error("Kein freier Slug gefunden.");
}
/** Freitext aus einem Formular: leer heisst „nicht gesetzt“. */
const optionalerText = (max: number) =>
z
.string()
.trim()
.max(max, `Höchstens ${max} Zeichen.`)
.nullish()
.transform((wert) => (wert ? wert : null));
const optionaleId = z
.string()
.trim()
.max(64)
.nullish()
.transform((wert) => (wert ? wert : null));
const tagsSchema = z
.preprocess(
(wert) => (typeof wert === "string" ? wert.split(/[\s,]+/).filter(Boolean) : (wert ?? [])),
z.array(
z
.string()
.trim()
.toLowerCase()
.regex(TAG, "Tags: 1–32 Zeichen aus a–z, 0–9, Punkt, Unterstrich, Bindestrich."),
),
)
.transform((tags) => [...new Set(tags)])
.refine((tags) => tags.length <= MAX_TAGS, `Höchstens ${MAX_TAGS} Tags.`);
/**
* Ablaufdatum als `JJJJ-MM-TT` aus `<input type="date">`. Gespeichert wird Mitternacht UTC
* des Tages – `datum()` zeigt in Europe/Berlin denselben Kalendertag.
*/
const ablaufSchema = z
.string()
.trim()
.nullish()
.transform((wert, kontext) => {
if (!wert) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(wert)) {
kontext.addIssue({ code: "custom", message: "Bitte ein Datum angeben." });
return z.NEVER;
}
const datum = new Date(`${wert}T00:00:00Z`);
if (Number.isNaN(datum.getTime()) || datum.toISOString().slice(0, 10) !== wert) {
kontext.addIssue({ code: "custom", message: "Bitte ein gültiges Datum angeben." });
return z.NEVER;
}
return datum;
});
/** Kommentar der Public-Key-Zeile: keine Steuerzeichen, sie braechen authorized_keys auf. */
const kommentarSchema = optionalerText(KOMMENTAR_MAX).refine(
(wert) => wert === null || !/[\x00-\x1f\x7f]/.test(wert),
"Der Kommentar enthält unzulässige Zeichen.",
);
const slugSchema = z
.string()
.trim()
.toLowerCase()
.nullish()
.transform((wert) => (wert ? wert : null))
.refine((wert) => wert === null || SLUG.test(wert), "1–64 Zeichen aus a–z, 0–9 und Bindestrich.");
/** Metadaten, die jeder Schluessel hat – beim Anlegen und Bearbeiten. */
const metadatenFelder = {
name: z.string().trim().min(1, "Bitte den Zweck angeben.").max(NAME_MAX, `Höchstens ${NAME_MAX} Zeichen.`),
tags: tagsSchema,
ablaufAm: ablaufSchema,
personId: optionaleId,
vorlageId: optionaleId,
};
/**
* Erzeugen (#012). Kategorie ist fest DIENST: Personen-Keys speichert sshkv nie mit Private
* Key (DB-CHECK `schluessel_person_ohne_private_key`), Deployment-Keys verwaltet der Worker.
*/
export const erzeugenSchema = z.object({
...metadatenFelder,
slug: slugSchema,
typ: z.enum(["ED25519", "RSA4096"]).default("ED25519"),
kommentar: kommentarSchema,
});
/** Import eines Paars: dieselben Regeln wie beim Erzeugen, der Typ steckt im Key selbst. */
export const paarImportSchema = z.object({
...metadatenFelder,
slug: slugSchema,
kommentar: kommentarSchema,
});
/** Import nur des Public Keys: auch fuer Personen-Keys. */
export const publicImportSchema = z.object({
...metadatenFelder,
slug: slugSchema,
kategorie: z.enum(["PERSON", "DIENST"]).default("DIENST"),
publicKey: z
.string()
.trim()
.min(1, "Bitte den Public Key einfügen.")
.max(16 * 1024, "Der Text ist zu lang für einen Public Key."),
kommentar: kommentarSchema,
});
/**
* Bearbeiten: Slug, Typ und Kommentar bleiben – sie stehen schon in Dateinamen und
* authorized_keys-Zeilen auf den Hosts. ROTIERT/WIDERRUFEN setzen nur Rotation und Widerruf
* (#023, #024); von Hand gibt es nur AKTIV und ABGELAUFEN.
*/
export const bearbeitenSchema = z.object({
...metadatenFelder,
status: z.enum(["AKTIV", "ABGELAUFEN"]).optional(),
});
/** Tags kommen als Text („pve, backup“) oder Liste; `z.preprocess` liesse sonst nur `unknown` uebrig. */
type MitTags<T> = Omit<T, "tags"> & { tags?: string | string[] };
export type ErzeugenEingabe = MitTags<z.input<typeof erzeugenSchema>>;
export type ErzeugenDaten = z.output<typeof erzeugenSchema>;
export type PaarImportEingabe = MitTags<z.input<typeof paarImportSchema>>;
export type PaarImportDaten = z.output<typeof paarImportSchema>;
export type PublicImportEingabe = MitTags<z.input<typeof publicImportSchema>>;
export type PublicImportDaten = z.output<typeof publicImportSchema>;
export type BearbeitenEingabe = MitTags<z.input<typeof bearbeitenSchema>>;
export type BearbeitenDaten = z.output<typeof bearbeitenSchema>;
/**
* Was ein Erzeugen- oder Import-Vorgang in `Vorgang.eingabe` traegt – nur Metadaten, nie ein
* Geheimnis. Datumswerte als ISO-Text, weil `eingabe` JSON ist.
*/
export type SchluesselVorgangEingabe = {
readonly name: string;
readonly slug: string;
readonly tags: string[];
readonly ablaufAm: string | null;
readonly personId: string | null;
readonly vorlageId: string | null;
readonly kommentar: string | null;
readonly typ?: "ED25519" | "RSA4096";
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
readonly mitPassphrase?: boolean;
};
/** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */
export const vorgangEingabeSchema = z.object({
name: z.string().trim().min(1).max(NAME_MAX),
slug: z.string().regex(SLUG),
tags: z.array(z.string().regex(TAG)).max(MAX_TAGS),
ablaufAm: z.iso.datetime().nullable(),
personId: z.string().max(64).nullable(),
vorlageId: z.string().max(64).nullable(),
kommentar: z
.string()
.max(KOMMENTAR_MAX)
.refine((wert) => !/[\x00-\x1f\x7f]/.test(wert))
.nullable(),
typ: z.enum(["ED25519", "RSA4096"]).optional(),
mitPassphrase: z.boolean().optional(),
});
/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
export type SchluesselGeheimnis = {
readonly privateKey?: string;
readonly passphrase?: string;
};
// ---------------------------------------------------------------- Anzeige
export type Kategorie = "PERSON" | "DIENST" | "DEPLOYMENT";
export type Status = "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
export const KATEGORIE_TEXT: Record<Kategorie, string> = {
PERSON: "Person",
DIENST: "Dienst",
DEPLOYMENT: "Deployment",
};
export const STATUS_TEXT: Record<Status, string> = {
AKTIV: "aktiv",
ROTIERT: "rotiert",
WIDERRUFEN: "widerrufen",
ABGELAUFEN: "abgelaufen",
};
/** Kurzform fuer Listen: `ed25519`, `rsa-4096`, sonst der Typ aus der Public-Key-Zeile. */
export function typAnzeige(typ: "ED25519" | "RSA4096" | "ANDERER", publicKey: string): string {
if (typ === "ED25519") return "ed25519";
if (typ === "RSA4096") return "rsa-4096";
const algorithmus = publicKey.split(/\s+/)[0] ?? "";
if (algorithmus === "ssh-rsa") return "rsa";
return algorithmus.replace(/^ssh-/, "").replace(/@openssh\.com$/, "") || "unbekannt";
}
/** `SHA256:q3vN8xP0…Qe0k` – lang genug zum Wiedererkennen, kurz genug fuer eine Tabellenzeile. */
export function fingerprintKurz(fingerprint: string): string {
const [praefix, rest] = fingerprint.includes(":") ? fingerprint.split(":", 2) : ["", fingerprint];
const wert = rest ?? "";
if (wert.length <= 14) return fingerprint;
return `${praefix ? `${praefix}:` : ""}${wert.slice(0, 8)}…${wert.slice(-4)}`;
}
export type AblaufZustand =
| { readonly art: "keiner" }
| { readonly art: "spaeter"; readonly tage: number }
| { readonly art: "bald"; readonly tage: number }
| { readonly art: "abgelaufen"; readonly tage: number };
/** Wie dringend ist das Ablaufdatum? `bald` heisst: in hoechstens 30 Tagen. */
export function ablaufZustand(ablaufAm: Date | string | null, jetzt: Date = new Date()): AblaufZustand {
if (!ablaufAm) return { art: "keiner" };
const tage = tageBis(ablaufAm, jetzt);
if (tage < 0) return { art: "abgelaufen", tage };
if (tage <= ABLAUF_WARNUNG_TAGE) return { art: "bald", tage };
return { art: "spaeter", tage };
}
/** Typ fuer die Datenbank aus Algorithmus und Laenge: nur ed25519 und RSA-4096 haben eigene Werte. */
export function schluesselTypVon(
algorithmus: string,
bits: number | null,
): "ED25519" | "RSA4096" | "ANDERER" {
if (algorithmus === "ssh-ed25519") return "ED25519";
if (algorithmus === "ssh-rsa" && bits === 4096) return "RSA4096";
return "ANDERER";
}
/** Public-Key-Zeile mit dem gespeicherten Kommentar: `typ base64 kommentar`. */
export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): string {
const [typ, base64] = zeile.trim().split(/\s+/);
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
}