Files
sshkeyvault/tests/geheimnisse-worker.test.ts
T
florian.wildeandClaude Opus 5.5 9b807c13ae feat(worker): Private Keys im Worker versiegeln, Deployment-Key (#006)
Der zweite Teil von #006 bindet den Kern an Datenbank und Worker. Nur
src/worker/geheimnisse.ts liest Chiffrat und Data-Key; ein struktureller
Test haelt fest, dass der Webprozess nichts von dort importiert und so
nie an den Master-Key kommt.

Die Zusatzdaten binden jedes Chiffrat an ID und Fingerprint seines
Schluessels: kopiert man es in eine andere Zeile, laesst es sich dort
nicht oeffnen. Der Deployment-Key entsteht beim ersten Start; ein Test
prueft, dass ein pg_dump keinen Klartext enthaelt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:33:18 +02:00

76 lines
2.8 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomBytes } from "node:crypto";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { privateKeyLesen } from "@/lib/ssh/openssh";
import {
deploymentKeySicherstellen,
masterKeyVergessen,
privateKeyLaden,
privateKeyOeffnen,
} from "@/worker/geheimnisse";
import { datenbankLeeren } from "./hilfen/db";
/** Verschluesselte Ablage im Worker (#006). */
let verzeichnis: string;
beforeAll(() => {
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
const datei = path.join(verzeichnis, "master.key");
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
masterKeyVergessen();
});
afterAll(() => {
rmSync(verzeichnis, { recursive: true, force: true });
});
beforeEach(async () => {
await datenbankLeeren();
});
describe("Deployment-Key", () => {
it("wird einmal erzeugt und danach wiederverwendet", async () => {
const [a, b] = await Promise.all([deploymentKeySicherstellen(), deploymentKeySicherstellen()]);
expect(a).toBe(b);
expect(await prisma.schluessel.count({ where: { kategorie: "DEPLOYMENT" } })).toBe(1);
expect(await deploymentKeySicherstellen()).toBe(a);
});
it("laesst sich oeffnen und passt zum Public Key", async () => {
const id = await deploymentKeySicherstellen();
const schluessel = await prisma.schluessel.findUniqueOrThrow({ where: { id } });
const klartext = await privateKeyLaden(id);
const info = privateKeyLesen(klartext.toString("utf8"));
klartext.fill(0);
expect(info.fingerprint).toBe(schluessel.fingerprint);
});
it("laesst sich nicht unter fremder ID oeffnen (Zusatzdaten)", async () => {
const id = await deploymentKeySicherstellen();
const schluessel = await prisma.schluessel.findUniqueOrThrow({ where: { id } });
await expect(privateKeyOeffnen({ ...schluessel, id: "andere-id" })).rejects.toThrow();
});
it("steht in keinem Datenbank-Dump im Klartext", async () => {
const id = await deploymentKeySicherstellen();
const klartext = (await privateKeyLaden(id)).toString("utf8");
// Der Teil zwischen Kopf und Fuss ist das eigentliche Geheimnis.
const kern = klartext.split("\n").slice(1, -2).join("");
const url = new URL(process.env["DATABASE_URL"] ?? "");
const dump = execFileSync(
"docker",
["exec", "sshkv-dev-db", "pg_dump", "-U", url.username, "--data-only", url.pathname.slice(1)],
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
);
expect(dump).toContain("Deployment-Key der Instanz");
expect(dump).not.toContain("PRIVATE KEY");
expect(dump).not.toContain(kern.slice(0, 40));
});
});