Files
NetMesh/electron/bridges/sshBridge.authRetryExit.test.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

2038 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { EventEmitter } = require("node:events");
const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const {
beginTransportDial,
buildConnectionReuseEndpoint,
getTransportStats,
resetSshTransportRegistryForTests,
waitForTransportDial,
} = require("./sshConnectionPool.cjs");
const TEST_PRIVATE_KEY = crypto.generateKeyPairSync("ec", {
namedCurve: "prime256v1",
privateKeyEncoding: { type: "sec1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
test.beforeEach(() => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
});
test.afterEach(() => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
});
function makeSender(events = null) {
return {
id: 1,
isDestroyed: () => false,
sent: [],
send(channel, payload) {
events?.push(`send:${channel}`);
this.sent.push({ channel, payload });
},
};
}
function makeIpcMain() {
return {
handlers: new Map(),
handle(channel, handler) {
this.handlers.set(channel, handler);
},
on() {},
};
}
function createShellStream() {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.write = () => true;
stream.end = () => {};
stream.destroy = () => {};
stream.setWindow = () => {};
return stream;
}
function nextTick() {
return new Promise((resolve) => setImmediate(resolve));
}
function makeReusableSourceSession(endpoint) {
const { createConnectionRef } = require("./sshConnectionPool.cjs");
const conn = new EventEmitter();
conn._sock = { destroyed: false };
conn._remoteVer = "OpenSSH_test";
conn.shell = () => { throw new Error("Not connected"); };
conn.end = () => {};
conn.destroy = () => {};
const stream = createShellStream();
const session = {
conn,
stream,
chainConnections: [],
webContentsId: 1,
zmodemSentry: { cancel() {} },
hostname: endpoint.hostname,
username: endpoint.username,
_reuseEndpoint: {
hostname: endpoint.hostname,
port: endpoint.port || 22,
username: endpoint.username,
},
};
createConnectionRef(session, conn, []);
return session;
}
function loadBridgeWithAuthRetryMocks(t, options = {}) {
const bridgePath = require.resolve("./sshBridge.cjs");
const startSessionPath = require.resolve("./sshBridge/startSession.cjs");
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
const originalLoad = Module._load;
const originalAuthHelper = require(authHelperPath);
const connectEvents = options.connectEvents || ["auth-error", "ready"];
const originalHome = process.env.HOME;
const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-auth-retry-home-"));
fs.mkdirSync(path.join(isolatedHome, ".ssh"));
process.env.HOME = isolatedHome;
class MockSSHClient extends EventEmitter {
constructor() {
super();
MockSSHClient.instances.push(this);
this._remoteVer = "OpenSSH_test";
this._sock = {
setTimeout() {},
setNoDelay() {},
};
}
connect(opts) {
this.connectOpts = opts;
const eventName = connectEvents[MockSSHClient.connectCount++] || "auth-error";
setImmediate(() => {
if (
eventName === "repeated-keyboard-interactive" ||
eventName === "excessive-keyboard-interactive" ||
eventName === "password-and-keyboard-interactive" ||
eventName === "password-then-keyboard-interactive" ||
eventName === "mfa-keyboard-interactive-before-password" ||
eventName === "agent-password-removes-keyboard-interactive" ||
eventName === "agent-then-keyboard-interactive-after-skip-password" ||
eventName === "agent-then-mfa-keyboard-interactive-before-password" ||
eventName === "publickey-then-password-and-keyboard-interactive" ||
eventName === "agent-then-password-and-keyboard-interactive"
) {
this.authMethodsOffered = [];
this.keyboardInteractiveResponses = [];
this.emit("connect");
this.emit("handshake");
const offerNext = (methodsLeft, partialSuccess) => {
let offered;
opts.authHandler(methodsLeft, partialSuccess, (method) => {
offered = method;
this.authMethodsOffered.push(method);
});
return offered;
};
offerNext(null, null);
const firstMethods = eventName === "password-and-keyboard-interactive"
? ["publickey", "password", "keyboard-interactive"]
: eventName === "password-then-keyboard-interactive"
? ["password"]
: eventName === "mfa-keyboard-interactive-before-password"
? ["publickey", "password", "keyboard-interactive"]
: eventName === "agent-password-removes-keyboard-interactive" ||
eventName === "agent-then-keyboard-interactive-after-skip-password"
? ["publickey", "password", "keyboard-interactive"]
: eventName === "agent-then-mfa-keyboard-interactive-before-password"
? ["agent", "password", "keyboard-interactive"]
: eventName === "publickey-then-password-and-keyboard-interactive"
? ["publickey"]
: eventName === "agent-then-password-and-keyboard-interactive"
? ["agent"]
: ["keyboard-interactive"];
const firstInteractive = offerNext(firstMethods, false);
if (eventName === "agent-password-removes-keyboard-interactive") {
if (firstInteractive !== "agent") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
const password = offerNext(firstMethods, false);
if (password !== "password") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
offerNext(["publickey"], false);
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
if (eventName === "agent-then-keyboard-interactive-after-skip-password") {
if (firstInteractive !== "agent") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
const firstKeyboardInteractive = offerNext(firstMethods, false);
if (firstKeyboardInteractive !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Login authentication",
"",
"",
[{ prompt: "Password:", echo: false }],
(responses) => {
this.keyboardInteractiveResponses.push(responses);
const secondKeyboardInteractive = offerNext(["keyboard-interactive"], true);
if (secondKeyboardInteractive !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Keyboard-interactive authentication prompts from server",
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
(secondResponses) => {
this.keyboardInteractiveResponses.push(secondResponses);
this.emit("ready");
},
);
},
);
return;
}
if (eventName === "agent-then-mfa-keyboard-interactive-before-password") {
if (firstInteractive !== "agent") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
const fallbackInteractive = offerNext(["password", "keyboard-interactive"], false);
if (fallbackInteractive !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Login authentication",
"",
"",
[{ prompt: "Password:", echo: false }],
(responses) => {
this.keyboardInteractiveResponses.push(responses);
this.emit("ready");
},
);
return;
}
if (eventName === "mfa-keyboard-interactive-before-password") {
if (opts._skipPasswordMethod) {
if (firstInteractive !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Login authentication",
"",
"",
[{ prompt: "Password:", echo: false }],
(responses) => {
this.keyboardInteractiveResponses.push(responses);
const secondInteractive = offerNext(["keyboard-interactive"], true);
if (secondInteractive !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Keyboard-interactive authentication prompts from server",
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
(secondResponses) => {
this.keyboardInteractiveResponses.push(secondResponses);
this.emit("ready");
},
);
},
);
return;
}
if (firstInteractive?.type === "password") {
// Mirrors the live EDR server: after a rejected password attempt,
// keyboard-interactive disappears from methodsLeft.
offerNext(["publickey"], false);
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
if (firstInteractive !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
}
if (eventName === "password-and-keyboard-interactive") {
// Password-first: login password method, then KI for secondary factor.
if (firstInteractive?.type !== "password") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
const secondFactor = offerNext(["password", "keyboard-interactive"], true);
if (secondFactor !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Keyboard-interactive authentication prompts from server",
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
(responses) => {
this.keyboardInteractiveResponses.push(responses);
this.emit("ready");
},
);
return;
}
if (eventName === "publickey-then-password-and-keyboard-interactive") {
if (firstInteractive?.type !== "publickey") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
const secondFactor = offerNext(["password", "keyboard-interactive"], true);
if (secondFactor !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Keyboard-interactive authentication prompts from server",
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
(responses) => {
this.keyboardInteractiveResponses.push(responses);
this.emit("ready");
},
);
return;
}
if (eventName === "agent-then-password-and-keyboard-interactive") {
if (firstInteractive !== "agent") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
const secondFactor = offerNext(["password", "keyboard-interactive"], true);
if (secondFactor !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Keyboard-interactive authentication prompts from server",
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
(responses) => {
this.keyboardInteractiveResponses.push(responses);
this.emit("ready");
},
);
return;
}
if (eventName === "password-then-keyboard-interactive") {
if (firstInteractive?.type !== "password") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
const fallbackInteractive = offerNext(["keyboard-interactive"], false);
if (fallbackInteractive !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Keyboard-interactive authentication prompts from server",
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
(responses) => {
this.keyboardInteractiveResponses.push(responses);
this.emit("ready");
},
);
return;
}
if (firstInteractive !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Login authentication",
"",
"",
[{ prompt: "Password:", echo: false }],
(responses) => {
this.keyboardInteractiveResponses.push(responses);
const secondInteractive = offerNext(["keyboard-interactive"], true);
if (secondInteractive !== "keyboard-interactive") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit(
"keyboard-interactive",
"Keyboard-interactive authentication prompts from server",
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
(secondResponses) => {
this.keyboardInteractiveResponses.push(secondResponses);
if (eventName === "excessive-keyboard-interactive") {
const thirdInteractive = offerNext(["keyboard-interactive"], true);
const err = new Error(
thirdInteractive === false
? "All configured authentication methods failed"
: "Repeated keyboard-interactive limit was not enforced",
);
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit("ready");
},
);
},
);
return;
}
if (eventName === "auth-error") {
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.emit("error", err);
return;
}
if (eventName === "encrypted-key-ready") {
this.authMethodsOffered = [];
this.emit("connect");
this.emit("handshake");
const offerNext = (methodsLeft, partialSuccess) => {
let offered;
opts.authHandler(methodsLeft, partialSuccess, (method) => {
offered = method;
this.authMethodsOffered.push(method);
});
return offered;
};
offerNext(null, null);
const password = offerNext(["publickey", "password", "keyboard-interactive"], false);
const unlockedKey = offerNext(["publickey"], false);
if (password?.type !== "password" || unlockedKey?.key !== "UNLOCKED_PRIVATE_KEY") {
const err = new Error("Unlocked encrypted key was not offered after password rejection");
err.level = "client-authentication";
this.emit("error", err);
return;
}
this.emit("ready");
return;
}
if (eventName === "socket-error") {
const err = new Error("Connection reset by auth-bastion.example.com");
err.level = "client-socket";
this.emit("error", err);
return;
}
if (eventName === "permission-denied-socket-error") {
const err = new Error("Permission denied opening channel to auth-bastion.example.com");
err.level = "client-socket";
this.emit("error", err);
return;
}
if (eventName === "too-many-auth") {
const err = new Error("Too many authentication failures");
this.emit("error", err);
return;
}
if (eventName === "ready") {
this.emit("connect");
this.emit("handshake");
this.emit("ready");
}
});
}
forwardOut(_srcHost, _srcPort, _dstHost, _dstPort, cb) {
setImmediate(() => cb(null, new EventEmitter()));
}
shell(_pty, shellOptions, cb) {
this.shellOptions = shellOptions;
setImmediate(() => cb(null, createShellStream()));
}
end() {
this.ended = true;
}
destroy() {
this.destroyed = true;
}
}
MockSSHClient.instances = [];
MockSSHClient.connectCount = 0;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2") {
return {
Client: MockSSHClient,
utils: { parseKey: () => options.parseKeyResult || new Error("no key parse needed") },
};
}
if (request === "./netcattyAgent.cjs" || request.endsWith("/netcattyAgent.cjs")) {
return { NetcattyAgent: class MockNetcattyAgent {} };
}
if (request === "./sshAuthHelper.cjs" || request.endsWith("/sshAuthHelper.cjs")) {
return {
...originalAuthHelper,
findAllDefaultPrivateKeys: async (args = {}) => {
if (args.includeEncrypted) {
return options.encryptedKeys || [];
}
return options.defaultKeys || [];
},
requestPassphrasesForEncryptedKeys: async () => (
options.onPassphraseRequest?.(),
options.passphraseResult || { cancelled: false, keys: [] }
),
};
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
delete require.cache[startSessionPath];
const bridge = require("./sshBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
delete require.cache[startSessionPath];
Module._load = originalLoad;
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(isolatedHome, { recursive: true, force: true });
});
return { bridge, MockSSHClient };
}
function createParsedPrivateKey() {
return {
isPrivateKey: () => true,
getPrivatePEM: () => TEST_PRIVATE_KEY,
};
}
test("terminal SSH supports consecutive keyboard-interactive factors (#2150)", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["repeated-keyboard-interactive"],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "repeated-ki-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "password",
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "repeated-ki-session" });
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered,
["none", "keyboard-interactive", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[0].keyboardInteractiveResponses,
[["login-password"], ["secondary-password"]],
);
const promptEvents = sender.sent.filter((message) => (
message.channel === "netcatty:keyboard-interactive"
));
assert.equal(promptEvents.length, 1);
assert.equal(
promptEvents[0].payload.prompts[0].prompt,
"Secondary Authentication Password:",
);
assert.equal(
promptEvents[0].payload.instructions,
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
);
assert.equal(promptEvents[0].payload.savedPassword, null);
assert.equal(promptEvents[0].payload.allowSavePassword, false);
assert.equal(promptEvents[0].payload.scope, "terminal");
});
test("terminal SSH retries keyboard-interactive first when password rejection removes KI", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["mfa-keyboard-interactive-before-password", "mfa-keyboard-interactive-before-password"],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "mfa-ki-first-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "password",
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "mfa-ki-first-session" });
assert.equal(MockSSHClient.instances.length, 2);
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered,
[
"none",
{ type: "password", username: "alice", password: "login-password" },
false,
],
);
assert.deepEqual(
MockSSHClient.instances[1].authMethodsOffered,
["none", "keyboard-interactive", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[1].keyboardInteractiveResponses,
[["login-password"], ["secondary-password"]],
);
});
test("terminal recoverable auth retry keeps SFTP and port-forward waiters on the same dial", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["mfa-keyboard-interactive-before-password", "mfa-keyboard-interactive-before-password"],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
const options = {
sessionId: "mfa-shared-dial-leader",
hostId: "mfa-shared-host",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "password",
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
};
const terminalOpen = ipcMain.handlers.get("netcatty:start")({ sender }, options);
while (getTransportStats().pendingDials === 0) {
await nextTick();
}
const endpoint = buildConnectionReuseEndpoint(options);
const sftpWaiter = beginTransportDial(endpoint, { kind: "channel" });
const forwardWaiter = beginTransportDial(endpoint, { kind: "channel" });
assert.equal(sftpWaiter.role, "join");
assert.equal(forwardWaiter.role, "join");
const [terminalResult, sftpTransport, forwardTransport] = await Promise.all([
terminalOpen,
waitForTransportDial(sftpWaiter),
waitForTransportDial(forwardWaiter),
]);
assert.deepEqual(terminalResult, { sessionId: options.sessionId });
assert.equal(sftpTransport, forwardTransport);
assert.equal(sftpTransport.conn, MockSSHClient.instances[1]);
assert.equal(MockSSHClient.instances.length, 2);
});
test("terminal SSH requiresMfa prefers keyboard-interactive before password", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["mfa-keyboard-interactive-before-password"],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "mfa-ki-preferred-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "password",
requiresMfa: true,
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "mfa-ki-preferred-session" });
assert.equal(MockSSHClient.instances.length, 1);
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered,
["none", "keyboard-interactive", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[0].keyboardInteractiveResponses,
[["login-password"], ["secondary-password"]],
);
});
test("terminal SSH requiresMfa prefers keyboard-interactive before automatic keys without a saved password", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["repeated-keyboard-interactive"],
defaultKeys: [{ keyName: "id_ed25519", privateKey: "DEFAULT_PRIVATE_KEY" }],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
let promptCount = 0;
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: [promptCount++ === 0 ? "login-password" : "secondary-password"],
cancelled: false,
},
);
}
};
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "mfa-ki-no-saved-password-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "auto",
requiresMfa: true,
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "mfa-ki-no-saved-password-session" });
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered,
["none", "keyboard-interactive", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[0].keyboardInteractiveResponses,
[["login-password"], ["secondary-password"]],
);
});
test("failed keyboard-interactive retry still offers encrypted default key fallback", async (t) => {
const events = [];
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["mfa-keyboard-interactive-before-password", "auth-error", "encrypted-key-ready"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
passphraseResult: {
cancelled: false,
keys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
privateKey: "UNLOCKED_PRIVATE_KEY",
passphrase: "secret",
},
],
},
onPassphraseRequest: () => events.push("passphrase-request"),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender(events);
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "mfa-ki-encrypted-fallback-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "auto",
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "mfa-ki-encrypted-fallback-session" });
assert.equal(MockSSHClient.instances.length, 3);
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered,
[
"none",
{ type: "password", username: "alice", password: "login-password" },
false,
],
);
assert.equal(events.includes("passphrase-request"), true);
assert.deepEqual(
MockSSHClient.instances[2].authMethodsOffered.map((method) => (
method && typeof method === "object" ? method.type : method
)),
["none", "password", "publickey"],
);
assert.equal(MockSSHClient.instances[2].authMethodsOffered[2].key, "UNLOCKED_PRIVATE_KEY");
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "mfa-ki-encrypted-fallback-session"
)),
false,
);
});
test("terminal SSH password-first then keyboard-interactive when both methods are advertised", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["password-and-keyboard-interactive"],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "password-or-ki-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "password",
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "password-or-ki-session" });
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered.map((method) => (
method && typeof method === "object" ? method.type : method
)),
["none", "password", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[0].keyboardInteractiveResponses,
[["secondary-password"]],
);
});
test("terminal SSH keeps keyboard-interactive eligible after password rejection", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["password-then-keyboard-interactive"],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "password-then-ki-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "password",
password: "stale-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "password-then-ki-session" });
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered.map((method) => (
method && typeof method === "object" ? method.type : method
)),
["none", "password", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[0].keyboardInteractiveResponses,
[["secondary-password"]],
);
});
test("terminal SSH prefers keyboard-interactive after publickey partial success", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["publickey-then-password-and-keyboard-interactive"],
parseKeyResult: createParsedPrivateKey(),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "publickey-then-ki-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "key",
privateKey: TEST_PRIVATE_KEY,
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "publickey-then-ki-session" });
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered.map((method) => (
method && typeof method === "object" ? method.type : method
)),
["none", "publickey", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[0].keyboardInteractiveResponses,
[["secondary-password"]],
);
});
test("terminal SSH certificate auth prefers keyboard-interactive after agent partial success after partial success", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["agent-then-password-and-keyboard-interactive"],
parseKeyResult: createParsedPrivateKey(),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "certificate-then-ki-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "certificate",
certificate: "ssh-rsa-cert-v01@openssh.com AAAA test-cert",
privateKey: TEST_PRIVATE_KEY,
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "certificate-then-ki-session" });
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered,
["none", "agent", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[0].keyboardInteractiveResponses,
[["secondary-password"]],
);
});
test("terminal SSH certificate requiresMfa prefers keyboard-interactive after a rejected certificate", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["agent-then-mfa-keyboard-interactive-before-password"],
parseKeyResult: createParsedPrivateKey(),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "certificate-mfa-fallback-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "certificate",
requiresMfa: true,
certificate: "ssh-rsa-cert-v01@openssh.com AAAA test-cert",
privateKey: TEST_PRIVATE_KEY,
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "certificate-mfa-fallback-session" });
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered,
["none", "agent", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[0].keyboardInteractiveResponses,
[["login-password"]],
);
});
test("terminal SSH certificate auth retries keyboard-interactive when password rejection removes KI", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: [
"agent-password-removes-keyboard-interactive",
"agent-then-keyboard-interactive-after-skip-password",
],
parseKeyResult: createParsedPrivateKey(),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "certificate-ki-first-retry-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "certificate",
certificate: "ssh-rsa-cert-v01@openssh.com AAAA test-cert",
privateKey: TEST_PRIVATE_KEY,
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "certificate-ki-first-retry-session" });
assert.equal(MockSSHClient.instances.length, 2);
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered,
["none", "agent", "password", false],
);
assert.deepEqual(
MockSSHClient.instances[1].authMethodsOffered,
["none", "agent", "keyboard-interactive", "keyboard-interactive"],
);
assert.deepEqual(
MockSSHClient.instances[1].keyboardInteractiveResponses,
[["login-password"], ["secondary-password"]],
);
});
test("terminal SSH stops after two successful keyboard-interactive factors", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["excessive-keyboard-interactive"],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const sender = makeSender();
const send = sender.send.bind(sender);
sender.send = (channel, payload) => {
send(channel, payload);
if (channel === "netcatty:keyboard-interactive") {
keyboardInteractiveHandler.handleResponse(
{ sender },
{
requestId: payload.requestId,
responses: ["secondary-password"],
cancelled: false,
},
);
}
};
await assert.rejects(
ipcMain.handlers.get("netcatty:start")(
{ sender },
{
sessionId: "excessive-ki-session",
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "password",
password: "login-password",
useSshAgent: false,
port: 22,
knownHosts: [],
},
),
/All configured authentication methods failed/,
);
assert.deepEqual(
MockSSHClient.instances[0].authMethodsOffered,
["none", "keyboard-interactive", "keyboard-interactive", false],
);
assert.deepEqual(
MockSSHClient.instances[0].keyboardInteractiveResponses,
[["login-password"], ["secondary-password"]],
);
});
test("fresh SSH sessions preserve the server locale for the default UTF-8 charset", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["ready"],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const result = await ipcMain.handlers.get("netcatty:start")(
{ sender: makeSender() },
{
sessionId: "default-locale-session",
hostname: "example.test",
username: "alice",
port: 22,
charset: "UTF-8",
env: { TERM: "xterm-256color" },
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "default-locale-session" });
assert.deepEqual(MockSSHClient.instances[0].shellOptions.env, {
COLORTERM: "truecolor",
TERM: "xterm-256color",
});
});
test("retryable encrypted-key auth failure does not emit exit before retry success", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error", "ready"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
passphraseResult: {
cancelled: false,
keys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
privateKey: "UNLOCKED_PRIVATE_KEY",
passphrase: "secret",
},
],
},
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender();
const result = await start(
{ sender },
{
sessionId: "retry-session",
hostname: "example.test",
username: "alice",
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "retry-session" });
assert.equal(MockSSHClient.instances.length, 2);
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "retry-session"
)),
false,
);
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:auth:failed"
&& message.payload.sessionId === "retry-session"
)),
true,
);
});
test("stale close from failed first attempt does not close successful retry session", async (t) => {
const sessions = new Map();
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error", "ready"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
passphraseResult: {
cancelled: false,
keys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
privateKey: "UNLOCKED_PRIVATE_KEY",
passphrase: "secret",
},
],
},
});
const ipcMain = makeIpcMain();
bridge.init({ sessions, electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender();
await start(
{ sender },
{
sessionId: "stale-close-session",
hostname: "example.test",
username: "alice",
port: 22,
knownHosts: [],
},
);
assert.equal(MockSSHClient.instances.length, 2);
assert.equal(sessions.has("stale-close-session"), true);
MockSSHClient.instances[0].emit("close");
await nextTick();
assert.equal(sessions.has("stale-close-session"), true);
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "stale-close-session"
)),
false,
);
});
test("stale error from failed first attempt does not mark successful retry session failed", async (t) => {
const sessions = new Map();
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error", "ready"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
passphraseResult: {
cancelled: false,
keys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
privateKey: "UNLOCKED_PRIVATE_KEY",
passphrase: "secret",
},
],
},
});
const ipcMain = makeIpcMain();
bridge.init({ sessions, electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender();
await start(
{ sender },
{
sessionId: "stale-error-session",
hostname: "example.test",
username: "alice",
port: 22,
knownHosts: [],
},
);
assert.equal(MockSSHClient.instances.length, 2);
assert.equal(sessions.has("stale-error-session"), true);
const err = new Error("late error from failed first attempt");
err.level = "client-socket";
MockSSHClient.instances[0].emit("error", err);
await nextTick();
assert.equal(sessions.get("stale-error-session")._transportError, undefined);
});
test("jump-host auth failure does not emit exit before encrypted-key retry success", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error", "ready", "ready"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
passphraseResult: {
cancelled: false,
keys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
privateKey: "UNLOCKED_PRIVATE_KEY",
passphrase: "secret",
},
],
},
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender();
const result = await start(
{ sender },
{
sessionId: "jump-retry-session",
hostname: "target.example",
username: "alice",
port: 22,
knownHosts: [],
jumpHosts: [{ hostname: "jump.example", username: "alice", port: 22 }],
},
);
assert.deepEqual(result, { sessionId: "jump-retry-session" });
assert.equal(MockSSHClient.connectCount, 3);
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "jump-retry-session"
)),
false,
);
});
test("jump-host auth failure is attributed to the failing jump host", async (t) => {
const { bridge } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error"],
encryptedKeys: [],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
await assert.rejects(
start(
{ sender: makeSender() },
{
sessionId: "jump-auth-failed-session",
hostname: "target.example",
username: "target-user",
port: 22,
knownHosts: [],
jumpHosts: [{
hostname: "jump.example",
username: "jump-user",
port: 22,
label: "Bastion",
}],
},
),
(err) => {
assert.equal(err.isJumpHostAuthError, true);
assert.equal(err.jumpHostLabel, "Bastion");
assert.match(err.message, /Jump host authentication failed for "Bastion"/);
return true;
},
);
});
test("jump-host too-many-auth failures are attributed to the failing jump host", async (t) => {
const { bridge } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["too-many-auth"],
encryptedKeys: [],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
await assert.rejects(
start(
{ sender: makeSender() },
{
sessionId: "jump-too-many-auth-session",
hostname: "target.example",
username: "target-user",
port: 22,
knownHosts: [],
jumpHosts: [{
hostname: "jump.example",
username: "jump-user",
port: 22,
label: "Bastion",
}],
},
),
(err) => {
assert.equal(err.isJumpHostAuthError, true);
assert.equal(err.jumpHostLabel, "Bastion");
assert.match(err.message, /Jump host authentication failed for "Bastion": Too many authentication failures/);
return true;
},
);
});
test("jump-host auth attribution survives encrypted-key retry failure", async (t) => {
const { bridge } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error", "auth-error"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
passphraseResult: {
cancelled: false,
keys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
privateKey: "UNLOCKED_PRIVATE_KEY",
passphrase: "secret",
},
],
},
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
await assert.rejects(
start(
{ sender: makeSender() },
{
sessionId: "jump-auth-retry-failed-session",
hostname: "target.example",
username: "target-user",
port: 22,
knownHosts: [],
jumpHosts: [{
hostname: "jump.example",
username: "jump-user",
port: 22,
label: "Bastion",
}],
},
),
(err) => {
assert.equal(err.isJumpHostAuthError, true);
assert.equal(err.jumpHostLabel, "Bastion");
assert.match(err.message, /Jump host authentication failed for "Bastion"/);
return true;
},
);
});
test("jump-host socket errors with auth in hostname are not wrapped as auth failures", async (t) => {
let passphraseRequests = 0;
const { bridge } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["socket-error"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
onPassphraseRequest: () => {
passphraseRequests += 1;
},
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender();
await assert.rejects(
start(
{ sender },
{
sessionId: "jump-socket-error-session",
hostname: "target.example",
username: "target-user",
port: 22,
knownHosts: [],
jumpHosts: [{
hostname: "auth-bastion.example.com",
username: "jump-user",
port: 22,
label: "Auth Bastion",
}],
},
),
(err) => {
assert.equal(err.isAuthError, undefined);
assert.equal(err.isJumpHostAuthError, undefined);
assert.equal(err.level, "client-socket");
assert.equal(err.message, "Connection reset by auth-bastion.example.com");
return true;
},
);
assert.equal(passphraseRequests, 0);
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "jump-socket-error-session"
&& message.payload.error === "Connection reset by auth-bastion.example.com"
)),
true,
);
});
test("jump-host permission-denied socket errors are not wrapped as auth failures", async (t) => {
let passphraseRequests = 0;
const { bridge } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["permission-denied-socket-error"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
onPassphraseRequest: () => {
passphraseRequests += 1;
},
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender();
await assert.rejects(
start(
{ sender },
{
sessionId: "jump-permission-denied-socket-error-session",
hostname: "target.example",
username: "target-user",
port: 22,
knownHosts: [],
jumpHosts: [{
hostname: "auth-bastion.example.com",
username: "jump-user",
port: 22,
label: "Auth Bastion",
}],
},
),
(err) => {
assert.equal(err.isAuthError, undefined);
assert.equal(err.isJumpHostAuthError, undefined);
assert.equal(err.level, "client-socket");
assert.equal(err.message, "Permission denied opening channel to auth-bastion.example.com");
return true;
},
);
assert.equal(passphraseRequests, 0);
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "jump-permission-denied-socket-error-session"
&& message.payload.error === "Permission denied opening channel to auth-bastion.example.com"
)),
true,
);
});
test("fresh fallback after reuse failure still retries encrypted default key", async (t) => {
const events = [];
const sessions = new Map([
[
"source",
makeReusableSourceSession({
hostname: "example.test",
username: "alice",
port: 22,
}),
],
]);
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error", "ready"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
passphraseResult: {
cancelled: false,
keys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
privateKey: "UNLOCKED_PRIVATE_KEY",
passphrase: "secret",
},
],
},
onPassphraseRequest: () => events.push("passphrase-request"),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions, electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender(events);
const result = await start(
{ sender },
{
sessionId: "reuse-fallback-retry-session",
sourceSessionId: "source",
hostname: "example.test",
username: "alice",
port: 22,
knownHosts: [],
},
);
assert.deepEqual(result, { sessionId: "reuse-fallback-retry-session" });
assert.equal(MockSSHClient.instances.length, 2);
assert.equal(events.includes("passphrase-request"), true);
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:connection-reuse:fallback"
&& message.payload.sessionId === "reuse-fallback-retry-session"
)),
true,
);
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "reuse-fallback-retry-session"
)),
false,
);
});
test("fresh fallback after reuse failure without encrypted keys emits one final exit", async (t) => {
const sessions = new Map([
[
"source",
makeReusableSourceSession({
hostname: "example.test",
username: "alice",
port: 22,
}),
],
]);
const { bridge } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error"],
encryptedKeys: [],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions, electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender();
await assert.rejects(
() => start(
{ sender },
{
sessionId: "reuse-fallback-failed-session",
sourceSessionId: "source",
hostname: "example.test",
username: "alice",
port: 22,
knownHosts: [],
},
),
/All configured authentication methods failed/,
);
assert.equal(
sender.sent.some((message) => (
message.channel === "netcatty:connection-reuse:fallback"
&& message.payload.sessionId === "reuse-fallback-failed-session"
)),
true,
);
const exits = sender.sent.filter((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "reuse-fallback-failed-session"
));
assert.equal(exits.length, 1);
assert.equal(exits[0].payload.reason, "error");
});
test("stale close from failed encrypted-key retry does not stop successful retry log stream", async (t) => {
const sessionId = "retry-session-log";
const logDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-auth-retry-log-"));
t.after(async () => {
await sessionLogStreamManager.stopStream(sessionId);
fs.rmSync(logDirectory, { recursive: true, force: true });
});
const sessions = new Map();
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error", "ready"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
passphraseResult: {
cancelled: false,
keys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
privateKey: "UNLOCKED_PRIVATE_KEY",
passphrase: "secret",
},
],
},
});
const ipcMain = makeIpcMain();
bridge.init({ sessions, electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender();
await start(
{ sender },
{
sessionId,
hostname: "example.test",
username: "alice",
port: 22,
knownHosts: [],
sessionLog: {
enabled: true,
directory: logDirectory,
format: "raw",
},
},
);
assert.equal(sessionLogStreamManager.hasStream(sessionId), true);
MockSSHClient.instances[0].emit("close");
await nextTick();
assert.equal(sessionLogStreamManager.hasStream(sessionId), true);
});
test("non-retryable auth failure still emits one exit", async (t) => {
const { bridge } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error"],
encryptedKeys: [],
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender();
await assert.rejects(
() => start(
{ sender },
{
sessionId: "failed-session",
hostname: "example.test",
username: "alice",
port: 22,
knownHosts: [],
},
),
/All configured authentication methods failed/,
);
const exits = sender.sent.filter((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "failed-session"
));
assert.equal(exits.length, 1);
assert.equal(exits[0].payload.reason, "error");
});
test("cancelled encrypted-key retry emits one final exit", async (t) => {
const events = [];
const { bridge } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["auth-error"],
encryptedKeys: [
{
keyPath: "/Users/test/.ssh/id_ed25519",
keyName: "id_ed25519",
isEncrypted: true,
},
],
passphraseResult: {
cancelled: true,
keys: [],
},
onPassphraseRequest: () => events.push("passphrase-request"),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers(ipcMain);
const start = ipcMain.handlers.get("netcatty:start");
const sender = makeSender(events);
await assert.rejects(
() => start(
{ sender },
{
sessionId: "cancelled-session",
hostname: "example.test",
username: "alice",
port: 22,
knownHosts: [],
},
),
/All configured authentication methods failed/,
);
const exits = sender.sent.filter((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "cancelled-session"
));
assert.equal(exits.length, 1);
assert.equal(exits[0].payload.reason, "error");
assert.equal(events.includes("passphrase-request"), true);
assert.equal(
events.indexOf("send:netcatty:exit") > events.indexOf("passphrase-request"),
true,
);
});