feat(security): Zugangsdaten verschluesselt speichern (#021)

AES-256-GCM mit Schluessel aus APP_SECRET fuer SMTP-Passwort und
Webhook-Geheimnis. Bestehende Klartextwerte werden weiter gelesen und beim
naechsten Speichern umgeschluesselt. 156 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 23:50:46 +02:00
co-authored by Claude Opus 5
parent d2ebc4e096
commit db3d38aa3c
21 changed files with 265 additions and 49 deletions
+5 -3
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse";
import { protokolliere } from "@/server/audit";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten";
@@ -45,8 +46,9 @@ export async function emailKanalSpeichern(
port,
secure: port === 465,
benutzer: formular.get("benutzer")?.toString().trim() ?? "",
// Leeres Feld heißt „unverändert“ – so muss das Passwort nicht erneut eingegeben werden.
passwort: passwortEingabe || (bisher?.passwort ?? ""),
// Leeres Feld heißt „unverändert“; das gespeicherte Passwort bleibt dann wie es ist.
// Neue Eingaben werden verschlüsselt abgelegt (#021).
passwort: passwortEingabe ? verschluesseln(passwortEingabe) : (bisher?.passwort ?? ""),
absender: formular.get("absender")?.toString().trim() ?? "",
empfaenger,
};
@@ -90,7 +92,7 @@ export async function webhookKanalSpeichern(
const konfiguration: WebhookKonfiguration = {
url,
geheimnis: geheimnisEingabe || bisher?.geheimnis,
geheimnis: geheimnisEingabe ? verschluesseln(geheimnisEingabe) : bisher?.geheimnis,
};
const daten = {
+15 -1
View File
@@ -2,6 +2,7 @@ import { createHmac } from "node:crypto";
import nodemailer from "nodemailer";
import type { NotificationKanal } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { entschluesseln } from "@/lib/geheimnisse";
/**
* Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook,
@@ -109,6 +110,19 @@ export interface KanalEintrag {
konfiguration: EmailKonfiguration | WebhookKonfiguration;
}
/** Holt die Zugangsdaten aus der Datenbank und entschlüsselt sie für den Versand. */
export function konfigurationLesen(
typ: NotificationKanal,
gespeichert: unknown,
): EmailKonfiguration | WebhookKonfiguration {
if (typ === "EMAIL") {
const eintrag = gespeichert as EmailKonfiguration;
return { ...eintrag, passwort: entschluesseln(eintrag.passwort ?? "") };
}
const eintrag = gespeichert as WebhookKonfiguration;
return { ...eintrag, geheimnis: eintrag.geheimnis ? entschluesseln(eintrag.geheimnis) : undefined };
}
export async function aktiveKanaele(): Promise<KanalEintrag[]> {
const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } });
return kanaele.map((kanal) => ({
@@ -116,7 +130,7 @@ export async function aktiveKanaele(): Promise<KanalEintrag[]> {
typ: kanal.typ,
name: kanal.name,
aktiv: kanal.aktiv,
konfiguration: kanal.konfiguration as unknown as EmailKonfiguration | WebhookKonfiguration,
konfiguration: konfigurationLesen(kanal.typ, kanal.konfiguration),
}));
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { kennzahlen } from "@/server/queries/hosts";
import { aktiveKanaele, ueberKanalSenden, type Nachricht } from "./kanaele";
import { aktiveKanaele, konfigurationLesen, ueberKanalSenden, type Nachricht } from "./kanaele";
import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten";
/**
@@ -149,7 +149,7 @@ export async function ueberEinenKanalSenden(kanalId: string, nachricht: Nachrich
typ: eintrag.typ,
name: eintrag.name,
aktiv: eintrag.aktiv,
konfiguration: eintrag.konfiguration as never,
konfiguration: konfigurationLesen(eintrag.typ, eintrag.konfiguration),
};
})());