From 893b840f06486acdc40642ef4274bf902dd7ea23 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:16:56 +0200 Subject: [PATCH] feat: Envelope Encryption und Master-Key aus Datei (#006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private Keys liegen spaeter verschluesselt in PostgreSQL. Ein eigener DEK je Geheimnis erlaubt eine Master-Key-Rotation, ohne die Chiffrate selbst anzufassen; das AAD (Schluessel-ID und Fingerprint) bindet jedes Chiffrat an seinen Datensatz, sodass vertauschte Zeilen beim Oeffnen auffallen statt einen fremden Key zu liefern. Die Master-Key-Version steckt im AAD des DEK. masterKeyLaden akzeptiert roh, Base64 und Hex, weil `openssl rand` der naheliegende Weg zum Anlegen ist, und bricht ab, wenn Gruppe oder Welt die Datei lesen duerfen – ein lesbarer Master-Key oeffnet alle Private Keys. SSHKV_MASTER_KEY_UNSICHER_ERLAUBT=1 erlaubt das bewusst. Co-Authored-By: Claude Opus 5.5 --- src/lib/krypto/umschlag.ts | 198 ++++++++++++++++++++++++++++++++++ tests/krypto/umschlag.test.ts | 167 ++++++++++++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 src/lib/krypto/umschlag.ts create mode 100644 tests/krypto/umschlag.test.ts diff --git a/src/lib/krypto/umschlag.ts b/src/lib/krypto/umschlag.ts new file mode 100644 index 0000000..9debf3a --- /dev/null +++ b/src/lib/krypto/umschlag.ts @@ -0,0 +1,198 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { open } from "node:fs/promises"; + +/** + * Envelope Encryption fuer Geheimnisse at rest (#006, docs/PLAN.md Abschnitt 3). + * + * Jedes Geheimnis bekommt einen eigenen zufaelligen Data Encryption Key (DEK). Der DEK + * verschluesselt den Klartext, der Master-Key verschluesselt nur den DEK. Dadurch + * - genuegt fuer eine Master-Key-Rotation das Umschluesseln der DEKs (`sshkv rewrap`), + * die eigentlichen Chiffrate bleiben unberuehrt; + * - bindet das AAD (`zusatz`, z. B. `schluessel::`) jedes Chiffrat an + * seinen Datensatz: Wer in der Datenbank Chiffrate zwischen Zeilen vertauscht, erhaelt + * beim Oeffnen einen Fehler statt eines fremden Private Keys. + * + * Aufbau beider Puffer (`chiffrat` und `dekVerschluesselt`): + * + * | Version (1 Byte, derzeit 1) | IV (12 Byte) | GCM-Tag (16 Byte) | Daten (n Byte) | + * + * AES-256-GCM in beiden Stufen. AAD des Klartexts ist `zusatz` (UTF-8), AAD des DEK ist + * `"sshkv-dek:v" + masterKeyVersion`, damit ein DEK nicht mit dem Master-Key einer + * anderen Version geoeffnet werden kann. + * + * Framework-frei; der Master-Key selbst darf nur im Worker geladen werden (CLAUDE.md). + */ + +const FORMAT_VERSION = 1; +const IV_LAENGE = 12; +const TAG_LAENGE = 16; +const KOPF_LAENGE = 1 + IV_LAENGE + TAG_LAENGE; +const SCHLUESSEL_LAENGE = 32; + +export interface Umschlag { + chiffrat: Buffer; + dekVerschluesselt: Buffer; + masterKeyVersion: number; +} + +/** + * Oeffnen ist gescheitert. Bewusst ohne Angabe, welcher Teil nicht passte (Master-Key, + * AAD oder Chiffrat): GCM kann das nicht unterscheiden, und Raten hilft niemandem. + */ +export class EntschluesselungsFehler extends Error { + override readonly name = "EntschluesselungsFehler"; + constructor( + meldung = "Entschlüsselung fehlgeschlagen: Master-Key, Zuordnung oder Chiffrat passen nicht zusammen.", + ) { + super(meldung); + } +} + +/** Fehler beim Laden des Master-Keys. Die Meldung nennt den Pfad, nie den Inhalt. */ +export class MasterKeyFehler extends Error { + override readonly name = "MasterKeyFehler"; +} + +function masterKeyPruefen(masterKey: Buffer): void { + if (masterKey.length !== SCHLUESSEL_LAENGE) { + throw new MasterKeyFehler("Der Master-Key muss genau 32 Byte lang sein."); + } +} + +function dekAad(masterKeyVersion: number): Buffer { + return Buffer.from(`sshkv-dek:v${masterKeyVersion}`, "utf8"); +} + +function gcmVerschluesseln(klartext: Buffer, schluessel: Buffer, aad: Buffer): Buffer { + const iv = randomBytes(IV_LAENGE); + const cipher = createCipheriv("aes-256-gcm", schluessel, iv, { authTagLength: TAG_LAENGE }); + cipher.setAAD(aad); + const daten = Buffer.concat([cipher.update(klartext), cipher.final()]); + return Buffer.concat([Buffer.from([FORMAT_VERSION]), iv, cipher.getAuthTag(), daten]); +} + +function gcmEntschluesseln(puffer: Buffer, schluessel: Buffer, aad: Buffer): Buffer { + if (puffer.length < KOPF_LAENGE || puffer[0] !== FORMAT_VERSION) { + throw new EntschluesselungsFehler(); + } + const iv = puffer.subarray(1, 1 + IV_LAENGE); + const tag = puffer.subarray(1 + IV_LAENGE, KOPF_LAENGE); + const daten = puffer.subarray(KOPF_LAENGE); + try { + const decipher = createDecipheriv("aes-256-gcm", schluessel, iv, { authTagLength: TAG_LAENGE }); + decipher.setAAD(aad); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(daten), decipher.final()]); + } catch { + // Die Original-Ausnahme verraet nichts Geheimes, ist aber englisch und ohne Nutzen. + throw new EntschluesselungsFehler(); + } +} + +/** + * Verschluesselt `klartext` mit einem frischen DEK. Der Klartext gehoert dem Aufrufer und + * wird hier nicht ueberschrieben; den DEK loescht die Funktion selbst. + */ +export function versiegeln( + klartext: Buffer, + masterKey: Buffer, + zusatz: string, + masterKeyVersion = 1, +): Umschlag { + masterKeyPruefen(masterKey); + const dek = randomBytes(SCHLUESSEL_LAENGE); + try { + const chiffrat = gcmVerschluesseln(klartext, dek, Buffer.from(zusatz, "utf8")); + const dekVerschluesselt = gcmVerschluesseln(dek, masterKey, dekAad(masterKeyVersion)); + return { chiffrat, dekVerschluesselt, masterKeyVersion }; + } finally { + dek.fill(0); + } +} + +/** + * Oeffnet einen Umschlag. `masterKey` muss zur `masterKeyVersion` des Umschlags gehoeren. + * Der zurueckgegebene Klartext gehoert dem Aufrufer, der ihn nach Gebrauch mit `fill(0)` + * ueberschreiben soll. + */ +export function oeffnen(umschlag: Umschlag, masterKey: Buffer, zusatz: string): Buffer { + masterKeyPruefen(masterKey); + const dek = gcmEntschluesseln(umschlag.dekVerschluesselt, masterKey, dekAad(umschlag.masterKeyVersion)); + try { + if (dek.length !== SCHLUESSEL_LAENGE) throw new EntschluesselungsFehler(); + return gcmEntschluesseln(umschlag.chiffrat, dek, Buffer.from(zusatz, "utf8")); + } finally { + dek.fill(0); + } +} + +/** Mit dieser Variable darf ein Master-Key mit zu offenen Dateirechten geladen werden. */ +export const UNSICHER_ERLAUBT_VARIABLE = "SSHKV_MASTER_KEY_UNSICHER_ERLAUBT"; + +/** + * Laedt den Master-Key aus einer Datei (`SSHKV_MASTER_KEY_FILE`). + * + * Akzeptiert 32 rohe Bytes, 44 Zeichen Base64 oder 64 Zeichen Hex (jeweils mit optionalem + * Zeilenende), weil `openssl rand -base64 32 > datei` der naheliegende Weg ist, einen + * Schluessel anzulegen. Darf Gruppe oder Welt die Datei lesen, wird abgebrochen: Ein + * versehentlich lesbarer Master-Key oeffnet jeden gespeicherten Private Key. + */ +export async function masterKeyLaden(pfad: string): Promise { + let inhalt: Buffer; + const datei = await open(pfad, "r").catch(() => { + throw new MasterKeyFehler(`Die Master-Key-Datei ${pfad} kann nicht geöffnet werden.`); + }); + try { + // Rechte am geoeffneten Handle pruefen, nicht am Pfad: sonst koennte die Datei + // zwischen Pruefung und Lesen ausgetauscht werden. + const status = await datei.stat(); + if (!status.isFile()) { + throw new MasterKeyFehler(`Die Master-Key-Datei ${pfad} ist keine reguläre Datei.`); + } + if ((status.mode & 0o044) !== 0) { + const meldung = + `Die Master-Key-Datei ${pfad} ist für Gruppe oder andere lesbar ` + + `(Rechte ${(status.mode & 0o777).toString(8)}). Bitte mit „chmod 600“ einschränken.`; + if (process.env[UNSICHER_ERLAUBT_VARIABLE] !== "1") throw new MasterKeyFehler(meldung); + console.warn(`${meldung} Wird wegen ${UNSICHER_ERLAUBT_VARIABLE}=1 trotzdem geladen.`); + } + // Groessere Dateien gar nicht erst ganz lesen; 70 Byte reichen fuer Hex plus CRLF. + if (status.size > 70) { + throw new MasterKeyFehler(`Die Master-Key-Datei ${pfad} ist zu groß für einen 32-Byte-Schlüssel.`); + } + inhalt = await datei.readFile(); + } finally { + await datei.close(); + } + + try { + const schluessel = masterKeyDekodieren(inhalt); + if (!schluessel) { + throw new MasterKeyFehler( + `Die Master-Key-Datei ${pfad} enthält keinen 32-Byte-Schlüssel ` + + "(erwartet: 32 Byte roh, 44 Zeichen Base64 oder 64 Zeichen Hex).", + ); + } + if (schluessel.every((byte) => byte === 0)) { + schluessel.fill(0); + throw new MasterKeyFehler(`Die Master-Key-Datei ${pfad} enthält nur Nullbytes.`); + } + return schluessel; + } finally { + inhalt.fill(0); + } +} + +function masterKeyDekodieren(inhalt: Buffer): Buffer | null { + if (inhalt.length === SCHLUESSEL_LAENGE) return Buffer.from(inhalt); + let ende = inhalt.length; + if (inhalt[ende - 1] === 0x0a) ende--; + if (inhalt[ende - 1] === 0x0d) ende--; + const text = inhalt.subarray(0, ende).toString("latin1"); + if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, "hex"); + if (/^[A-Za-z0-9+/]{43}=$/.test(text)) { + const schluessel = Buffer.from(text, "base64"); + return schluessel.length === SCHLUESSEL_LAENGE ? schluessel : null; + } + return null; +} diff --git a/tests/krypto/umschlag.test.ts b/tests/krypto/umschlag.test.ts new file mode 100644 index 0000000..aef8639 --- /dev/null +++ b/tests/krypto/umschlag.test.ts @@ -0,0 +1,167 @@ +import { randomBytes } from "node:crypto"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + EntschluesselungsFehler, + MasterKeyFehler, + masterKeyLaden, + oeffnen, + UNSICHER_ERLAUBT_VARIABLE, + versiegeln, +} from "@/lib/krypto/umschlag"; + +const ZUSATZ = "schluessel:k_8f3a:SHA256:abc"; +const KLARTEXT = + "-----BEGIN OPENSSH PRIVATE KEY-----\nnur ein Platzhalter\n-----END OPENSSH PRIVATE KEY-----\n"; + +describe("versiegeln und oeffnen", () => { + const masterKey = randomBytes(32); + + it("Rundreise liefert den Klartext", () => { + const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); + expect(umschlag.masterKeyVersion).toBe(1); + expect(oeffnen(umschlag, masterKey, ZUSATZ).toString()).toBe(KLARTEXT); + }); + + it("hält das dokumentierte Format ein", () => { + const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ, 3); + expect(umschlag.chiffrat[0]).toBe(1); + expect(umschlag.chiffrat.length).toBe(1 + 12 + 16 + Buffer.byteLength(KLARTEXT)); + expect(umschlag.dekVerschluesselt.length).toBe(1 + 12 + 16 + 32); + expect(umschlag.masterKeyVersion).toBe(3); + expect(umschlag.chiffrat.toString("latin1")).not.toContain("Platzhalter"); + }); + + it("liefert bei jedem Versiegeln ein anderes Chiffrat", () => { + const a = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); + const b = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); + expect(a.chiffrat.equals(b.chiffrat)).toBe(false); + expect(a.dekVerschluesselt.equals(b.dekVerschluesselt)).toBe(false); + }); + + it("lehnt einen falschen Master-Key ab", () => { + const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); + expect(() => oeffnen(umschlag, randomBytes(32), ZUSATZ)).toThrow(EntschluesselungsFehler); + }); + + it("lehnt ein falsches AAD ab (vertauschte Datensätze)", () => { + const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); + expect(() => oeffnen(umschlag, masterKey, "schluessel:k_anders:SHA256:abc")).toThrow( + EntschluesselungsFehler, + ); + }); + + it("lehnt eine falsche Master-Key-Version ab", () => { + const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ, 1); + expect(() => oeffnen({ ...umschlag, masterKeyVersion: 2 }, masterKey, ZUSATZ)).toThrow( + EntschluesselungsFehler, + ); + }); + + it.each(["chiffrat", "dekVerschluesselt"] as const)("erkennt jeden Bit-Flip in %s", (feld) => { + const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); + // Version, IV, Tag und Daten: jede Stelle muss auffallen. + for (const stelle of [0, 1, 13, 29, umschlag[feld].length - 1]) { + const kaputt = Buffer.from(umschlag[feld]); + kaputt[stelle]! ^= 0x01; + expect(() => oeffnen({ ...umschlag, [feld]: kaputt }, masterKey, ZUSATZ)).toThrow( + EntschluesselungsFehler, + ); + } + }); + + it("lehnt abgeschnittene Puffer ab", () => { + const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); + expect(() => + oeffnen({ ...umschlag, chiffrat: umschlag.chiffrat.subarray(0, 20) }, masterKey, ZUSATZ), + ).toThrow(EntschluesselungsFehler); + }); + + it("verlangt einen 32-Byte-Master-Key", () => { + expect(() => versiegeln(Buffer.from("x"), randomBytes(16), ZUSATZ)).toThrow(MasterKeyFehler); + }); + + it("nennt in Fehlermeldungen weder Klartext noch Schlüssel", () => { + const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ); + try { + oeffnen(umschlag, randomBytes(32), ZUSATZ); + expect.unreachable(); + } catch (fehler) { + const text = String((fehler as Error).message) + String((fehler as Error).stack); + expect(text).not.toContain("Platzhalter"); + expect(text).not.toContain(masterKey.toString("hex")); + expect(text).not.toContain(masterKey.toString("base64")); + } + }); +}); + +describe("masterKeyLaden", () => { + let verzeichnis: string; + const schluessel = randomBytes(32); + + beforeAll(async () => { + verzeichnis = await mkdtemp(join(tmpdir(), "sshkv-masterkey-")); + }); + afterAll(async () => { + await rm(verzeichnis, { recursive: true, force: true }); + }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + async function datei(name: string, inhalt: Buffer | string, modus = 0o600): Promise { + const pfad = join(verzeichnis, name); + await writeFile(pfad, inhalt); + await chmod(pfad, modus); + return pfad; + } + + it.each([ + ["roh", schluessel], + ["base64", schluessel.toString("base64")], + ["base64 mit Zeilenende", schluessel.toString("base64") + "\n"], + ["hex", schluessel.toString("hex")], + ["hex mit CRLF", schluessel.toString("hex") + "\r\n"], + ])("liest %s", async (name, inhalt) => { + const geladen = await masterKeyLaden(await datei(name, inhalt)); + expect(geladen.equals(schluessel)).toBe(true); + }); + + it("lehnt falsche Längen ab", async () => { + await expect(masterKeyLaden(await datei("kurz", randomBytes(31)))).rejects.toThrow(MasterKeyFehler); + await expect(masterKeyLaden(await datei("lang", randomBytes(33)))).rejects.toThrow(MasterKeyFehler); + await expect(masterKeyLaden(await datei("hex-kurz", randomBytes(31).toString("hex")))).rejects.toThrow( + "32-Byte-Schlüssel", + ); + }); + + it("lehnt einen Schlüssel aus lauter Nullbytes ab", async () => { + await expect(masterKeyLaden(await datei("null", Buffer.alloc(32)))).rejects.toThrow("Nullbytes"); + }); + + it("lehnt eine fehlende Datei ab", async () => { + await expect(masterKeyLaden(join(verzeichnis, "gibt-es-nicht"))).rejects.toThrow(MasterKeyFehler); + }); + + it("verweigert Dateien, die Gruppe oder Welt lesen dürfen", async () => { + for (const modus of [0o640, 0o604, 0o644]) { + const pfad = await datei(`offen-${modus.toString(8)}`, schluessel, modus); + await expect(masterKeyLaden(pfad)).rejects.toThrow("chmod 600"); + } + expect((await masterKeyLaden(await datei("nur-lesen", schluessel, 0o400))).equals(schluessel)).toBe(true); + }); + + it("lädt offene Dateien nur mit ausdrücklicher Erlaubnis und warnt dann", async () => { + vi.stubEnv(UNSICHER_ERLAUBT_VARIABLE, "1"); + const warnung = vi.spyOn(console, "warn").mockImplementation(() => {}); + const geladen = await masterKeyLaden(await datei("offen-erlaubt", schluessel.toString("hex"), 0o644)); + expect(geladen.equals(schluessel)).toBe(true); + expect(warnung).toHaveBeenCalledOnce(); + const ausgabe = String(warnung.mock.calls[0]?.[0]); + expect(ausgabe).not.toContain(schluessel.toString("hex")); + expect(ausgabe).not.toContain(schluessel.toString("base64")); + }); +});