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