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); + }); +});