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:
2026-10-12 09:33:18 +02:00
co-authored by Claude Opus 5.5
parent 855d77cf17
commit 9b807c13ae
5 changed files with 318 additions and 5 deletions
@@ -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:<id>:<fingerprint>`: Die ID wird deshalb vor dem Versiegeln vergeben (`randomUUID`), nicht von Prisma.
- **Deployment-Key** entsteht beim Start des Workers (ed25519, Kommentar `sshkv-deployment@<rechner>`). 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.
+189
View File
@@ -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<Buffer> | undefined;
function masterKeyHolen(): Promise<Buffer> {
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<ArrayBuffer>;
readonly dekVerschluesselt: Uint8Array<ArrayBuffer>;
readonly masterKeyVersion: number;
};
export async function privateKeyVersiegeln(
schluesselId: string,
fingerprint: string,
privateKeyOpenssh: string,
): Promise<VersiegelterKey> {
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<Buffer> {
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<Buffer> {
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<NeuerSchluessel> {
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<string> {
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;
}
+4
View File
@@ -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<void> {
// 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<VorgangsJob>(
+41
View File
@@ -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([]);
});
});
+75
View File
@@ -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));
});
});