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:
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user