Files
sshkeyvault/src/lib/schluessel.ts
T
florian.wildeandClaude Opus 5.5 f6ed66fc7e feat(rotation): gefuehrte Rotation mit Test vor dem Widerruf (#023)
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>
2026-10-12 13:30:39 +02:00

296 lines
11 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 { 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;
/**
* Wie lange ein vom Worker geoeffneter Private Key zur Abholung bereitliegt (#014). Worker
* (Aufraeumen) und Web (Abholen) pruefen dieselbe Frist.
*/
export const ABHOLFRIST_MS = 2 * 60 * 1000;
/** 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;
/** Rotation (#023): der abgeloeste Schluessel – so ist die Kette sofort da, auch ohne Browser. */
readonly vorgaengerId?: string | null;
};
/** 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(),
vorgaengerId: z.string().max(64).nullish(),
});
/** 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}`;
}