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
+198
View File
@@ -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;
}