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>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Strukturell (#006): Nur der Worker liest Chiffrat und Data-Key, und der Webprozess
|
||||
* importiert nichts aus src/worker – sonst laege der Master-Key-Zugriff im Web.
|
||||
*/
|
||||
function dateien(verzeichnis: string): string[] {
|
||||
return readdirSync(verzeichnis).flatMap((name) => {
|
||||
const pfad = path.join(verzeichnis, name);
|
||||
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
|
||||
return /\.(ts|tsx)$/.test(name) ? [pfad.split(path.sep).join("/")] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const ALLE = dateien("src");
|
||||
|
||||
/** Dateien, die die Namen tragen, ohne die Datenbank zu lesen – mit Begruendung. */
|
||||
const NAMEN_ERLAUBT: Record<string, string> = {
|
||||
"src/worker/geheimnisse.ts": "die eine Stelle, die liest",
|
||||
"src/lib/krypto/umschlag.ts": "Feldname des Umschlags, rein rechnerisch",
|
||||
"src/lib/protokoll-schwaerzen.ts": "Feldname in der Schwaerz-Liste",
|
||||
};
|
||||
|
||||
describe("Grenze um die Private Keys", () => {
|
||||
it("nur src/worker/geheimnisse.ts liest privatVerschluesselt und dekVerschluesselt", () => {
|
||||
const verstoesse = ALLE.filter(
|
||||
(datei) =>
|
||||
!NAMEN_ERLAUBT[datei] && /privatVerschluesselt|dekVerschluesselt/.test(readFileSync(datei, "utf8")),
|
||||
);
|
||||
expect(verstoesse).toEqual([]);
|
||||
});
|
||||
|
||||
it("nichts ausserhalb von src/worker importiert aus src/worker", () => {
|
||||
const verstoesse = ALLE.filter(
|
||||
(datei) => !datei.startsWith("src/worker/") && /from ["']@\/worker\//.test(readFileSync(datei, "utf8")),
|
||||
);
|
||||
expect(verstoesse).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user