Compare commits

...
13 changed files with 632 additions and 10 deletions

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 = {
+78
View File
@@ -0,0 +1,78 @@
# Verify the saved ChaCha20-only setting in the native app
Keep the application checkout at `8fd8f5580ba13ec2c308f2bd327b9db638493028`.
This support branch supplies a disposable loopback SSH echo server. The server
uses the checkout's installed `ssh2` package and Node/OpenSSL. It accepts one
locally generated synthetic identity and advertises exactly
`chacha20-poly1305@openssh.com` in both directions. It executes no OS commands.
## Start the fixture on the GUI machine
Use a shell with Node.js 22 or newer. Set `APP_CHECKOUT` to the existing app
checkout that already has its dependencies installed. Fetching and extracting
the support files does not change the app's checked-out revision.
```sh
set -eu
APP_CHECKOUT=/path/to/Netcatty
test "$(git -C "$APP_CHECKOUT" rev-parse HEAD)" = 8fd8f5580ba13ec2c308f2bd327b9db638493028
git -C "$APP_CHECKOUT" fetch origin support/3984-chacha-gui-fixture
FIXTURE_REF=$(git -C "$APP_CHECKOUT" rev-parse FETCH_HEAD)
FIXTURE_DIR=$(mktemp -d /tmp/netcatty-3984-gui.XXXXXX)
git -C "$APP_CHECKOUT" show "$FIXTURE_REF:scripts/fixtures/ssh-chacha-gui-server.cjs" > "$FIXTURE_DIR/server.cjs"
node - "$FIXTURE_DIR/credentials.json" <<'JS'
const fs = require('node:fs');
const crypto = require('node:crypto');
fs.writeFileSync(process.argv[2], JSON.stringify({
username: 'smoke-' + crypto.randomBytes(6).toString('hex'),
password: crypto.randomBytes(24).toString('hex'),
}), { mode: 0o600, flag: 'wx' });
JS
node "$FIXTURE_DIR/server.cjs" "$APP_CHECKOUT" "$FIXTURE_DIR/credentials.json" > "$FIXTURE_DIR/evidence.jsonl" 2>&1 &
FIXTURE_PID=$!
```
Read the `listening` event in `evidence.jsonl` for the assigned loopback port.
Read the generated credentials file locally to fill the app's authentication
fields. Keep that file and the authentication controls out of reports,
screenshots, and recordings. The server never logs credential values or raw
terminal input.
## Exercise the saved setting
1. Create a disposable SSH host for `127.0.0.1` and the emitted port.
2. Use the generated local identity with password authentication. Use the
network-device host setting to avoid OS discovery and stats probes against
this echo-only server.
3. Open advanced SSH algorithms. Select only
`chacha20-poly1305@openssh.com` under Cipher.
4. Save the host. Close and reopen its editor. Confirm that Cipher still has
only that selection. Capture the cipher section without authentication fields.
5. Connect to the host and accept the disposable host key.
6. Wait for `SYNTHETIC CHACHA20 SMOKE READY` in the terminal.
7. Type `NETCATTY_CHACHA_SMOKE` and press Enter. Capture the connected terminal
and echoed marker. Start any recording after the credentials are filled.
## Check the sanitized evidence
The JSONL log must contain both `client-offer` events with exactly one entry:
`chacha20-poly1305@openssh.com`. These are parsed from the client's actual
KEXINIT, rather than from Netcatty's configuration helper.
The `negotiated` event must name that cipher for both `clientToServer` and
`serverToClient`. The same connection must then report `authenticated`,
`shell-open`, and an `echo-check` event with `matched: true`.
This is synthetic SSH transport and saved-setting coverage. It does not test
the reporter's exact OpenWrt/Dropbear device or the separate post-handshake
session-close behavior in #3731. The earlier reporter PCAP predates the final
saved-setting attempt and is not evidence for that final attempt.
## Stop and remove the fixture
```sh
kill "$FIXTURE_PID"
```
Preserve only the sanitized JSONL log and selected screenshots/video. Delete
the credentials file and disposable host entry after the smoke test.
+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)}`);
});
@@ -0,0 +1,83 @@
// Disposable GUI smoke fixture. No OS account or command execution is used.
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { createRequire } = require("node:module");
const [appCheckout, credentialsPath] = process.argv.slice(2);
if (!appCheckout || !credentialsPath) {
throw new Error("Usage: node ssh-chacha-gui-server.cjs APP_CHECKOUT CREDENTIALS_JSON");
}
const credentials = JSON.parse(fs.readFileSync(credentialsPath, "utf8"));
if (typeof credentials.username !== "string" || !credentials.username
|| typeof credentials.password !== "string" || !credentials.password) {
throw new Error("The local fixture credentials file needs non-empty username and password fields");
}
const fromApp = createRequire(path.join(path.resolve(appCheckout), "package.json"));
const { Server } = fromApp("ssh2");
const CHACHA = "chacha20-poly1305@openssh.com";
const MARKER = "NETCATTY_CHACHA_SMOKE";
const { privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const clients = new Set();
let nextConnectionId = 0;
const log = (event) => console.log(JSON.stringify(event));
const server = new Server({
hostKeys: [privateKey.export({ type: "pkcs1", format: "pem" })],
algorithms: { cipher: [CHACHA] },
debug(line) {
const offer = /Handshake: \(remote\) (C->S|S->C) cipher: (.*)/.exec(line);
if (offer) log({ event: "client-offer", direction: offer[1], ciphers: offer[2].split(",") });
},
}, (client) => {
const connectionId = ++nextConnectionId;
clients.add(client);
client.on("close", () => clients.delete(client));
client.on("error", () => log({ event: "transport-error", connectionId }));
client.on("handshake", (info) => log({
event: "negotiated", connectionId,
clientToServer: info.cs.cipher, serverToClient: info.sc.cipher,
}));
client.on("authentication", (ctx) => {
if (ctx.method === "password" && ctx.username === credentials.username
&& ctx.password === credentials.password) {
log({ event: "authenticated", connectionId, method: "password" });
ctx.accept();
} else {
ctx.reject(["password"]);
}
});
client.on("ready", () => {
client.on("session", (accept) => {
const session = accept();
session.on("pty", (acceptPty) => acceptPty?.());
session.on("env", (acceptEnv) => acceptEnv?.());
session.on("exec", (_acceptExec, rejectExec) => rejectExec());
session.on("shell", (acceptShell) => {
const stream = acceptShell();
let tail = "";
let markerSeen = false;
log({ event: "shell-open", connectionId });
stream.write("SYNTHETIC CHACHA20 SMOKE READY\r\n");
stream.on("data", (data) => {
stream.write(data);
const text = tail + data.toString("utf8");
if (!markerSeen && text.includes(MARKER)) {
markerSeen = true;
log({ event: "echo-check", connectionId, marker: MARKER, matched: true });
}
tail = text.slice(-(MARKER.length - 1));
});
});
});
});
});
server.listen(0, "127.0.0.1", () => log({
event: "listening", address: "127.0.0.1", port: server.address().port,
serverCiphers: [CHACHA],
}));
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => {
for (const client of clients) client.end();
server.close(() => process.exit(0));
});
+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 });
}
}