Files
sshkeyvault/tests/datenbankregeln.test.ts
T
florian.wildeandClaude Opus 5.5 263143134f 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>
2026-10-12 10:29:52 +02:00

247 lines
9.6 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { datenbankLeeren } from "./hilfen/db";
/**
* Regeln, die in der Datenbank stehen (#003, #005).
*
* Die Liste ist **namentlich**: eine neue Regel in der Migration ohne Eintrag hier faellt
* im ersten Test auf.
*/
const REGELN = [
"schluessel_umschlag_vollstaendig",
"schluessel_person_ohne_private_key",
"schluessel_instanzstandard_nur_deployment",
"publicverwendung_zielbenutzer_gueltig",
"privateverwendung_zielbenutzer_gueltig",
"zielkonto_benutzername_gueltig",
"host_adresse_gueltig",
"host_port_gueltig",
"privateverwendung_pfad_gueltig",
"publicverwendung_host_oder_gruppe",
"benutzer_notfall_vollstaendig",
"schluessel_slug_gueltig",
"schluessel_geloescht_ohne_private_key",
].sort();
const INDIZES = [
"schluessel_ein_instanzstandard",
"publicverwendung_eindeutig_host",
"publicverwendung_eindeutig_gruppe",
"privateverwendung_eindeutig",
"hostschluessel_ein_vertrauter",
].sort();
let zaehler = 0;
async function schluesselAnlegen(
daten: Partial<Parameters<typeof prisma.schluessel.create>[0]["data"]> = {},
) {
zaehler += 1;
return prisma.schluessel.create({
data: {
name: `Test ${zaehler}`,
slug: `test-${zaehler}`,
typ: "ED25519",
kategorie: "DIENST",
publicKey: "ssh-ed25519 AAAA test",
fingerprint: `SHA256:test${zaehler}`,
kommentar: "test",
...daten,
},
});
}
async function hostAnlegen(name = "srv-test01") {
return prisma.host.create({ data: { name, adresse: "10.0.0.1" } });
}
beforeEach(async () => {
await datenbankLeeren();
});
describe("Datenbankregeln", () => {
it("sind vollstaendig aufgezaehlt", async () => {
const checks = await prisma.$queryRaw<{ conname: string }[]>`
SELECT conname FROM pg_constraint WHERE contype = 'c' AND connamespace = 'public'::regnamespace`;
expect(checks.map((c) => c.conname).sort()).toEqual(REGELN);
const indizes = await prisma.$queryRaw<{ indexname: string }[]>`
SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexdef LIKE '% WHERE %'`;
expect(indizes.map((i) => i.indexname).sort()).toEqual(INDIZES);
});
it("verlangt Chiffrat, Data-Key und Version nur zusammen", async () => {
await expect(schluesselAnlegen({ privatVerschluesselt: Buffer.from("x") })).rejects.toThrow();
await expect(
schluesselAnlegen({
privatVerschluesselt: Buffer.from("x"),
dekVerschluesselt: Buffer.from("y"),
masterKeyVersion: 1,
}),
).resolves.toBeDefined();
});
it("speichert keine Private Keys von Personen", async () => {
await expect(
schluesselAnlegen({
kategorie: "PERSON",
privatVerschluesselt: Buffer.from("x"),
dekVerschluesselt: Buffer.from("y"),
masterKeyVersion: 1,
}),
).rejects.toThrow();
});
it("laesst nur Slugs zu, die in Dateinamen passen (#012)", async () => {
for (const slug of ["Gross", "mit leer", "../x", "a_b", "", "x".repeat(65)]) {
await expect(schluesselAnlegen({ slug })).rejects.toThrow();
}
await expect(schluesselAnlegen({ slug: "backup-srv-pve01" })).resolves.toBeDefined();
});
it("kennt „Private Key geloescht“ nur ohne Chiffrat (#014)", async () => {
await expect(
schluesselAnlegen({
privatGeloeschtAm: new Date(),
privatVerschluesselt: Buffer.from("x"),
dekVerschluesselt: Buffer.from("y"),
masterKeyVersion: 1,
}),
).rejects.toThrow();
await expect(schluesselAnlegen({ privatGeloeschtAm: new Date() })).resolves.toBeDefined();
});
it("laesst nur einen aktiven Instanz-Deployment-Key zu", async () => {
await expect(schluesselAnlegen({ kategorie: "DIENST", instanzStandard: true })).rejects.toThrow();
await schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true });
await expect(schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true })).rejects.toThrow();
// Ein rotierter alter Deployment-Key darf daneben stehen bleiben.
await expect(
schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true, status: "ROTIERT" }),
).resolves.toBeDefined();
});
it("weist Zielbenutzer und Adressen ab, die in einer Shell Schaden anrichten koennten", async () => {
const host = await hostAnlegen();
const schluessel = await schluesselAnlegen();
for (const zielbenutzer of ["root; rm -rf /", "Root", "a b", "$(id)", ""]) {
await expect(
prisma.publicVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer },
}),
).rejects.toThrow();
}
await expect(prisma.host.create({ data: { name: "x", adresse: "host;id" } })).rejects.toThrow();
await expect(
prisma.host.create({ data: { name: "y", adresse: "10.0.0.2", port: 70000 } }),
).rejects.toThrow();
await expect(prisma.host.create({ data: { name: "z", adresse: "fe80::1" } })).resolves.toBeDefined();
});
it("prueft Private-Key-Pfade", async () => {
const host = await hostAnlegen();
const schluessel = await schluesselAnlegen();
const anlegen = (pfad: string) =>
prisma.privateVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root", pfad },
});
await expect(anlegen("~/.ssh/id_ed25519_backup")).resolves.toBeDefined();
await expect(anlegen("/root/.ssh/id_x")).resolves.toBeDefined();
for (const pfad of ["relativ/pfad", "~/../etc/shadow", "/etc/../x", "~/a b", "~/x\ny"]) {
await expect(anlegen(pfad)).rejects.toThrow();
}
});
it("verlangt bei einer Public-Verwendung genau Host oder Gruppe", async () => {
const host = await hostAnlegen();
const gruppe = await prisma.hostgruppe.create({ data: { name: "Proxmox" } });
const schluessel = await schluesselAnlegen();
await expect(
prisma.publicVerwendung.create({ data: { schluesselId: schluessel.id, zielbenutzer: "root" } }),
).rejects.toThrow();
await expect(
prisma.publicVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, hostgruppeId: gruppe.id, zielbenutzer: "root" },
}),
).rejects.toThrow();
await prisma.publicVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
});
// Doppelt nur, solange die erste nicht entfernt ist.
await expect(
prisma.publicVerwendung.create({
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
}),
).rejects.toThrow();
});
it("laesst je Host und Algorithmus nur einen vertrauten Host-Key zu", async () => {
const host = await hostAnlegen();
const basis = { hostId: host.id, algorithmus: "ssh-ed25519", publicKey: "AAAA" };
await prisma.hostSchluessel.create({ data: { ...basis, fingerprint: "SHA256:a", status: "VERTRAUT" } });
await expect(
prisma.hostSchluessel.create({ data: { ...basis, fingerprint: "SHA256:b", status: "VERTRAUT" } }),
).rejects.toThrow();
await expect(
prisma.hostSchluessel.create({ data: { ...basis, fingerprint: "SHA256:b", status: "AUSSTEHEND" } }),
).resolves.toBeDefined();
});
it("verlangt beim Notfall-Admin Anmeldename und Passwort", async () => {
await expect(prisma.benutzer.create({ data: { name: "Notfall", istNotfall: true } })).rejects.toThrow();
});
});
describe("Protokoll", () => {
async function eintragen(aktion: string) {
return prisma.protokoll.create({
data: { akteurName: "Test", aktion, ergebnis: "ERFOLG", details: { a: 1 } },
});
}
it("verkettet jede Zeile mit ihrer Vorgaengerin", async () => {
const erster = await eintragen("eins");
const zweiter = await eintragen("zwei");
expect(erster.vorgaengerHash).toBe("");
expect(erster.hash).toMatch(/^[0-9a-f]{64}$/);
expect(zweiter.vorgaengerHash).toBe(erster.hash);
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
expect(pruefung?.ergebnis).toBeNull();
});
it("verweigert Aendern, Loeschen und Leeren", async () => {
const eintrag = await eintragen("eins");
await expect(
prisma.protokoll.update({ where: { id: eintrag.id }, data: { aktion: "x" } }),
).rejects.toThrow(/unveränderlich/);
await expect(prisma.protokoll.delete({ where: { id: eintrag.id } })).rejects.toThrow(/unveränderlich/);
await expect(prisma.$executeRawUnsafe(`TRUNCATE "Protokoll"`)).rejects.toThrow(/unveränderlich/);
});
it("findet eine manipulierte Zeile", async () => {
await eintragen("eins");
const zweiter = await eintragen("zwei");
await eintragen("drei");
// Ein Angreifer mit Datenbankrechten schaltet den Trigger ab und aendert eine Zeile.
await prisma.$transaction([
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" DISABLE TRIGGER "protokoll_kein_update"`),
prisma.$executeRawUnsafe(`UPDATE "Protokoll" SET "aktion" = 'gefaelscht' WHERE "id" = ${zweiter.id}`),
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" ENABLE TRIGGER "protokoll_kein_update"`),
]);
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
expect(pruefung?.ergebnis).toBe(zweiter.id);
});
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
expect(pruefung?.ergebnis).toBeNull();
});
});