feat: Envelope Encryption und Master-Key aus Datei (#006)

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:16:56 +02:00
co-authored by Claude Opus 5.5
parent 6b20566676
commit 893b840f06
2 changed files with 365 additions and 0 deletions
+167
View File
@@ -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<string> {
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"));
});
});