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,137 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
erzeugenAnfordern,
|
||||
paarImportAnfordern,
|
||||
publicKeyImportieren,
|
||||
schluesselBearbeiten,
|
||||
} from "@/server/services/schluessel";
|
||||
|
||||
/**
|
||||
* Aktionen fuer Schluessel (#012). Duenn: Recht pruefen, Felder lesen, Service rufen.
|
||||
*
|
||||
* Passphrase und Private Key verlassen diese Funktionen nur verschluesselt im Job (der
|
||||
* Service verschluesselt sie) – nie in einer Antwort, einem Log oder dem Protokoll.
|
||||
*/
|
||||
export type SchluesselAntwort = FormularZustand & {
|
||||
/** Vorgang im Worker (Erzeugen, Paar-Import) – die Oberflaeche verfolgt ihn. */
|
||||
readonly vorgangId?: string;
|
||||
/** Sofort angelegt (Public-Key-Import). */
|
||||
readonly schluesselId?: string;
|
||||
};
|
||||
|
||||
function metadaten(formular: FormData) {
|
||||
return {
|
||||
name: textfeld(formular, "name") ?? "",
|
||||
slug: textfeld(formular, "slug"),
|
||||
kommentar: textfeld(formular, "kommentar"),
|
||||
tags: textfeld(formular, "tags") ?? "",
|
||||
ablaufAm: textfeld(formular, "ablaufAm"),
|
||||
personId: textfeld(formular, "personId"),
|
||||
vorlageId: textfeld(formular, "vorlageId"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Passphrase mit Wiederholung – die Wiederholung prueft der Server, nicht nur das Formular. */
|
||||
function passphraseLesen(formular: FormData): { passphrase?: string; fehler?: SchluesselAntwort } {
|
||||
const passphrase = textfeld(formular, "passphrase");
|
||||
const wiederholung = textfeld(formular, "passphraseWiederholung");
|
||||
if (passphrase && passphrase !== wiederholung) {
|
||||
return {
|
||||
fehler: {
|
||||
fehler: "Die Passphrasen stimmen nicht überein.",
|
||||
feldfehler: { passphraseWiederholung: "Stimmt nicht mit der Passphrase überein." },
|
||||
},
|
||||
};
|
||||
}
|
||||
return passphrase ? { passphrase } : {};
|
||||
}
|
||||
|
||||
export async function schluesselErzeugenAktion(
|
||||
_vorher: SchluesselAntwort,
|
||||
formular: FormData,
|
||||
): Promise<SchluesselAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||
const { passphrase, fehler } = passphraseLesen(formular);
|
||||
if (fehler) return fehler;
|
||||
const vorgangId = await erzeugenAnfordern(
|
||||
{ ...metadaten(formular), typ: textfeld(formular, "typ") as "ED25519" | "RSA4096" | undefined },
|
||||
passphrase,
|
||||
akteurVon(benutzer),
|
||||
);
|
||||
return { vorgangId };
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function paarImportierenAktion(
|
||||
_vorher: SchluesselAntwort,
|
||||
formular: FormData,
|
||||
): Promise<SchluesselAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||
const vorgangId = await paarImportAnfordern(
|
||||
metadaten(formular),
|
||||
textfeld(formular, "privateKey"),
|
||||
// Beim Import nur zur Pruefung – keine Wiederholung noetig, falsche merkt der Worker.
|
||||
textfeld(formular, "passphrase"),
|
||||
akteurVon(benutzer),
|
||||
);
|
||||
return { vorgangId };
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publicKeyImportierenAktion(
|
||||
_vorher: SchluesselAntwort,
|
||||
formular: FormData,
|
||||
): Promise<SchluesselAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||
const schluessel = await publicKeyImportieren(
|
||||
{
|
||||
...metadaten(formular),
|
||||
kategorie: textfeld(formular, "kategorie") as "PERSON" | "DIENST" | undefined,
|
||||
publicKey: textfeld(formular, "publicKey") ?? "",
|
||||
},
|
||||
akteurVon(benutzer),
|
||||
);
|
||||
revalidatePath("/schluessel");
|
||||
return { schluesselId: schluessel.id };
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function schluesselBearbeitenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const id = textfeld(formular, "id") ?? "";
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||
await schluesselBearbeiten(
|
||||
id,
|
||||
{
|
||||
name: textfeld(formular, "name") ?? "",
|
||||
tags: textfeld(formular, "tags") ?? "",
|
||||
ablaufAm: textfeld(formular, "ablaufAm"),
|
||||
personId: textfeld(formular, "personId"),
|
||||
vorlageId: textfeld(formular, "vorlageId"),
|
||||
status: textfeld(formular, "status") as "AKTIV" | "ABGELAUFEN" | undefined,
|
||||
},
|
||||
akteurVon(benutzer),
|
||||
);
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
revalidatePath("/schluessel", "layout");
|
||||
redirect(`/schluessel/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
|
||||
*
|
||||
* Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md, „Feste Regeln“). Ob ein Private Key
|
||||
* gespeichert ist, verraet `masterKeyVersion`: der DB-CHECK `schluessel_umschlag_vollstaendig`
|
||||
* setzt sie genau dann, wenn auch ein Chiffrat da ist – so muss das Chiffrat nie gelesen
|
||||
* werden, nicht einmal fuer ein „ist null?“.
|
||||
*/
|
||||
|
||||
export const SCHLUESSEL_LISTE_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
typ: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
publicKey: true,
|
||||
fingerprint: true,
|
||||
ablaufAm: true,
|
||||
masterKeyVersion: true,
|
||||
passphraseGeschuetzt: true,
|
||||
person: { select: { id: true, name: true } },
|
||||
_count: {
|
||||
select: {
|
||||
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.SchluesselSelect;
|
||||
|
||||
export const SCHLUESSEL_DETAIL_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
typ: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
publicKey: true,
|
||||
fingerprint: true,
|
||||
kommentar: true,
|
||||
masterKeyVersion: true,
|
||||
privatGeloeschtAm: true,
|
||||
passphraseGeschuetzt: true,
|
||||
instanzStandard: true,
|
||||
selbstHinterlegt: true,
|
||||
tags: true,
|
||||
ablaufAm: true,
|
||||
zuletztVerwendetAm: true,
|
||||
angelegtAm: true,
|
||||
personId: true,
|
||||
vorlageId: true,
|
||||
person: { select: { id: true, name: true } },
|
||||
vorlage: { select: { id: true, name: true } },
|
||||
erstelltVon: { select: { name: true } },
|
||||
publicVerwendungen: {
|
||||
where: { zustand: { not: "ENTFERNT" } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
zielbenutzer: true,
|
||||
optionen: true,
|
||||
zustand: true,
|
||||
zuletztAngewendetAm: true,
|
||||
host: { select: { id: true, name: true } },
|
||||
hostgruppe: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
privateVerwendungen: {
|
||||
where: { zustand: { not: "ENTFERNT" } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
zielbenutzer: true,
|
||||
pfad: true,
|
||||
sshConfig: true,
|
||||
zustand: true,
|
||||
zuletztAngewendetAm: true,
|
||||
host: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.SchluesselSelect;
|
||||
|
||||
export type SchluesselFilter = {
|
||||
readonly q?: string;
|
||||
readonly kategorie?: string;
|
||||
readonly status?: string;
|
||||
};
|
||||
|
||||
const KATEGORIEN: readonly SchluesselKategorie[] = ["PERSON", "DIENST", "DEPLOYMENT"];
|
||||
const STATI: readonly SchluesselStatus[] = ["AKTIV", "ROTIERT", "WIDERRUFEN", "ABGELAUFEN"];
|
||||
|
||||
function alsKategorie(wert: string | undefined): SchluesselKategorie | undefined {
|
||||
return KATEGORIEN.find((k) => k === wert);
|
||||
}
|
||||
|
||||
function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
|
||||
return STATI.find((s) => s === wert);
|
||||
}
|
||||
|
||||
export async function schluesselListe(filter: SchluesselFilter = {}) {
|
||||
const q = filter.q?.trim().slice(0, 100);
|
||||
const kategorie = alsKategorie(filter.kategorie);
|
||||
const status = alsStatus(filter.status);
|
||||
const where: Prisma.SchluesselWhereInput = {
|
||||
...(kategorie ? { kategorie } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ slug: { contains: q.toLowerCase() } },
|
||||
{ fingerprint: { contains: q } },
|
||||
{ kommentar: { contains: q, mode: "insensitive" } },
|
||||
{ tags: { has: q.toLowerCase() } },
|
||||
{ person: { name: { contains: q, mode: "insensitive" } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const zeilen = await prisma.schluessel.findMany({
|
||||
where,
|
||||
orderBy: [{ name: "asc" }],
|
||||
select: SCHLUESSEL_LISTE_SELECT,
|
||||
});
|
||||
return zeilen.map(({ _count, masterKeyVersion, ...rest }) => ({
|
||||
...rest,
|
||||
privatGespeichert: masterKeyVersion !== null,
|
||||
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
|
||||
}));
|
||||
}
|
||||
|
||||
export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>>[number];
|
||||
|
||||
export async function schluesselDetail(id: string) {
|
||||
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
|
||||
if (!schluessel) return null;
|
||||
const { masterKeyVersion, ...rest } = schluessel;
|
||||
return { ...rest, privatGespeichert: masterKeyVersion !== null };
|
||||
}
|
||||
|
||||
export type SchluesselDetail = NonNullable<Awaited<ReturnType<typeof schluesselDetail>>>;
|
||||
|
||||
/** Nur Slug und Public Key – fuer den Download `<slug>.pub`. */
|
||||
export async function publicKeyExport(id: string) {
|
||||
return prisma.schluessel.findUnique({ where: { id }, select: { slug: true, publicKey: true } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Auswahllisten
|
||||
|
||||
export async function personenAuswahl() {
|
||||
return prisma.person.findMany({
|
||||
where: { archiviertAm: null },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, organisation: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function vorlagenAuswahl() {
|
||||
return prisma.vorlage.findMany({
|
||||
where: { archiviertAm: null },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, standardLaufzeitTage: true },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HostFehler } from "@/server/services/hosts";
|
||||
|
||||
/**
|
||||
* Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld (#012, #013).
|
||||
*
|
||||
* Erbt von `HostFehler`, weil `fehlerZustand()` in src/server/actions/formular.ts genau den
|
||||
* als „Meldung fuers Formular“ erkennt. So bleibt die eine Stelle, die Fehler in
|
||||
* Formularzustaende uebersetzt, unveraendert.
|
||||
*/
|
||||
export class FachFehler extends HostFehler {
|
||||
constructor(nachricht: string, feldfehler: Record<string, string> = {}) {
|
||||
super(nachricht, feldfehler);
|
||||
this.name = "FachFehler";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { feldfehlerAus } from "@/lib/hosts";
|
||||
import {
|
||||
bearbeitenSchema,
|
||||
erzeugenSchema,
|
||||
freierSlug,
|
||||
PASSPHRASE_MAX,
|
||||
paarImportSchema,
|
||||
PRIVATE_KEY_MAX,
|
||||
publicImportSchema,
|
||||
publicKeyZeileMitKommentar,
|
||||
schluesselTypVon,
|
||||
slugAus,
|
||||
type BearbeitenEingabe,
|
||||
type ErzeugenEingabe,
|
||||
type PaarImportEingabe,
|
||||
type PublicImportEingabe,
|
||||
type SchluesselGeheimnis,
|
||||
type SchluesselVorgangEingabe,
|
||||
} from "@/lib/schluessel";
|
||||
import { publicKeyZeileLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
|
||||
import type { z } from "@/lib/validierung";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Schluessel anlegen, importieren, bearbeiten (#012).
|
||||
*
|
||||
* Alles, was einen Private Key beruehrt (Erzeugen, Import eines Paars), wird hier nur
|
||||
* geprueft und als Vorgang an den Worker gegeben – der Private Key reist mit
|
||||
* SSHKV_APP_SECRET verschluesselt im Job, nie in `Vorgang.eingabe`. Ein reiner Public Key
|
||||
* ist kein Geheimnis und wird direkt hier angelegt.
|
||||
*
|
||||
* Jede Prisma-Abfrage auf `Schluessel` hat ein festes `select` ohne Chiffrat-Felder
|
||||
* (tests/schluessel-grenze.test.ts).
|
||||
*/
|
||||
|
||||
/** Was die Oberflaeche nach dem Anlegen braucht – und nicht mehr. */
|
||||
const ANGELEGT = { id: true, slug: true, fingerprint: true } satisfies Prisma.SchluesselSelect;
|
||||
|
||||
function pruefen<S extends z.ZodType>(schema: S, eingabe: unknown): z.output<S> {
|
||||
const ergebnis = schema.safeParse(eingabe);
|
||||
if (!ergebnis.success) {
|
||||
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
|
||||
}
|
||||
return ergebnis.data;
|
||||
}
|
||||
|
||||
async function slugFrei(slug: string, client: Prisma.TransactionClient = prisma): Promise<boolean> {
|
||||
return (await client.schluessel.findUnique({ where: { slug }, select: { id: true } })) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein ausdruecklich gewuenschter Slug muss frei sein; ein aus dem Namen abgeleiteter bekommt
|
||||
* bei Bedarf eine Nummer angehaengt.
|
||||
*/
|
||||
export async function eindeutigerSlug(wunsch: string | null, name: string): Promise<string> {
|
||||
if (wunsch) {
|
||||
if (!(await slugFrei(wunsch))) {
|
||||
throw new FachFehler(`Der Kurzname „${wunsch}“ ist schon vergeben.`, {
|
||||
slug: "Dieser Kurzname ist schon vergeben.",
|
||||
});
|
||||
}
|
||||
return wunsch;
|
||||
}
|
||||
return freierSlug(slugAus(name), (kandidat) => slugFrei(kandidat));
|
||||
}
|
||||
|
||||
/** Gibt es diesen Fingerprint schon? Fuer eine Meldung mit Verweis auf den vorhandenen Key. */
|
||||
export async function schluesselMitFingerprint(fingerprint: string) {
|
||||
return prisma.schluessel.findUnique({
|
||||
where: { fingerprint },
|
||||
select: { id: true, name: true, slug: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function bereitsVerwaltet(vorhanden: { id: string; name: string }): FachFehler {
|
||||
return new FachFehler(
|
||||
`Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“ (/schluessel/${vorhanden.id}).`,
|
||||
{ publicKey: "Dieser Schlüssel ist schon in sshkv." },
|
||||
);
|
||||
}
|
||||
|
||||
/** Heute 00:00 UTC – Vergleichsbasis fuer „Ablaufdatum liegt nicht in der Vergangenheit“. */
|
||||
function heute(jetzt = new Date()): Date {
|
||||
return new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate()));
|
||||
}
|
||||
|
||||
type Bezuege = { readonly personId: string | null; readonly vorlageId: string | null };
|
||||
|
||||
/**
|
||||
* Prueft Person und Vorlage und setzt ein fehlendes Ablaufdatum aus der Standardlaufzeit der
|
||||
* Vorlage. Exportiert, weil der Worker dieselbe Pruefung beim Ausfuehren wiederholt.
|
||||
*/
|
||||
export async function bezuegePruefen(
|
||||
daten: Bezuege & { readonly ablaufAm: Date | null },
|
||||
client: Prisma.TransactionClient = prisma,
|
||||
): Promise<{ ablaufAm: Date | null }> {
|
||||
if (daten.personId) {
|
||||
const person = await client.person.findUnique({
|
||||
where: { id: daten.personId },
|
||||
select: { archiviertAm: true },
|
||||
});
|
||||
if (!person || person.archiviertAm) {
|
||||
throw new FachFehler("Die gewählte Person gibt es nicht (mehr).", { personId: "Bitte neu wählen." });
|
||||
}
|
||||
}
|
||||
let ablaufAm = daten.ablaufAm;
|
||||
if (daten.vorlageId) {
|
||||
const vorlage = await client.vorlage.findUnique({
|
||||
where: { id: daten.vorlageId },
|
||||
select: { archiviertAm: true, standardLaufzeitTage: true },
|
||||
});
|
||||
if (!vorlage || vorlage.archiviertAm) {
|
||||
throw new FachFehler("Die gewählte Vorlage gibt es nicht (mehr).", { vorlageId: "Bitte neu wählen." });
|
||||
}
|
||||
if (!ablaufAm && vorlage.standardLaufzeitTage) {
|
||||
ablaufAm = new Date(heute().getTime() + vorlage.standardLaufzeitTage * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
return { ablaufAm };
|
||||
}
|
||||
|
||||
function ablaufNichtVergangen(ablaufAm: Date | null): void {
|
||||
if (ablaufAm && ablaufAm < heute()) {
|
||||
throw new FachFehler("Das Ablaufdatum liegt in der Vergangenheit.", {
|
||||
ablaufAm: "Bitte ein Datum ab heute wählen.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function passphrasePruefen(passphrase: string | undefined): string | undefined {
|
||||
if (!passphrase) return undefined;
|
||||
if (passphrase.length > PASSPHRASE_MAX) {
|
||||
throw new FachFehler("Die Passphrase ist zu lang.", {
|
||||
passphrase: `Höchstens ${PASSPHRASE_MAX} Zeichen.`,
|
||||
});
|
||||
}
|
||||
return passphrase;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Erzeugen und Paar-Import
|
||||
|
||||
/**
|
||||
* Fordert ein neues Schluesselpaar an (#012). Liefert die Vorgangs-ID; die Oberflaeche
|
||||
* verfolgt den Vorgang und springt nach Erfolg zum Schluessel.
|
||||
*/
|
||||
export async function erzeugenAnfordern(
|
||||
eingabe: ErzeugenEingabe,
|
||||
passphraseRoh: string | undefined,
|
||||
akteur: Akteur,
|
||||
): Promise<string> {
|
||||
const daten = pruefen(erzeugenSchema, eingabe);
|
||||
const passphrase = passphrasePruefen(passphraseRoh);
|
||||
ablaufNichtVergangen(daten.ablaufAm);
|
||||
const { ablaufAm } = await bezuegePruefen(daten);
|
||||
const slug = await eindeutigerSlug(daten.slug, daten.name);
|
||||
|
||||
const vorgangsEingabe: SchluesselVorgangEingabe = {
|
||||
name: daten.name,
|
||||
slug,
|
||||
tags: daten.tags,
|
||||
ablaufAm: ablaufAm?.toISOString() ?? null,
|
||||
personId: daten.personId,
|
||||
vorlageId: daten.vorlageId,
|
||||
kommentar: daten.kommentar,
|
||||
typ: daten.typ,
|
||||
mitPassphrase: Boolean(passphrase),
|
||||
};
|
||||
const geheimnis: SchluesselGeheimnis = passphrase ? { passphrase } : {};
|
||||
return vorgangEinreihen({
|
||||
art: "SCHLUESSEL_ERZEUGEN",
|
||||
angefordertVonId: akteur.id,
|
||||
eingabe: vorgangsEingabe,
|
||||
...(passphrase ? { geheim: verschluesseln(JSON.stringify(geheimnis)) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fordert den Import eines Paars an (#012). Der Private Key wird hier nur grob angesehen
|
||||
* (Groesse, Kopfzeile) – lesen, pruefen und versiegeln tut der Worker. Eine Passphrase dient
|
||||
* dort nur der Pruefung und wird verworfen; gespeichert wird der Key, wie er kam.
|
||||
*/
|
||||
export async function paarImportAnfordern(
|
||||
eingabe: PaarImportEingabe,
|
||||
privateKey: string | undefined,
|
||||
passphraseRoh: string | undefined,
|
||||
akteur: Akteur,
|
||||
): Promise<string> {
|
||||
const daten = pruefen(paarImportSchema, eingabe);
|
||||
// Wie ssh-keygen ihn schreibt: LF-Zeilenenden und genau ein Zeilenumbruch am Ende.
|
||||
const text = (privateKey ?? "").replace(/\r\n?/g, "\n").trim();
|
||||
if (!text) {
|
||||
throw new FachFehler("Bitte den Private Key einfügen.", {
|
||||
privateKey: "Bitte den Private Key einfügen.",
|
||||
});
|
||||
}
|
||||
if (text.length > PRIVATE_KEY_MAX || !text.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
// Feste Meldung – nie etwas aus dem eingefuegten Text zurueckgeben.
|
||||
throw new FachFehler("Das ist kein Private Key im OpenSSH-Format.", {
|
||||
privateKey: "Erwartet wird „-----BEGIN OPENSSH PRIVATE KEY-----“ …",
|
||||
});
|
||||
}
|
||||
const passphrase = passphrasePruefen(passphraseRoh);
|
||||
ablaufNichtVergangen(daten.ablaufAm);
|
||||
const { ablaufAm } = await bezuegePruefen(daten);
|
||||
const slug = await eindeutigerSlug(daten.slug, daten.name);
|
||||
|
||||
const vorgangsEingabe: SchluesselVorgangEingabe = {
|
||||
name: daten.name,
|
||||
slug,
|
||||
tags: daten.tags,
|
||||
ablaufAm: ablaufAm?.toISOString() ?? null,
|
||||
personId: daten.personId,
|
||||
vorlageId: daten.vorlageId,
|
||||
kommentar: daten.kommentar,
|
||||
mitPassphrase: Boolean(passphrase),
|
||||
};
|
||||
const geheimnis: SchluesselGeheimnis = { privateKey: `${text}\n`, ...(passphrase ? { passphrase } : {}) };
|
||||
return vorgangEinreihen({
|
||||
art: "SCHLUESSEL_IMPORTIEREN",
|
||||
angefordertVonId: akteur.id,
|
||||
eingabe: vorgangsEingabe,
|
||||
geheim: verschluesseln(JSON.stringify(geheimnis)),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Public-Key-Import
|
||||
|
||||
/** Liest eine Public-Key-Zeile und uebersetzt Formatfehler in eine Feldmeldung. */
|
||||
export function publicKeyPruefen(zeile: string) {
|
||||
try {
|
||||
// Die RSA-Mindestlaenge gilt beim Import (Standard von publicKeyZeileLesen).
|
||||
return publicKeyZeileLesen(zeile);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof SchluesselFormatFehler) {
|
||||
throw new FachFehler(fehler.message, { publicKey: fehler.message });
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Schluessel nur mit Public Key an (#012). Auch Grundlage des Self-Service
|
||||
* (#013), der Kategorie, Person und `selbstHinterlegt` selbst setzt.
|
||||
*/
|
||||
export async function publicKeyAnlegen(
|
||||
daten: {
|
||||
readonly name: string;
|
||||
readonly slug: string | null;
|
||||
readonly kategorie: "PERSON" | "DIENST";
|
||||
readonly publicKey: string;
|
||||
readonly kommentar: string | null;
|
||||
readonly tags: string[];
|
||||
readonly ablaufAm: Date | null;
|
||||
readonly personId: string | null;
|
||||
readonly vorlageId: string | null;
|
||||
readonly selbstHinterlegt?: boolean;
|
||||
},
|
||||
akteur: Akteur,
|
||||
aktion = "schluessel.importiert",
|
||||
) {
|
||||
const info = publicKeyPruefen(daten.publicKey);
|
||||
const vorhanden = await schluesselMitFingerprint(info.fingerprint);
|
||||
if (vorhanden) throw bereitsVerwaltet(vorhanden);
|
||||
ablaufNichtVergangen(daten.ablaufAm);
|
||||
const { ablaufAm } = await bezuegePruefen(daten);
|
||||
const slug = await eindeutigerSlug(daten.slug, daten.name);
|
||||
const kommentar = daten.kommentar ?? (info.kommentar || `sshkv:${slug}`);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const schluessel = await tx.schluessel.create({
|
||||
data: {
|
||||
name: daten.name,
|
||||
slug,
|
||||
typ: schluesselTypVon(info.typ, info.bits),
|
||||
kategorie: daten.kategorie,
|
||||
publicKey: publicKeyZeileMitKommentar(daten.publicKey, kommentar),
|
||||
fingerprint: info.fingerprint,
|
||||
kommentar,
|
||||
tags: daten.tags,
|
||||
ablaufAm,
|
||||
personId: daten.personId,
|
||||
vorlageId: daten.vorlageId,
|
||||
erstelltVonId: akteur.id,
|
||||
selbstHinterlegt: daten.selbstHinterlegt ?? false,
|
||||
},
|
||||
select: ANGELEGT,
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
akteur,
|
||||
aktion,
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: schluessel.fingerprint,
|
||||
details: {
|
||||
name: daten.name,
|
||||
slug,
|
||||
kategorie: daten.kategorie,
|
||||
art: "public",
|
||||
algorithmus: info.typ,
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return schluessel;
|
||||
});
|
||||
} catch (fehler) {
|
||||
// Zwischen Pruefung und Insert kann derselbe Key oder Slug angelegt worden sein.
|
||||
if (istEindeutigkeitsFehler(fehler)) {
|
||||
const jetzt = await schluesselMitFingerprint(info.fingerprint);
|
||||
if (jetzt) throw bereitsVerwaltet(jetzt);
|
||||
throw new FachFehler("Der Kurzname wurde gerade vergeben. Bitte erneut speichern.");
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publicKeyImportieren(eingabe: PublicImportEingabe, akteur: Akteur) {
|
||||
const daten = pruefen(publicImportSchema, eingabe);
|
||||
return publicKeyAnlegen(daten, akteur);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bearbeiten
|
||||
|
||||
export async function schluesselBearbeiten(id: string, eingabe: BearbeitenEingabe, akteur: Akteur) {
|
||||
const daten = pruefen(bearbeitenSchema, eingabe);
|
||||
const alt = await prisma.schluessel.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
name: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
tags: true,
|
||||
ablaufAm: true,
|
||||
personId: true,
|
||||
vorlageId: true,
|
||||
fingerprint: true,
|
||||
},
|
||||
});
|
||||
if (!alt) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
|
||||
if (daten.status && daten.status !== alt.status) {
|
||||
if (alt.status !== "AKTIV" && alt.status !== "ABGELAUFEN") {
|
||||
throw new FachFehler("Rotierte oder widerrufene Schlüssel lassen sich nicht wieder aktivieren.", {
|
||||
status: "Status nicht änderbar.",
|
||||
});
|
||||
}
|
||||
// Der Deployment-Key der Instanz wird nur ueber seine eigene Rotation ersetzt; ein
|
||||
// „abgelaufener“ Deployment-Key liesse den Worker beim naechsten Start einen neuen erzeugen.
|
||||
if (alt.kategorie === "DEPLOYMENT") {
|
||||
throw new FachFehler("Der Status eines Deployment-Keys lässt sich hier nicht ändern.", {
|
||||
status: "Status nicht änderbar.",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (daten.ablaufAm?.getTime() !== alt.ablaufAm?.getTime()) ablaufNichtVergangen(daten.ablaufAm);
|
||||
if (daten.personId !== alt.personId || daten.vorlageId !== alt.vorlageId) {
|
||||
await bezuegePruefen({ personId: daten.personId, vorlageId: daten.vorlageId, ablaufAm: daten.ablaufAm });
|
||||
}
|
||||
|
||||
const nachher = {
|
||||
name: daten.name,
|
||||
tags: daten.tags,
|
||||
ablaufAm: daten.ablaufAm,
|
||||
personId: daten.personId,
|
||||
vorlageId: daten.vorlageId,
|
||||
status: daten.status ?? alt.status,
|
||||
};
|
||||
const geaendert = Object.fromEntries(
|
||||
Object.entries(nachher)
|
||||
.map(([feld, wert]) => [feld, { vorher: alt[feld as keyof typeof nachher], nachher: wert }] as const)
|
||||
.filter(([, a]) => JSON.stringify(a.vorher) !== JSON.stringify(a.nachher)),
|
||||
);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const schluessel = await tx.schluessel.update({ where: { id }, data: nachher, select: ANGELEGT });
|
||||
if (Object.keys(geaendert).length > 0) {
|
||||
await protokollieren(
|
||||
{
|
||||
akteur,
|
||||
aktion: "schluessel.geaendert",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: alt.fingerprint,
|
||||
details: geaendert,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
return schluessel;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user