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:
@@ -165,11 +165,7 @@ describe.runIf(datenbankVerfuegbar)("Agent-API", () => {
|
||||
["fehlendem Token", async () => undefined, 401],
|
||||
["unbekanntem Token", async () => "plx_e_unbekannt", 401],
|
||||
["widerrufenem Token", async () => enrollmentTokenAnlegen({ widerrufen: true }), 401],
|
||||
[
|
||||
"abgelaufenem Token",
|
||||
async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }),
|
||||
401,
|
||||
],
|
||||
["abgelaufenem Token", async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }), 401],
|
||||
["aufgebrauchtem Token", async () => enrollmentTokenAnlegen({ maxNutzungen: 0 }), 401],
|
||||
])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => {
|
||||
const token = await tokenErzeugen();
|
||||
|
||||
@@ -57,8 +57,14 @@ describe("Agent-Skript", () => {
|
||||
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
|
||||
befehlErsetzen("pveversion", "pve-manager/8.3.2/dc2b2f4e");
|
||||
befehlErsetzen("lscpu", "Socket(s): 2\nModel name: Intel(R) Xeon(R) E-2388G");
|
||||
befehlErsetzen("ip", "1: lo inet 127.0.0.1/8 scope host lo\n2: vmbr0 inet 192.168.10.11/24 scope global vmbr0");
|
||||
befehlErsetzen("apt", "Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]");
|
||||
befehlErsetzen(
|
||||
"ip",
|
||||
"1: lo inet 127.0.0.1/8 scope host lo\n2: vmbr0 inet 192.168.10.11/24 scope global vmbr0",
|
||||
);
|
||||
befehlErsetzen(
|
||||
"apt",
|
||||
"Auflistung...\nlibc6/stable 2.40 amd64 [upgradable from: 2.39]\nbash/stable 5.3 amd64 [upgradable from: 5.2]",
|
||||
);
|
||||
|
||||
const report = reportErzeugen() as {
|
||||
reportId: string;
|
||||
|
||||
@@ -82,9 +82,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
|
||||
});
|
||||
const ergebnis = await alertsAbgleichen(JETZT);
|
||||
|
||||
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(
|
||||
`subscription-laeuft-ab:${host.id}:7`,
|
||||
);
|
||||
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(`subscription-laeuft-ab:${host.id}:7`);
|
||||
const alerts = await testPrisma.alert.findMany({
|
||||
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
|
||||
});
|
||||
|
||||
+9
-2
@@ -1,7 +1,12 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { cookieOptionen, sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
|
||||
import { AnmeldeFehler, anmeldeRateLimit, mitOidcAnmelden, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
anmeldeRateLimit,
|
||||
mitOidcAnmelden,
|
||||
mitPasswortAnmelden,
|
||||
} from "@/server/auth/benutzer";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
const GEHEIMNIS = "test-geheimnis-mit-mindestens-32-zeichen!!";
|
||||
@@ -108,7 +113,9 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
|
||||
await admin();
|
||||
await expect(mitPasswortAnmelden("admin@example.de", "falsch", "1.2.3.4")).rejects.toThrow(AnmeldeFehler);
|
||||
|
||||
const protokoll = await testPrisma.auditEintrag.findFirst({ where: { aktion: "anmeldung.fehlgeschlagen" } });
|
||||
const protokoll = await testPrisma.auditEintrag.findFirst({
|
||||
where: { aktion: "anmeldung.fehlgeschlagen" },
|
||||
});
|
||||
expect(protokoll).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { konfigurationLesen, perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele";
|
||||
import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten";
|
||||
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
@@ -43,9 +44,7 @@ describe("Nachrichten", () => {
|
||||
},
|
||||
],
|
||||
neueAlerts: [],
|
||||
baldAblaufend: [
|
||||
{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 },
|
||||
],
|
||||
baldAblaufend: [{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 }],
|
||||
offeneRechnungspunkte: 3,
|
||||
hostsOhneMeldung: 1,
|
||||
},
|
||||
@@ -219,6 +218,52 @@ describe.runIf(datenbankVerfuegbar)("Versand", () => {
|
||||
expect(zweiter.gesendet).toBe(1);
|
||||
});
|
||||
|
||||
it("nutzt ein verschlüsselt gespeichertes Webhook-Geheimnis für die Signatur", async () => {
|
||||
process.env.APP_SECRET = "test-geheimnis-mit-mindestens-32-zeichen!!";
|
||||
|
||||
const aufrufe: RequestInit[] = [];
|
||||
vi.stubGlobal("fetch", async (_url: string, optionen: RequestInit) => {
|
||||
aufrufe.push(optionen);
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
await testPrisma.benachrichtigungskanal.create({
|
||||
data: {
|
||||
typ: "WEBHOOK",
|
||||
name: "Signierter Hook",
|
||||
aktiv: true,
|
||||
konfiguration: { url: "https://hook.example.de/x", geheimnis: verschluesseln("s3cret") },
|
||||
},
|
||||
});
|
||||
const eintrag = await alert();
|
||||
|
||||
await neueAlertsMelden([eintrag.fingerprint]);
|
||||
|
||||
const kopfzeilen = aufrufe[0].headers as Record<string, string>;
|
||||
const koerper = aufrufe[0].body as string;
|
||||
expect(kopfzeilen["X-ProxLedger-Signatur"]).toBe(
|
||||
`sha256=${createHmac("sha256", "s3cret").update(koerper).digest("hex")}`,
|
||||
);
|
||||
|
||||
// In der Datenbank steht kein Klartext.
|
||||
const gespeichert = await testPrisma.benachrichtigungskanal.findFirstOrThrow();
|
||||
expect(JSON.stringify(gespeichert.konfiguration)).not.toContain("s3cret");
|
||||
});
|
||||
|
||||
it("liest Zugangsdaten aus der Zeit vor der Verschlüsselung weiter", () => {
|
||||
const konfiguration = konfigurationLesen("EMAIL", {
|
||||
host: "smtp.example.de",
|
||||
port: 587,
|
||||
secure: false,
|
||||
benutzer: "post",
|
||||
passwort: "alt-im-klartext",
|
||||
absender: "post@example.de",
|
||||
empfaenger: ["florian@example.de"],
|
||||
});
|
||||
|
||||
expect((konfiguration as { passwort: string }).passwort).toBe("alt-im-klartext");
|
||||
});
|
||||
|
||||
it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => {
|
||||
await webhookKanal();
|
||||
await alert();
|
||||
|
||||
@@ -65,9 +65,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
|
||||
|
||||
expect(laufzeiten).toHaveLength(2);
|
||||
expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true);
|
||||
expect(
|
||||
laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0),
|
||||
).toBe(230);
|
||||
expect(laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0)).toBe(230);
|
||||
});
|
||||
|
||||
it("erlaubt Hosts ohne Kunde und setzt die Zuordnung beim Löschen des Kunden zurück", async () => {
|
||||
@@ -98,4 +96,4 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
|
||||
await testPrisma.laufzeit.create({ data: daten });
|
||||
await expect(testPrisma.laufzeit.create({ data: daten })).rejects.toThrow();
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { entschluesseln, GeheimnisFehler, istVerschluesselt, verschluesseln } from "@/lib/geheimnisse";
|
||||
|
||||
const SCHLUESSEL = "test-geheimnis-mit-mindestens-32-zeichen!!";
|
||||
|
||||
describe("Geheimnisse", () => {
|
||||
beforeEach(() => {
|
||||
process.env.APP_SECRET = SCHLUESSEL;
|
||||
});
|
||||
|
||||
it("verschlüsselt und entschlüsselt einen Wert", () => {
|
||||
const klartext = "sehr-geheimes-smtp-passwort";
|
||||
const chiffrat = verschluesseln(klartext);
|
||||
|
||||
expect(chiffrat).not.toContain(klartext);
|
||||
expect(istVerschluesselt(chiffrat)).toBe(true);
|
||||
expect(entschluesseln(chiffrat)).toBe(klartext);
|
||||
});
|
||||
|
||||
it("erzeugt bei gleichem Klartext unterschiedliche Chiffrate", () => {
|
||||
// Ein zufälliger Nonce verhindert, dass gleiche Passwörter erkennbar sind.
|
||||
expect(verschluesseln("gleich")).not.toBe(verschluesseln("gleich"));
|
||||
});
|
||||
|
||||
it("lässt Klartext aus der Zeit vor der Verschlüsselung unverändert durch", () => {
|
||||
expect(istVerschluesselt("altes-passwort")).toBe(false);
|
||||
expect(entschluesseln("altes-passwort")).toBe("altes-passwort");
|
||||
});
|
||||
|
||||
it("behandelt leere Werte", () => {
|
||||
expect(verschluesseln("")).toBe("");
|
||||
expect(entschluesseln("")).toBe("");
|
||||
});
|
||||
|
||||
it("meldet einen geänderten Schlüssel verständlich", () => {
|
||||
const chiffrat = verschluesseln("geheim");
|
||||
process.env.APP_SECRET = "ein-voellig-anderes-geheimnis-mit-32-z!";
|
||||
|
||||
expect(() => entschluesseln(chiffrat)).toThrow(GeheimnisFehler);
|
||||
expect(() => entschluesseln(chiffrat)).toThrow(/APP_SECRET/);
|
||||
});
|
||||
|
||||
it("erkennt Manipulation am Chiffrat", () => {
|
||||
const chiffrat = verschluesseln("geheim");
|
||||
const [praefix, nonce, authTag, daten] = chiffrat.split(":");
|
||||
const veraendert = [praefix, nonce, authTag, `${daten.slice(0, -2)}xy`].join(":");
|
||||
|
||||
expect(() => entschluesseln(veraendert)).toThrow(GeheimnisFehler);
|
||||
});
|
||||
|
||||
it("verlangt einen ausreichend langen Schlüssel", () => {
|
||||
process.env.APP_SECRET = "kurz";
|
||||
expect(() => verschluesseln("geheim")).toThrow(/APP_SECRET/);
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,10 @@ describe.runIf(datenbankVerfuegbar)("Host-Tabelle", () => {
|
||||
it("findet Hosts über Server-ID, IP, FQDN und Kundenname", async () => {
|
||||
for (const suche of ["A1B2C3D4E5F60718", "192.168.10.11", "brandl.local", "Brandl Elektro"]) {
|
||||
const { hosts } = await hostListe({ q: suche }, JETZT);
|
||||
expect(hosts.map((h) => h.hostname), `Suche nach ${suche}`).toContain("pve-brandl-01");
|
||||
expect(
|
||||
hosts.map((h) => h.hostname),
|
||||
`Suche nach ${suche}`,
|
||||
).toContain("pve-brandl-01");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,7 +120,9 @@ describe.runIf(datenbankVerfuegbar)("Host-Tabelle", () => {
|
||||
});
|
||||
|
||||
it("filtert nach Kunde, Status und Produkt", async () => {
|
||||
const { kunde } = { kunde: await testPrisma.kunde.findFirstOrThrow({ where: { kundennummer: "K-1001" } }) };
|
||||
const { kunde } = {
|
||||
kunde: await testPrisma.kunde.findFirstOrThrow({ where: { kundennummer: "K-1001" } }),
|
||||
};
|
||||
|
||||
expect((await hostListe({ kunde: kunde.id }, JETZT)).gesamt).toBe(1);
|
||||
expect((await hostListe({ kunde: "ohne" }, JETZT)).gesamt).toBe(1);
|
||||
|
||||
@@ -264,7 +264,9 @@ describe.runIf(datenbankVerfuegbar)("Laufzeiten und Rechnungen", () => {
|
||||
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
|
||||
|
||||
await einkaufBezahltSetzen(laufzeitA.id, true);
|
||||
let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } });
|
||||
let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
|
||||
where: { nummer: EINKAUF.nummer },
|
||||
});
|
||||
expect(rechnung.bezahlt).toBe(true);
|
||||
expect(rechnung.bezahltAm).not.toBeNull();
|
||||
|
||||
|
||||
@@ -253,7 +253,10 @@ describe("Warnregeln", () => {
|
||||
|
||||
it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => {
|
||||
expect(
|
||||
ausRegel(kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }), "proxmox-rechnung-faellig"),
|
||||
ausRegel(
|
||||
kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }),
|
||||
"proxmox-rechnung-faellig",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
ausRegel(
|
||||
|
||||
Reference in New Issue
Block a user