From 8d826594d39fa2fe233b6d0dd851dd8b1be5dd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:59:11 +0800 Subject: [PATCH] fix(ssh): support ChaCha20 in Electron crypto runtimes (#3992) * fix(ssh): support ChaCha20 in Electron crypto runtimes * test(ssh): verify packaged cipher transport across platforms * test(ssh): isolate unused Electron UI APIs in packaged transport harness * test(ssh): isolate packaged dependency resolution from the checkout --- .github/workflows/build.yml | 9 ++ .github/workflows/test.yml | 10 ++ .../createTerminalSessionStarters.test.ts | 28 ++++ electron/bridges/sshAlgorithms.cjs | 3 +- electron/bridges/sshAlgorithms.test.cjs | 11 +- electron/bridges/sshChaChaTransport.test.cjs | 140 ++++++++++++++++++ package-lock.json | 13 ++ package.json | 1 + patches/ssh2+1.17.0.patch | 125 +++++++++++++++- scripts/fixtures/ssh-chacha-client.cjs | 104 +++++++++++++ scripts/test-packaged-ssh-ciphers.cjs | 37 +++++ 11 files changed, 471 insertions(+), 10 deletions(-) create mode 100644 electron/bridges/sshChaChaTransport.test.cjs create mode 100644 scripts/fixtures/ssh-chacha-client.cjs create mode 100644 scripts/test-packaged-ssh-ciphers.cjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3526a86af..33ac2b53c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -330,6 +330,9 @@ jobs: APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }} run: npm run ${{ matrix.pack_script }} + - name: Test packaged SSH cipher negotiation + run: node scripts/test-packaged-ssh-ciphers.cjs + - name: Test tray panel layout run: npm run test:tray-panel-layout @@ -631,6 +634,9 @@ jobs: done npm run pack:linux-x64 + - name: Test packaged SSH cipher negotiation + run: node scripts/test-packaged-ssh-ciphers.cjs + - name: Verify packaged node-pty Linux runtime run: bash scripts/ensure-node-pty-linux.sh verify x64 @@ -765,6 +771,9 @@ jobs: ELECTRON_BUILDER_PUBLISH: "never" run: npm run pack:linux-arm64 + - name: Test packaged SSH cipher negotiation + run: node scripts/test-packaged-ssh-ciphers.cjs + - name: Verify packaged node-pty Linux runtime run: bash scripts/ensure-node-pty-linux.sh verify arm64 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7b97331b9..afe3ec763 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,6 +52,11 @@ jobs: - name: Test run: npm test + - name: Test SSH cipher negotiation in Electron's crypto runtime + env: + NETCATTY_SSH_TEST_RUNTIME: electron + run: node --test electron/bridges/sshChaChaTransport.test.cjs + - name: Test browser first-link lifecycle run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs @@ -138,5 +143,10 @@ jobs: - name: Build run: npm run build + - name: Test SSH cipher negotiation in Windows Electron's crypto runtime + env: + NETCATTY_SSH_TEST_RUNTIME: electron + run: node --test electron/bridges/sshChaChaTransport.test.cjs + - name: Exercise two SSH password prompts in the desktop app run: node scripts/terminal-broadcast-ssh.live.cjs diff --git a/components/terminal/runtime/createTerminalSessionStarters.test.ts b/components/terminal/runtime/createTerminalSessionStarters.test.ts index 014a9cad0..79b8493bc 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.test.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.test.ts @@ -357,6 +357,34 @@ test("startPluginConnection displays status diagnostics when a Provider exits wi ]); }); +test("startSSH forwards a saved ChaCha20-only host setting to the transport", async () => { + const host = JSON.parse(JSON.stringify({ + id: "host-chacha", + label: "Synthetic ChaCha20 server", + hostname: "127.0.0.1", + username: "synthetic", + algorithms: { cipher: ["chacha20-poly1305@openssh.com"] }, + })); + let captured: Record | undefined; + const ctx = createStarterContext({ + host, + terminalBackend: { + backendAvailable: () => true, + startSSHSession: async (options: Record) => { + captured = options; + return "ssh-session"; + }, + onSessionData: () => noop, + onSessionExit: () => noop, + onChainProgress: () => noop, + writeToSession: noop, + resizeSession: noop, + }, + }); + await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never); + assert.deepEqual(captured?.algorithmOverrides, host.algorithms); +}); + test("startSSH forwards imported system agent authentication settings", async () => { let capturedOptions: Record | null = null; const terminalBackend = { diff --git a/electron/bridges/sshAlgorithms.cjs b/electron/bridges/sshAlgorithms.cjs index d4d57adc1..8e6e34c96 100644 --- a/electron/bridges/sshAlgorithms.cjs +++ b/electron/bridges/sshAlgorithms.cjs @@ -9,7 +9,8 @@ const FIXED_DH_GROUP_BY_KEX = Object.freeze({ }); const CIPHER_SSL_NAME_BY_ALGORITHM = Object.freeze({ - "chacha20-poly1305@openssh.com": "chacha20", + // Patched ssh2 supplies original ChaCha20 when Electron/BoringSSL lacks it. + // Do not filter this SSH cipher against the OpenSSL-only inventory. "aes128-gcm@openssh.com": "aes-128-gcm", "aes256-gcm@openssh.com": "aes-256-gcm", "aes128-gcm": "aes-128-gcm", diff --git a/electron/bridges/sshAlgorithms.test.cjs b/electron/bridges/sshAlgorithms.test.cjs index 835d8d835..727bc8f14 100644 --- a/electron/bridges/sshAlgorithms.test.cjs +++ b/electron/bridges/sshAlgorithms.test.cjs @@ -144,17 +144,17 @@ for (const [label, buildAlgorithms] of [ }); }); - test(`${label} drops chacha20-poly1305 when the runtime lacks chacha20`, () => { + test(`${label} retains chacha20-poly1305 through ssh2's fallback when the runtime lacks chacha20`, () => { const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20"); withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => { const algorithms = buildAlgorithms(false); - assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), false); + assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), true); }); }); - test(`${label} cipher override is filtered against runtime support`, () => { + test(`${label} preserves explicit ChaCha20 and mixed cipher overrides without native crypto`, () => { const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20"); withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => { @@ -164,7 +164,10 @@ for (const [label, buildAlgorithms] of [ }, }); - assert.deepEqual(algorithms.cipher, ["aes256-ctr"]); + assert.deepEqual(algorithms.cipher, ["chacha20-poly1305@openssh.com", "aes256-ctr"]); + assert.deepEqual(buildAlgorithms(false, { + algorithmOverrides: { cipher: ["chacha20-poly1305@openssh.com"] }, + }).cipher, ["chacha20-poly1305@openssh.com"]); }); }); diff --git a/electron/bridges/sshChaChaTransport.test.cjs b/electron/bridges/sshChaChaTransport.test.cjs new file mode 100644 index 000000000..5faf09b9d --- /dev/null +++ b/electron/bridges/sshChaChaTransport.test.cjs @@ -0,0 +1,140 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const { Server } = require("ssh2"); + +const CHACHA = "chacha20-poly1305@openssh.com"; +const AES = "aes128-gcm@openssh.com"; +const root = path.resolve(__dirname, "../.."); +const { privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); +const hostKey = privateKey.export({ type: "pkcs1", format: "pem" }); + +async function connect(t, { + serverCiphers = [CHACHA], + cipher, + entrypoint = "netcatty:start", + withoutNativeChacha = true, +} = {}) { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-chacha-")); + t.after(() => fs.rmSync(homeDir, { recursive: true, force: true })); + const offered = {}; + let negotiated; + const connections = new Set(); + // The server uses Node/OpenSSL's ChaCha20; only the child uses the fallback. + const server = new Server({ + hostKeys: [hostKey], + algorithms: { cipher: serverCiphers }, + debug(line) { + const match = /Handshake: \(remote\) (C->S|S->C) cipher: (.*)/.exec(line); + if (match) offered[match[1]] = match[2].split(","); + }, + }, (client) => { + connections.add(client); + client.on("error", () => {}); + client.on("close", () => connections.delete(client)); + client.on("handshake", (info) => { negotiated = info; }); + client.on("authentication", (ctx) => ctx.accept()); + client.on("ready", () => { + client.on("session", (accept) => { + const session = accept(); + session.on("pty", (acceptPty) => acceptPty?.()); + session.on("env", (acceptEnv) => acceptEnv?.()); + session.on("shell", (acceptShell) => { + const stream = acceptShell(); + stream.on("data", (data) => stream.write(data)); + }); + }); + }); + }); + t.after(() => { + for (const client of connections) client.end(); + server.close(); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const runtime = process.env.NETCATTY_SSH_TEST_RUNTIME === "electron" + ? require("electron") + : path.resolve(process.env.NETCATTY_SSH_TEST_RUNTIME || process.execPath); + const child = spawn(runtime, ["scripts/fixtures/ssh-chacha-client.cjs"], { + cwd: root, + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }); + t.after(() => child.kill()); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data) => { stdout += data; }); + child.stderr.on("data", (data) => { stderr += data; }); + child.stdin.end(JSON.stringify({ + port: server.address().port, + appRoot: process.env.NETCATTY_SSH_TEST_APP_ROOT, + homeDir, + // Cross several SSH packets, exercising both nonce/counter directions. + input: "synthetic SSH echo\r\n".repeat(4000), + algorithmOverrides: cipher ? { cipher } : undefined, + entrypoint, + withoutNativeChacha, + })); + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + assert.equal(code, 0, stderr || stdout); + const resultLine = stdout.split("\n").find((line) => line.startsWith("NETCATTY_TRANSPORT_RESULT=")); + assert.ok(resultLine, stderr || stdout); + const result = JSON.parse(resultLine.slice("NETCATTY_TRANSPORT_RESULT=".length)); + if (process.env.NETCATTY_SSH_TEST_APP_ROOT && result.connected) { + assert.equal(result.packagedDependency, "node_modules/@noble/ciphers/chacha.js"); + } + return { ...result, offered, negotiated }; +} + +for (const entrypoint of ["netcatty:start", "netcatty:test-connection"]) { + test(`${entrypoint} advertises and negotiates an explicit ChaCha20-only override without native crypto`, { timeout: 15000 }, async (t) => { + const result = await connect(t, { cipher: [CHACHA], entrypoint }); + assert.equal(result.connected, true, result.error); + assert.equal(result.nativeChacha, false); + assert.deepEqual(result.offered["C->S"], [CHACHA]); + assert.deepEqual(result.offered["S->C"], [CHACHA]); + assert.equal(result.negotiated.cs.cipher, CHACHA); + assert.equal(result.negotiated.sc.cipher, CHACHA); + if (entrypoint === "netcatty:start") assert.equal(result.echoed, true); + }); +} + +test("default ciphers can connect to a ChaCha20-only server", async (t) => { + const result = await connect(t); + assert.equal(result.connected, true, result.error); + assert.equal(result.echoed, true); + assert.ok(result.offered["C->S"].includes(CHACHA)); + assert.equal(result.negotiated.cs.cipher, CHACHA); +}); + +test("default and mixed offers retain AES preference and explicit cipher order", async (t) => { + for (const cipher of [undefined, [AES, CHACHA], [CHACHA, AES]]) { + const result = await connect(t, { serverCiphers: [AES, CHACHA], cipher }); + assert.equal(result.connected, true, result.error); + assert.equal(result.echoed, true); + assert.equal(result.negotiated.cs.cipher, cipher?.[0] || AES); + if (cipher) assert.deepEqual(result.offered["C->S"], cipher); + } +}); + +test("an explicit AES-only choice never falls back to ChaCha20", async (t) => { + const result = await connect(t, { cipher: [AES] }); + assert.equal(result.connected, false); + assert.match(result.error, /no matching C->S cipher/); + assert.deepEqual(result.offered["C->S"], [AES]); +}); + +test("the runtime's unmodified crypto path still supports ChaCha20", async (t) => { + const result = await connect(t, { cipher: [CHACHA], withoutNativeChacha: false }); + assert.equal(result.connected, true, result.error); + assert.equal(result.echoed, true); +}); diff --git a/package-lock.json b/package-lock.json index 61fee24eb..633701da9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@netcatty/plugin-cli": "0.1.0-internal", "@netcatty/plugin-contract": "0.1.0-internal", "@netcatty/plugin-sdk": "0.1.0-internal", + "@noble/ciphers": "2.4.0", "@openai/codex-sdk": "0.144.3", "@smithy/node-http-handler": "^4.4.9", "@withfig/autocomplete": "^2.692.3", @@ -4683,6 +4684,18 @@ "resolved": "packages/plugin-sdk", "link": true }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", diff --git a/package.json b/package.json index 1af4aa4ed..96cd7b7f1 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@netcatty/plugin-cli": "0.1.0-internal", "@netcatty/plugin-contract": "0.1.0-internal", "@netcatty/plugin-sdk": "0.1.0-internal", + "@noble/ciphers": "2.4.0", "@openai/codex-sdk": "0.144.3", "@smithy/node-http-handler": "^4.4.9", "@withfig/autocomplete": "^2.692.3", diff --git a/patches/ssh2+1.17.0.patch b/patches/ssh2+1.17.0.patch index b4732f133..6d62d716b 100644 --- a/patches/ssh2+1.17.0.patch +++ b/patches/ssh2+1.17.0.patch @@ -860,10 +860,10 @@ index 9f33c02..9751164 100644 if (names !== undefined) { sftp._debug && sftp._debug( diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js -index ad77592..a249e00 100644 +index ad77592..03ab33b 100644 --- a/node_modules/ssh2/lib/protocol/constants.js +++ b/node_modules/ssh2/lib/protocol/constants.js -@@ -71,9 +71,30 @@ const DEFAULT_SERVER_HOST_KEY = [ +@@ -71,14 +71,37 @@ const DEFAULT_SERVER_HOST_KEY = [ ]; if (eddsaSupported) DEFAULT_SERVER_HOST_KEY.unshift('ssh-ed25519'); @@ -894,7 +894,15 @@ index ad77592..a249e00 100644 const canUseCipher = (() => { -@@ -160,6 +181,7 @@ const COMPAT = { + const ciphers = crypto.getCiphers(); +- return (name) => ciphers.includes(CIPHER_INFO[name].sslName); ++ // crypto.js supplies the original ChaCha20 primitive on BoringSSL too. ++ return (name) => (name === 'chacha20-poly1305@openssh.com' ++ || ciphers.includes(CIPHER_INFO[name].sslName)); + })(); + let DEFAULT_CIPHER = [ + // http://tools.ietf.org/html/rfc5647 +@@ -160,6 +183,7 @@ const COMPAT = { DYN_RPORT_BUG: 1 << 2, BUG_DHGEX_LARGE: 1 << 3, IMPLY_RSA_SHA2_SIGALGS: 1 << 4, @@ -902,7 +910,7 @@ index ad77592..a249e00 100644 }; module.exports = { -@@ -330,6 +352,7 @@ module.exports = { +@@ -330,6 +354,7 @@ module.exports = { COMPAT_CHECKS: [ [ 'Cisco-1.25', COMPAT.BAD_DHGEX ], [ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ], @@ -910,6 +918,113 @@ index ad77592..a249e00 100644 [ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations [ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ], [ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ], +diff --git a/node_modules/ssh2/lib/protocol/crypto.js b/node_modules/ssh2/lib/protocol/crypto.js +index 851cc73..088de2a 100644 +--- a/node_modules/ssh2/lib/protocol/crypto.js ++++ b/node_modules/ssh2/lib/protocol/crypto.js +@@ -11,6 +11,7 @@ const { + } = require('crypto'); + + const { readUInt32BE, writeUInt32BE } = require('./utils.js'); ++const { chacha20, nativeChacha20 } = require('./crypto/chacha20.js'); + + const FastBuffer = Buffer[Symbol.species]; + const MAX_SEQNO = 2 ** 32 - 1; +@@ -198,20 +199,17 @@ class ChaChaPolyCipherNative { + POLY1305_OUT_COMPUTE[0] = 0; // Set counter to 0 (little endian) + writeUInt32BE(POLY1305_OUT_COMPUTE, this.outSeqno, 12); + const polyKey = +- createCipheriv('chacha20', this._encKeyMain, POLY1305_OUT_COMPUTE) +- .update(POLY1305_ZEROS); ++ chacha20(this._encKeyMain, POLY1305_OUT_COMPUTE, POLY1305_ZEROS); + + // Encrypt packet length + const pktLenEnc = +- createCipheriv('chacha20', this._encKeyPktLen, POLY1305_OUT_COMPUTE) +- .update(packet.slice(0, 4)); ++ chacha20(this._encKeyPktLen, POLY1305_OUT_COMPUTE, packet.slice(0, 4)); + this._onWrite(pktLenEnc); + + // Encrypt rest of packet + POLY1305_OUT_COMPUTE[0] = 1; // Set counter to 1 (little endian) + const payloadEnc = +- createCipheriv('chacha20', this._encKeyMain, POLY1305_OUT_COMPUTE) +- .update(packet.slice(4)); ++ chacha20(this._encKeyMain, POLY1305_OUT_COMPUTE, packet.slice(4)); + this._onWrite(payloadEnc); + + // Calculate Poly1305 MAC +@@ -650,8 +648,7 @@ class ChaChaPolyDecipherNative { + writeUInt32BE(POLY1305_OUT_COMPUTE, this.inSeqno, 12); + + const decLenBytes = +- createDecipheriv('chacha20', this._decKeyPktLen, POLY1305_OUT_COMPUTE) +- .update(this._lenBuf); ++ chacha20(this._decKeyPktLen, POLY1305_OUT_COMPUTE, this._lenBuf); + this._len = readUInt32BE(decLenBytes, 0); + + if (this._len > MAX_PACKET_SIZE +@@ -706,8 +703,7 @@ class ChaChaPolyDecipherNative { + POLY1305_OUT_COMPUTE[0] = 0; // Set counter to 0 (little endian) + writeUInt32BE(POLY1305_OUT_COMPUTE, this.inSeqno, 12); + const polyKey = +- createCipheriv('chacha20', this._decKeyMain, POLY1305_OUT_COMPUTE) +- .update(POLY1305_ZEROS); ++ chacha20(this._decKeyMain, POLY1305_OUT_COMPUTE, POLY1305_ZEROS); + + // Calculate and compare Poly1305 MACs + poly1305_auth(POLY1305_RESULT_MALLOC, +@@ -729,8 +725,7 @@ class ChaChaPolyDecipherNative { + // Decrypt packet + POLY1305_OUT_COMPUTE[0] = 1; // Set counter to 1 (little endian) + const packet = +- createDecipheriv('chacha20', this._decKeyMain, POLY1305_OUT_COMPUTE) +- .update(this._packet); ++ chacha20(this._decKeyMain, POLY1305_OUT_COMPUTE, this._packet); + + const payload = new FastBuffer(packet.buffer, + packet.byteOffset + 1, +@@ -1496,7 +1491,7 @@ function createCipher(config) { + ? new AESGCMCipherBinding(config) + : new AESGCMCipherNative(config)); + case 'chacha20': +- return (ChaChaPolyCipher && !forceNative ++ return (ChaChaPolyCipher && nativeChacha20 && !forceNative + ? new ChaChaPolyCipherBinding(config) + : new ChaChaPolyCipherNative(config)); + default: { +@@ -1556,7 +1551,7 @@ function createDecipher(config) { + ? new AESGCMDecipherBinding(config) + : new AESGCMDecipherNative(config)); + case 'chacha20': +- return (ChaChaPolyDecipher && !forceNative ++ return (ChaChaPolyDecipher && nativeChacha20 && !forceNative + ? new ChaChaPolyDecipherBinding(config) + : new ChaChaPolyDecipherNative(config)); + default: { +diff --git a/node_modules/ssh2/lib/protocol/crypto/chacha20.js b/node_modules/ssh2/lib/protocol/crypto/chacha20.js +new file mode 100644 +index 0000000..86dd731 +--- /dev/null ++++ b/node_modules/ssh2/lib/protocol/crypto/chacha20.js +@@ -0,0 +1,17 @@ ++// OpenSSH uses original ChaCha20 (64-bit nonce), not the IETF/TLS AEAD. ++// Electron's BoringSSL lacks the standalone cipher, including in sshcrypto. ++'use strict'; ++const { createCipheriv, getCiphers } = require('crypto'); ++const nativeChacha20 = getCiphers().includes('chacha20'); ++const { chacha20orig } = nativeChacha20 ? {} : require('@noble/ciphers/chacha.js'); ++ ++function chacha20(key, iv, data) { ++ if (nativeChacha20) ++ return createCipheriv('chacha20', key, iv).update(data); ++ // ssh2 only uses block counters 0 (length/Poly1305 key) and 1 (payload). ++ // The last eight IV bytes are its big-endian SSH packet sequence nonce. ++ return Buffer.from(chacha20orig(key, iv.subarray(8), data, undefined, ++ iv.readUInt32LE(0))); ++} ++ ++module.exports = { chacha20, nativeChacha20 }; diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js index 811e631..422a198 100644 --- a/node_modules/ssh2/lib/protocol/kex.js @@ -1127,7 +1242,7 @@ index 811e631..422a198 100644 onKEXPayload, DEFAULT_KEXINIT_CLIENT: new KexInit({ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js -index a276c1a..c8ff9d1 100644 +index a276c1a..57a05d6 100644 --- a/node_modules/ssh2/lib/protocol/keyParser.js +++ b/node_modules/ssh2/lib/protocol/keyParser.js @@ -35,6 +35,9 @@ const SYM_PRIV_PEM = Symbol('Private key PEM'); diff --git a/scripts/fixtures/ssh-chacha-client.cjs b/scripts/fixtures/ssh-chacha-client.cjs new file mode 100644 index 000000000..bb7e8d0db --- /dev/null +++ b/scripts/fixtures/ssh-chacha-client.cjs @@ -0,0 +1,104 @@ +// Runs in a separate Node/Electron process so ssh2 detects its actual crypto +// capabilities at module load. The parent owns the disposable loopback server. +const os = require("node:os"); +const path = require("node:path"); +const crypto = require("node:crypto"); + +async function main(config) { + os.homedir = () => config.homeDir; + delete process.env.SSH_AUTH_SOCK; + delete process.env.SSH_AGENT_PID; + if (config.withoutNativeChacha) { + const getCiphers = crypto.getCiphers; + crypto.getCiphers = () => getCiphers().filter((name) => name !== "chacha20"); + for (const name of ["createCipheriv", "createDecipheriv"]) { + const original = crypto[name]; + crypto[name] = (algorithm, ...args) => { + if (algorithm === "chacha20") throw new Error("Standalone ChaCha20 unavailable"); + return original(algorithm, ...args); + }; + } + } + let packagedDependency; + if (config.appRoot) { + config.appRoot = require("node:fs").realpathSync(config.appRoot); + // RUN_AS_NODE supports ASAR files, but does not expose Electron's main + // process APIs. The packaged bridge imports sessionLogsBridge's dialog/shell + // even with logging disabled. Supply only that unrelated UI host seam; + // ssh2, crypto, algorithm selection, and sockets remain the shipped code. + const Module = require("node:module"); + const load = Module._load; + Module._load = function loadWithHeadlessElectron(request, ...args) { + if (request === "electron") return { dialog: {}, shell: {} }; + return load.call(this, request, ...args); + }; + const packagedRequire = Module.createRequire(path.join(config.appRoot, "node_modules/ssh2/lib/protocol/crypto/chacha20.js")); + const noblePath = packagedRequire.resolve("@noble/ciphers/chacha.js"); + if (!noblePath.startsWith(`${config.appRoot}${path.sep}`)) { + throw new Error("ChaCha20 dependency resolved outside the packaged app"); + } + packagedDependency = path.relative(config.appRoot, noblePath).replaceAll("\\", "/"); + } + const bridge = require(path.join(config.appRoot || path.resolve(__dirname, "../.."), "electron/bridges/sshBridge.cjs")); + const sessions = new Map(); + let output = ""; + let resolveOutput; + const echoed = new Promise((resolve) => { resolveOutput = resolve; }); + bridge.init({ + sessions, + electronModule: {}, + terminalOutputChannel: { + openSession() {}, + send(_sessionId, data) { + output += data; + if (output.length >= config.input.length) resolveOutput(); + return true; + }, + }, + }); + const handlers = new Map(); + bridge.registerHandlers({ handle: (name, handler) => handlers.set(name, handler), on() {} }); + const sender = new (require("node:events").EventEmitter)(); + Object.assign(sender, { id: 1, isDestroyed: () => false, send() {} }); + try { + const result = await handlers.get(config.entrypoint)({ sender }, { + sessionId: "synthetic-chacha-test", + hostname: "127.0.0.1", + port: config.port, + username: "synthetic", + authMethod: "password", + useSshAgent: false, + verifyHostKeys: false, + skipShellDiscovery: true, + algorithmOverrides: config.algorithmOverrides, + }); + if (config.entrypoint === "netcatty:start") { + sessions.get(result.sessionId).stream.write(config.input); + await echoed; + } + return { + connected: true, + echoed: output === config.input, + electron: process.versions.electron, + nativeChacha: crypto.getCiphers().includes("chacha20"), + packagedDependency, + }; + } finally { + for (const session of sessions.values()) session.conn?.destroy(); + bridge.stopAllMonitoring(); + } +} + +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (data) => { input += data; }); +process.stdin.on("end", async () => { + const config = JSON.parse(input); + const timeout = setTimeout(() => process.exit(2), 10000); + let result; + try { result = await main(config); } + catch (error) { result = { connected: false, error: error.message }; } + clearTimeout(timeout); + // Only public algorithm/runtime information leaves the synthetic client. + console.log(`NETCATTY_TRANSPORT_RESULT=${JSON.stringify(result)}`); +}); diff --git a/scripts/test-packaged-ssh-ciphers.cjs b/scripts/test-packaged-ssh-ciphers.cjs new file mode 100644 index 000000000..f0387515c --- /dev/null +++ b/scripts/test-packaged-ssh-ciphers.cjs @@ -0,0 +1,37 @@ +// Exercise shipped JS/dependencies with the same Electron runtime, headlessly. +// No BrowserWindow, real accounts, or external SSH hosts are involved. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const releaseDir = path.resolve(process.argv[2] || "release"); +const archives = fs.readdirSync(releaseDir, { recursive: true }) + .filter((entry) => path.basename(entry) === "app.asar") + .map((entry) => path.join(releaseDir, entry)); +if (archives.length === 0) throw new Error("No packaged app.asar found"); +for (const appRoot of archives) { + // Outside the checkout, a missing packaged dependency cannot resolve from + // the checkout's node_modules. Copy the paired unpacked payload as shipped. + const detachedDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-packaged-ssh-")); + try { + const detachedRoot = path.join(detachedDir, "app.asar"); + fs.copyFileSync(appRoot, detachedRoot); + fs.cpSync(`${appRoot}.unpacked`, `${detachedRoot}.unpacked`, { recursive: true }); + const env = { + ...process.env, + NETCATTY_SSH_TEST_APP_ROOT: detachedRoot, + NETCATTY_SSH_TEST_RUNTIME: require("electron"), + }; + delete env.NODE_PATH; + console.log(`Testing detached packaged SSH transport: ${appRoot}`); + const result = spawnSync(process.execPath, ["--test", "electron/bridges/sshChaChaTransport.test.cjs"], { + env, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`Packaged SSH transport tests failed (${result.status})`); + } finally { + fs.rmSync(detachedDir, { recursive: true, force: true }); + } +}