diff --git a/src/backend/tests/utils/ssh-algorithms.test.ts b/src/backend/tests/utils/ssh-algorithms.test.ts new file mode 100644 index 000000000..a6c1561bd --- /dev/null +++ b/src/backend/tests/utils/ssh-algorithms.test.ts @@ -0,0 +1,137 @@ +import { generateKeyPairSync } from "node:crypto"; +import net from "node:net"; +import { Client, Server } from "ssh2"; +import { describe, expect, it } from "vitest"; +import { buildSSHAlgorithms } from "../../utils/ssh-algorithms.js"; + +// An SSH peer advertising only DH group exchange. Stop after the client's +// plaintext GEX request, before expensive prime validation or authentication. +async function requestedGroupBits(): Promise { + const name = (value: string) => { + const data = Buffer.from(value); + const size = Buffer.alloc(4); + size.writeUInt32BE(data.length); + return Buffer.concat([size, data]); + }; + const payload = Buffer.concat([ + Buffer.from([20]), + Buffer.alloc(16), + ...[ + "diffie-hellman-group-exchange-sha256", + "rsa-sha2-256", + "aes256-gcm@openssh.com,aes128-gcm@openssh.com", + "aes256-gcm@openssh.com,aes128-gcm@openssh.com", + "hmac-sha2-256", + "hmac-sha2-256", + "none", + "none", + "", + "", + ].map(name), + Buffer.alloc(5), + ]); + const padding = 8 - ((payload.length + 5) % 8) + 8; + const header = Buffer.alloc(5); + header.writeUInt32BE(payload.length + padding + 1); + header[4] = padding; + const client = new Client(); + let peer: net.Socket | undefined; + let timer: ReturnType | undefined; + const server = net.createServer(); + try { + const result = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error("No SSH group request")), 3000); + client.on("error", reject); + server.on("connection", (socket) => { + peer = socket; + let buffer = Buffer.alloc(0); + let banner = false; + socket.write( + Buffer.concat([ + Buffer.from("SSH-2.0-OpenSSH_7.4\r\n"), + header, + payload, + Buffer.alloc(padding), + ]), + ); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!banner) { + const end = buffer.indexOf("\n"); + if (end < 0) return; + buffer = buffer.subarray(end + 1); + banner = true; + } + while (buffer.length >= 5) { + const length = buffer.readUInt32BE(0) + 4; + if (buffer.length < length) return; + if (buffer[5] === 34) resolve(buffer.readUInt32BE(10)); + buffer = buffer.subarray(length); + } + }); + }); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + client.connect({ + host: "127.0.0.1", + port: (server.address() as net.AddressInfo).port, + username: "test", + algorithms: buildSSHAlgorithms(false), + }); + return await result; + } finally { + clearTimeout(timer); + client.destroy(); + peer?.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe("SSH cipher preference", () => { + it("requests a 3072-bit group from a GEX-only peer offering both GCM ciphers", async () => { + expect(await requestedGroupBits()).toBe(3072); + }); + + it("still connects when a server only supports AES-256-GCM", async () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const server = new Server( + { + hostKeys: [privateKey.export({ type: "pkcs1", format: "pem" })], + algorithms: { cipher: ["aes256-gcm@openssh.com"] }, + }, + (peer) => { + peer.on("authentication", (auth) => auth.accept()); + }, + ); + const client = new Client(); + try { + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + await new Promise((resolve, reject) => { + client.once("ready", resolve).once("error", reject); + client.connect({ + host: "127.0.0.1", + port: (server.address() as net.AddressInfo).port, + username: "test", + readyTimeout: 2000, + algorithms: buildSSHAlgorithms(false), + }); + }); + } finally { + client.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("keeps legacy algorithms opt-in", () => { + expect(buildSSHAlgorithms(false).kex).not.toContain( + "diffie-hellman-group-exchange-sha1", + ); + expect(buildSSHAlgorithms(true).kex).toContain( + "diffie-hellman-group-exchange-sha1", + ); + }); +}); diff --git a/src/backend/utils/ssh-algorithms.ts b/src/backend/utils/ssh-algorithms.ts index 2e5d6a8f2..a88b0ba64 100644 --- a/src/backend/utils/ssh-algorithms.ts +++ b/src/backend/utils/ssh-algorithms.ts @@ -97,8 +97,10 @@ export function buildSSHAlgorithms( ]; const cipher = filterCiphers([ "chacha20-poly1305@openssh.com", - "aes256-gcm@openssh.com", + // ssh2 sizes DH group exchange from the cipher key length. Prefer + // AES-128-GCM to avoid 8192-bit prime validation on GEX-only hosts. "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", "aes256-ctr", "aes192-ctr", "aes128-ctr",