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
This commit is contained in:
陈大猫 authored and GitHub committed 2026-10-27 01:59:11 +08:00
1 parent a4999fdf1d
commit 8d826594d3
11 files changed
+471 -10

No files matched your search

+9
View File
@@ -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
+10
View File
@@ -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
@@ -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<string, unknown> | undefined;
const ctx = createStarterContext({
host,
terminalBackend: {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
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<string, unknown> | null = null;
const terminalBackend = {
+2 -1
View File
@@ -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",
+7 -4
View File
@@ -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"]);
});
});
@@ -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);
});
+13
View File
@@ -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",
+1
View File
@@ -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",
+120 -5
View File
@@ -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');
+104
View File
@@ -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)}`);
});
+37
View File
@@ -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 });
}
}