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]`. diff --git a/package.json b/package.json index b612a47..1ce6437 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,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/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/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/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/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 } }); + }); +}); 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")); + }); +}); 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); + }); +});