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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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