Files
NetMesh/electron/bridges/sftpBridge.authRetry.test.cjs

450 lines
15 KiB
JavaScript
Raw Permalink Normal View History

// Created: 2026-07-17
// Purpose: Verify SFTP retries KI first after EDR removes keyboard-interactive.
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
const {
beginTransportDial,
failTransportDial,
getTransportStats,
resetSshTransportRegistryForTests,
} = require("./sshConnectionPool.cjs");
const { shouldRegisterFreshSftpTransport } = require("./sftpBridge/openConnection.cjs");
test("sudo SFTP connections are never parked in the shared transport registry", () => {
assert.equal(shouldRegisterFreshSftpTransport({ sudo: true, reuseTransport: true }), false);
assert.equal(shouldRegisterFreshSftpTransport({ sudo: false, reuseTransport: true }), true);
});
/** Build a minimal renderer sender for SFTP progress and auth prompt IPC. */
function makeSender() {
return {
id: 1,
isDestroyed: () => false,
sent: [],
send(channel, payload) {
this.sent.push({ channel, payload });
},
};
}
/** Load the SFTP bridge with ssh2-sftp-client mocked for auth retry scenarios. */
function loadSftpBridgeWithAuthRetryMocks(t, options = {}) {
const bridgePath = require.resolve("./sftpBridge.cjs");
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
const originalLoad = Module._load;
const realAuthHelper = require(authHelperPath);
/** Mock SFTP client that simulates password rejection removing KI, then KI retry success. */
class MockSftpClient extends EventEmitter {
/** Create a mock ssh2-sftp-client instance with an embedded ssh2 client emitter. */
constructor() {
super();
MockSftpClient.instances.push(this);
this.sftp = null;
this.client = new EventEmitter();
this.client.setMaxListeners = () => {};
this.client._sock = { setTimeout() {} };
this.client.connect = (opts) => {
this.client.connectOpts = opts;
this.connect(opts);
};
this.client.sftp = (cb) => {
if (options.sftpError) {
setImmediate(() => cb(options.sftpError));
return;
}
const channel = new EventEmitter();
channel.readdir = () => {};
channel.stat = () => {};
channel.mkdir = () => {};
channel.unlink = () => {};
setImmediate(() => cb(null, channel));
};
this.client.end = () => {
this.client.ended = true;
};
this.client.destroy = () => {
this.client.destroyed = true;
};
}
/** Drive the fake server-side auth flow for each connection attempt. */
connect(opts) {
const attemptIndex = MockSftpClient.instances.length;
const offered = [];
this.authMethodsOffered = offered;
setImmediate(() => {
this.client.emit("connect");
this.client.emit("handshake");
if (options.alwaysSucceed) {
this.client.emit("ready");
return;
}
const offerNext = (methodsLeft, partialSuccess) => {
let nextMethod;
opts.authHandler(methodsLeft, partialSuccess, (method) => {
nextMethod = method;
offered.push(method);
});
return nextMethod;
};
offerNext(null, null);
const firstMethods = options.dynamicAuth
? ["publickey", "password", "keyboard-interactive"]
: ["password", "keyboard-interactive"];
const first = offerNext(firstMethods, false);
if (attemptIndex === 1 && options.dynamicAuth) {
assert.equal(first?.type, "publickey");
const password = offerNext(["password", "keyboard-interactive"], false);
assert.equal(password?.type, "password");
offerNext(["publickey"], false);
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.client.emit("error", err);
return;
}
if (attemptIndex === 1) {
assert.equal(first, "password");
offerNext(["publickey"], false);
const err = new Error("All configured authentication methods failed");
err.level = "client-authentication";
this.client.emit("error", err);
return;
}
if (options.dynamicAuth) {
assert.equal(first?.type, "publickey");
const keyboardInteractive = offerNext(["keyboard-interactive"], false);
assert.equal(keyboardInteractive, "keyboard-interactive");
} else {
assert.equal(first, "keyboard-interactive");
}
this.client.emit("ready");
});
}
/** Mark the high-level SFTP client as ended. */
end() {
this.ended = true;
}
}
MockSftpClient.instances = [];
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2-sftp-client") {
return MockSftpClient;
}
if (request === "./sshAuthHelper.cjs") {
return {
...realAuthHelper,
findAllDefaultPrivateKeys: async () => {
if (options.defaultKeysError) throw options.defaultKeysError;
return options.defaultKeys || [];
},
getAvailableAgentSocket: async () => null,
prepareSystemSshAgentForAuth: async () => null,
};
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
const bridge = require("./sftpBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
Module._load = originalLoad;
});
return { bridge, MockSftpClient };
}
test("simultaneous SFTP browser opens share one physical SSH dial", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, { alwaysSucceed: true });
const sftpClients = new Map();
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
const options = {
hostname: "192.168.9.138",
hostId: "host-sftp-shared",
port: 22,
username: "root",
authMethod: "password",
password: "saved-login-password",
useSshAgent: false,
verifyHostKeys: false,
fileProtocol: "sftp",
};
const [first, second] = await Promise.all([
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-shared-1" }),
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-shared-2" }),
]);
assert.equal(first.sftpId, "sftp-shared-1");
assert.equal(second.sftpId, "sftp-shared-2");
assert.equal(MockSftpClient.instances.length, 1);
assert.equal(sftpClients.size, 2);
});
test("sequential SFTP opens without a terminal authenticate only once", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, { alwaysSucceed: true });
const sftpClients = new Map();
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
const options = {
hostname: "no-terminal.example",
hostId: "host-no-terminal",
port: 22,
username: "root",
authMethod: "password",
password: "saved-login-password",
useSshAgent: false,
verifyHostKeys: false,
fileProtocol: "sftp",
};
const first = await bridge.openSftp(
{ sender: makeSender() },
{ ...options, sessionId: "sftp-no-terminal-1" },
);
await bridge.closeSftp(null, { sftpId: first.sftpId });
assert.equal(sftpClients.size, 0);
assert.equal(getTransportStats().idle, 1);
const second = await bridge.openSftp(
{ sender: makeSender() },
{ ...options, sessionId: "sftp-no-terminal-2" },
);
assert.equal(second.sftpId, "sftp-no-terminal-2");
assert.equal(MockSftpClient.instances.length, 1, "the second open must not repeat SSH authentication");
assert.equal(sftpClients.size, 1);
});
test("explicitly dedicated SFTP opens stay outside shared dial coordination", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, { alwaysSucceed: true });
const sftpClients = new Map();
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
const options = {
hostname: "192.168.9.138",
hostId: "host-sftp-dedicated",
port: 22,
username: "root",
authMethod: "password",
password: "saved-login-password",
useSshAgent: false,
verifyHostKeys: false,
fileProtocol: "sftp",
reuseTransport: false,
};
await Promise.all([
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-dedicated-1" }),
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-dedicated-2" }),
]);
assert.equal(MockSftpClient.instances.length, 2);
assert.deepEqual(getTransportStats(), {
transports: 0,
pendingDials: 0,
live: 0,
idle: 0,
leases: 0,
defaultIdleTtlMs: 0,
});
await bridge.openSftp(
{ sender: makeSender() },
{ ...options, reuseTransport: true, sessionId: "sftp-shareable-after-dedicated" },
);
assert.equal(
MockSftpClient.instances.length,
3,
"a later shareable open must dial instead of borrowing a dedicated connection",
);
});
test("early SFTP preparation failure releases the coordinated dial", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge } = loadSftpBridgeWithAuthRetryMocks(t, {
alwaysSucceed: true,
defaultKeysError: new Error("identity discovery failed"),
});
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
const options = {
hostname: "early-failure.example",
hostId: "host-early-failure",
username: "root",
authMethod: "password",
password: "secret",
useSshAgent: false,
};
await assert.rejects(
bridge.openSftp({ sender: makeSender() }, options),
/identity discovery failed/,
);
assert.equal(getTransportStats().pendingDials, 0);
const retry = beginTransportDial(options, { kind: "channel" });
assert.equal(retry.role, "leader", "the failed opener must not leave a permanent join slot");
failTransportDial(retry, new Error("test cleanup"));
});
test("forced SFTP subsystem rejection closes the authenticated client without retaining pool state", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, {
alwaysSucceed: true,
sftpError: new Error("SFTP subsystem rejected"),
});
const sftpClients = new Map();
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
await assert.rejects(
bridge.openSftp(
{ sender: makeSender() },
{
sessionId: "sftp-subsystem-rejected",
hostname: "no-sftp.example",
hostId: "host-no-sftp",
username: "root",
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
fileProtocol: "sftp",
},
),
/SFTP subsystem rejected/,
);
assert.equal(MockSftpClient.instances.length, 1);
assert.equal(MockSftpClient.instances[0].client.ended, true);
assert.equal(MockSftpClient.instances[0].client.destroyed, true);
assert.equal(sftpClients.size, 0);
assert.deepEqual(getTransportStats(), {
transports: 0,
pendingDials: 0,
live: 0,
idle: 0,
leases: 0,
defaultIdleTtlMs: 0,
});
});
test("concurrent SFTP waiter survives the leader's recoverable auth retry", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t);
const sftpClients = new Map();
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
const options = {
hostname: "retry-shared.example",
hostId: "host-retry-shared",
port: 22,
username: "root",
authMethod: "password",
password: "saved-login-password",
useSshAgent: false,
verifyHostKeys: false,
fileProtocol: "sftp",
};
const [leader, waiter] = await Promise.all([
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-retry-leader" }),
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-retry-waiter" }),
]);
assert.equal(leader.sftpId, "sftp-retry-leader");
assert.equal(waiter.sftpId, "sftp-retry-waiter");
assert.equal(MockSftpClient.instances.length, 2, "only the leader's initial and retry dials are physical");
assert.equal(sftpClients.size, 2);
assert.equal(getTransportStats().pendingDials, 0);
});
test("openSftp retries keyboard-interactive first when password rejection removes KI", async (t) => {
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t);
const sftpClients = new Map();
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
const result = await bridge.openSftp(
{ sender: makeSender() },
{
sessionId: "sftp-edr-mfa",
hostname: "192.168.9.138",
port: 22,
username: "root",
authMethod: "password",
password: "saved-login-password",
sshTcpConnectTimeoutMs: 45_000,
sshAuthReadyTimeoutMs: 300_000,
},
);
assert.equal(result.sftpId, "sftp-edr-mfa");
assert.equal(MockSftpClient.instances.length, 2);
assert.deepEqual(MockSftpClient.instances[0].authMethodsOffered, [
"none",
"password",
false,
]);
assert.deepEqual(MockSftpClient.instances[1].authMethodsOffered, [
"none",
"keyboard-interactive",
]);
assert.equal(sftpClients.has("sftp-edr-mfa"), true);
});
test("openSftp retries keyboard-interactive after dynamic auth password rejection removes KI", async (t) => {
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, {
dynamicAuth: true,
defaultKeys: [{
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nmock-default-key\n-----END OPENSSH PRIVATE KEY-----",
keyPath: "id_ed25519",
keyName: "id_ed25519",
}],
});
const sftpClients = new Map();
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
const result = await bridge.openSftp(
{ sender: makeSender() },
{
sessionId: "sftp-edr-mfa-auto",
hostname: "192.168.9.138",
port: 22,
username: "root",
authMethod: "auto",
password: "saved-login-password",
sshTcpConnectTimeoutMs: 45_000,
sshAuthReadyTimeoutMs: 300_000,
},
);
assert.equal(result.sftpId, "sftp-edr-mfa-auto");
assert.equal(MockSftpClient.instances.length, 2);
assert.deepEqual(
MockSftpClient.instances[0].authMethodsOffered.map((method) => (
method?.type === "publickey" ? "publickey" : method?.type || method
)),
["none", "publickey", "password", false],
);
assert.deepEqual(
MockSftpClient.instances[1].authMethodsOffered.map((method) => (
method?.type === "publickey" ? "publickey" : method?.type || method
)),
["none", "publickey", "keyboard-interactive"],
);
assert.equal(sftpClients.has("sftp-edr-mfa-auto"), true);
});