From 9b807c13ae368db294916d9128620c6f1b5b489f Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:33:18 +0200 Subject: [PATCH] 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 --- .../006-geheimnisse-schluesselformat.md | 14 +- src/worker/geheimnisse.ts | 189 ++++++++++++++++++ src/worker/index.ts | 4 + tests/geheimnisse-grenze.test.ts | 41 ++++ tests/geheimnisse-worker.test.ts | 75 +++++++ 5 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 src/worker/geheimnisse.ts create mode 100644 tests/geheimnisse-grenze.test.ts create mode 100644 tests/geheimnisse-worker.test.ts diff --git a/docs/issues/006-geheimnisse-schluesselformat.md b/docs/issues/006-geheimnisse-schluesselformat.md index 5d6ce97..ed2149a 100644 --- a/docs/issues/006-geheimnisse-schluesselformat.md +++ b/docs/issues/006-geheimnisse-schluesselformat.md @@ -1,25 +1,25 @@ # #006 Verschlüsselte Ablage und OpenSSH-Schlüsselformat -**Status:** in Arbeit +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** sicherheit -**Geschätzt:** 4 h **Tatsächlich:** – +**Geschätzt:** 4 h **Tatsächlich:** 0 h 25 min ## Ziel Envelope Encryption für Private Keys mit Master-Key aus Datei; eigene Erzeugung und Serialisierung von OpenSSH-Schlüsseln ohne temporäre Dateien; Deployment-Key der Instanz. ## Aufgaben - [x] Master-Key aus Datei laden (32 Byte roh, Base64 oder Hex; Rechteprüfung) – `masterKeyLaden` -- [ ] Master-Key aus `SSHKV_MASTER_KEY_FILE` nur im Worker anbinden +- [x] Master-Key aus `SSHKV_MASTER_KEY_FILE` nur im Worker anbinden - [x] AES-256-GCM, DEK je Schlüssel, AAD = Schlüssel-ID + Fingerprint, Master-Key-Version - [x] ed25519 und RSA-4096 erzeugen (Node `crypto`), OpenSSH-Format schreiben und lesen (optional Passphrase: bcrypt-pbkdf + aes256-ctr) - [x] Fingerprint SHA256 wie `ssh-keygen -l` -- [ ] Deployment-Key beim ersten Start erzeugen +- [x] Deployment-Key beim ersten Start erzeugen - [x] Log-Redaction (`schwaerzen`; Einbau in Logger/Protokoll folgt) ## Akzeptanzkriterien - [x] `ssh-keygen -y -f` liest erzeugte Schlüssel (mit und ohne Passphrase) - [x] Fingerprints identisch zu `ssh-keygen -lf` -- [ ] Datenbank-Dump enthält keinen Klartext-Schlüssel +- [x] Datenbank-Dump enthält keinen Klartext-Schlüssel ## Abhängigkeiten #003 @@ -55,3 +55,7 @@ Envelope Encryption für Private Keys mit Master-Key aus Datei; eigene Erzeugung `passphrase`, `secret`, `geheimnis`, `token`, `masterkey`, `authorization`, `cookie` enthalten (nicht nur als Präfix), sowie auf PEM-Private-Keys in jeder Zeichenkette (auch abgeschnitten, auch in `message`/`stack` von Fehlern). Rohe Bytes werden zu `[Binärdaten]`. +- **Anbindung im Worker (Teil 2):** `src/worker/geheimnisse.ts` ist die einzige Stelle, die `privatVerschluesselt`/`dekVerschluesselt` liest; `tests/geheimnisse-grenze.test.ts` hält das strukturell fest, ebenso dass nichts außerhalb von `src/worker` von dort importiert. +- **Zusatzdaten** `schluessel::`: Die ID wird deshalb vor dem Versiegeln vergeben (`randomUUID`), nicht von Prisma. +- **Deployment-Key** entsteht beim Start des Workers (ed25519, Kommentar `sshkv-deployment@`). Gleichzeitige Starts: der partielle Index lässt nur einen zu, der zweite liest den ersten. +- **Dump-Test:** `pg_dump --data-only` der Testdatenbank enthält weder „PRIVATE KEY“ noch den Schlüsselkern. diff --git a/src/worker/geheimnisse.ts b/src/worker/geheimnisse.ts new file mode 100644 index 0000000..1e423f8 --- /dev/null +++ b/src/worker/geheimnisse.ts @@ -0,0 +1,189 @@ +import { randomUUID } from "node:crypto"; +import { hostname } from "node:os"; +import type { Schluessel } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { masterKeyLaden, oeffnen, versiegeln, type Umschlag } from "@/lib/krypto/umschlag"; +import { schluesselpaarErzeugen, type ErzeugbarerSchluesseltyp } from "@/lib/ssh/openssh"; +import { protokollieren } from "@/server/services/protokoll"; + +/** + * Die einzige Stelle, die Private Keys ver- und entschluesselt (#006). Laeuft nur im Worker: + * nur dort liegt der Master-Key (CLAUDE.md, „Feste Regeln“). `tests/geheimnisse-grenze.test.ts` + * prueft, dass niemand sonst `privatVerschluesselt` liest. + */ + +/** Aktuelle Master-Key-Version. Steigt mit einer Master-Key-Rotation (spaeteres Issue). */ +export const MASTER_KEY_VERSION = 1; + +let masterKey: Promise | undefined; + +function masterKeyHolen(): Promise { + const pfad = process.env["SSHKV_MASTER_KEY_FILE"]; + if (!pfad) { + return Promise.reject(new Error("SSHKV_MASTER_KEY_FILE ist nicht gesetzt. Siehe .env.example.")); + } + masterKey ??= masterKeyLaden(pfad).catch((fehler: unknown) => { + // Beim naechsten Versuch neu lesen, statt den Fehler ewig zu behalten. + masterKey = undefined; + throw fehler; + }); + return masterKey; +} + +/** Nur fuer Tests: einen anderen Master-Key-Pfad wirksam machen. */ +export function masterKeyVergessen(): void { + masterKey = undefined; +} + +/** + * Zusatzdaten (AAD) des Umschlags. Bindet das Chiffrat an genau diesen Schluessel: ein in + * der Datenbank auf eine andere Zeile kopiertes Chiffrat laesst sich dort nicht oeffnen. + */ +export function zusatzFuer(schluesselId: string, fingerprint: string): string { + return `schluessel:${schluesselId}:${fingerprint}`; +} + +export type VersiegelterKey = { + readonly privatVerschluesselt: Uint8Array; + readonly dekVerschluesselt: Uint8Array; + readonly masterKeyVersion: number; +}; + +export async function privateKeyVersiegeln( + schluesselId: string, + fingerprint: string, + privateKeyOpenssh: string, +): Promise { + const klartext = Buffer.from(privateKeyOpenssh, "utf8"); + try { + const umschlag = versiegeln( + klartext, + await masterKeyHolen(), + zusatzFuer(schluesselId, fingerprint), + MASTER_KEY_VERSION, + ); + return { + privatVerschluesselt: new Uint8Array(umschlag.chiffrat), + dekVerschluesselt: new Uint8Array(umschlag.dekVerschluesselt), + masterKeyVersion: umschlag.masterKeyVersion, + }; + } finally { + klartext.fill(0); + } +} + +/** + * Oeffnet den Private Key eines Schluessels. Der Aufrufer bekommt einen Buffer und muss ihn + * nach Gebrauch mit `fill(0)` ueberschreiben. + */ +export async function privateKeyOeffnen( + schluessel: Pick< + Schluessel, + "id" | "fingerprint" | "privatVerschluesselt" | "dekVerschluesselt" | "masterKeyVersion" + >, +): Promise { + if ( + !schluessel.privatVerschluesselt || + !schluessel.dekVerschluesselt || + schluessel.masterKeyVersion === null + ) { + throw new Error("Zu diesem Schlüssel ist kein Private Key gespeichert."); + } + if (schluessel.masterKeyVersion !== MASTER_KEY_VERSION) { + throw new Error( + `Der Private Key ist mit Master-Key-Version ${schluessel.masterKeyVersion} verschlüsselt.`, + ); + } + const umschlag: Umschlag = { + chiffrat: Buffer.from(schluessel.privatVerschluesselt), + dekVerschluesselt: Buffer.from(schluessel.dekVerschluesselt), + masterKeyVersion: schluessel.masterKeyVersion, + }; + return oeffnen(umschlag, await masterKeyHolen(), zusatzFuer(schluessel.id, schluessel.fingerprint)); +} + +/** Liest den Private Key eines Schluessels aus der Datenbank – nur hier selektiert. */ +export async function privateKeyLaden(schluesselId: string): Promise { + const schluessel = await prisma.schluessel.findUniqueOrThrow({ + where: { id: schluesselId }, + select: { + id: true, + fingerprint: true, + privatVerschluesselt: true, + dekVerschluesselt: true, + masterKeyVersion: true, + }, + }); + return privateKeyOeffnen(schluessel); +} + +export type NeuerSchluessel = { + readonly id: string; + readonly publicKeyZeile: string; + readonly fingerprint: string; + readonly versiegelt: VersiegelterKey; +}; + +/** + * Erzeugt ein Schluesselpaar und versiegelt den Private Key sofort. Die ID wird hier + * vergeben, weil sie in die Zusatzdaten des Umschlags eingeht. + */ +export async function schluesselpaarVersiegeltErzeugen( + typ: ErzeugbarerSchluesseltyp, + kommentar: string, +): Promise { + const paar = await schluesselpaarErzeugen(typ, { kommentar }); + const id = randomUUID(); + return { + id, + publicKeyZeile: paar.publicKeyZeile, + fingerprint: paar.fingerprint, + versiegelt: await privateKeyVersiegeln(id, paar.fingerprint, paar.privateKeyOpenssh), + }; +} + +/** + * Stellt beim Start des Workers sicher, dass es einen Deployment-Key der Instanz gibt + * (#006). Gibt die ID zurueck. Gleichzeitige Starts scheitern am partiellen Index + * `schluessel_ein_instanzstandard` und lesen dann den Key des anderen. + */ +export async function deploymentKeySicherstellen(): Promise { + const vorhanden = await prisma.schluessel.findFirst({ + where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" }, + select: { id: true }, + }); + if (vorhanden) return vorhanden.id; + + const kommentar = `sshkv-deployment@${hostname()}`; + const neu = await schluesselpaarVersiegeltErzeugen("ED25519", kommentar); + try { + await prisma.schluessel.create({ + data: { + id: neu.id, + name: "Deployment-Key der Instanz", + slug: "sshkv-deployment", + typ: "ED25519", + kategorie: "DEPLOYMENT", + publicKey: neu.publicKeyZeile, + fingerprint: neu.fingerprint, + kommentar, + instanzStandard: true, + ...neu.versiegelt, + }, + }); + } catch (fehler) { + const anderer = await prisma.schluessel.findFirst({ + where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" }, + select: { id: true }, + }); + if (anderer) return anderer.id; + throw fehler; + } + await protokollieren({ + akteur: "System", + aktion: "deploymentkey.erzeugt", + ergebnis: "ERFOLG", + fingerprint: neu.fingerprint, + }); + return neu.id; +} diff --git a/src/worker/index.ts b/src/worker/index.ts index 7ac02ba..ca0b997 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -3,6 +3,7 @@ import type { Job } from "pg-boss"; import { VERSION } from "@/lib/version"; import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange"; import { vorgangAusfuehren } from "@/worker/ausfuehren"; +import { deploymentKeySicherstellen } from "@/worker/geheimnisse"; import { HANDLER } from "@/worker/handler"; /** @@ -10,6 +11,9 @@ import { HANDLER } from "@/worker/handler"; * und nur von hier aus spricht sshkv SSH mit den Hosts. */ async function starten(): Promise { + // Zuerst: ohne lesbaren Master-Key und Deployment-Key kann der Worker nichts tun, und das + // soll beim Start auffallen, nicht beim ersten Vorgang. + await deploymentKeySicherstellen(); const boss = await warteschlangeHolen(); await boss.work( diff --git a/tests/geheimnisse-grenze.test.ts b/tests/geheimnisse-grenze.test.ts new file mode 100644 index 0000000..54797b1 --- /dev/null +++ b/tests/geheimnisse-grenze.test.ts @@ -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 = { + "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([]); + }); +}); diff --git a/tests/geheimnisse-worker.test.ts b/tests/geheimnisse-worker.test.ts new file mode 100644 index 0000000..a5f5ac1 --- /dev/null +++ b/tests/geheimnisse-worker.test.ts @@ -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)); + }); +});