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