From 6b20566676adfe00326945dd094fa552cc745118 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:16:42 +0200 Subject: [PATCH 1/4] feat: OpenSSH-Schluessel erzeugen, schreiben und lesen (#006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private Keys sollen nie als Datei auf dem sshkv-Server liegen, auch nicht kurz. ssh-keygen braucht aber genau das. Deshalb erzeugt Node crypto die Schluessel (ed25519, RSA-4096), und ein eigener Serializer schreibt das Format openssh-key-v1 – unverschluesselt oder mit aes256-ctr und bcrypt_pbkdf (16 Runden, 16 Byte Salt), Checkint und Padding wie OpenSSH. Der Parser liest auch von ssh-keygen erzeugte Dateien, prueft, dass Private und Public Key zusammengehoeren, und meldet fehlende oder falsche Passphrasen ueber eigene Codes. Fehlermeldungen sind feste Saetze ohne Eingabe darin, damit kein Schluesselmaterial in Logs geraet. RSA unter 2048 Bit wird beim Import abgelehnt; wer nur beobachtet, kann die Grenze ausschalten. Die Tests pruefen beide Richtungen gegen das echte ssh-keygen (-y, -lf). Co-Authored-By: Claude Opus 5.5 --- package.json | 1 + pnpm-lock.yaml | 3 + src/lib/ssh/openssh.ts | 682 ++++++++++++++++++++++ src/types/bcrypt-pbkdf.d.ts | 20 + tests/ssh/hilfen.ts | 81 +++ tests/ssh/openssh-kompatibilitaet.test.ts | 131 +++++ tests/ssh/openssh.test.ts | 227 +++++++ 7 files changed, 1145 insertions(+) create mode 100644 src/lib/ssh/openssh.ts create mode 100644 src/types/bcrypt-pbkdf.d.ts create mode 100644 tests/ssh/hilfen.ts create mode 100644 tests/ssh/openssh-kompatibilitaet.test.ts create mode 100644 tests/ssh/openssh.test.ts diff --git a/package.json b/package.json index b7ddb15..f4f666f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@node-rs/argon2": "^2.2.2", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", + "bcrypt-pbkdf": "^1.0.2", "clsx": "^2.1.1", "jose": "^6.2.12", "lucide-react": "1.47.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42ad289..a0b4493 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: '@prisma/client': specifier: 7.10.0 version: 7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3) + bcrypt-pbkdf: + specifier: ^1.0.2 + version: 1.0.2 clsx: specifier: ^2.1.1 version: 2.1.1 diff --git a/src/lib/ssh/openssh.ts b/src/lib/ssh/openssh.ts new file mode 100644 index 0000000..a381e82 --- /dev/null +++ b/src/lib/ssh/openssh.ts @@ -0,0 +1,682 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createPrivateKey, + createPublicKey, + generateKeyPair, + randomBytes, + timingSafeEqual, +} from "node:crypto"; +import { promisify } from "node:util"; +import bcryptPbkdf from "bcrypt-pbkdf"; + +/** + * OpenSSH-Schluesselformate (#006): Erzeugen, Schreiben und Lesen von Private Keys im + * Format `openssh-key-v1` sowie Lesen von Public-Key-Zeilen. + * + * Bewusst ohne `ssh-keygen`: Der Aufruf braeuchte temporaere Dateien mit dem Private Key + * auf der Platte des Servers, und genau die wollen wir nicht (siehe docs/PLAN.md). Node + * `crypto` erzeugt die Schluessel, die Serialisierung ist hier von Hand nachgebaut + * (Referenz: PROTOCOL.key und sshkey.c in OpenSSH). Die Tests pruefen das Ergebnis gegen + * das echte `ssh-keygen`. + * + * Fehlermeldungen sind feste Saetze. Nichts aus der Eingabe wird in eine Meldung + * eingesetzt, weil ein Private Key sonst ueber Log oder Oberflaeche nach aussen geraten + * koennte. + */ + +const erzeugeSchluesselpaar = promisify(generateKeyPair); + +export type ErzeugbarerSchluesseltyp = "ED25519" | "RSA4096"; +export type PrivateSchluesseltyp = "ED25519" | "RSA"; +export type PublicSchluesseltyp = + | "ssh-ed25519" + | "ssh-rsa" + | "ecdsa-sha2-nistp256" + | "ecdsa-sha2-nistp384" + | "ecdsa-sha2-nistp521" + | "sk-ssh-ed25519@openssh.com" + | "sk-ecdsa-sha2-nistp256@openssh.com"; + +export const PUBLIC_SCHLUESSELTYPEN: readonly PublicSchluesseltyp[] = [ + "ssh-ed25519", + "ssh-rsa", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "sk-ssh-ed25519@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", +]; + +/** Unterhalb davon gilt RSA als gebrochen bzw. wird von aktuellem OpenSSH abgelehnt. */ +export const RSA_MINDEST_BITS = 2048; + +/** + * Runden fuer bcrypt_pbkdf beim Schreiben. `ssh-keygen` nimmt inzwischen 24; 16 war der + * fruehere Standard und haelt das Verschluesseln im Worker unter einer Sekunde, obwohl + * bcrypt-pbkdf reines JavaScript ist. Beim Lesen gilt, was in der Datei steht. + */ +const BCRYPT_RUNDEN = 16; +const BCRYPT_SALT_LAENGE = 16; + +/** Grenze gegen absichtlich teure Dateien beim Import (ssh-keygen -a erlaubt beliebig viel). */ +const BCRYPT_RUNDEN_HOECHSTENS = 1000; + +const MAGIC = Buffer.from("openssh-key-v1\0", "latin1"); +const PEM_ANFANG = "-----BEGIN OPENSSH PRIVATE KEY-----"; +const PEM_ENDE = "-----END OPENSSH PRIVATE KEY-----"; + +export type SchluesselFormatFehlerCode = + "ungueltig" | "nicht_unterstuetzt" | "passphrase_fehlt" | "passphrase_falsch" | "zu_schwach"; + +/** + * Fehler beim Lesen oder Erzeugen eines Schluessels. `code` erlaubt der Oberflaeche, + * gezielt zu reagieren (etwa nach der Passphrase zu fragen), ohne Meldungstexte zu + * vergleichen. Die Meldung enthaelt nie Schluesselmaterial. + */ +export class SchluesselFormatFehler extends Error { + override readonly name = "SchluesselFormatFehler"; + constructor( + readonly code: SchluesselFormatFehlerCode, + meldung: string, + ) { + super(meldung); + } +} + +function ungueltig(meldung = "Der Schlüssel ist beschädigt oder unvollständig."): SchluesselFormatFehler { + return new SchluesselFormatFehler("ungueltig", meldung); +} + +// --------------------------------------------------------------------------- +// SSH-Wire-Format (RFC 4251, Abschnitt 5) +// --------------------------------------------------------------------------- + +function uint32(wert: number): Buffer { + const puffer = Buffer.alloc(4); + puffer.writeUInt32BE(wert >>> 0); + return puffer; +} + +function sshString(inhalt: Buffer | string): Buffer { + const daten = typeof inhalt === "string" ? Buffer.from(inhalt, "utf8") : inhalt; + return Buffer.concat([uint32(daten.length), daten]); +} + +/** Vorzeichenbehaftete Big-Endian-Zahl ohne fuehrende Nullen, bei gesetztem Hochbit mit 0x00 davor. */ +function mpint(betrag: Buffer): Buffer { + let start = 0; + while (start < betrag.length && betrag[start] === 0) start++; + const ohneNullen = betrag.subarray(start); + if (ohneNullen.length > 0 && (ohneNullen[0]! & 0x80) !== 0) { + return sshString(Buffer.concat([Buffer.from([0]), ohneNullen])); + } + return sshString(ohneNullen); +} + +/** + * Liest SSH-Wire-Daten. Jeder Zugriff hinter das Ende wirft einen SchluesselFormatFehler, + * damit abgeschnittene Eingaben nie zu RangeErrors mit Pufferausschnitten werden. + */ +class Leser { + private position = 0; + constructor(private readonly puffer: Buffer) {} + + uint32(): number { + if (this.position + 4 > this.puffer.length) throw ungueltig(); + const wert = this.puffer.readUInt32BE(this.position); + this.position += 4; + return wert; + } + + bytes(laenge: number): Buffer { + if (laenge < 0 || this.position + laenge > this.puffer.length) throw ungueltig(); + const teil = this.puffer.subarray(this.position, this.position + laenge); + this.position += laenge; + return teil; + } + + string(): Buffer { + return this.bytes(this.uint32()); + } + + text(): string { + return this.string().toString("utf8"); + } + + /** Liefert den Betrag einer nicht-negativen mpint ohne fuehrende Nullen. */ + mpint(): Buffer { + const roh = this.string(); + if (roh.length > 0 && (roh[0]! & 0x80) !== 0) throw ungueltig(); + let start = 0; + while (start < roh.length && roh[start] === 0) start++; + return roh.subarray(start); + } + + rest(): Buffer { + return this.puffer.subarray(this.position); + } + + amEnde(): boolean { + return this.position === this.puffer.length; + } +} + +function bitsVonBetrag(betrag: Buffer): number { + if (betrag.length === 0) return 0; + return (betrag.length - 1) * 8 + (32 - Math.clz32(betrag[0]!)); +} + +function alsBigInt(betrag: Buffer): bigint { + return betrag.length === 0 ? 0n : BigInt("0x" + betrag.toString("hex")); +} + +// --------------------------------------------------------------------------- +// Fingerprint und Public Keys +// --------------------------------------------------------------------------- + +/** SHA-256-Fingerprint im Format von `ssh-keygen -l`: Base64 ohne Padding. */ +export function fingerprintSha256(blob: Buffer): string { + return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, ""); +} + +/** Schluessellaenge eines `ssh-rsa`-Blobs in Bit (Laenge des Moduls n). */ +export function bitsVonRsa(blob: Buffer): number { + const leser = new Leser(blob); + if (leser.text() !== "ssh-rsa") { + throw new SchluesselFormatFehler("ungueltig", "Der Schlüssel ist kein RSA-Schlüssel."); + } + leser.mpint(); // e + return bitsVonBetrag(leser.mpint()); +} + +const ECDSA_PUNKTLAENGE: Record = { + nistp256: 65, + nistp384: 97, + nistp521: 133, +}; + +/** + * Prueft den Aufbau eines Public-Key-Blobs. Erwartet wird genau der Typ `typ`; zusaetzliche + * Bytes am Ende gelten als Fehler, weil OpenSSH sie ebenfalls ablehnt. + */ +function publicBlobPruefen(typ: PublicSchluesseltyp, blob: Buffer, rsaMindestBits: number): number | null { + const leser = new Leser(blob); + if (leser.text() !== typ) { + throw new SchluesselFormatFehler( + "ungueltig", + "Der Schlüsseltyp in der Zeile passt nicht zum Inhalt des Schlüssels.", + ); + } + let bits: number | null = null; + switch (typ) { + case "ssh-ed25519": + if (leser.string().length !== 32) throw ungueltig(); + bits = 256; + break; + case "sk-ssh-ed25519@openssh.com": + if (leser.string().length !== 32) throw ungueltig(); + leser.string(); // application, z. B. "ssh:" + bits = 256; + break; + case "ssh-rsa": { + const e = leser.mpint(); + const n = leser.mpint(); + if (e.length === 0 || n.length === 0 || (e[e.length - 1]! & 1) === 0) throw ungueltig(); + bits = bitsVonBetrag(n); + if (bits < rsaMindestBits) { + throw new SchluesselFormatFehler( + "zu_schwach", + `RSA-Schlüssel müssen mindestens ${rsaMindestBits} Bit lang sein.`, + ); + } + break; + } + case "ecdsa-sha2-nistp256": + case "ecdsa-sha2-nistp384": + case "ecdsa-sha2-nistp521": + case "sk-ecdsa-sha2-nistp256@openssh.com": { + const erwarteteKurve = typ.startsWith("sk-") ? "nistp256" : typ.slice("ecdsa-sha2-".length); + if (leser.text() !== erwarteteKurve) throw ungueltig(); + const punkt = leser.string(); + if (punkt.length !== ECDSA_PUNKTLAENGE[erwarteteKurve] || punkt[0] !== 0x04) throw ungueltig(); + if (typ.startsWith("sk-")) leser.string(); // application + bits = Number(erwarteteKurve.slice("nistp".length)); + break; + } + } + if (!leser.amEnde()) throw ungueltig(); + return bits; +} + +export interface PublicKeyInfo { + typ: PublicSchluesseltyp; + blob: Buffer; + kommentar: string; + fingerprint: string; + /** Schluessellaenge in Bit (RSA: Modul, ECDSA: Kurve, ed25519: 256). */ + bits: number | null; +} + +export interface PublicKeyLeseOptionen { + /** + * Mindestlaenge fuer RSA. Standard ist RSA_MINDEST_BITS (Import). Wer vorhandene + * authorized_keys nur beobachtet, setzt 0, damit alte Schluessel sichtbar bleiben. + */ + rsaMindestBits?: number; +} + +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/; + +function istPublicTyp(wert: string): wert is PublicSchluesseltyp { + return (PUBLIC_SCHLUESSELTYPEN as readonly string[]).includes(wert); +} + +/** Steuerzeichen im Kommentar wuerden authorized_keys-Zeilen aufbrechen. */ +function kommentarPruefen(kommentar: string): void { + if (/[\x00-\x1f\x7f]/.test(kommentar)) { + throw new SchluesselFormatFehler("ungueltig", "Der Kommentar enthält unzulässige Steuerzeichen."); + } +} + +/** + * Liest eine Public-Key-Zeile `typ base64 [kommentar]` (ohne authorized_keys-Optionen). + */ +export function publicKeyZeileLesen(zeile: string, optionen: PublicKeyLeseOptionen = {}): PublicKeyInfo { + const treffer = /^(\S+)[ \t]+(\S+)(?:[ \t]+(.*))?$/.exec(zeile.trim()); + if (!treffer) { + throw new SchluesselFormatFehler("ungueltig", "Die Zeile ist kein Public Key im OpenSSH-Format."); + } + const typText = treffer[1]!; + const base64 = treffer[2]!; + const kommentar = (treffer[3] ?? "").trim(); + if (!istPublicTyp(typText)) { + throw new SchluesselFormatFehler("nicht_unterstuetzt", "Dieser Schlüsseltyp wird nicht unterstützt."); + } + if (!BASE64.test(base64) || base64.length % 4 !== 0) { + throw new SchluesselFormatFehler("ungueltig", "Der Public Key ist nicht korrekt Base64-kodiert."); + } + const blob = Buffer.from(base64, "base64"); + kommentarPruefen(kommentar); + const bits = publicBlobPruefen(typText, blob, optionen.rsaMindestBits ?? RSA_MINDEST_BITS); + return { typ: typText, blob, kommentar, fingerprint: fingerprintSha256(blob), bits }; +} + +function publicKeyZeileBauen(blob: Buffer, typ: string, kommentar: string): string { + const basis = `${typ} ${blob.toString("base64")}`; + return kommentar ? `${basis} ${kommentar}` : basis; +} + +// --------------------------------------------------------------------------- +// Private Keys: Hilfen +// --------------------------------------------------------------------------- + +/** 48 Byte aus bcrypt_pbkdf: 32 Byte AES-Schluessel, 16 Byte Zaehler-Startwert. */ +function bcryptAbleiten(passphrase: string, salt: Buffer, runden: number): Buffer { + const passwortBytes = Buffer.from(passphrase, "utf8"); + const ergebnis = Buffer.alloc(48); + try { + const rueckgabe = bcryptPbkdf.pbkdf( + passwortBytes, + passwortBytes.length, + salt, + salt.length, + ergebnis, + ergebnis.length, + runden, + ); + if (rueckgabe !== 0) throw ungueltig(); + return ergebnis; + } finally { + passwortBytes.fill(0); + } +} + +/** OpenSSH bricht die Base64-Zeilen nach 70 Zeichen um. */ +function pemKodieren(datei: Buffer): string { + const base64 = datei.toString("base64"); + const zeilen: string[] = []; + for (let i = 0; i < base64.length; i += 70) zeilen.push(base64.slice(i, i + 70)); + return `${PEM_ANFANG}\n${zeilen.join("\n")}\n${PEM_ENDE}\n`; +} + +function pemDekodieren(text: string): Buffer { + const normalisiert = text.replace(/\r\n?/g, "\n").trim(); + if (!normalisiert.startsWith(PEM_ANFANG)) { + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(normalisiert)) { + throw new SchluesselFormatFehler( + "nicht_unterstuetzt", + "Nur Private Keys im OpenSSH-Format werden unterstützt („BEGIN OPENSSH PRIVATE KEY“).", + ); + } + throw new SchluesselFormatFehler("ungueltig", "Der Text ist kein Private Key im OpenSSH-Format."); + } + if (!normalisiert.endsWith(PEM_ENDE)) { + throw ungueltig("Der Private Key ist unvollständig (Endzeile fehlt)."); + } + const rumpf = normalisiert + .slice(PEM_ANFANG.length, normalisiert.length - PEM_ENDE.length) + .replace(/\s+/g, ""); + if (!BASE64.test(rumpf) || rumpf.length % 4 !== 0) { + throw ungueltig("Der Private Key ist nicht korrekt Base64-kodiert."); + } + return Buffer.from(rumpf, "base64"); +} + +/** PKCS#8-Huelle fuer einen ed25519-Seed (RFC 8410), damit Node daraus den Public Key ableitet. */ +const ED25519_PKCS8_PRAEFIX = Buffer.from("302e020100300506032b657004220420", "hex"); + +function ed25519PublicAusSeed(seed: Buffer): Buffer { + const der = Buffer.concat([ED25519_PKCS8_PRAEFIX, seed]); + try { + const jwk = createPublicKey(createPrivateKey({ key: der, format: "der", type: "pkcs8" })).export({ + format: "jwk", + }); + return Buffer.from(jwk.x ?? "", "base64url"); + } catch { + throw ungueltig(); + } finally { + der.fill(0); + } +} + +function gleich(a: Buffer, b: Buffer): boolean { + return a.length === b.length && timingSafeEqual(a, b); +} + +// --------------------------------------------------------------------------- +// Private Keys: Erzeugen und Schreiben +// --------------------------------------------------------------------------- + +export interface SchluesselpaarOptionen { + kommentar: string; + /** Leer oder nicht gesetzt: unverschluesselt, wie `ssh-keygen -N ''`. */ + passphrase?: string; +} + +export interface ErzeugtesSchluesselpaar { + /** z. B. "ssh-ed25519 AAAA… kommentar" */ + publicKeyZeile: string; + /** "-----BEGIN OPENSSH PRIVATE KEY-----…" */ + privateKeyOpenssh: string; + /** "SHA256:…" ohne Padding */ + fingerprint: string; +} + +/** + * Baut die Datei `openssh-key-v1` (PROTOCOL.key). `privatTeil` sind die typabhaengigen + * Felder ohne Kommentar. + */ +function privateKeySchreiben( + publicBlob: Buffer, + privatTeil: Buffer, + kommentar: string, + passphrase: string | undefined, +): string { + const verschluesseln = passphrase !== undefined && passphrase !== ""; + const blockgroesse = verschluesseln ? 16 : 8; + // Zweimal derselbe Zufallswert: Beim Lesen verraet ein Unterschied die falsche Passphrase. + const checkint = randomBytes(4); + const ohnePadding = Buffer.concat([checkint, checkint, privatTeil, sshString(kommentar)]); + const paddingLaenge = (blockgroesse - (ohnePadding.length % blockgroesse)) % blockgroesse; + const padding = Buffer.from(Array.from({ length: paddingLaenge }, (_, i) => i + 1)); + const abschnitt = Buffer.concat([ohnePadding, padding]); + ohnePadding.fill(0); + + let cipherName = "none"; + let kdfName = "none"; + let kdfOptionen = Buffer.alloc(0); + let inhalt = abschnitt; + if (verschluesseln) { + const salt = randomBytes(BCRYPT_SALT_LAENGE); + const abgeleitet = bcryptAbleiten(passphrase, salt, BCRYPT_RUNDEN); + try { + const cipher = createCipheriv("aes-256-ctr", abgeleitet.subarray(0, 32), abgeleitet.subarray(32, 48)); + inhalt = Buffer.concat([cipher.update(abschnitt), cipher.final()]); + } finally { + abgeleitet.fill(0); + } + cipherName = "aes256-ctr"; + kdfName = "bcrypt"; + kdfOptionen = Buffer.concat([sshString(salt), uint32(BCRYPT_RUNDEN)]); + } + + const datei = Buffer.concat([ + MAGIC, + sshString(cipherName), + sshString(kdfName), + sshString(kdfOptionen), + uint32(1), + sshString(publicBlob), + sshString(inhalt), + ]); + const pem = pemKodieren(datei); + datei.fill(0); + abschnitt.fill(0); + return pem; +} + +/** + * Erzeugt ein neues Schluesselpaar. Asynchron, weil RSA-4096 sonst den Worker fuer + * spuerbare Zeit blockiert. Der Private Key liegt danach als String vor; den kann + * JavaScript nicht ueberschreiben – Aufrufer sollen ihn sofort versiegeln. + */ +export async function schluesselpaarErzeugen( + typ: ErzeugbarerSchluesseltyp, + { kommentar, passphrase }: SchluesselpaarOptionen, +): Promise { + kommentarPruefen(kommentar); + let typName: string; + let publicBlob: Buffer; + let privatTeil: Buffer; + + if (typ === "ED25519") { + typName = "ssh-ed25519"; + const { privateKey } = await erzeugeSchluesselpaar("ed25519"); + const jwk = privateKey.export({ format: "jwk" }); + const oeffentlich = Buffer.from(jwk.x ?? "", "base64url"); + const seed = Buffer.from(jwk.d ?? "", "base64url"); + // OpenSSH speichert 64 Byte: Seed gefolgt vom Public Key (Format von ref10/NaCl). + const geheim = Buffer.concat([seed, oeffentlich]); + publicBlob = Buffer.concat([sshString(typName), sshString(oeffentlich)]); + privatTeil = Buffer.concat([sshString(typName), sshString(oeffentlich), sshString(geheim)]); + seed.fill(0); + geheim.fill(0); + } else { + typName = "ssh-rsa"; + const { privateKey } = await erzeugeSchluesselpaar("rsa", { + modulusLength: 4096, + publicExponent: 0x10001, + }); + const jwk = privateKey.export({ format: "jwk" }); + const feld = (wert: string | undefined) => Buffer.from(wert ?? "", "base64url"); + const n = feld(jwk.n); + const e = feld(jwk.e); + const d = feld(jwk.d); + const p = feld(jwk.p); + const q = feld(jwk.q); + const iqmp = feld(jwk.qi); + // Public-Teil: e vor n (RFC 4253). Private-Teil: n e d iqmp p q (sshkey.c). JWK `qi` + // ist q^-1 mod p und damit genau das `iqmp` von OpenSSH. + publicBlob = Buffer.concat([sshString(typName), mpint(e), mpint(n)]); + privatTeil = Buffer.concat([ + sshString(typName), + mpint(n), + mpint(e), + mpint(d), + mpint(iqmp), + mpint(p), + mpint(q), + ]); + for (const geheim of [d, p, q, iqmp]) geheim.fill(0); + } + + try { + const privateKeyOpenssh = privateKeySchreiben(publicBlob, privatTeil, kommentar, passphrase); + return { + publicKeyZeile: publicKeyZeileBauen(publicBlob, typName, kommentar), + privateKeyOpenssh, + fingerprint: fingerprintSha256(publicBlob), + }; + } finally { + privatTeil.fill(0); + } +} + +// --------------------------------------------------------------------------- +// Private Keys: Lesen +// --------------------------------------------------------------------------- + +export interface PrivateKeyInfo { + typ: PrivateSchluesseltyp; + bits: number; + publicKeyZeile: string; + kommentar: string; + fingerprint: string; + /** War die Datei mit einer Passphrase geschuetzt? */ + verschluesselt: boolean; +} + +export interface PrivateKeyLeseOptionen { + rsaMindestBits?: number; +} + +/** + * Liest einen Private Key im OpenSSH-Format, entschluesselt ihn bei Bedarf und prueft, + * dass Private und Public Key zusammengehoeren. Gibt nur oeffentliche Angaben zurueck. + */ +export function privateKeyLesen( + openssh: string, + passphrase?: string, + optionen: PrivateKeyLeseOptionen = {}, +): PrivateKeyInfo { + const datei = pemDekodieren(openssh); + const zuLoeschen: Buffer[] = [datei]; + try { + const leser = new Leser(datei); + if (!gleich(leser.bytes(MAGIC.length), MAGIC)) { + throw new SchluesselFormatFehler("ungueltig", "Der Text ist kein Private Key im OpenSSH-Format."); + } + const cipherName = leser.text(); + const kdfName = leser.text(); + const kdfOptionen = leser.string(); + if (leser.uint32() !== 1) { + throw new SchluesselFormatFehler( + "nicht_unterstuetzt", + "Dateien mit mehr als einem Schlüssel werden nicht unterstützt.", + ); + } + const publicBlob = Buffer.from(leser.string()); + const inhalt = leser.string(); + if (!leser.amEnde()) throw ungueltig(); + + const publicTyp = new Leser(publicBlob).text(); + if (publicTyp !== "ssh-ed25519" && publicTyp !== "ssh-rsa") { + throw new SchluesselFormatFehler( + "nicht_unterstuetzt", + "Es werden nur Private Keys vom Typ ed25519 und RSA unterstützt.", + ); + } + // Der Public Key liegt unverschluesselt vor; eine zu kurze RSA-Laenge faellt so auf, + // bevor jemand nach der Passphrase gefragt wird. + const bits = publicBlobPruefen(publicTyp, publicBlob, optionen.rsaMindestBits ?? RSA_MINDEST_BITS) ?? 0; + + let abschnitt: Buffer; + let blockgroesse: number; + const verschluesselt = cipherName !== "none"; + if (cipherName === "none") { + if (kdfName !== "none" || kdfOptionen.length !== 0) throw ungueltig(); + abschnitt = inhalt; + blockgroesse = 8; + } else if (cipherName === "aes256-ctr") { + if (kdfName !== "bcrypt") { + throw new SchluesselFormatFehler( + "nicht_unterstuetzt", + "Das Verfahren zur Ableitung des Schlüssels wird nicht unterstützt.", + ); + } + if (passphrase === undefined || passphrase === "") { + throw new SchluesselFormatFehler( + "passphrase_fehlt", + "Der Private Key ist mit einer Passphrase geschützt.", + ); + } + const kdfLeser = new Leser(kdfOptionen); + const salt = kdfLeser.string(); + const runden = kdfLeser.uint32(); + if (!kdfLeser.amEnde() || salt.length === 0 || runden < 1 || runden > BCRYPT_RUNDEN_HOECHSTENS) { + throw ungueltig(); + } + blockgroesse = 16; + if (inhalt.length % blockgroesse !== 0) throw ungueltig(); + const abgeleitet = bcryptAbleiten(passphrase, salt, runden); + try { + const decipher = createDecipheriv( + "aes-256-ctr", + abgeleitet.subarray(0, 32), + abgeleitet.subarray(32, 48), + ); + abschnitt = Buffer.concat([decipher.update(inhalt), decipher.final()]); + zuLoeschen.push(abschnitt); + } finally { + abgeleitet.fill(0); + } + } else { + throw new SchluesselFormatFehler( + "nicht_unterstuetzt", + "Die Verschlüsselung dieses Private Keys wird nicht unterstützt (erwartet: aes256-ctr).", + ); + } + if (abschnitt.length % blockgroesse !== 0) throw ungueltig(); + + const privat = new Leser(abschnitt); + if (privat.uint32() !== privat.uint32()) { + throw verschluesselt + ? new SchluesselFormatFehler("passphrase_falsch", "Die Passphrase ist falsch.") + : ungueltig(); + } + if (privat.text() !== publicTyp) throw ungueltig(); + + let typ: PrivateSchluesseltyp; + const erwartet = new Leser(publicBlob); + erwartet.text(); + if (publicTyp === "ssh-ed25519") { + typ = "ED25519"; + const oeffentlich = privat.string(); + const geheim = privat.string(); + if (geheim.length !== 64 || !gleich(oeffentlich, erwartet.string())) throw ungueltig(); + if (!gleich(geheim.subarray(32), oeffentlich)) throw ungueltig(); + if (!gleich(ed25519PublicAusSeed(geheim.subarray(0, 32)), oeffentlich)) throw ungueltig(); + } else { + typ = "RSA"; + const n = privat.mpint(); + const e = privat.mpint(); + const d = privat.mpint(); + privat.mpint(); // iqmp + const p = privat.mpint(); + const q = privat.mpint(); + const erwartetesE = erwartet.mpint(); + const erwartetesN = erwartet.mpint(); + if (!gleich(n, erwartetesN) || !gleich(e, erwartetesE) || d.length === 0) throw ungueltig(); + // Billige Plausibilitaet: Ein Private Key, dessen Primfaktoren nicht n ergeben, ist kaputt. + if (alsBigInt(p) * alsBigInt(q) !== alsBigInt(n)) throw ungueltig(); + } + + const kommentar = privat.text(); + kommentarPruefen(kommentar); + const padding = privat.rest(); + if (padding.length >= blockgroesse || padding.some((wert, i) => wert !== i + 1)) throw ungueltig(); + + return { + typ, + bits, + publicKeyZeile: publicKeyZeileBauen(publicBlob, publicTyp, kommentar), + kommentar, + fingerprint: fingerprintSha256(publicBlob), + verschluesselt, + }; + } finally { + for (const puffer of zuLoeschen) puffer.fill(0); + } +} diff --git a/src/types/bcrypt-pbkdf.d.ts b/src/types/bcrypt-pbkdf.d.ts new file mode 100644 index 0000000..307a85b --- /dev/null +++ b/src/types/bcrypt-pbkdf.d.ts @@ -0,0 +1,20 @@ +// Das Paket bringt keine Typen mit, und @types/bcrypt-pbkdf gibt es nicht (#006). +// Deklariert ist nur, was src/lib/ssh/openssh.ts tatsaechlich benutzt. +declare module "bcrypt-pbkdf" { + /** + * bcrypt_pbkdf wie in OpenSSH. Schreibt `keylen` Byte in `key` und gibt 0 zurueck, + * bei ungueltigen Argumenten (leere Passphrase, leeres Salt, rounds < 1) -1. + */ + export function pbkdf( + pass: Uint8Array, + passlen: number, + salt: Uint8Array, + saltlen: number, + key: Uint8Array, + keylen: number, + rounds: number, + ): number; + + const bcryptPbkdf: { pbkdf: typeof pbkdf }; + export default bcryptPbkdf; +} diff --git a/tests/ssh/hilfen.ts b/tests/ssh/hilfen.ts new file mode 100644 index 0000000..61a0e27 --- /dev/null +++ b/tests/ssh/hilfen.ts @@ -0,0 +1,81 @@ +import { generateKeyPairSync } from "node:crypto"; + +/** Kleine Wire-Format-Helfer, um gezielt kaputte oder seltene Blobs zu bauen (#006). */ +export function sshString(inhalt: Buffer | string): Buffer { + const daten = typeof inhalt === "string" ? Buffer.from(inhalt) : inhalt; + const laenge = Buffer.alloc(4); + laenge.writeUInt32BE(daten.length); + return Buffer.concat([laenge, daten]); +} + +export function mpint(betrag: Buffer): Buffer { + return sshString((betrag[0]! & 0x80) !== 0 ? Buffer.concat([Buffer.from([0]), betrag]) : betrag); +} + +export function zeile(typ: string, blob: Buffer, kommentar = "test"): string { + return `${typ} ${blob.toString("base64")} ${kommentar}`; +} + +export function rsaBlob(bits: number): Buffer { + const { publicKey } = generateKeyPairSync("rsa", { modulusLength: bits }); + const jwk = publicKey.export({ format: "jwk" }); + return Buffer.concat([ + sshString("ssh-rsa"), + mpint(Buffer.from(jwk.e!, "base64url")), + mpint(Buffer.from(jwk.n!, "base64url")), + ]); +} + +const KURVEN = { nistp256: "P-256", nistp384: "P-384", nistp521: "P-521" } as const; + +export function ecdsaPunkt(kurve: keyof typeof KURVEN): Buffer { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: KURVEN[kurve] }); + const jwk = publicKey.export({ format: "jwk" }); + return Buffer.concat([ + Buffer.from([4]), + Buffer.from(jwk.x!, "base64url"), + Buffer.from(jwk.y!, "base64url"), + ]); +} + +export function ecdsaBlob(kurve: keyof typeof KURVEN): Buffer { + return Buffer.concat([sshString(`ecdsa-sha2-${kurve}`), sshString(kurve), sshString(ecdsaPunkt(kurve))]); +} + +export function ed25519Roh(): Buffer { + const { publicKey } = generateKeyPairSync("ed25519"); + return Buffer.from(publicKey.export({ format: "jwk" }).x!, "base64url"); +} + +export function ed25519Blob(): Buffer { + return Buffer.concat([sshString("ssh-ed25519"), sshString(ed25519Roh())]); +} + +export function skEd25519Blob(): Buffer { + return Buffer.concat([sshString("sk-ssh-ed25519@openssh.com"), sshString(ed25519Roh()), sshString("ssh:")]); +} + +export function skEcdsaBlob(): Buffer { + return Buffer.concat([ + sshString("sk-ecdsa-sha2-nistp256@openssh.com"), + sshString("nistp256"), + sshString(ecdsaPunkt("nistp256")), + sshString("ssh:"), + ]); +} + +/** Base64-Rumpf eines PEM-Textes, um Fehlermeldungen auf Schluesselmaterial zu pruefen. */ +export function pemRumpf(pem: string): string { + return pem + .split("\n") + .filter((z) => z && !z.startsWith("-----")) + .join(""); +} + +/** Ob `text` irgendein 16-Zeichen-Stueck aus `material` enthaelt. */ +export function enthaeltMaterial(text: string, material: string): boolean { + for (let i = 0; i + 16 <= material.length; i += 4) { + if (text.includes(material.slice(i, i + 16))) return true; + } + return false; +} diff --git a/tests/ssh/openssh-kompatibilitaet.test.ts b/tests/ssh/openssh-kompatibilitaet.test.ts new file mode 100644 index 0000000..57b6e04 --- /dev/null +++ b/tests/ssh/openssh-kompatibilitaet.test.ts @@ -0,0 +1,131 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + type ErzeugbarerSchluesseltyp, + privateKeyLesen, + publicKeyZeileLesen, + schluesselpaarErzeugen, +} from "@/lib/ssh/openssh"; +import { ecdsaBlob, ed25519Blob, rsaBlob, skEcdsaBlob, skEd25519Blob, zeile } from "./hilfen"; + +/** + * Gegenprobe mit dem echten OpenSSH (#006). `ssh-keygen` kommt nur hier vor, nie im + * Produktivcode – dort waeren dafuer Private Keys als Dateien noetig. + */ +const SSH_KEYGEN = "/usr/bin/ssh-keygen"; +const PASSPHRASE = "geheim und lang genug"; + +let verzeichnis: string; +let zaehler = 0; + +function sshKeygen(...argumente: string[]): string { + // stdin geschlossen: Fragt ssh-keygen doch einmal nach etwas, scheitert der Test, statt zu haengen. + return execFileSync(SSH_KEYGEN, argumente, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +async function dateiSchreiben(inhalt: string): Promise { + const pfad = join(verzeichnis, `schluessel-${zaehler++}`); + await writeFile(pfad, inhalt, { mode: 0o600 }); + return pfad; +} + +/** "256 SHA256:… kommentar (ED25519)" → "SHA256:…" */ +function fingerprintAusAusgabe(ausgabe: string): string { + return ausgabe.trim().split(/\s+/)[1]!; +} + +beforeAll(async () => { + verzeichnis = await mkdtemp(join(tmpdir(), "sshkv-test-")); +}); + +afterAll(async () => { + await rm(verzeichnis, { recursive: true, force: true }); +}); + +describe.skipIf(!existsSync(SSH_KEYGEN))("Kompatibilität mit ssh-keygen", () => { + describe.each([ + ["ED25519", undefined], + ["ED25519", PASSPHRASE], + ["RSA4096", undefined], + ["RSA4096", PASSPHRASE], + ] as [ErzeugbarerSchluesseltyp, string | undefined][])( + "selbst erzeugt: %s, Passphrase %s", + (typ, passphrase) => { + it("ssh-keygen -y liest den Private Key, -lf liefert denselben Fingerprint", async () => { + const paar = await schluesselpaarErzeugen(typ, { kommentar: "sshkv:k_test pruefung", passphrase }); + const pfad = await dateiSchreiben(paar.privateKeyOpenssh); + + const oeffentlich = sshKeygen("-y", "-P", passphrase ?? "", "-f", pfad).trim(); + expect(oeffentlich).toBe(paar.publicKeyZeile); + + const pfadPublic = await dateiSchreiben(paar.publicKeyZeile + "\n"); + expect(fingerprintAusAusgabe(sshKeygen("-lf", pfadPublic))).toBe(paar.fingerprint); + // -lf auf den Private Key liest bei verschluesselten Dateien nur den Public-Teil. + expect(fingerprintAusAusgabe(sshKeygen("-lf", pfad))).toBe(paar.fingerprint); + }, 30_000); + + if (passphrase) { + it("ssh-keygen lehnt eine falsche Passphrase ab", async () => { + const paar = await schluesselpaarErzeugen(typ, { kommentar: "x", passphrase }); + const pfad = await dateiSchreiben(paar.privateKeyOpenssh); + expect(() => sshKeygen("-y", "-P", "falsch", "-f", pfad)).toThrow(); + }, 30_000); + } + }, + ); + + describe.each([ + ["ed25519", [], ""], + ["ed25519", [], PASSPHRASE], + ["rsa", ["-b", "2048"], ""], + ["rsa", ["-b", "2048"], PASSPHRASE], + ] as [string, string[], string][])( + "von ssh-keygen erzeugt: %s, Passphrase „%s“", + (typ, extra, passphrase) => { + it("wird gelesen, Public Key und Fingerprint stimmen", async () => { + const pfad = join(verzeichnis, `fremd-${zaehler++}`); + sshKeygen("-q", "-t", typ, ...extra, "-N", passphrase, "-C", "fremd@rechner", "-f", pfad); + const privat = await readFile(pfad, "utf8"); + const publicZeile = (await readFile(`${pfad}.pub`, "utf8")).trim(); + + const gelesen = privateKeyLesen(privat, passphrase || undefined); + expect(gelesen.publicKeyZeile).toBe(publicZeile); + expect(gelesen.kommentar).toBe("fremd@rechner"); + expect(gelesen.verschluesselt).toBe(passphrase !== ""); + expect(gelesen.fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", `${pfad}.pub`))); + expect(publicKeyZeileLesen(publicZeile).fingerprint).toBe(gelesen.fingerprint); + + if (passphrase) { + expect(() => privateKeyLesen(privat, "falsch")).toThrow("Die Passphrase ist falsch."); + } + }, 30_000); + }, + ); + + it("liest von ssh-keygen erzeugte ECDSA-Public-Keys", async () => { + for (const bits of ["256", "384", "521"]) { + const pfad = join(verzeichnis, `ecdsa-${bits}`); + sshKeygen("-q", "-t", "ecdsa", "-b", bits, "-N", "", "-C", "e", "-f", pfad); + const publicZeile = await readFile(`${pfad}.pub`, "utf8"); + const info = publicKeyZeileLesen(publicZeile); + expect(info.typ).toBe(`ecdsa-sha2-nistp${bits}`); + expect(info.fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", `${pfad}.pub`))); + } + }); + + it.each([ + ["ssh-ed25519", ed25519Blob], + ["ssh-rsa", () => rsaBlob(2048)], + ["ecdsa-sha2-nistp256", () => ecdsaBlob("nistp256")], + ["sk-ssh-ed25519@openssh.com", skEd25519Blob], + ["sk-ecdsa-sha2-nistp256@openssh.com", skEcdsaBlob], + ] as const)("Fingerprint von %s stimmt mit ssh-keygen -lf überein", async (typ, blobBauen) => { + const text = zeile(typ, blobBauen()); + const pfad = await dateiSchreiben(text + "\n"); + expect(publicKeyZeileLesen(text).fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", pfad))); + }); +}); diff --git a/tests/ssh/openssh.test.ts b/tests/ssh/openssh.test.ts new file mode 100644 index 0000000..fcd9ad1 --- /dev/null +++ b/tests/ssh/openssh.test.ts @@ -0,0 +1,227 @@ +import { createHash } from "node:crypto"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + bitsVonRsa, + type ErzeugtesSchluesselpaar, + fingerprintSha256, + privateKeyLesen, + publicKeyZeileLesen, + SchluesselFormatFehler, + schluesselpaarErzeugen, +} from "@/lib/ssh/openssh"; +import { + ecdsaBlob, + ed25519Blob, + enthaeltMaterial, + pemRumpf, + rsaBlob, + skEcdsaBlob, + skEd25519Blob, + sshString, + zeile, +} from "./hilfen"; + +const PASSPHRASE = "korrekt Pferd Batterie Heftklammer"; + +function fehlerVon(aufruf: () => unknown): SchluesselFormatFehler { + try { + aufruf(); + } catch (fehler) { + expect(fehler).toBeInstanceOf(SchluesselFormatFehler); + return fehler as SchluesselFormatFehler; + } + throw new Error("Es wurde ein Fehler erwartet."); +} + +// RSA-4096 kostet je Erzeugung bis zu einige Sekunden; einmal je Variante reicht. +const paare: Record = {}; + +beforeAll(async () => { + const [ed, edPass, rsa, rsaPass] = await Promise.all([ + schluesselpaarErzeugen("ED25519", { kommentar: "sshkv:k_1 backup" }), + schluesselpaarErzeugen("ED25519", { kommentar: "mit passphrase", passphrase: PASSPHRASE }), + schluesselpaarErzeugen("RSA4096", { kommentar: "rsa ohne" }), + schluesselpaarErzeugen("RSA4096", { kommentar: "rsa mit", passphrase: PASSPHRASE }), + ]); + Object.assign(paare, { ed, edPass, rsa, rsaPass }); +}, 60_000); + +describe("schluesselpaarErzeugen und privateKeyLesen", () => { + it.each([ + ["ed", "ED25519", 256, false], + ["edPass", "ED25519", 256, true], + ["rsa", "RSA", 4096, false], + ["rsaPass", "RSA", 4096, true], + ] as const)("Rundreise %s", (name, typ, bits, mitPassphrase) => { + const paar = paare[name]!; + expect(paar.privateKeyOpenssh.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----\n")).toBe(true); + expect(paar.privateKeyOpenssh.endsWith("-----END OPENSSH PRIVATE KEY-----\n")).toBe(true); + expect(paar.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]{43}$/); + + const gelesen = privateKeyLesen(paar.privateKeyOpenssh, mitPassphrase ? PASSPHRASE : undefined); + expect(gelesen).toMatchObject({ + typ, + bits, + publicKeyZeile: paar.publicKeyZeile, + fingerprint: paar.fingerprint, + verschluesselt: mitPassphrase, + }); + + const oeffentlich = publicKeyZeileLesen(paar.publicKeyZeile); + expect(oeffentlich.fingerprint).toBe(paar.fingerprint); + expect(oeffentlich.kommentar).toBe(gelesen.kommentar); + }); + + it("bricht die Base64-Zeilen wie OpenSSH nach 70 Zeichen um", () => { + const zeilen = paare["rsa"]!.privateKeyOpenssh.trim().split("\n").slice(1, -1); + expect(zeilen.slice(0, -1).every((z) => z.length === 70)).toBe(true); + }); + + it("verschluesselt mit aes256-ctr und bcrypt", () => { + const roh = Buffer.from(pemRumpf(paare["edPass"]!.privateKeyOpenssh), "base64").toString("latin1"); + expect(roh).toContain("aes256-ctr"); + expect(roh).toContain("bcrypt"); + }); + + it("erzeugt jedes Mal einen anderen Schluessel", async () => { + const a = await schluesselpaarErzeugen("ED25519", { kommentar: "a" }); + expect(a.fingerprint).not.toBe(paare["ed"]!.fingerprint); + }); + + it("verträgt CRLF und Leerraum um den Private Key", () => { + const text = "\n " + paare["ed"]!.privateKeyOpenssh.replace(/\n/g, "\r\n") + " \n"; + expect(privateKeyLesen(text).fingerprint).toBe(paare["ed"]!.fingerprint); + }); + + it("lehnt Steuerzeichen im Kommentar ab", async () => { + await expect(schluesselpaarErzeugen("ED25519", { kommentar: "a\nssh-rsa AAAA" })).rejects.toBeInstanceOf( + SchluesselFormatFehler, + ); + }); +}); + +describe("privateKeyLesen – Fehlerfälle", () => { + it("meldet eine fehlende Passphrase", () => { + expect(fehlerVon(() => privateKeyLesen(paare["edPass"]!.privateKeyOpenssh)).code).toBe( + "passphrase_fehlt", + ); + }); + + it.each(["edPass", "rsaPass"])("meldet eine falsche Passphrase (%s)", (name) => { + const fehler = fehlerVon(() => privateKeyLesen(paare[name]!.privateKeyOpenssh, "falsch")); + expect(fehler.code).toBe("passphrase_falsch"); + expect(fehler.message).toBe("Die Passphrase ist falsch."); + }); + + it("erkennt einen abgeschnittenen Key", () => { + const pem = paare["rsa"]!.privateKeyOpenssh; + const zeilen = pem.trim().split("\n"); + const halb = [...zeilen.slice(0, zeilen.length / 2), zeilen.at(-1)!].join("\n"); + expect(fehlerVon(() => privateKeyLesen(halb)).code).toBe("ungueltig"); + expect(fehlerVon(() => privateKeyLesen(pem.slice(0, pem.length / 2))).code).toBe("ungueltig"); + }); + + it("erkennt ein vertauschtes Byte im unverschlüsselten Teil", () => { + const roh = Buffer.from(pemRumpf(paare["ed"]!.privateKeyOpenssh), "base64"); + roh[roh.length - 60]! ^= 0x01; + const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${roh.toString("base64")}\n-----END OPENSSH PRIVATE KEY-----\n`; + expect(fehlerVon(() => privateKeyLesen(pem)).code).toBe("ungueltig"); + }); + + it("weist PEM-Formate außer OpenSSH mit eigener Meldung ab", () => { + const fehler = fehlerVon(() => + privateKeyLesen("-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----"), + ); + expect(fehler.code).toBe("nicht_unterstuetzt"); + }); + + it("weist Unsinn ab", () => { + expect(fehlerVon(() => privateKeyLesen("hallo")).code).toBe("ungueltig"); + }); + + it("enthält in keiner Fehlermeldung Schlüsselmaterial oder die Passphrase", () => { + const pem = paare["rsaPass"]!.privateKeyOpenssh; + const rumpf = pemRumpf(pem); + const meldungen = [ + fehlerVon(() => privateKeyLesen(pem, "falsch")).message, + fehlerVon(() => privateKeyLesen(pem)).message, + fehlerVon(() => privateKeyLesen(pem.slice(0, 400))).message, + fehlerVon(() => privateKeyLesen(pem.replace("A", "!"))).message, + fehlerVon(() => privateKeyLesen(pem.replace("OPENSSH", "RSA"))).message, + ]; + for (const meldung of meldungen) { + expect(enthaeltMaterial(meldung, rumpf)).toBe(false); + expect(meldung).not.toContain(PASSPHRASE); + expect(meldung).not.toContain("-----BEGIN"); + } + }); +}); + +describe("publicKeyZeileLesen", () => { + it.each([ + ["ssh-ed25519", ed25519Blob, 256], + ["ssh-rsa", () => rsaBlob(2048), 2048], + ["ecdsa-sha2-nistp256", () => ecdsaBlob("nistp256"), 256], + ["ecdsa-sha2-nistp384", () => ecdsaBlob("nistp384"), 384], + ["ecdsa-sha2-nistp521", () => ecdsaBlob("nistp521"), 521], + ["sk-ssh-ed25519@openssh.com", skEd25519Blob, 256], + ["sk-ecdsa-sha2-nistp256@openssh.com", skEcdsaBlob, 256], + ] as const)("liest %s", (typ, blobBauen, bits) => { + const blob = blobBauen(); + const info = publicKeyZeileLesen(zeile(typ, blob, "jemand@rechner mit leerzeichen")); + expect(info).toMatchObject({ typ, kommentar: "jemand@rechner mit leerzeichen", bits }); + expect(info.blob.equals(blob)).toBe(true); + }); + + it("akzeptiert Zeilen ohne Kommentar", () => { + expect(publicKeyZeileLesen(`ssh-ed25519 ${ed25519Blob().toString("base64")}`).kommentar).toBe(""); + }); + + it("lehnt einen Blob ab, dessen Typ nicht zur Zeile passt", () => { + expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-rsa", ed25519Blob()))).code).toBe("ungueltig"); + expect( + fehlerVon(() => publicKeyZeileLesen(zeile("ecdsa-sha2-nistp384", ecdsaBlob("nistp256")))).code, + ).toBe("ungueltig"); + }); + + it("lehnt RSA unter 2048 Bit ab, außer der Aufrufer erlaubt es", () => { + const kurz = zeile("ssh-rsa", rsaBlob(1024)); + const fehler = fehlerVon(() => publicKeyZeileLesen(kurz)); + expect(fehler.code).toBe("zu_schwach"); + expect(fehler.message).toContain("2048"); + expect(publicKeyZeileLesen(kurz, { rsaMindestBits: 0 }).bits).toBe(1024); + }); + + it("lehnt unbekannte Typen, kaputtes Base64 und angehängte Bytes ab", () => { + expect(fehlerVon(() => publicKeyZeileLesen("ssh-dss AAAAB3NzaC1kc3M= x")).code).toBe( + "nicht_unterstuetzt", + ); + expect(fehlerVon(() => publicKeyZeileLesen("ssh-ed25519 AAA*AAA x")).code).toBe("ungueltig"); + expect(fehlerVon(() => publicKeyZeileLesen("ssh-ed25519")).code).toBe("ungueltig"); + const mitRest = Buffer.concat([ed25519Blob(), sshString("x")]); + expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-ed25519", mitRest))).code).toBe("ungueltig"); + const abgeschnitten = ed25519Blob().subarray(0, 30); + expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-ed25519", abgeschnitten))).code).toBe("ungueltig"); + }); + + it("lehnt Steuerzeichen im Kommentar ab", () => { + expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-ed25519", ed25519Blob(), "a\u0000b"))).code).toBe( + "ungueltig", + ); + }); +}); + +describe("fingerprintSha256 und bitsVonRsa", () => { + it("ist SHA-256 über den Blob, Base64 ohne Padding", () => { + const blob = ed25519Blob(); + const erwartet = createHash("sha256").update(blob).digest("base64").replace(/=+$/, ""); + expect(fingerprintSha256(blob)).toBe(`SHA256:${erwartet}`); + expect(fingerprintSha256(blob)).not.toContain("="); + }); + + it("zählt die Bits des RSA-Moduls", () => { + expect(bitsVonRsa(rsaBlob(2048))).toBe(2048); + expect(bitsVonRsa(rsaBlob(3072))).toBe(3072); + expect(() => bitsVonRsa(ed25519Blob())).toThrow(SchluesselFormatFehler); + }); +}); From 893b840f06486acdc40642ef4274bf902dd7ea23 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:16:56 +0200 Subject: [PATCH 2/4] feat: Envelope Encryption und Master-Key aus Datei (#006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/krypto/umschlag.ts | 198 ++++++++++++++++++++++++++++++++++ tests/krypto/umschlag.test.ts | 167 ++++++++++++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 src/lib/krypto/umschlag.ts create mode 100644 tests/krypto/umschlag.test.ts diff --git a/src/lib/krypto/umschlag.ts b/src/lib/krypto/umschlag.ts new file mode 100644 index 0000000..9debf3a --- /dev/null +++ b/src/lib/krypto/umschlag.ts @@ -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::`) 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 { + 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; +} diff --git a/tests/krypto/umschlag.test.ts b/tests/krypto/umschlag.test.ts new file mode 100644 index 0000000..aef8639 --- /dev/null +++ b/tests/krypto/umschlag.test.ts @@ -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 { + 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")); + }); +}); From d359b7d4c3d3415e618713e9aefd1ee0ced972d0 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:17:34 +0200 Subject: [PATCH 3/4] feat: Geheimnisse vor Log und Protokoll schwaerzen (#006) Private Keys, Passwoerter und Tokens duerfen nie in Log oder Protokoll landen (CLAUDE.md). Ein Netz ueber Feldnamen allein reicht nicht: Ein Key kann in einer Fehlermeldung oder einem Stacktrace stecken, ohne dass das Feld danach heisst. Deshalb schwaerzt `schwaerzen` sowohl Felder mit verdaechtigen Namen (irgendwo im Namen, nicht nur am Anfang) als auch jeden PEM-Private-Key in beliebigen Zeichenketten, auch abgeschnittene. Der Helfer ist framework-frei und wird mit dem Logger und dem Protokoll eingebaut. Co-Authored-By: Claude Opus 5.5 --- src/lib/protokoll-schwaerzen.ts | 95 ++++++++++++++++++++++ tests/krypto/protokoll-schwaerzen.test.ts | 98 +++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/lib/protokoll-schwaerzen.ts create mode 100644 tests/krypto/protokoll-schwaerzen.test.ts diff --git a/src/lib/protokoll-schwaerzen.ts b/src/lib/protokoll-schwaerzen.ts new file mode 100644 index 0000000..2fffbf7 --- /dev/null +++ b/src/lib/protokoll-schwaerzen.ts @@ -0,0 +1,95 @@ +/** + * Schwaerzt Geheimnisse in beliebigen Werten, bevor sie in Log oder Protokoll landen (#006). + * + * Zwei Netze, weil eines allein nicht reicht: + * - Feldnamen: Alles, was nach Private Key, Passwort, Passphrase, Secret oder Token heisst, + * wird unabhaengig vom Inhalt ersetzt – auch Objekte und Puffer darunter. + * - Inhalte: Ein Private Key im PEM-Format wird auch dort erkannt, wo niemand ihn erwartet, + * etwa eingebettet in eine Fehlermeldung oder einen Stacktrace. + * + * Gibt immer eine Kopie zurueck; der Eingabewert bleibt unveraendert. + */ + +export const GESCHWAERZT = "[geschwärzt]"; + +/** + * Wortstaemme, die einen Feldnamen als geheim markieren. Verglichen wird ohne Gross-/ + * Kleinschreibung und ohne `_`/`-`, und zwar irgendwo im Namen: `clientSecret` und + * `x-api-token` sollen genauso greifen wie `privateKeyOpenssh` oder `privatVerschluesselt`. + * Lieber einmal zu viel schwaerzen als einen Key ins Log schreiben. + */ +const GEHEIME_WORTSTAEMME = [ + "privat", + "passwort", + "password", + "passphrase", + "secret", + "geheimnis", + "token", + "masterkey", + "authorization", + "cookie", +]; + +const HOECHSTE_TIEFE = 12; + +/** Von BEGIN bis END eines beliebigen PEM-Private-Keys, oder bis zum Textende, falls abgeschnitten. */ +const PEM_PRIVATE_KEY = /-----BEGIN[^-\n]*PRIVATE KEY-----[\s\S]*?(?:-----END[^-\n]*PRIVATE KEY-----|$)/g; + +function istGeheimerName(name: string): boolean { + const normalisiert = name.toLowerCase().replace(/[_\-\s]/g, ""); + return GEHEIME_WORTSTAEMME.some((stamm) => normalisiert.includes(stamm)); +} + +function textSchwaerzen(text: string): string { + if (!text.includes("-----BEGIN")) return text; + const ersetzt = text.replace(PEM_PRIVATE_KEY, GESCHWAERZT); + // Faengt seltsame Varianten ab, die der Ausdruck oben nicht trifft (z. B. Zeilenumbruch + // mitten im Kopf). Dann geht die ganze Zeichenkette. + return ersetzt.includes("-----BEGIN") && ersetzt.includes("PRIVATE KEY") ? GESCHWAERZT : ersetzt; +} + +export function schwaerzen(wert: unknown): unknown { + return schwaerzenRekursiv(wert, new WeakSet(), 0); +} + +function schwaerzenRekursiv(wert: unknown, gesehen: WeakSet, tiefe: number): unknown { + if (typeof wert === "string") return textSchwaerzen(wert); + if (wert === null || typeof wert !== "object") return wert; + // Rohe Bytes koennen Schluesselmaterial sein und sind im Log ohnehin nicht lesbar. + if (wert instanceof Uint8Array || wert instanceof ArrayBuffer) return "[Binärdaten]"; + if (wert instanceof Date) return new Date(wert.getTime()); + if (gesehen.has(wert)) return "[zirkulär]"; + if (tiefe >= HOECHSTE_TIEFE) return "[zu tief]"; + gesehen.add(wert); + try { + if (Array.isArray(wert)) return wert.map((eintrag) => schwaerzenRekursiv(eintrag, gesehen, tiefe + 1)); + if (wert instanceof Map) { + return Object.fromEntries( + [...wert.entries()].map(([schluessel, eintrag]) => [ + String(schluessel), + istGeheimerName(String(schluessel)) ? GESCHWAERZT : schwaerzenRekursiv(eintrag, gesehen, tiefe + 1), + ]), + ); + } + if (wert instanceof Set) + return [...wert].map((eintrag) => schwaerzenRekursiv(eintrag, gesehen, tiefe + 1)); + + const ergebnis: Record = {}; + // Bei Fehlern sind name, message und stack nicht aufzaehlbar, tragen aber gerade den + // Text, in dem ein Key stecken kann. + if (wert instanceof Error) { + ergebnis["name"] = wert.name; + ergebnis["message"] = textSchwaerzen(wert.message); + if (wert.stack) ergebnis["stack"] = textSchwaerzen(wert.stack); + if (wert.cause !== undefined) ergebnis["cause"] = schwaerzenRekursiv(wert.cause, gesehen, tiefe + 1); + } + for (const [name, eintrag] of Object.entries(wert)) { + ergebnis[name] = istGeheimerName(name) ? GESCHWAERZT : schwaerzenRekursiv(eintrag, gesehen, tiefe + 1); + } + return ergebnis; + } finally { + // Nur Zyklen erkennen, nicht mehrfach referenzierte Teilbaeume verstecken. + gesehen.delete(wert); + } +} diff --git a/tests/krypto/protokoll-schwaerzen.test.ts b/tests/krypto/protokoll-schwaerzen.test.ts new file mode 100644 index 0000000..b740216 --- /dev/null +++ b/tests/krypto/protokoll-schwaerzen.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { GESCHWAERZT, schwaerzen } from "@/lib/protokoll-schwaerzen"; + +const KEY = + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n" + + "-----END OPENSSH PRIVATE KEY-----"; + +describe("schwaerzen", () => { + it("ersetzt geheime Felder in verschachtelten Objekten und Arrays", () => { + const eingabe = { + name: "backup", + privateKeyOpenssh: "irgendwas", + privatVerschluesselt: Buffer.from("x"), + einstellungen: { + passwort: "hunter2", + Password: "hunter2", + sshPassphrase: "abc", + client_secret: "s", + geheimnisWert: { tief: 1 }, + accessToken: "t", + }, + liste: [{ token: "t", erlaubt: "ja" }, "frei", 3], + }; + expect(schwaerzen(eingabe)).toEqual({ + name: "backup", + privateKeyOpenssh: GESCHWAERZT, + privatVerschluesselt: GESCHWAERZT, + einstellungen: { + passwort: GESCHWAERZT, + Password: GESCHWAERZT, + sshPassphrase: GESCHWAERZT, + client_secret: GESCHWAERZT, + geheimnisWert: GESCHWAERZT, + accessToken: GESCHWAERZT, + }, + liste: [{ token: GESCHWAERZT, erlaubt: "ja" }, "frei", 3], + }); + // Eingabe bleibt unberuehrt. + expect(eingabe.einstellungen.passwort).toBe("hunter2"); + }); + + it("findet Private Keys in beliebigen Zeichenketten", () => { + const ergebnis = schwaerzen({ notiz: `vorher ${KEY} nachher`, tief: [[KEY]] }) as { + notiz: string; + tief: string[][]; + }; + expect(ergebnis.notiz).toBe(`vorher ${GESCHWAERZT} nachher`); + expect(ergebnis.tief[0]![0]).toBe(GESCHWAERZT); + expect(JSON.stringify(ergebnis)).not.toContain("b3BlbnNzaC1rZXkt"); + }); + + it("schwärzt Keys in Fehlermeldungen und Stacktraces", () => { + const fehler = new Error(`Parser scheiterte an ${KEY}`, { cause: { passphrase: "x", text: KEY } }); + const ergebnis = schwaerzen({ fehler }) as { fehler: Record }; + expect(ergebnis.fehler["name"]).toBe("Error"); + expect(ergebnis.fehler["message"]).toBe(`Parser scheiterte an ${GESCHWAERZT}`); + expect(JSON.stringify(ergebnis)).not.toContain("b3BlbnNzaC1rZXkt"); + expect(JSON.stringify(ergebnis)).not.toContain("PRIVATE KEY"); + expect(ergebnis.fehler["cause"]).toEqual({ passphrase: GESCHWAERZT, text: GESCHWAERZT }); + }); + + it("schwärzt auch abgeschnittene Keys ohne END-Zeile", () => { + const abgeschnitten = KEY.slice(0, 60); + expect(schwaerzen(`log: ${abgeschnitten}`)).toBe(`log: ${GESCHWAERZT}`); + }); + + it("schwärzt andere PEM-Private-Keys (PKCS#1, PKCS#8)", () => { + for (const art of ["RSA PRIVATE KEY", "PRIVATE KEY", "ENCRYPTED PRIVATE KEY"]) { + const pem = `-----BEGIN ${art}-----\nMIIEow\n-----END ${art}-----`; + expect(schwaerzen(pem)).toBe(GESCHWAERZT); + } + }); + + it("lässt Public Keys und harmlose Werte stehen", () => { + const wert = { + publicKeyZeile: "ssh-ed25519 AAAAC3Nza x", + fingerprint: "SHA256:abc", + anzahl: 3, + aktiv: true, + leer: null, + zeit: new Date(0), + }; + expect(schwaerzen(wert)).toEqual(wert); + expect(schwaerzen("-----BEGIN CERTIFICATE-----")).toBe("-----BEGIN CERTIFICATE-----"); + }); + + it("ersetzt rohe Bytes und übersteht Zyklen", () => { + const zyklisch: Record = { daten: Buffer.from("geheim") }; + zyklisch["selbst"] = zyklisch; + expect(schwaerzen(zyklisch)).toEqual({ daten: "[Binärdaten]", selbst: "[zirkulär]" }); + }); + + it("zeigt mehrfach referenzierte Teilbäume vollständig", () => { + const geteilt = { a: 1 }; + expect(schwaerzen({ x: geteilt, y: geteilt })).toEqual({ x: { a: 1 }, y: { a: 1 } }); + }); +}); From 6cdc49df49e3cce345701c72ad25e3dc1115d0bf Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:17:46 +0200 Subject: [PATCH 4/4] docs: Stand und Entscheidungen zu #006 festhalten (#006) Der framework-freie Kern ist fertig; Deployment-Key, Master-Key im Worker und der Nachweis am Datenbank-Dump haengen an Datenbank und Worker und kommen mit #007. Die Entscheidungen (16 bcrypt-Runden, Fehlercodes, RSA-Mindestlaenge abschaltbar fuers Beobachten, Umschlag-Format, Dateirechte des Master-Keys) stehen im Issue, damit die Anbindung nicht raten muss. Co-Authored-By: Claude Opus 5.5 --- .../006-geheimnisse-schluesselformat.md | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/issues/006-geheimnisse-schluesselformat.md b/docs/issues/006-geheimnisse-schluesselformat.md index b8b83ca..5d6ce97 100644 --- a/docs/issues/006-geheimnisse-schluesselformat.md +++ b/docs/issues/006-geheimnisse-schluesselformat.md @@ -1,5 +1,5 @@ # #006 Verschlüsselte Ablage und OpenSSH-Schlüsselformat -**Status:** offen +**Status:** in Arbeit **Meilenstein:** v0.1.0 **Labels:** sicherheit **Geschätzt:** 4 h **Tatsächlich:** – @@ -8,19 +8,50 @@ Envelope Encryption für Private Keys mit Master-Key aus Datei; eigene Erzeugung und Serialisierung von OpenSSH-Schlüsseln ohne temporäre Dateien; Deployment-Key der Instanz. ## Aufgaben -- [ ] Master-Key aus `SSHKV_MASTER_KEY_FILE` (32 Byte), nur im Worker -- [ ] AES-256-GCM, DEK je Schlüssel, AAD = Schlüssel-ID + Fingerprint, Master-Key-Version -- [ ] ed25519 und RSA-4096 erzeugen (Node `crypto`), OpenSSH-Format schreiben und lesen (optional Passphrase: bcrypt-pbkdf + aes256-ctr) -- [ ] Fingerprint SHA256 wie `ssh-keygen -l` +- [x] Master-Key aus Datei laden (32 Byte roh, Base64 oder Hex; Rechteprüfung) – `masterKeyLaden` +- [ ] Master-Key aus `SSHKV_MASTER_KEY_FILE` nur im Worker anbinden +- [x] AES-256-GCM, DEK je Schlüssel, AAD = Schlüssel-ID + Fingerprint, Master-Key-Version +- [x] ed25519 und RSA-4096 erzeugen (Node `crypto`), OpenSSH-Format schreiben und lesen (optional Passphrase: bcrypt-pbkdf + aes256-ctr) +- [x] Fingerprint SHA256 wie `ssh-keygen -l` - [ ] Deployment-Key beim ersten Start erzeugen -- [ ] Log-Redaction +- [x] Log-Redaction (`schwaerzen`; Einbau in Logger/Protokoll folgt) ## Akzeptanzkriterien -- [ ] `ssh-keygen -y -f` liest erzeugte Schlüssel (mit und ohne Passphrase) -- [ ] Fingerprints identisch zu `ssh-keygen -lf` +- [x] `ssh-keygen -y -f` liest erzeugte Schlüssel (mit und ohne Passphrase) +- [x] Fingerprints identisch zu `ssh-keygen -lf` - [ ] Datenbank-Dump enthält keinen Klartext-Schlüssel ## Abhängigkeiten #003 ## Notizen / Entscheidungen +- **Kern fertig; Anbindung an Datenbank/Worker folgt mit #007.** Offen sind Deployment-Key beim + ersten Start, Master-Key nur im Worker laden und der Nachweis „Datenbank-Dump enthält keinen + Klartext“. +- Module: `src/lib/ssh/openssh.ts` (Erzeugen/Schreiben/Lesen, Fingerprint, Public-Key-Zeilen), + `src/lib/krypto/umschlag.ts` (Envelope Encryption, Master-Key laden), + `src/lib/protokoll-schwaerzen.ts` (Redaction). Alle framework-frei. +- Eigener Serializer für `openssh-key-v1` statt `ssh-keygen`: keine Private Keys als Temp-Dateien. + Die Tests prüfen gegen das echte `ssh-keygen` (beide Richtungen, mit/ohne Passphrase). +- Passphrase: `aes256-ctr` + `bcrypt` mit **16 Runden** (ssh-keygen nimmt heute 24). `bcrypt-pbkdf` + ist reines JavaScript; 16 Runden halten das im Worker unter einer Sekunde. Beim Lesen gilt die + Rundenzahl aus der Datei (Obergrenze 1000 gegen absichtlich teure Importe). + Andere Cipher (z. B. `aes256-gcm@openssh.com` via `ssh-keygen -Z`) werden mit klarer Meldung + abgelehnt. Private-Key-Import nur für ed25519 und RSA. +- `SchluesselFormatFehler` trägt einen `code` (`ungueltig`, `nicht_unterstuetzt`, + `passphrase_fehlt`, `passphrase_falsch`, `zu_schwach`); Meldungen sind feste Sätze ohne Eingabe. +- RSA-Mindestlänge 2048 Bit standardmäßig in `publicKeyZeileLesen` und `privateKeyLesen`; + `{ rsaMindestBits: 0 }` für das reine Beobachten vorhandener `authorized_keys` (Drift). +- Kommentare mit Steuerzeichen werden abgelehnt, sonst ließe sich über einen Kommentar eine zweite + Zeile in `authorized_keys` einschleusen. +- Umschlag-Format je Puffer: `Version (1) | IV (12) | Tag (16) | Daten`. AAD Klartext = `zusatz` + (vorgesehen: `schluessel::`), AAD DEK = `sshkv-dek:v`. + `versiegeln(..., masterKeyVersion = 1)`; beim Öffnen muss der Aufrufer den Master-Key passend zur + Version wählen. +- Master-Key-Datei mit Gruppe/Welt-Leserecht → Abbruch, außer `SSHKV_MASTER_KEY_UNSICHER_ERLAUBT=1` + (dann Warnung). Achtung Docker: Secrets ohne Swarm sind Bind-Mounts mit den Rechten der + Host-Datei – dort `chmod 400` und passenden Eigentümer setzen. +- Schwärzen greift auf Feldnamen, die einen der Wortstämme `privat`, `passwort`, `password`, + `passphrase`, `secret`, `geheimnis`, `token`, `masterkey`, `authorization`, `cookie` enthalten + (nicht nur als Präfix), sowie auf PEM-Private-Keys in jeder Zeichenkette (auch abgeschnitten, + auch in `message`/`stack` von Fehlern). Rohe Bytes werden zu `[Binärdaten]`.