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. Mit `passphrase` liegt im * Umschlag der bereits passphrasengeschuetzte OpenSSH-Key; die Passphrase selbst wird nie * gespeichert (Freigabe Frage 6, #012). */ export async function schluesselpaarVersiegeltErzeugen( typ: ErzeugbarerSchluesseltyp, kommentar: string, passphrase?: string, ): Promise { const paar = await schluesselpaarErzeugen(typ, { kommentar, ...(passphrase ? { passphrase } : {}) }); const id = randomUUID(); return { id, publicKeyZeile: paar.publicKeyZeile, fingerprint: paar.fingerprint, versiegelt: await privateKeyVersiegeln(id, paar.fingerprint, paar.privateKeyOpenssh), }; } /** * Versiegelt einen importierten Private Key (#012) so, wie er kam – ein * passphrasengeschuetzter Key bleibt es. Die ID wird hier vergeben (Zusatzdaten). */ export async function importiertenKeyVersiegeln( fingerprint: string, privateKeyOpenssh: string, ): Promise<{ readonly id: string; readonly versiegelt: VersiegelterKey }> { const id = randomUUID(); return { id, versiegelt: await privateKeyVersiegeln(id, fingerprint, privateKeyOpenssh) }; } /** * Verwirft den gespeicherten Private Key endgueltig (#014): Chiffrat, Data-Key und Version * auf null, Zeitpunkt festhalten. Liefert false, wenn keiner (mehr) gespeichert war. */ export async function privateKeyVerwerfen(schluesselId: string): Promise { const ergebnis = await prisma.schluessel.updateMany({ where: { id: schluesselId, privatVerschluesselt: { not: null } }, data: { privatVerschluesselt: null, dekVerschluesselt: null, masterKeyVersion: null, privatGeloeschtAm: new Date(), }, }); return ergebnis.count === 1; } /** * 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; }