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,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:<id>:<fingerprint>`) 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<Buffer> {
|
||||
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;
|
||||
}
|
||||
@@ -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