Files
sshkeyvault/src/worker/geheimnisse.ts
T
florian.wildeandClaude Opus 5.5 3b28f73c95 docs(betrieb): Handbuch, Bedrohungsanalyse, geprobte Sicherung (#029)
Handbuch mit Bildern fuer alle Ablaeufe, darunter der Kern-
Anwendungsfall und der Deployment-Zugang fuer Claude Code.
Betriebsdoku zu Installation, authentik und Reverse Proxy sowie eine
Bedrohungsanalyse mit Notfallplan fuer den Fall, dass sshkv selbst
kompromittiert wird.

Sicherung und Wiederherstellung als Skripte, auf einem Wegwerf-Stapel
geprobt. Dabei fielen zwei Fehler auf: das Schema der Warteschlange
liess sich nicht zurueckspielen, und ein Abbruch ohne Transaktion
loeschte es halb. Jetzt nur Schema public, in einer Transaktion, mit
Pruefwert des Master-Keys. Der Worker startet nicht, wenn der
Master-Key nicht zur Datenbank passt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:46:38 +02:00

240 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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. 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<NeuerSchluessel> {
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<boolean> {
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<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;
}
/**
* Passt der Master-Key zur Datenbank? (#029) Oeffnet probeweise den Deployment-Key. Schlaegt
* das fehl, ist entweder die falsche Master-Key-Datei eingebunden oder eine Sicherung ohne den
* dazugehoerigen Master-Key zurueckgespielt worden – beides soll beim Start auffallen, nicht
* erst beim ersten Ausrollen.
*/
export async function masterKeyPruefen(deploymentKeyId: string): Promise<void> {
try {
const klartext = await privateKeyLaden(deploymentKeyId);
klartext.fill(0);
} catch {
throw new Error(
"Der Master-Key passt nicht zur Datenbank: Der Deployment-Key lässt sich nicht entschlüsseln. " +
"Ist SSHKV_MASTER_KEY_FILE die Datei, die zu dieser Datenbank gehört (Sicherung)?",
);
}
}