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