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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user