[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
942
electron/bridges/sshBridge/moshStatsConnection.test.cjs
Normal file
942
electron/bridges/sshBridge/moshStatsConnection.test.cjs
Normal file
@@ -0,0 +1,942 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const { createMoshStatsConnectionApi } = require("./moshStatsConnection.cjs");
|
||||
|
||||
// The connection is created inside an async flow (after credentials are
|
||||
// resolved, which may touch the filesystem), so the fake SSH client appears a
|
||||
// few microtasks/immediates after ensureMoshStatsConnection() is called.
|
||||
function tick() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
// Minimal fake ssh2 Client. Records connect() opts and lets the test drive
|
||||
// the lifecycle via emitReady / emitError.
|
||||
class FakeSSHClient extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
FakeSSHClient.instances.push(this);
|
||||
this.connectOpts = null;
|
||||
this.ended = false;
|
||||
}
|
||||
|
||||
connect(opts) {
|
||||
this.connectOpts = opts;
|
||||
return this;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
emitReady() {
|
||||
this.emit("ready");
|
||||
}
|
||||
|
||||
emitError(err) {
|
||||
this.emit("error", err);
|
||||
}
|
||||
}
|
||||
FakeSSHClient.instances = [];
|
||||
|
||||
function makeApi(overrides = {}) {
|
||||
FakeSSHClient.instances = [];
|
||||
const sessions = overrides.sessions || new Map();
|
||||
const logs = [];
|
||||
const api = createMoshStatsConnectionApi({
|
||||
get sessions() {
|
||||
return sessions;
|
||||
},
|
||||
SSHClient: overrides.SSHClient || FakeSSHClient,
|
||||
sshUtils: overrides.sshUtils || {
|
||||
// Default: treat any non-empty string as a parseable key.
|
||||
parseKey: (key) => (key && key.length > 0 ? { ok: true } : new Error("bad key")),
|
||||
},
|
||||
NetcattyAgent: overrides.NetcattyAgent || class {},
|
||||
buildAlgorithms: overrides.buildAlgorithms || (() => ({ algos: true })),
|
||||
getSshAgentSocket: overrides.getSshAgentSocket || (() => null),
|
||||
prepareSystemSshAgentForAuth: overrides.prepareSystemSshAgentForAuth,
|
||||
readFileNoFollow: overrides.readFileNoFollow || (async () => null),
|
||||
expandIdentityFilePath: overrides.expandIdentityFilePath || ((p) => p),
|
||||
isAutoFillablePasswordChallenge:
|
||||
overrides.isAutoFillablePasswordChallenge || (() => false),
|
||||
hostKeyVerifier: overrides.hostKeyVerifier || {
|
||||
// Default: classify everything as trusted so password tests connect.
|
||||
describeHostKey: () => ({ keyType: "ssh-ed25519", fingerprint: "fp" }),
|
||||
classifyHostKey: () => ({ status: "trusted" }),
|
||||
},
|
||||
// Default: the system known_hosts vouches for nothing, so trust comes
|
||||
// solely from the Netcatty classifier above. Individual tests override this
|
||||
// to exercise the system-known_hosts fallback.
|
||||
isHostKeyTrustedBySystem:
|
||||
"isHostKeyTrustedBySystem" in overrides
|
||||
? overrides.isHostKeyTrustedBySystem
|
||||
: () => false,
|
||||
log: (...args) => logs.push(args),
|
||||
});
|
||||
return { api, sessions, logs };
|
||||
}
|
||||
|
||||
test("reuses an already-established companion (moshStatsConn) without reconnecting", async () => {
|
||||
const existing = { exec() {} };
|
||||
const { api } = makeApi();
|
||||
const session = { moshStatsConn: existing, moshStatsAuth: { hostname: "h", password: "p" } };
|
||||
|
||||
const result = await api.ensureMoshStatsConnection(session, "sid");
|
||||
|
||||
assert.equal(result, existing);
|
||||
assert.equal(FakeSSHClient.instances.length, 0);
|
||||
});
|
||||
|
||||
test("gives up (no connection) when there is no usable non-interactive auth", async () => {
|
||||
const { api } = makeApi();
|
||||
// Only an interactively-typed password would have worked; nothing stored.
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u" } };
|
||||
|
||||
const result = await api.ensureMoshStatsConnection(session, "sid");
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(session.moshStatsConnFailed, true);
|
||||
assert.equal(FakeSSHClient.instances.length, 0);
|
||||
});
|
||||
|
||||
test("missing moshStatsAuth is transient (handshake not yet swapped), not a permanent failure", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
// Session is connected (renderer polls) but the handshake hasn't swapped to
|
||||
// mosh-client yet, so moshStatsAuth is not assigned.
|
||||
const session = {};
|
||||
sessions.set("sid", session);
|
||||
|
||||
const result = await api.ensureMoshStatsConnection(session, "sid");
|
||||
|
||||
assert.equal(result, null);
|
||||
// Must NOT be permanently disabled — a later poll (after the swap sets
|
||||
// moshStatsAuth) has to be able to establish the companion.
|
||||
assert.notEqual(session.moshStatsConnFailed, true);
|
||||
assert.equal(FakeSSHClient.instances.length, 0);
|
||||
|
||||
// Once auth becomes available, a subsequent poll connects.
|
||||
session.moshStatsAuth = { hostname: "h", username: "u", password: "p" };
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
assert.equal(FakeSSHClient.instances.length, 1);
|
||||
});
|
||||
|
||||
test("connects with a stored password and adopts the connection on ready", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { moshStatsAuth: { hostname: "example.com", port: 2222, username: "alice", password: "secret" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
// One connection attempt with the password and host wired in.
|
||||
assert.equal(FakeSSHClient.instances.length, 1);
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.equal(client.connectOpts.host, "example.com");
|
||||
assert.equal(client.connectOpts.port, 2222);
|
||||
assert.equal(client.connectOpts.username, "alice");
|
||||
assert.equal(client.connectOpts.password, "secret");
|
||||
|
||||
client.emitReady();
|
||||
const result = await pending;
|
||||
|
||||
assert.equal(result, client);
|
||||
// Stored ONLY on moshStatsConn, never on session.conn (keeps the companion
|
||||
// invisible to getSessionPwd / SFTP / MCP exec).
|
||||
assert.equal(session.moshStatsConn, client);
|
||||
assert.equal(session.conn, undefined);
|
||||
});
|
||||
|
||||
test("ET stats companion uses ET-specific session fields", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { etStatsAuth: { hostname: "example.com", port: 2222, username: "alice", password: "secret" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureEtStatsConnection(session, "sid");
|
||||
await tick();
|
||||
assert.equal(FakeSSHClient.instances.length, 1);
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.equal(client.connectOpts.host, "example.com");
|
||||
assert.equal(client.connectOpts.port, 2222);
|
||||
assert.equal(client.connectOpts.username, "alice");
|
||||
assert.equal(client.connectOpts.password, "secret");
|
||||
|
||||
client.emitReady();
|
||||
const result = await pending;
|
||||
|
||||
assert.equal(result, client);
|
||||
assert.equal(session.etStatsConn, client);
|
||||
assert.equal(session.moshStatsConn, undefined);
|
||||
assert.equal(session.conn, undefined);
|
||||
});
|
||||
|
||||
test("ET stats companion caches permanent failures on ET-specific fields", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { etStatsAuth: { hostname: "h", username: "u", password: "p" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const p1 = api.ensureEtStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const authErr = new Error("All configured authentication methods failed");
|
||||
authErr.level = "client-authentication";
|
||||
FakeSSHClient.instances[0].emitError(authErr);
|
||||
assert.equal(await p1, null);
|
||||
assert.equal(session.etStatsConnFailed, true);
|
||||
|
||||
const r2 = await api.ensureEtStatsConnection(session, "sid");
|
||||
assert.equal(r2, null);
|
||||
assert.equal(FakeSSHClient.instances.length, 1);
|
||||
});
|
||||
|
||||
test("uses a parseable private key and passphrase, not password fallback only", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
passphrase: "pw",
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.equal(client.connectOpts.privateKey, session.moshStatsAuth.privateKey);
|
||||
assert.equal(client.connectOpts.passphrase, "pw");
|
||||
});
|
||||
|
||||
test("skips an unparseable (e.g. encrypted, wrong passphrase) private key", async () => {
|
||||
const { api, sessions } = makeApi({
|
||||
sshUtils: { parseKey: () => new Error("encrypted") },
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: { hostname: "h", username: "u", privateKey: "enc", password: "fallback" },
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
// Falls back to the stored password instead of offering the bad key.
|
||||
assert.equal(client.connectOpts.privateKey, undefined);
|
||||
assert.equal(client.connectOpts.password, "fallback");
|
||||
});
|
||||
|
||||
test("reads identity files non-interactively when no inline key is present", async () => {
|
||||
const reads = [];
|
||||
const { api, sessions } = makeApi({
|
||||
readFileNoFollow: async (p) => {
|
||||
reads.push(p);
|
||||
return "FILEKEY";
|
||||
},
|
||||
sshUtils: { parseKey: (k) => (k === "FILEKEY" ? { ok: true } : new Error("no")) },
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: { hostname: "h", username: "u", identityFilePaths: ["~/.ssh/id_ed25519"] },
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
await tick(); // allow chained async identity read + client creation
|
||||
|
||||
assert.deepEqual(reads, ["~/.ssh/id_ed25519"]);
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.equal(client.connectOpts.privateKey, "FILEKEY");
|
||||
});
|
||||
|
||||
test("tries every discovered identity file for automatic stats auth", async () => {
|
||||
const { api, sessions } = makeApi({
|
||||
readFileNoFollow: async (p) => p.endsWith("id_first") ? "FIRSTKEY" : "SECONDKEY",
|
||||
getSshAgentSocket: () => "/tmp/agent.sock",
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
identityFilePaths: ["~/.ssh/id_first", "~/.ssh/id_second"],
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const handler = FakeSSHClient.instances[0].connectOpts.authHandler;
|
||||
const nextMethod = () => {
|
||||
let method;
|
||||
handler(null, false, (value) => { method = value; });
|
||||
return method;
|
||||
};
|
||||
|
||||
assert.equal(nextMethod(), "none");
|
||||
assert.equal(nextMethod(), "agent");
|
||||
assert.deepEqual(nextMethod(), {
|
||||
type: "publickey",
|
||||
username: "u",
|
||||
key: "FIRSTKEY",
|
||||
passphrase: undefined,
|
||||
});
|
||||
assert.deepEqual(nextMethod(), {
|
||||
type: "publickey",
|
||||
username: "u",
|
||||
key: "SECONDKEY",
|
||||
passphrase: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("automatic stats auth keeps local-key fallback with an explicit agent", async () => {
|
||||
const { api, sessions } = makeApi({
|
||||
readFileNoFollow: async (p) => p.endsWith("id_first") ? "FIRSTKEY" : "SECONDKEY",
|
||||
prepareSystemSshAgentForAuth: async () => "/tmp/selected-agent.sock",
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
authMethod: "auto",
|
||||
useSshAgent: true,
|
||||
identityFilePaths: ["~/.ssh/id_first", "~/.ssh/id_second"],
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const connectOpts = FakeSSHClient.instances[0].connectOpts;
|
||||
assert.equal(connectOpts.agent, "/tmp/selected-agent.sock");
|
||||
const offered = drainAuthHandler(connectOpts.authHandler);
|
||||
assert.equal(offered[0], "none");
|
||||
assert.equal(offered[1], "agent");
|
||||
assert.deepEqual(offered.slice(2), [
|
||||
{ type: "publickey", username: "u", key: "FIRSTKEY", passphrase: undefined },
|
||||
{ type: "publickey", username: "u", key: "SECONDKEY", passphrase: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("falls back to ssh-agent when a socket is available and no inline creds", async () => {
|
||||
// The system ssh used by the Mosh handshake authenticates via the local
|
||||
// agent by default, so the companion should too — regardless of the
|
||||
// agentForwarding (remote forwarding) setting.
|
||||
for (const agentForwarding of [true, false, undefined]) {
|
||||
const { api, sessions } = makeApi({
|
||||
getSshAgentSocket: () => "/tmp/agent.sock",
|
||||
});
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", agentForwarding } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.equal(client.connectOpts.agent, "/tmp/agent.sock");
|
||||
}
|
||||
});
|
||||
|
||||
test("does not attempt a connection when no agent socket and no inline creds", async () => {
|
||||
const { api } = makeApi({ getSshAgentSocket: () => null });
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u" } };
|
||||
|
||||
const result = await api.ensureMoshStatsConnection(session, "sid");
|
||||
assert.equal(result, null);
|
||||
assert.equal(FakeSSHClient.instances.length, 0);
|
||||
});
|
||||
|
||||
test("enables keyboard-interactive and auto-fills the saved password for a single prompt", async () => {
|
||||
// Use the real auto-fill predicate so this exercises the actual handler.
|
||||
const { isAutoFillablePasswordChallenge } = require("../sshAuthHelper.cjs");
|
||||
const { api, sessions } = makeApi({ isAutoFillablePasswordChallenge });
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "secret" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.equal(client.connectOpts.tryKeyboard, true);
|
||||
|
||||
let answered = null;
|
||||
client.emit(
|
||||
"keyboard-interactive",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Password:", echo: false }],
|
||||
(responses) => { answered = responses; },
|
||||
);
|
||||
assert.deepEqual(answered, ["secret"]);
|
||||
});
|
||||
|
||||
test("keyboard-interactive finishes empty on a 2FA / OTP challenge (no hang, no prompt)", async () => {
|
||||
const { isAutoFillablePasswordChallenge } = require("../sshAuthHelper.cjs");
|
||||
const { api, sessions } = makeApi({ isAutoFillablePasswordChallenge });
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "secret" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
|
||||
let answered = null;
|
||||
client.emit(
|
||||
"keyboard-interactive",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Verification code:", echo: false }],
|
||||
(responses) => { answered = responses; },
|
||||
);
|
||||
assert.deepEqual(answered, []);
|
||||
});
|
||||
|
||||
test("keyboard-interactive only auto-fills once, then finishes empty to avoid a loop", async () => {
|
||||
const { isAutoFillablePasswordChallenge } = require("../sshAuthHelper.cjs");
|
||||
const { api, sessions } = makeApi({ isAutoFillablePasswordChallenge });
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "secret" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
|
||||
const prompts = [{ prompt: "Password:", echo: false }];
|
||||
let first = null;
|
||||
let second = null;
|
||||
client.emit("keyboard-interactive", "", "", "", prompts, (r) => { first = r; });
|
||||
client.emit("keyboard-interactive", "", "", "", prompts, (r) => { second = r; });
|
||||
assert.deepEqual(first, ["secret"]);
|
||||
assert.deepEqual(second, []); // retry not re-filled with the same wrong password
|
||||
});
|
||||
|
||||
test("does not enable keyboard-interactive when there is no password", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.notEqual(client.connectOpts.tryKeyboard, true);
|
||||
});
|
||||
|
||||
// Drive an ssh2 function-form authHandler to completion, collecting the
|
||||
// method names it offers. Each call is answered by invoking the callback,
|
||||
// then we ask for the next method until it yields false (exhausted).
|
||||
function drainAuthHandler(authHandler) {
|
||||
const offered = [];
|
||||
let done = false;
|
||||
let guard = 0;
|
||||
while (!done && guard++ < 50) {
|
||||
let answered = false;
|
||||
authHandler([], false, (method) => {
|
||||
answered = true;
|
||||
if (method === false) {
|
||||
done = true;
|
||||
} else {
|
||||
offered.push(method);
|
||||
}
|
||||
});
|
||||
if (!answered) break;
|
||||
}
|
||||
return offered;
|
||||
}
|
||||
|
||||
test("verifier is attached for every auth method; gated authHandler only with a password", async () => {
|
||||
// Password present -> verifier + gated authHandler attached.
|
||||
{
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "p" } };
|
||||
sessions.set("sid", session);
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
assert.equal(typeof FakeSSHClient.instances[0].connectOpts.hostVerifier, "function");
|
||||
assert.equal(typeof FakeSSHClient.instances[0].connectOpts.authHandler, "function");
|
||||
}
|
||||
// Key only -> verifier still attached (the host must be vetted); no authHandler.
|
||||
{
|
||||
const { api, sessions } = makeApi();
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
assert.equal(typeof FakeSSHClient.instances[0].connectOpts.hostVerifier, "function");
|
||||
assert.equal(FakeSSHClient.instances[0].connectOpts.authHandler, undefined);
|
||||
}
|
||||
// Agent only -> verifier still attached; no authHandler.
|
||||
{
|
||||
const { api, sessions } = makeApi({ getSshAgentSocket: () => "/tmp/agent.sock" });
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", agentForwarding: true } };
|
||||
sessions.set("sid", session);
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
assert.equal(typeof FakeSSHClient.instances[0].connectOpts.hostVerifier, "function");
|
||||
assert.equal(FakeSSHClient.instances[0].connectOpts.authHandler, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("the verifier rejects the transport for an untrusted host (key auth included)", async () => {
|
||||
const hostKeyVerifier = require("../hostKeyVerifier.cjs");
|
||||
const { api, sessions } = makeApi({ hostKeyVerifier });
|
||||
// Untrusted host: empty known-hosts, key auth and NO password — must still be
|
||||
// refused, even though key auth would leak no reusable secret.
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "unknown.example.com",
|
||||
port: 22,
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
knownHosts: [],
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
|
||||
const verify = FakeSSHClient.instances[0].connectOpts.hostVerifier;
|
||||
let accepted = null;
|
||||
verify(require("node:crypto").randomBytes(32), (ok) => { accepted = ok; });
|
||||
// Refuses an unvetted host outright — no auth attempted, no stats command run.
|
||||
assert.equal(accepted, false);
|
||||
});
|
||||
|
||||
test("the gated authHandler offers password ONLY when the host key is trusted", async () => {
|
||||
const hostKeyVerifier = require("../hostKeyVerifier.cjs");
|
||||
const rawKey = require("node:crypto").randomBytes(32);
|
||||
const { keyType, fingerprint } = hostKeyVerifier.describeHostKey(rawKey);
|
||||
const knownHosts = [
|
||||
{ id: "k1", hostname: "trusted.example.com", port: 22, keyType, fingerprint, publicKey: "" },
|
||||
];
|
||||
|
||||
// Trusted host: after the verifier runs, password + keyboard-interactive
|
||||
// are offered.
|
||||
{
|
||||
const { api, sessions } = makeApi({ hostKeyVerifier });
|
||||
const session = {
|
||||
moshStatsAuth: { hostname: "trusted.example.com", port: 22, username: "u", password: "p", knownHosts },
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const { hostVerifier, authHandler } = FakeSSHClient.instances[0].connectOpts;
|
||||
hostVerifier(rawKey, () => {}); // verifier runs during transport, sets trust
|
||||
const offered = drainAuthHandler(authHandler);
|
||||
assert.ok(offered.includes("password"));
|
||||
assert.ok(offered.includes("keyboard-interactive"));
|
||||
}
|
||||
|
||||
// Untrusted host: password methods are withheld even though a password is
|
||||
// saved, so the secret is never sent.
|
||||
{
|
||||
const { api, sessions } = makeApi({ hostKeyVerifier });
|
||||
const session = {
|
||||
moshStatsAuth: { hostname: "other.example.com", port: 22, username: "u", password: "p", knownHosts },
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const { hostVerifier, authHandler } = FakeSSHClient.instances[0].connectOpts;
|
||||
hostVerifier(rawKey, () => {});
|
||||
const offered = drainAuthHandler(authHandler);
|
||||
assert.ok(!offered.includes("password"));
|
||||
assert.ok(!offered.includes("keyboard-interactive"));
|
||||
}
|
||||
});
|
||||
|
||||
test("an explicit private key suppresses the ssh-agent fallback", async () => {
|
||||
const { api, sessions } = makeApi({ getSshAgentSocket: () => "/tmp/agent.sock" });
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.ok(client.connectOpts.privateKey);
|
||||
assert.equal(client.connectOpts.agent, undefined);
|
||||
});
|
||||
|
||||
test("a saved password does NOT suppress agent auth (agent offered alongside password)", async () => {
|
||||
// A public-key host that authenticates via the agent may still carry a
|
||||
// stored password; the companion must offer both so agent auth (tried
|
||||
// first by ssh2) can succeed instead of failing on password only.
|
||||
const { api, sessions } = makeApi({ getSshAgentSocket: () => "/tmp/agent.sock" });
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "pw" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.equal(client.connectOpts.agent, "/tmp/agent.sock");
|
||||
assert.equal(client.connectOpts.password, "pw");
|
||||
});
|
||||
|
||||
test("concurrent calls share a single in-flight connection attempt", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "p" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const p1 = api.ensureMoshStatsConnection(session, "sid");
|
||||
const p2 = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
|
||||
assert.equal(FakeSSHClient.instances.length, 1);
|
||||
FakeSSHClient.instances[0].emitReady();
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
assert.equal(r1, FakeSSHClient.instances[0]);
|
||||
assert.equal(r2, FakeSSHClient.instances[0]);
|
||||
});
|
||||
|
||||
test("auth rejection is permanent: no reconnect on the next poll", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "p" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const p1 = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const authErr = new Error("All configured authentication methods failed");
|
||||
authErr.level = "client-authentication";
|
||||
FakeSSHClient.instances[0].emitError(authErr);
|
||||
assert.equal(await p1, null);
|
||||
assert.equal(session.moshStatsConnFailed, true);
|
||||
|
||||
// Second poll must not open a new connection.
|
||||
const r2 = await api.ensureMoshStatsConnection(session, "sid");
|
||||
assert.equal(r2, null);
|
||||
assert.equal(FakeSSHClient.instances.length, 1);
|
||||
});
|
||||
|
||||
test("a transient error allows a reconnect on the next poll", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "p" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const p1 = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const netErr = new Error("connect ETIMEDOUT");
|
||||
netErr.level = "client-socket";
|
||||
FakeSSHClient.instances[0].emitError(netErr);
|
||||
assert.equal(await p1, null);
|
||||
assert.notEqual(session.moshStatsConnFailed, true);
|
||||
|
||||
// Next poll is allowed to try again.
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
assert.equal(FakeSSHClient.instances.length, 2);
|
||||
});
|
||||
|
||||
test("a socket that closes mid-handshake settles the attempt instead of hanging", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "p" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
// Socket drops during the handshake with no prior "ready" or "error".
|
||||
FakeSSHClient.instances[0].emit("close");
|
||||
|
||||
const result = await pending;
|
||||
assert.equal(result, null);
|
||||
// Transient — must not permanently disable stats, and the promise must clear.
|
||||
assert.notEqual(session.moshStatsConnFailed, true);
|
||||
assert.equal(session.moshStatsConnPromise, null);
|
||||
|
||||
// The next poll is allowed to retry.
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
assert.equal(FakeSSHClient.instances.length, 2);
|
||||
});
|
||||
|
||||
test("a connection that becomes ready after the session closed is discarded", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "p" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
// Session goes away before the handshake completes.
|
||||
session.closed = true;
|
||||
sessions.delete("sid");
|
||||
FakeSSHClient.instances[0].emitReady();
|
||||
|
||||
const result = await pending;
|
||||
assert.equal(result, null);
|
||||
assert.equal(FakeSSHClient.instances[0].ended, true);
|
||||
assert.equal(session.conn, undefined);
|
||||
assert.equal(session.moshStatsConn, undefined);
|
||||
});
|
||||
|
||||
test("honors host algorithm settings via buildAlgorithms", async () => {
|
||||
const calls = [];
|
||||
const { api, sessions } = makeApi({
|
||||
buildAlgorithms: (legacy, opts) => {
|
||||
calls.push({ legacy, opts });
|
||||
return { built: true };
|
||||
},
|
||||
});
|
||||
const overrides = { cipher: ["aes128-cbc"] };
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
password: "p",
|
||||
legacyAlgorithms: true,
|
||||
skipEcdsaHostKey: true,
|
||||
algorithmOverrides: overrides,
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].legacy, true);
|
||||
assert.equal(calls[0].opts.skipEcdsaHostKey, true);
|
||||
assert.equal(calls[0].opts.algorithmOverrides, overrides);
|
||||
assert.deepEqual(FakeSSHClient.instances[0].connectOpts.algorithms, { built: true });
|
||||
});
|
||||
|
||||
test("installs a host-key verifier even for key-only auth (no password)", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
// Regression (#1198 review): a background companion must verify the host key
|
||||
// for EVERY auth method, not only when a password is present.
|
||||
assert.equal(typeof client.connectOpts.hostVerifier, "function");
|
||||
});
|
||||
|
||||
test("rejects an untrusted host key for key auth and treats it as permanent", async () => {
|
||||
const { api, sessions } = makeApi({
|
||||
hostKeyVerifier: {
|
||||
describeHostKey: () => ({ keyType: "ssh-ed25519", fingerprint: "live-fp" }),
|
||||
classifyHostKey: () => ({ status: "unknown" }),
|
||||
},
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
|
||||
// The verifier refuses the transport for an unvetted host, even though the
|
||||
// companion would have authenticated with a key (no reusable secret leaked).
|
||||
let verdict;
|
||||
client.connectOpts.hostVerifier(Buffer.from("rawkey"), (ok) => { verdict = ok; });
|
||||
assert.equal(verdict, false);
|
||||
|
||||
// ssh2 then aborts the handshake; an untrusted host must be a permanent
|
||||
// failure so we don't reconnect (and re-reject) on every stats poll.
|
||||
client.emitError(Object.assign(new Error("handshake failed"), { level: "protocol" }));
|
||||
const result = await pending;
|
||||
assert.equal(result, null);
|
||||
assert.equal(session.moshStatsConnFailed, true);
|
||||
});
|
||||
|
||||
test("accepts a trusted host key and adopts the connection", async () => {
|
||||
const { api, sessions } = makeApi({
|
||||
hostKeyVerifier: {
|
||||
describeHostKey: () => ({ keyType: "ssh-ed25519", fingerprint: "fp" }),
|
||||
classifyHostKey: () => ({ status: "trusted" }),
|
||||
},
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: { hostname: "h", username: "u", password: "secret" },
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
|
||||
let verdict;
|
||||
client.connectOpts.hostVerifier(Buffer.from("rawkey"), (ok) => { verdict = ok; });
|
||||
assert.equal(verdict, true);
|
||||
|
||||
client.emitReady();
|
||||
assert.equal(await pending, client);
|
||||
});
|
||||
|
||||
test("trusts a host vouched for ONLY by the system known_hosts (Netcatty snapshot empty)", async () => {
|
||||
// Netcatty's in-app vault has no record (classify -> unknown), but the user's
|
||||
// system OpenSSH known_hosts already trusts the exact live key — which is
|
||||
// what the Mosh handshake's system ssh actually used. The companion must
|
||||
// accept it so Mosh stats appear.
|
||||
const seen = [];
|
||||
const { api, sessions } = makeApi({
|
||||
hostKeyVerifier: {
|
||||
describeHostKey: () => ({ keyType: "ssh-ed25519", fingerprint: "live-fp" }),
|
||||
classifyHostKey: () => ({ status: "unknown" }),
|
||||
},
|
||||
isHostKeyTrustedBySystem: (args) => {
|
||||
seen.push(args);
|
||||
return args.hostname === "sys.example.com" && args.fingerprint === "live-fp";
|
||||
},
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "sys.example.com",
|
||||
port: 22,
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
knownHosts: [],
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
|
||||
let verdict;
|
||||
client.connectOpts.hostVerifier(Buffer.from("rawkey"), (ok) => { verdict = ok; });
|
||||
assert.equal(verdict, true);
|
||||
// The system check was consulted with the live key's fingerprint.
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].fingerprint, "live-fp");
|
||||
|
||||
client.emitReady();
|
||||
assert.equal(await pending, client);
|
||||
});
|
||||
|
||||
test("rejects (permanently) when NEITHER Netcatty nor the system known_hosts trust the key", async () => {
|
||||
const { api, sessions } = makeApi({
|
||||
hostKeyVerifier: {
|
||||
describeHostKey: () => ({ keyType: "ssh-ed25519", fingerprint: "live-fp" }),
|
||||
classifyHostKey: () => ({ status: "unknown" }),
|
||||
},
|
||||
isHostKeyTrustedBySystem: () => false,
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "untrusted.example.com",
|
||||
port: 22,
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
knownHosts: [],
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
|
||||
let verdict;
|
||||
client.connectOpts.hostVerifier(Buffer.from("rawkey"), (ok) => { verdict = ok; });
|
||||
assert.equal(verdict, false);
|
||||
|
||||
// ssh2 aborts; an untrusted host is a permanent failure (no re-poll loop).
|
||||
client.emitError(Object.assign(new Error("handshake failed"), { level: "protocol" }));
|
||||
assert.equal(await pending, null);
|
||||
assert.equal(session.moshStatsConnFailed, true);
|
||||
});
|
||||
|
||||
test("the system fallback is NOT consulted when Netcatty already trusts the key", async () => {
|
||||
// Netcatty says trusted -> accept without even touching the system files.
|
||||
let consulted = false;
|
||||
const { api, sessions } = makeApi({
|
||||
hostKeyVerifier: {
|
||||
describeHostKey: () => ({ keyType: "ssh-ed25519", fingerprint: "fp" }),
|
||||
classifyHostKey: () => ({ status: "trusted" }),
|
||||
},
|
||||
isHostKeyTrustedBySystem: () => {
|
||||
consulted = true;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "p", knownHosts: [] } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
let verdict;
|
||||
FakeSSHClient.instances[0].connectOpts.hostVerifier(Buffer.from("rawkey"), (ok) => { verdict = ok; });
|
||||
assert.equal(verdict, true);
|
||||
assert.equal(consulted, false);
|
||||
});
|
||||
|
||||
test("a Netcatty 'changed' key is NOT rescued by a non-matching system check (key rotation stays rejected)", async () => {
|
||||
// Netcatty flags a key rotation (changed). The system check is consulted with
|
||||
// the LIVE fingerprint; since the system does not record this exact new key,
|
||||
// it returns false and the connection is refused — the mismatch is never
|
||||
// silently accepted.
|
||||
const { api, sessions } = makeApi({
|
||||
hostKeyVerifier: {
|
||||
describeHostKey: () => ({ keyType: "ssh-ed25519", fingerprint: "rotated-fp" }),
|
||||
classifyHostKey: () => ({ status: "changed", expectedFingerprint: "old-fp" }),
|
||||
},
|
||||
isHostKeyTrustedBySystem: () => false,
|
||||
});
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", password: "p", knownHosts: [] } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
let verdict;
|
||||
FakeSSHClient.instances[0].connectOpts.hostVerifier(Buffer.from("rawkey"), (ok) => { verdict = ok; });
|
||||
assert.equal(verdict, false);
|
||||
});
|
||||
|
||||
test("works when isHostKeyTrustedBySystem is not wired in (optional dependency)", async () => {
|
||||
// Backward-compat: an api built without the system-known_hosts dependency
|
||||
// must not throw; it simply falls back to the Netcatty-only decision.
|
||||
const { api, sessions } = makeApi({
|
||||
hostKeyVerifier: {
|
||||
describeHostKey: () => ({ keyType: "ssh-ed25519", fingerprint: "fp" }),
|
||||
classifyHostKey: () => ({ status: "unknown" }),
|
||||
},
|
||||
isHostKeyTrustedBySystem: undefined,
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
knownHosts: [],
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
let verdict;
|
||||
FakeSSHClient.instances[0].connectOpts.hostVerifier(Buffer.from("rawkey"), (ok) => { verdict = ok; });
|
||||
assert.equal(verdict, false);
|
||||
});
|
||||
Reference in New Issue
Block a user