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
2038 lines
65 KiB
JavaScript
2038 lines
65 KiB
JavaScript
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,
|
||
);
|
||
});
|