Files
sshkeyvault/src/worker/geheimnisse.ts
T
florian.wildeandClaude Opus 5.5 263143134f feat(schluessel): erzeugen, importieren, exportieren (#012)
Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste
mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem
Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub.

Warum so:
- Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil
  nur er den Master-Key hat. Passphrase und importierter Private Key
  reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt
  im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach.
- Ein reiner Public Key ist kein Geheimnis und wird direkt im Web
  angelegt, mit verstaendlicher Meldung bei einem Duplikat.
- Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist,
  verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein
  struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf
  Schluessel ausserhalb des Workers ein festes select.
- Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das
  Loeschen des Private Keys; CHECKs fuer den Slug (landet in
  Dateinamen) und "geloescht nur ohne Chiffrat".
- Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017.

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

222 lines
7.4 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;
}