mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb878675d6 | ||
|
|
8fd8f5580b | ||
|
|
4d03520999 | ||
|
|
ea228c3c2f | ||
|
|
c6fcab6680 |
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 = {
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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));
|
||||
});
|
||||
@@ -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