From 8dca30e2543b87aaf4bb139d6696b131ddb50c48 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 16:39:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(ssh):=20nur=20ed25519-Schl=C3=BCssel=20spei?= =?UTF-8?q?chern,=20die=20ssh2=20lesen=20kann?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ssh2.utils.generateKeyPairSync liefert in etwa 4 von 1000 Fällen einen privaten Schlüssel, an dem der eigene Parser scheitert. Ein so erzeugter Server-Schlüssel wäre dauerhaft unbrauchbar gewesen; in den Tests war es die Ursache des gelegentlich roten SSH-Tests. Refs #014 Co-Authored-By: Claude Opus 5.5 --- src/engine/ssh/schluessel.ts | 19 +++++++++++++++++++ src/server/services/bereitstellung.ts | 6 +++--- src/server/services/server.ts | 8 ++++---- tests/engine/erkundung.test.ts | 7 +++---- tests/engine/ssh-schluessel.test.ts | 13 +++++++++++++ tests/ssh-mock.ts | 9 ++++++++- tests/verbindungstest.test.ts | 5 ++--- 7 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 src/engine/ssh/schluessel.ts create mode 100644 tests/engine/ssh-schluessel.test.ts diff --git a/src/engine/ssh/schluessel.ts b/src/engine/ssh/schluessel.ts new file mode 100644 index 0000000..8487e15 --- /dev/null +++ b/src/engine/ssh/schluessel.ts @@ -0,0 +1,19 @@ +import ssh2 from "ssh2"; + +/** + * Ein ed25519-Schlüsselpaar im OpenSSH-Format, das ssh2 auch selbst wieder lesen kann. + * + * `ssh2.utils.generateKeyPairSync("ed25519")` liefert in etwa 4 von 1000 Fällen einen privaten + * Schlüssel, an dem der eigene Parser mit „Malformed OpenSSH private key“ scheitert (gemessen mit + * ssh2 1.17.0). Gespeichert wäre so ein Server-Schlüssel dauerhaft unbrauchbar – deshalb wird + * geprüft und notfalls neu erzeugt. + */ +export function ed25519Erzeugen(kommentar?: string): { privat: string; oeffentlich: string } { + for (let versuch = 0; versuch < 20; versuch += 1) { + const paar = ssh2.utils.generateKeyPairSync("ed25519", kommentar ? { comment: kommentar } : {}); + if (!(ssh2.utils.parseKey(paar.private) instanceof Error)) { + return { privat: paar.private, oeffentlich: paar.public.trim() }; + } + } + throw new Error("Kein lesbarer ed25519-Schlüssel erzeugt."); +} diff --git a/src/server/services/bereitstellung.ts b/src/server/services/bereitstellung.ts index 1fab88f..6a3c9ca 100644 --- a/src/server/services/bereitstellung.ts +++ b/src/server/services/bereitstellung.ts @@ -1,5 +1,5 @@ -import ssh2 from "ssh2"; import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen"; +import { ed25519Erzeugen } from "@/engine/ssh/schluessel"; import { prisma } from "@/lib/db"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; @@ -18,8 +18,8 @@ export async function bereitstellungsschluessel(): Promise { const server = await serverHolen(name); - const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` }); + const paar = ed25519Erzeugen(`proxdeploy@${server.name}`); const neu = await prisma.$transaction(async (tx) => { const aktualisiert = await tx.server.update({ where: { id: server.id }, data: { - sshPrivat: verschluesseln(paar.private), - sshOeffentlich: paar.public.trim(), + sshPrivat: verschluesseln(paar.privat), + sshOeffentlich: paar.oeffentlich, // Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf // den Nodes noch hinterlegt – das sagt die Oberfläche dazu. }, diff --git a/tests/engine/erkundung.test.ts b/tests/engine/erkundung.test.ts index 2e7b1ce..24f900a 100644 --- a/tests/engine/erkundung.test.ts +++ b/tests/engine/erkundung.test.ts @@ -1,11 +1,10 @@ -import { utils } from "ssh2"; import { afterEach, describe, expect, it } from "vitest"; import { ProxmoxClient } from "@/engine/proxmox/client"; import { erkunden } from "@/engine/proxmox/erkundung"; import { sshAusfuehren, SshFehler } from "@/engine/ssh/verbindung"; import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock"; import { szenarioEinrichten, type Szenario } from "../proxmox-mock/szenario"; -import { SshMock } from "../ssh-mock"; +import { SshMock, testPaar } from "../ssh-mock"; const aufraeumen: Array<() => Promise> = []; afterEach(async () => { @@ -102,7 +101,7 @@ describe("Verbindungstest (Erkundung)", () => { }); describe("SSH", () => { - const paar = utils.generateKeyPairSync("ed25519"); + const paar = testPaar(); async function sshServer() { const ssh = await new SshMock(paar.public).starten(); @@ -147,7 +146,7 @@ describe("SSH", () => { it("meldet einen nicht hinterlegten Schlüssel als Anmeldefehler mit Hinweis", async () => { const ssh = await sshServer(); - const fremd = utils.generateKeyPairSync("ed25519"); + const fremd = testPaar(); const fehler = (await sshAusfuehren( { host: "127.0.0.1", diff --git a/tests/engine/ssh-schluessel.test.ts b/tests/engine/ssh-schluessel.test.ts new file mode 100644 index 0000000..405434e --- /dev/null +++ b/tests/engine/ssh-schluessel.test.ts @@ -0,0 +1,13 @@ +import { utils } from "ssh2"; +import { describe, expect, it } from "vitest"; +import { ed25519Erzeugen } from "@/engine/ssh/schluessel"; + +describe("ed25519Erzeugen", () => { + it("liefert nur Schlüssel, die ssh2 selbst lesen kann (roh scheitern etwa 4 von 1000)", () => { + for (let i = 0; i < 1500; i++) { + const paar = ed25519Erzeugen("test"); + expect(utils.parseKey(paar.privat)).not.toBeInstanceOf(Error); + expect(paar.oeffentlich).toMatch(/^ssh-ed25519 \S+ test$/); + } + }); +}); diff --git a/tests/ssh-mock.ts b/tests/ssh-mock.ts index bcb1cb4..ce7877e 100644 --- a/tests/ssh-mock.ts +++ b/tests/ssh-mock.ts @@ -1,5 +1,6 @@ import type { AddressInfo } from "node:net"; import { Server, utils } from "ssh2"; +import { ed25519Erzeugen } from "@/engine/ssh/schluessel"; import { hostSchluesselFingerabdruck } from "@/engine/ssh/verbindung"; /** @@ -25,7 +26,7 @@ export class SshMock { async starten(): Promise { // ssh2 liest ed25519 nur im OpenSSH-Format, nicht als PKCS8. - const hostPem = utils.generateKeyPairSync("ed25519").private; + const hostPem = ed25519Erzeugen().privat; const host = utils.parseKey(hostPem); if (host instanceof Error) throw host; this.hostSchluessel = hostSchluesselFingerabdruck((Array.isArray(host) ? host[0]! : host).getPublicSSH()); @@ -64,3 +65,9 @@ export class SshMock { await new Promise((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen()); } } + +/** Schlüsselpaar für Tests im Format von ssh2 (`public`/`private`) – immer lesbar. */ +export function testPaar(): { public: string; private: string } { + const paar = ed25519Erzeugen(); + return { public: paar.oeffentlich, private: paar.privat }; +} diff --git a/tests/verbindungstest.test.ts b/tests/verbindungstest.test.ts index ecfa667..08dd69a 100644 --- a/tests/verbindungstest.test.ts +++ b/tests/verbindungstest.test.ts @@ -1,16 +1,15 @@ -import { utils } from "ssh2"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { POST as hostSchluesselPost } from "@/app/api/v1/server/[name]/ssh/hostschluessel/route"; import { POST as testPost } from "@/app/api/v1/server/[name]/test/route"; import { verschluesseln } from "@/lib/geheimnisse"; import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock"; import { szenarioEinrichten } from "./proxmox-mock/szenario"; -import { SshMock } from "./ssh-mock"; +import { SshMock, testPaar } from "./ssh-mock"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { angemeldet, anfrage, kontext } from "./helpers/anfragen"; const mock = new ProxmoxMock(); -const paar = utils.generateKeyPairSync("ed25519"); +const paar = testPaar(); let ssh: SshMock; describe.runIf(datenbankVerfuegbar)("Verbindungstest über die API", () => {