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:
11 files changed
+471
-10
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Generated
+13
@@ -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",
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user