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:
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user