[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,219 @@
/* eslint-disable no-undef */
const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const SSH_EXEC_COMMAND_MAX_OUTPUT_BYTES = 1024 * 1024;
function createExecCommandApi(ctx) {
with (ctx) {
async function execCommand(event, payload) {
const enableKeyboardInteractive = !!payload.enableKeyboardInteractive;
const commandTimeoutMs = payload.timeout || 10000;
const { tcpConnectTimeoutMs, authReadyTimeoutMs } = resolveSshConnectionTimeouts(payload);
const sender = event.sender;
const sessionId = payload.sessionId || randomUUID();
const hasCertificate = typeof payload.certificate === "string" && payload.certificate.trim().length > 0;
const fallbackAgentSocket = payload.useSshAgent === false
? null
: payload.useSshAgent === true
? undefined
: await getAvailableAgentSocket();
const systemAuthAgent = hasCertificate
? null
: await prepareSystemSshAgentForAuth(payload, "[SSH Exec]");
const defaultKeys = enableKeyboardInteractive && !(systemAuthAgent && payload.identitiesOnly)
? await findAllDefaultPrivateKeysFromHelper()
: [];
let identityFilePrivateKey = null;
let identityFilePassphrase = null;
const inlineKey = payload.privateKey && !systemAuthAgent
? await preparePrivateKeyForAuth({
sender,
privateKey: payload.privateKey,
keyId: payload.keyId,
keyName: payload.keyId || payload.username,
hostname: payload.hostname,
initialPassphrase: payload.passphrase,
logPrefix: "[SSH Exec]",
})
: null;
if (!payload.privateKey && !systemAuthAgent && payload.identityFilePaths?.length > 0) {
for (const keyPath of payload.identityFilePaths) {
try {
const identityFile = await loadIdentityFileForAuth({
sender,
keyPath,
hostname: payload.hostname,
initialPassphrase: payload.passphrase,
logPrefix: "[SSH Exec]",
});
if (!identityFile) {
continue;
}
identityFilePrivateKey = identityFile.privateKey;
identityFilePassphrase = identityFile.passphrase || null;
break;
} catch (err) {
if (isPassphraseCancelledError(err)) {
throw err;
}
console.warn("[SSH Exec] Failed to read identity file:", err?.message || err);
}
}
}
return new Promise((resolve, reject) => {
const conn = new SSHClient();
let settled = false;
let authReadyTimer = null;
let commandController = null;
const clearTimers = () => {
if (authReadyTimer) clearTimeout(authReadyTimer);
authReadyTimer = null;
};
const rejectConnection = (err) => {
if (settled) return;
settled = true;
clearTimers();
commandController?.abort?.(err);
conn.end();
reject(err);
};
conn
.once("connect", () => {
runWhenProxyConnectionReady(conn._sock, () => {
try { conn._sock?.setTimeout?.(0); } catch { /* ignore */ }
authReadyTimer = setTimeout(() => {
rejectConnection(new Error(`SSH authentication timeout to ${payload.hostname}`));
}, authReadyTimeoutMs);
authReadyTimer.unref?.();
});
})
.once("ready", () => {
if (authReadyTimer) clearTimeout(authReadyTimer);
authReadyTimer = null;
commandController = new AbortController();
void executeBoundedSshCommand(conn, payload.command, {
signal: commandController.signal,
openingTimeoutMs: commandTimeoutMs,
runTimeoutMs: commandTimeoutMs,
maxOutputBytes: SSH_EXEC_COMMAND_MAX_OUTPUT_BYTES,
}).then((result) => {
if (settled) return;
settled = true;
clearTimers();
conn.end();
resolve({
stdout: result.stdout,
stderr: result.stderr,
code: result.code ?? (result.stderr ? 1 : 0),
});
}, rejectConnection);
})
.on("error", (err) => {
rejectConnection(err);
})
.once("timeout", () => {
rejectConnection(new Error(`SSH connection timeout to ${payload.hostname}`));
})
.once("end", () => {
if (settled) return;
rejectConnection(new Error("SSH connection closed unexpectedly"));
});
const connectOpts = {
host: payload.hostname,
port: payload.port || 22,
username: payload.username,
timeout: tcpConnectTimeoutMs,
readyTimeout: 0,
keepaliveInterval: 0,
// Honor the host's algorithm settings so one-off commands (e.g. the
// keychain "export public key to host" flow) negotiate with the same
// KEX / cipher / host-key set as the interactive terminal. Without
// this, a host that needs the ECDSA skip or legacy algorithms would
// connect in the terminal but still fail the same handshake here.
algorithms: buildAlgorithms(payload.legacyAlgorithms, {
skipEcdsaHostKey: payload.skipEcdsaHostKey,
algorithmOverrides: payload.algorithmOverrides,
}),
};
let authAgent = null;
const effectivePrivateKey = inlineKey?.privateKey || identityFilePrivateKey;
const effectivePassphrase = inlineKey?.passphrase || identityFilePassphrase;
if (systemAuthAgent) {
connectOpts.agent = systemAuthAgent;
} else if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
meta: {
label: payload.keyId || payload.username || "",
certificate: payload.certificate,
privateKey: effectivePrivateKey,
passphrase: effectivePassphrase,
},
});
connectOpts.agent = authAgent;
} else if (effectivePrivateKey) {
connectOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connectOpts.passphrase = effectivePassphrase;
}
}
if (payload.password) connectOpts.password = payload.password;
let authBanner = "";
if (enableKeyboardInteractive) {
connectOpts.tryKeyboard = true;
const authConfig = buildAuthHandler({
authMethod: payload.authMethod,
requiresMfa: !!payload.requiresMfa,
privateKey: connectOpts.privateKey,
password: connectOpts.password,
passphrase: connectOpts.passphrase,
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[SSH Exec]",
defaultKeys,
sshAgentSocketOverride: fallbackAgentSocket,
allowAgentFallback: payload.useSshAgent !== false,
});
applyAuthToConnOpts(connectOpts, authConfig);
const execAuthPhase = authConfig.authPhase || { hadPartialSuccess: false };
conn.on("banner", (message) => {
authBanner = String(message || "").trim();
});
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
sender,
sessionId,
hostId: payload.hostId,
hostname: payload.hostname,
password: payload.password,
logPrefix: "[SSH Exec]",
scope: "external",
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(execAuthPhase),
}));
} else if (connectOpts.agent) {
const order = ["agent"];
if (connectOpts.password) order.push("password");
connectOpts.authHandler = order;
}
conn.connect(connectOpts);
});
}
return { execCommand };
}
}
module.exports = { createExecCommandApi, SSH_EXEC_COMMAND_MAX_OUTPUT_BYTES };

View File

@@ -0,0 +1,136 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { createExecCommandApi } = require("./execCommand.cjs");
function createHarness({ identitiesOnly }) {
const preparedAgent = { kind: "system-agent" };
const calls = {
connectOptions: null,
defaultKeyScans: 0,
identityFileLoads: 0,
inlineKeyLoads: 0,
endCount: 0,
authConfig: null,
agentSocketChecks: 0,
};
class MockSSHClient extends EventEmitter {
connect(options) {
calls.connectOptions = options;
queueMicrotask(() => this.emit("error", new Error("stop after options capture")));
}
end() {
calls.endCount += 1;
}
exec() {}
}
const api = createExecCommandApi({
SSHClient: MockSSHClient,
NetcattyAgent: class {},
randomUUID: () => "exec-test",
console,
setTimeout,
clearTimeout,
Error,
prepareSystemSshAgentForAuth: async (payload) => {
if (payload.useSshAgent !== true) return null;
assert.equal(payload.useSshAgent, true);
assert.equal(payload.identitiesOnly, identitiesOnly);
return preparedAgent;
},
getAvailableAgentSocket: async () => {
calls.agentSocketChecks += 1;
return null;
},
findAllDefaultPrivateKeysFromHelper: async () => {
calls.defaultKeyScans += 1;
return [{ privateKey: "default-key" }];
},
preparePrivateKeyForAuth: async () => {
calls.inlineKeyLoads += 1;
return { privateKey: "inline-key" };
},
loadIdentityFileForAuth: async () => {
calls.identityFileLoads += 1;
return { privateKey: "identity-file-key" };
},
isPassphraseCancelledError: () => false,
resolveSshConnectionTimeouts: () => ({
tcpConnectTimeoutMs: 20000,
authReadyTimeoutMs: 120000,
}),
buildAlgorithms: () => undefined,
buildAuthHandler: (options) => {
calls.authConfig = options;
return { authHandler: ["agent"], agent: options.agent };
},
applyAuthToConnOpts: (connectOptions, authConfig) => {
connectOptions.agent = authConfig.agent;
connectOptions.authHandler = authConfig.authHandler;
},
createKeyboardInteractiveHandler: () => () => {},
});
return { api, calls, preparedAgent };
}
test("execCommand disables an unavailable optional agent before automatic fallback", async () => {
const { api, calls } = createHarness({ identitiesOnly: false });
await assert.rejects(
() => api.execCommand(
{ sender: {} },
{
hostname: "example.test",
username: "alice",
command: "true",
authMethod: "auto",
password: "fallback-password",
enableKeyboardInteractive: true,
timeout: 10,
},
),
/stop after options capture/,
);
assert.equal(calls.agentSocketChecks, 1);
assert.equal(calls.authConfig.sshAgentSocketOverride, null);
});
for (const identitiesOnly of [true, false]) {
test(`execCommand uses the system agent without reading private keys (identitiesOnly=${identitiesOnly})`, async () => {
const { api, calls, preparedAgent } = createHarness({ identitiesOnly });
await assert.rejects(
() => api.execCommand(
{ sender: {} },
{
hostname: "example.test",
username: "alice",
command: "true",
privateKey: "encrypted-inline-key",
identityFilePaths: ["/keys/encrypted-key"],
useSshAgent: true,
identitiesOnly,
enableKeyboardInteractive: true,
timeout: 10,
},
),
/stop after options capture/,
);
assert.equal(calls.inlineKeyLoads, 0);
assert.equal(calls.identityFileLoads, 0);
assert.equal(calls.defaultKeyScans, identitiesOnly ? 0 : 1);
assert.equal(calls.authConfig.agent, preparedAgent);
assert.deepEqual(calls.authConfig.defaultKeys, identitiesOnly ? [] : [{ privateKey: "default-key" }]);
assert.equal(calls.connectOptions.agent, preparedAgent);
assert.deepEqual(calls.connectOptions.authHandler, ["agent"]);
assert.equal(calls.endCount, 1);
});
}

View File

@@ -0,0 +1,507 @@
/* eslint-disable no-undef */
/**
* Companion SSH connection for Mosh sessions.
*
* A Mosh session runs over UDP via a local `mosh-client` PTY and therefore
* has no ssh2 `Client` (`session.conn`) — the very thing `getServerStats`
* needs to run its periodic `/proc`-based stats command on an exec channel.
* Without it the terminal's host-info bar (CPU / memory / disk / network)
* stays empty for Mosh, while SSH sessions show it (issue #1198).
*
* This module lazily opens a *second*, stats-only ssh2 connection to the
* same host using the same credentials the Mosh handshake already used, and
* stores it as `session.conn` so the existing `getServerStats` code path
* works unchanged. It is intentionally best-effort and non-interactive:
*
* - It never prompts the user for a password or key passphrase. The Mosh
* handshake (driven by the system `ssh` in the user's PTY) is where the
* real, interactive auth happens; this companion only reuses credentials
* Netcatty already holds (stored password, parseable private key,
* unencrypted / stored-passphrase identity files, ssh-agent).
* - If it cannot authenticate or connect, it fails silently and records
* the failure so it does not hammer the host on every stats poll. Mosh
* keeps working; only the stats bar stays empty (graceful degradation).
*
* Security — host-key handling:
* - The companion connects ONLY to a host whose live key is already
* "trusted" in Netcatty's known-hosts store. A host verifier classifies
* the key during the transport handshake and REJECTS the connection for an
* unknown / changed key — for every auth method, not just password. This is
* done silently and never prompts: the user vets and trusts a host key
* through the real interactive session (#1191), and this background stats
* poll only ever rides on a host that vetting already approved. An
* untrusted host just leaves the stats bar empty (graceful degradation).
* - Rejecting outright (rather than merely withholding the password) is
* deliberate. Even though public-key / ssh-agent auth discloses no reusable
* secret and its signature is session-bound, a *background, user-invisible*
* connection that authenticated against an unverified host would still run
* the stats command there — letting a MITM / DNS-spoofed host feed bogus
* host-info to the user and enumerate the agent's public keys. That breaks
* the same host-key guarantee the interactive session enforces, so the
* companion refuses unvetted hosts regardless of auth method.
* - A gated authHandler additionally withholds the plaintext password until
* the verifier has confirmed trust, as defense in depth.
*/
function createMoshStatsConnectionApi(ctx) {
// Read off ctx (not via the `with` scope) so an absent dependency reads as
// `undefined` instead of throwing a ReferenceError under `with`. Optional:
// when not wired in (e.g. older callers / some unit tests) the verifier
// simply skips the system-known_hosts fallback.
const isHostKeyTrustedBySystem = ctx.isHostKeyTrustedBySystem;
with (ctx) {
// Resolve a usable, non-interactive private key (+ passphrase) for the
// companion connection. Returns null when the key is missing, encrypted
// without a usable stored passphrase, or otherwise unparseable — the
// caller then falls back to password / agent auth or gives up.
function resolveNonInteractiveKey(privateKey, passphrase) {
if (typeof privateKey !== "string" || privateKey.trim().length === 0) {
return null;
}
try {
const parsed = sshUtils.parseKey(privateKey, passphrase);
if (parsed && !(parsed instanceof Error)) {
return { privateKey, passphrase: passphrase || undefined };
}
} catch {
// parseKey throws on malformed input — treat as unusable.
}
return null;
}
// Read identity files from disk without prompting. Only unencrypted keys
// (or keys whose stored passphrase parses them) are returned.
async function resolveNonInteractiveIdentityFiles(identityFilePaths, passphrase) {
if (!Array.isArray(identityFilePaths) || identityFilePaths.length === 0) {
return [];
}
const keys = [];
for (const rawPath of identityFilePaths) {
if (typeof rawPath !== "string" || rawPath.trim().length === 0) continue;
const resolvedPath = expandIdentityFilePath(rawPath);
let content;
try {
content = await readFileNoFollow(resolvedPath);
} catch {
continue;
}
if (!content) continue;
const key = resolveNonInteractiveKey(content, passphrase);
if (key) keys.push(key);
}
return keys;
}
// An ssh2 hostVerifier that ACCEPTS the transport only when the live host
// key is already trusted — by Netcatty's in-app known-hosts store OR by the
// user's *system* OpenSSH known_hosts — and REJECTS it for an unknown /
// changed key. It never prompts — an untrusted host fails the background
// companion silently (stats stay empty) instead of popping a modal the user
// can't meaningfully answer for a stats poll.
//
// Why also consult the system known_hosts: a Mosh session is bootstrapped
// by the system `ssh`, which records (and vets, via its own prompt) the
// host key in `~/.ssh/known_hosts`. Netcatty's vault snapshot is NOT updated
// by that handshake, so a host the user trusted purely through system ssh
// would otherwise be misread as "unknown" and the companion permanently
// disabled — leaving the stats bar empty even though the system already
// trusts the exact key. We match the LIVE key's SHA-256 fingerprint against
// those files, so this only ever grants trust for the precise key the user's
// own OpenSSH already trusts; it never accepts an arbitrary or mismatched
// key. Unknown / changed keys stay rejected.
//
// Rejecting (not merely gating password auth) is required: a background,
// user-invisible connection that completed key/agent auth against an
// unverified host would still run the stats command there, letting a MITM /
// DNS-spoofed host feed bogus host-info and enumerate the agent's public
// keys. `trust.trusted` additionally gates the password method in the
// authHandler (defense in depth); `trust.rejected` lets the caller treat an
// untrusted host as a permanent failure so it stops reconnecting every poll.
function createTrustEnforcingHostVerifier({ hostname, port, knownHosts, verifyHostKeys = true, trust, label }) {
return (rawKey, callback) => {
if (verifyHostKeys === false) {
trust.trusted = true;
callback(true);
return;
}
try {
const keyInfo = hostKeyVerifier.describeHostKey(rawKey);
const decision = hostKeyVerifier.classifyHostKey({
knownHosts: Array.isArray(knownHosts) ? knownHosts : [],
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
});
trust.trusted = decision.status === "trusted";
// Fall back to the system OpenSSH known_hosts (Mosh's real trust
// source) only when Netcatty's snapshot does not already vouch for
// the key. Matching is by the live key's fingerprint, so this can
// only confirm — never override a mismatch into acceptance.
if (!trust.trusted && isHostKeyTrustedBySystem) {
trust.trusted = isHostKeyTrustedBySystem({
hostname,
port,
fingerprint: keyInfo.fingerprint,
}) === true;
}
} catch (err) {
log(`[${label}] stats companion host-key check failed:`, err?.message || String(err));
trust.trusted = false;
}
if (!trust.trusted) trust.rejected = true;
callback(trust.trusted);
};
}
// A function-form ssh2 authHandler that offers, in order: none, agent (if
// available), publickey (if a key was resolved), and — only when the host
// key is trusted — password and keyboard-interactive. Agent/password
// methods use names from connectOpts; identity files use explicit key
// objects so every discovered key can be attempted. This is what actually
// withholds the password from an untrusted host while still letting
// key/agent auth succeed.
function createGatedAuthHandler({ hasAgent, keys, hasPassword, trust, username }) {
const methods = ["none"];
if (hasAgent) methods.push("agent");
for (const key of keys) {
methods.push({
type: "publickey",
username,
key: key.privateKey,
passphrase: key.passphrase,
});
}
let index = 0;
let trustedMethodsAppended = false;
return (_methodsLeft, _partialSuccess, callback) => {
// Append the password methods lazily, the first time we run out of the
// always-allowed ones, so the trust flag (set by the verifier during
// the transport handshake) is up to date.
if (index >= methods.length && !trustedMethodsAppended) {
trustedMethodsAppended = true;
if (hasPassword && trust.trusted) {
methods.push("password", "keyboard-interactive");
}
}
if (index >= methods.length) {
callback(false);
return;
}
callback(methods[index++]);
};
}
async function buildStatsConnectOpts(auth, label = "Mosh") {
const connectOpts = {
host: auth.hostname,
port: auth.port || 22,
username: auth.username || "root",
// Stats are a background nicety — keep the timeout short so a slow or
// firewalled host fails fast instead of holding a poll for 30s+.
readyTimeout: 10000,
keepaliveInterval: 0,
// Honor the host's algorithm settings so the companion negotiates the
// same KEX / cipher / host-key set as the interactive session would.
algorithms: buildAlgorithms(auth.legacyAlgorithms, {
skipEcdsaHostKey: auth.skipEcdsaHostKey,
algorithmOverrides: auth.algorithmOverrides,
}),
};
const hasCertificate =
typeof auth.certificate === "string" && auth.certificate.trim().length > 0;
const allowLocalKeyFallbackWithAgent = auth.authMethod === "auto";
const inlineKey = auth.useSshAgent && !allowLocalKeyFallbackWithAgent
? null
: resolveNonInteractiveKey(auth.privateKey, auth.passphrase);
const keys = inlineKey
? [inlineKey]
: auth.useSshAgent && !allowLocalKeyFallbackWithAgent
? []
: await resolveNonInteractiveIdentityFiles(auth.identityFilePaths, auth.passphrase);
const key = keys[0] || null;
let agent = null;
if (hasCertificate && key) {
try {
agent = new NetcattyAgent({
mode: "certificate",
webContents: auth.webContents,
meta: {
label: auth.keyId || auth.username || "",
certificate: auth.certificate,
privateKey: key.privateKey,
passphrase: key.passphrase,
},
});
connectOpts.agent = agent;
} catch {
// Certificate could not be parsed non-interactively — fall through
// to plain key / password auth below.
agent = null;
}
}
if (!agent && key) {
connectOpts.privateKey = key.privateKey;
if (key.passphrase) connectOpts.passphrase = key.passphrase;
}
if (!agent && auth.useSshAgent && typeof prepareSystemSshAgentForAuth === "function") {
connectOpts.agent = await prepareSystemSshAgentForAuth(auth, `[${label} Stats]`);
}
if (typeof auth.password === "string" && auth.password.length > 0) {
connectOpts.password = auth.password;
// Many SSH servers (PAM-backed) only offer password auth through
// keyboard-interactive, not the plain "password" method. The Mosh
// handshake's system ssh handles that via its PTY responder, so the
// companion must too — otherwise stats stay empty on those hosts
// despite a saved password. The handler (attached at connect time)
// auto-fills non-interactively and never shows a prompt.
connectOpts.tryKeyboard = true;
}
// ssh-agent fallback whenever a socket is available and no *explicit*
// key / certificate-agent was resolved. The Mosh handshake runs the
// system `ssh` with the inherited environment, so it authenticates via
// the local ssh-agent by default — independent of agentForwarding
// (which only controls *remote* forwarding). This is offered alongside
// any saved password (ssh2 tries agent before password), so a
// public-key host that also happens to have a stored password still
// authenticates via the agent instead of failing on password-only.
if (auth.useSshAgent !== false && !connectOpts.agent && !agent && !inlineKey) {
const agentSocket = getSshAgentSocket(auth.identityAgent);
if (agentSocket) {
connectOpts.agent = agentSocket;
}
}
const hasAnyAuth = Boolean(
connectOpts.agent || connectOpts.privateKey || connectOpts.password,
);
// Always install a host verifier that refuses an untrusted host, for
// EVERY auth method — a background, user-invisible companion must never
// authenticate to or run commands against a host Netcatty has not vetted
// (it could feed bogus host-info or enumerate agent keys), even though
// key/agent auth discloses no reusable secret.
const trust = { trusted: false, rejected: false };
connectOpts.hostVerifier = createTrustEnforcingHostVerifier({
hostname: connectOpts.host,
port: connectOpts.port,
knownHosts: auth.knownHosts,
verifyHostKeys: auth.verifyHostKeys,
trust,
label,
});
// When a plaintext password is in play, also gate it behind the trust
// flag in the authHandler (defense in depth): key/agent methods are
// offered first, and the password / keyboard-interactive methods only
// once the verifier has confirmed the host key is trusted.
const authKeys = agent ? [] : keys;
if (connectOpts.password || authKeys.length > 1) {
connectOpts.authHandler = createGatedAuthHandler({
hasAgent: Boolean(connectOpts.agent),
keys: authKeys,
hasPassword: Boolean(connectOpts.password),
trust,
username: connectOpts.username,
});
}
return { connectOpts, hasAnyAuth, trust };
}
/**
* Ensure a Mosh session has a usable stats companion connection.
*
* Returns the ssh2 Client on success (also stored on
* `session.moshStatsConn`), or null when one could not be established.
*
* The companion is stored ONLY on `session.moshStatsConn`, deliberately
* NOT on `session.conn`: other bridges treat `session.conn` as the
* session's primary interactive SSH connection (getSessionPwd assumes its
* exec channel is a sibling of the interactive shell; SFTP / MCP exec run
* over it). A Mosh session's interactive shell lives on the UDP
* mosh-client, not on this background stats connection, so exposing it as
* `session.conn` would make those paths return bogus results or run over
* the wrong connection. Only getServerStats reads `session.moshStatsConn`.
*
* Safe to call repeatedly: concurrent calls share a single in-flight
* attempt, and a permanent failure is cached so later polls don't
* reconnect on every tick.
*
* @param {object} session - the shared session record
* @param {string} sessionId - the session's key in the shared sessions map
* @param {Electron.WebContents} [webContents] - sender of the stats IPC,
* used only for certificate-agent construction.
*/
function ensureStatsConnection(session, sessionId, webContents, opts) {
if (!session) return Promise.resolve(null);
// A previously established companion is reused.
if (session[opts.connProp]) return Promise.resolve(session[opts.connProp]);
// A prior attempt permanently failed — don't keep retrying every poll.
if (session[opts.failedProp]) return Promise.resolve(null);
// Reuse an in-flight attempt so two near-simultaneous polls don't open
// two connections.
if (session[opts.promiseProp]) return session[opts.promiseProp];
const promise = establishStatsConnection(session, sessionId, webContents, opts).finally(() => {
session[opts.promiseProp] = null;
});
session[opts.promiseProp] = promise;
return promise;
}
function ensureMoshStatsConnection(session, sessionId, webContents) {
return ensureStatsConnection(session, sessionId, webContents, {
label: "Mosh",
authProp: "moshStatsAuth",
connProp: "moshStatsConn",
failedProp: "moshStatsConnFailed",
promiseProp: "moshStatsConnPromise",
});
}
function ensureEtStatsConnection(session, sessionId, webContents) {
return ensureStatsConnection(session, sessionId, webContents, {
label: "ET",
authProp: "etStatsAuth",
connProp: "etStatsConn",
failedProp: "etStatsConnFailed",
promiseProp: "etStatsConnPromise",
});
}
// True once the session has gone away — either explicitly closed or
// dropped from the shared map (e.g. the mosh-client PTY exited while we
// were still connecting the companion).
function sessionGone(session, sessionId) {
return session.closed || sessions.get(sessionId) !== session;
}
async function establishStatsConnection(session, sessionId, webContents, opts) {
const auth = session[opts.authProp];
if (!auth || !auth.hostname) {
// moshStatsAuth is only assigned once the handshake completes and the
// session swaps to mosh-client. The renderer can mark a session
// "connected" (and start polling) from the SSH bootstrap's visible
// PTY output *before* that swap, so a missing auth here is transient —
// do NOT permanently disable stats, or the companion would never be
// attempted after the handshake finishes.
return null;
}
const { connectOpts, hasAnyAuth, trust } = await buildStatsConnectOpts({
...auth,
webContents,
}, opts.label);
if (!hasAnyAuth) {
// Nothing we can authenticate with non-interactively (e.g. the user
// typed a password into the Mosh handshake PTY that we never stored).
session[opts.failedProp] = true;
return null;
}
// The session may have been closed while we were reading identity files.
if (sessionGone(session, sessionId)) {
return null;
}
return new Promise((resolve) => {
const conn = new SSHClient();
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
// Non-interactive keyboard-interactive auth: auto-fill the saved
// password for a single password prompt and never show a modal. On a
// 2FA / multi-prompt / OTP challenge we finish empty so ssh2 moves on
// to the next method (or fails) instead of hanging on a prompt the
// user can't answer for a background connection.
if (connectOpts.tryKeyboard && connectOpts.password) {
// Only auto-fill once. If the password was wrong, ssh2 may re-issue
// the challenge; finishing empty on the retry lets auth fail cleanly
// instead of looping on the same wrong password.
let autoFilledOnce = false;
conn.on("keyboard-interactive", (_name, _instr, _lang, prompts, finishKbd) => {
if (!autoFilledOnce && isAutoFillablePasswordChallenge(prompts, connectOpts.password)) {
autoFilledOnce = true;
finishKbd([connectOpts.password]);
} else {
finishKbd([]);
}
});
}
// `permanent` distinguishes futile retries (auth rejected, or a throw
// building the connection) from transient ones (network blip,
// timeout). Only the former disables stats for the session's
// lifetime; transient errors just skip this poll and let the next one
// retry.
const fail = (err, permanent) => {
try { conn.end(); } catch { /* ignore */ }
if (permanent) session[opts.failedProp] = true;
finish(null);
};
conn.once("ready", () => {
// The session may have been closed while we were connecting.
if (sessionGone(session, sessionId)) {
try { conn.end(); } catch { /* ignore */ }
finish(null);
return;
}
// Stored only on the protocol-specific companion property
// (opts.connProp, e.g. moshStatsConn / etStatsConn) — never on
// session.conn (see ensureMoshStatsConnection docstring for why).
session[opts.connProp] = conn;
finish(conn);
});
conn.on("error", (err) => {
log(`[${opts.label}] stats companion connection error:`, err?.message || String(err));
// If this fired after we already adopted the connection, drop the
// stale handle so the next poll can rebuild a fresh one.
if (session[opts.connProp] === conn) session[opts.connProp] = null;
// Auth rejection won't change with the same stored credentials, and a
// host-key rejection (untrusted host) won't either until the user
// vets the host via a real session — treat both as permanent so we
// stop reconnecting on every poll. Everything else may be transient.
fail(err, err?.level === "client-authentication" || trust.rejected);
});
conn.on("close", () => {
if (session[opts.connProp] === conn) session[opts.connProp] = null;
// If the socket closed mid-handshake without ever emitting "ready"
// or "error", settle the attempt here so the awaiting getServerStats
// call (and the in-flight promise on opts.promiseProp) don't hang
// forever. This
// is treated as transient — the next poll may retry.
finish(null);
});
try {
conn.connect(connectOpts);
} catch (err) {
log(`[${opts.label}] stats companion connect threw:`, err?.message || String(err));
// A synchronous throw from connect() (e.g. malformed options) won't
// succeed on retry either.
fail(err, true);
}
});
}
return { ensureMoshStatsConnection, ensureEtStatsConnection };
}
}
module.exports = { createMoshStatsConnectionApi };

View 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);
});

View File

@@ -0,0 +1,14 @@
"use strict";
function selectServerStatsFixtureOutput(command, stdout) {
const parts = String(stdout || "").split("|");
const diskPart = parts.find((part) => part.startsWith("DISKS:")) || "DISKS:";
const baseOutput = parts.filter((part) => !part.startsWith("DISKS:")).join("|");
if (command.includes('echo "DISKS:$disks"')) return diskPart;
if (command.includes("NC_LATENCY_MARK") && !baseOutput.includes("NC_LATENCY_MARK")) {
return `NC_LATENCY_MARK|${baseOutput}`;
}
return baseOutput;
}
module.exports = { selectServerStatsFixtureOutput };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,781 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { createSessionOpsApi, decodeLsofFileName } = require("./sessionOps.cjs");
function quoteShellArg(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'";
}
function makePwdStream(cwd, loginPid) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
setImmediate(() => {
stream.emit("data", Buffer.from(`${cwd}\n`));
stream.stderr.emit("data", Buffer.from(`NETCATTY_LOGIN_PID=${loginPid}\n`));
stream.emit("close", 0);
});
return stream;
}
function makeApi(session, siblingSessions = [], overrides = {}) {
const sessions = overrides.sessions || new Map([
["session-1", session],
...siblingSessions,
]);
return createSessionOpsApi({
sessions,
setTimeout: overrides.setTimeout || setTimeout,
clearTimeout: overrides.clearTimeout || clearTimeout,
quoteShellArg,
log: () => {},
});
}
test("shared terminal cwd probe refuses to guess without a shell pid", async () => {
let execCalls = 0;
const connRef = { count: 2 };
const api = makeApi({
connRef,
stream: {},
conn: {
exec() { execCalls += 1; },
},
}, [["session-2", { connRef, stream: {} }]]);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(result.success, false);
assert.match(result.error, /ambiguous/);
assert.equal(execCalls, 0);
});
test("shared terminal cwd probe targets the shell pid assigned to that tab", async () => {
let command = "";
const session = {
shellPid: "4242",
connRef: { count: 2 },
stream: {},
conn: {
exec(nextCommand, callback) {
command = nextCommand;
callback(null, makePwdStream("/srv/copied-tab", "4242"));
},
},
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.deepEqual(result, { success: true, cwd: "/srv/copied-tab" });
assert.match(command, /TARGET_LOGIN=4242/);
assert.ok(command.includes("sub(/^.*\\//"));
assert.ok(command.includes("$3 !~ /^\\?+$/"));
assert.match(command, /LC_ALL=C lsof/);
assert.equal(session.shellPid, "4242");
});
test("lsof cwd output decodes UTF-8 bytes and escaped control characters", () => {
assert.equal(
decodeLsofFileName("/tmp/\\xe4\\xb8\\xad\\xe6\\x96\\x87"),
"/tmp/中文",
);
assert.equal(decodeLsofFileName("/tmp/line1\\nline2\\\\tail"), "/tmp/line1\nline2\\tail");
assert.equal(decodeLsofFileName("/tmp/bad\\xQZ"), null);
assert.equal(decodeLsofFileName("/tmp/control-^G-name"), null);
});
test("macOS ps shell names and multi-character no-tty markers classify correctly", () => {
const awk = String.raw`
function isshell(c) { sub(/^.*\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
isshell($4) { print $1, ($3 !~ /^\?+$/ ? "tty" : "no-tty") }
`;
const { spawnSync } = require("node:child_process");
const result = spawnSync("awk", [awk], {
input: "4242 100 ttys001 /bin/zsh\n4243 100 ?? /bin/sh\n",
encoding: "utf8",
});
assert.equal(result.status, 0);
assert.equal(result.stdout, "4242 tty\n4243 no-tty\n");
});
test("session cwd probe decodes the marked lsof pathname", async () => {
const session = {
shellPid: "4242",
connRef: { count: 1 },
stream: {},
conn: {
exec(_command, callback) {
callback(null, makePwdStream(
"NETCATTY_LSOF_CWD=/srv/\\xe4\\xb8\\xad\\xe6\\x96\\x87",
"4242",
));
},
},
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.deepEqual(result, { success: true, cwd: "/srv/中文" });
});
test("session cwd probe closes a remote command that exceeds its timeout", async () => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
let closed = false;
stream.close = () => { closed = true; };
const session = {
shellPid: "4242",
connRef: { count: 1 },
stream: {},
conn: { exec(_command, callback) { callback(null, stream); } },
};
const api = makeApi(session, [], {
setTimeout(callback) { setImmediate(callback); return 1; },
clearTimeout() {},
});
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.deepEqual(result, { success: false, error: "Timeout getting pwd" });
assert.equal(closed, true);
});
test("session cwd probe honors a caller-provided timeout budget", async () => {
const timeouts = [];
const session = {
shellPid: "4242",
connRef: { count: 1 },
stream: {},
conn: {
exec(_command, callback) {
callback(null, makePwdStream("/srv/project", "4242"));
},
},
};
const api = makeApi(session, [], {
setTimeout(callback, timeoutMs) {
timeouts.push(timeoutMs);
return setTimeout(callback, timeoutMs);
},
});
const result = await api.getSessionPwd(null, { sessionId: "session-1", timeoutMs: 1234 });
assert.deepEqual(result, { success: true, cwd: "/srv/project" });
assert.ok(timeouts.includes(1234));
});
test("session cwd probe closes a stream returned after its timeout", async () => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
let closed = false;
stream.close = () => { closed = true; };
const session = {
shellPid: "4242",
connRef: { count: 1 },
stream: {},
conn: { exec(_command, callback) { setImmediate(() => callback(null, stream)); } },
};
const api = makeApi(session, [], {
setTimeout(callback) { setImmediate(callback); return 1; },
clearTimeout() {},
});
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(result, { success: false, error: "Timeout getting pwd" });
assert.equal(closed, true);
});
test("an unshared terminal remembers the shell pid discovered by its cwd probe", async () => {
const session = {
connRef: { count: 1 },
stream: {},
conn: {
exec(_command, callback) {
callback(null, makePwdStream("/home/alice/project", "3131"));
},
},
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.deepEqual(result, { success: true, cwd: "/home/alice/project" });
assert.equal(session.shellPid, "3131");
});
test("cwd probe forces POSIX sh, captures sshd PPID, and keeps the watchdog", async () => {
let command = "";
const session = {
connRef: { count: 1 },
stream: {},
conn: {
exec(nextCommand, callback) {
command = nextCommand;
callback(null, makePwdStream("/home/alice/project", "3131"));
},
},
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.deepEqual(result, { success: true, cwd: "/home/alice/project" });
assert.ok(command.startsWith("exec sh -c "), command.slice(0, 80));
assert.ok(command.includes("export NC_SSHD_PPID=$PPID"));
assert.ok(command.includes("( sleep 5 &&"), "cwd watchdog bound must match the 5s default timeout");
assert.ok(command.includes('kill -9 $nc_tree "$$"'));
assert.ok(command.includes(") </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!"));
assert.ok(command.includes('kill -9 $nc_kids "$nc_watchdog_pid"'));
assert.ok(command.includes('find_login_shell "${NC_SSHD_PPID:-$PPID}"'));
});
test("immediate parked reconnect does not guess cwd from an exiting shell", async () => {
let execCalls = 0;
const session = {
blockUntargetedCwdProbe: true,
connRef: { count: 1 },
stream: {},
conn: { exec() { execCalls += 1; } },
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(result.success, false);
assert.equal(execCalls, 0);
assert.equal(session.shellPid, undefined);
});
test("parked reconnect recovers cwd only after the new shell is unambiguous", async () => {
let scan = ["111", "222", "333"];
let scanCalls = 0;
let targetedPwdCalls = 0;
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef: { count: 1 },
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
scanCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from(`${scan.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`));
stream.emit("close", 0);
});
return;
}
targetedPwdCalls += 1;
assert.match(command, /TARGET_LOGIN=222/);
callback(null, makePwdStream("/new/cwd", "222"));
},
},
};
const api = makeApi(session);
const ambiguous = await api.getSessionPwd(null, {
sessionId: "session-1",
});
assert.equal(ambiguous.success, false);
assert.equal(session.shellPid, undefined);
assert.equal(targetedPwdCalls, 0);
scan = ["222"];
const withoutAnotherCommand = await api.getSessionPwd(null, {
sessionId: "session-1",
});
assert.equal(withoutAnotherCommand.success, false);
assert.equal(scanCalls, 1, "an ambiguous attempt consumes its command/output signal");
// A failed/ambiguous attempt consumes the command/output signal. A later
// real command can safely authorize one new uniqueness check.
session.allowCwdRecovery = true;
const recovered = await api.getSessionPwd(null, {
sessionId: "session-1",
});
assert.deepEqual(recovered, { success: true, cwd: "/new/cwd" });
assert.equal(session.shellPid, "222");
assert.equal(session.blockUntargetedCwdProbe, false);
assert.equal(targetedPwdCalls, 1);
assert.equal(scanCalls, 2);
});
test("parked reconnect never binds a scan that only sees the old shell", async () => {
let targetedPwdCalls = 0;
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef: { count: 1 },
stream: {},
conn: {
exec(command, callback) {
if (!command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
targetedPwdCalls += 1;
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("111\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
},
},
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(result.success, false);
assert.equal(session.shellPid, undefined);
assert.equal(targetedPwdCalls, 0);
});
test("parked reconnect with an unknown old shell remains fail closed", async () => {
let execCalls = 0;
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: [], hasUnknownOldShell: true },
connRef: { count: 1 },
stream: {},
conn: { exec() { execCalls += 1; } },
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(result.success, false);
assert.equal(execCalls, 0);
assert.equal(session.shellPid, undefined);
});
test("parked reconnect does not scan while another terminal shares the transport", async () => {
let execCalls = 0;
const connRef = { count: 2, shellCloseGeneration: 0 };
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: { exec() { execCalls += 1; } },
};
const api = makeApi(session, [["session-2", { connRef, stream: {} }]]);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(result.success, false);
assert.equal(execCalls, 0);
assert.equal(session.shellPid, undefined);
});
test("a shell close during recovery invalidates the PID scan result", async () => {
let scanStream;
let targetedPwdCalls = 0;
const connRef = {
count: 1,
shellCloseGeneration: 0,
closedShellPids: new Set(),
closedShellPidUnknown: false,
};
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (!command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
targetedPwdCalls += 1;
return;
}
scanStream = new EventEmitter();
scanStream.stderr = new EventEmitter();
scanStream.close = () => {};
callback(null, scanStream);
},
},
};
const api = makeApi(session);
const pending = api.getSessionPwd(null, { sessionId: "session-1" });
connRef.shellCloseGeneration += 1;
connRef.closedShellPids.add("333");
scanStream.emit("data", Buffer.from("333\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
scanStream.emit("close", 0);
const result = await pending;
assert.equal(result.success, false);
assert.equal(targetedPwdCalls, 0);
assert.equal(session.shellPid, undefined);
});
test("concurrent cwd recovery uses one scan and one targeted probe", async () => {
let scanCalls = 0;
let targetedPwdCalls = 0;
const connRef = { count: 1, shellCloseGeneration: 0 };
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
scanCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
return;
}
targetedPwdCalls += 1;
callback(null, makePwdStream("/new/cwd", "222"));
},
},
};
const api = makeApi(session);
const [left, right] = await Promise.all([
api.getSessionPwd(null, { sessionId: "session-1" }),
api.getSessionPwd(null, { sessionId: "session-1" }),
]);
assert.deepEqual(left, { success: true, cwd: "/new/cwd" });
assert.deepEqual(right, left);
assert.equal(scanCalls, 1);
assert.equal(targetedPwdCalls, 1);
});
test("a late cwd request joins recovery after PID scan but before targeted pwd completes", async () => {
let scanCalls = 0;
let targetedPwdCalls = 0;
let targetedStream;
const connRef = { count: 1, shellCloseGeneration: 0 };
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
scanCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
return;
}
targetedPwdCalls += 1;
targetedStream = new EventEmitter();
targetedStream.stderr = new EventEmitter();
targetedStream.close = () => {};
callback(null, targetedStream);
},
},
};
const api = makeApi(session);
const first = api.getSessionPwd(null, { sessionId: "session-1" });
while (!targetedStream) await new Promise((resolve) => setImmediate(resolve));
assert.equal(session.shellPid, undefined, "candidate PID stays private until pwd succeeds");
const late = api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(scanCalls, 1);
assert.equal(targetedPwdCalls, 1);
targetedStream.emit("data", Buffer.from("/new/cwd\n"));
targetedStream.stderr.emit("data", Buffer.from("NETCATTY_LOGIN_PID=222\n"));
targetedStream.emit("close", 0);
const [left, right] = await Promise.all([first, late]);
assert.deepEqual(left, { success: true, cwd: "/new/cwd" });
assert.deepEqual(right, left);
assert.equal(session.shellPid, "222");
assert.equal(scanCalls, 1);
assert.equal(targetedPwdCalls, 1);
});
test("targeted recovery ignores cwd returned after the session is replaced", async () => {
let targetedStream;
const connRef = { count: 1, shellCloseGeneration: 0 };
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
return;
}
targetedStream = new EventEmitter();
targetedStream.stderr = new EventEmitter();
targetedStream.close = () => {};
callback(null, targetedStream);
},
},
};
const sessions = new Map([["session-1", session]]);
const api = makeApi(session, [], { sessions });
const pending = api.getSessionPwd(null, { sessionId: "session-1" });
while (!targetedStream) await new Promise((resolve) => setImmediate(resolve));
const replacement = { conn: session.conn, connRef, stream: {} };
sessions.set("session-1", replacement);
targetedStream.emit("data", Buffer.from("/old/cwd\n"));
targetedStream.stderr.emit("data", Buffer.from("NETCATTY_LOGIN_PID=222\n"));
targetedStream.emit("close", 0);
const result = await pending;
assert.equal(result.success, false);
assert.equal(replacement.shellPid, undefined);
assert.equal(session.shellPid, undefined);
});
test("a sibling close during targeted pwd invalidates the recovery result", async () => {
let targetedStream;
const connRef = {
count: 1,
shellCloseGeneration: 0,
closedShellPids: new Set(),
closedShellPidUnknown: false,
};
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
return;
}
targetedStream = new EventEmitter();
targetedStream.stderr = new EventEmitter();
targetedStream.close = () => {};
callback(null, targetedStream);
},
},
};
const sessions = new Map([["session-1", session]]);
const api = makeApi(session, [], { sessions });
const pending = api.getSessionPwd(null, { sessionId: "session-1" });
while (!targetedStream) await new Promise((resolve) => setImmediate(resolve));
// A second shell opened and closed while targeted pwd was pending. Its local
// session is gone again, but the close generation proves the terminal set
// changed and the candidate can no longer be committed safely.
connRef.shellCloseGeneration += 1;
connRef.closedShellPids.add("222");
targetedStream.emit("data", Buffer.from("/closed-sibling/cwd\n"));
targetedStream.stderr.emit("data", Buffer.from("NETCATTY_LOGIN_PID=222\n"));
targetedStream.emit("close", 0);
const result = await pending;
assert.equal(result.success, false);
assert.equal(session.shellPid, undefined);
assert.equal(session.blockUntargetedCwdProbe, true);
assert.notEqual(session.parkedReconnectRisk, null);
});
test("targeted recovery open timeout preserves and disables the shared transport", async () => {
const timers = [];
const setTimeoutFn = (callback) => {
const timer = { callback, active: true };
timers.push(timer);
return timer;
};
const clearTimeoutFn = (timer) => { if (timer) timer.active = false; };
let execCalls = 0;
let endCalls = 0;
let destroyCalls = 0;
const connRef = { count: 2, shellCloseGeneration: 0 };
const conn = {
end() { endCalls += 1; },
destroy() { destroyCalls += 1; },
exec(command, callback) {
execCalls += 1;
if (!command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) return;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
},
};
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn,
};
const sessions = new Map([["session-1", session]]);
const api = makeApi(session, [], {
sessions,
setTimeout: setTimeoutFn,
clearTimeout: clearTimeoutFn,
});
const pending = api.getSessionPwd(null, { sessionId: "session-1" });
while (execCalls < 2) await Promise.resolve();
sessions.delete("session-1");
const openingTimer = timers.find((timer) => timer.active);
assert.ok(openingTimer);
openingTimer.callback();
const result = await pending;
assert.equal(result.success, false);
assert.equal(connRef.cwdRecoveryDisabled, true);
assert.equal(endCalls, 0);
assert.equal(destroyCalls, 0);
const replacement = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["222"], hasUnknownOldShell: false },
connRef,
stream: {},
conn,
};
sessions.set("session-2", replacement);
const replacementApi = createSessionOpsApi({
sessions,
setTimeout: setTimeoutFn,
clearTimeout: clearTimeoutFn,
quoteShellArg,
log: () => {},
});
const retry = await replacementApi.getSessionPwd(null, { sessionId: "session-2" });
assert.equal(retry.success, false);
assert.equal(execCalls, 2, "disabled transport must not accumulate another hung open");
});
test("an SFTP reference does not make one terminal cwd ambiguous", async () => {
const session = {
connRef: { count: 2 },
stream: {},
conn: {
exec(_command, callback) {
callback(null, makePwdStream("/home/alice/project", "5151"));
},
},
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.deepEqual(result, { success: true, cwd: "/home/alice/project" });
assert.equal(session.shellPid, "5151");
});
test("cwd probe keeps login-shell fallback when home fallback is disabled (#2886)", async () => {
// After `sudo su`, the active shell cwd is often unreadable to the login-uid
// exec channel. preferFreshBackend disables home guessing, but must still
// fall back to the same-uid login shell cwd so terminal drag-drop SFTP
// uploads land in a writable directory instead of failing closed.
let command = "";
const session = {
shellPid: "4242",
connRef: { count: 1 },
stream: {},
conn: {
exec(nextCommand, callback) {
command = nextCommand;
callback(null, makePwdStream("/home/alice", "4242"));
},
},
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, {
sessionId: "session-1",
allowHomeFallback: false,
allowLoginShellFallback: true,
});
assert.deepEqual(result, { success: true, cwd: "/home/alice" });
assert.match(command, /ALLOW_HOME_FALLBACK=0/);
assert.match(command, /ALLOW_LOGIN_FALLBACK=1/);
assert.match(
command,
/if \[ -z "\$cwd" \] && \[ "\$pid" != "\$login" \] && \[ "\$ALLOW_LOGIN_FALLBACK" = "1" \]; then/,
);
assert.match(command, /\[ "\$ALLOW_HOME_FALLBACK" = "1" \] \|\| exit 1/);
});
test("cwd probe couples login-shell fallback to home fallback when unset", async () => {
// captureInheritedCwd passes allowHomeFallback: false without opting into
// login-shell fallback; the backend must keep ALLOW_LOGIN_FALLBACK=0 so a
// failed active-shell probe fails closed and the caller can fall through to
// lastCwd instead of inheriting the parent login shell directory after sudo.
let command = "";
const session = {
shellPid: "4242",
connRef: { count: 1 },
stream: {},
conn: {
exec(nextCommand, callback) {
command = nextCommand;
callback(null, makePwdStream("/home/alice", "4242"));
},
},
};
const api = makeApi(session);
await api.getSessionPwd(null, {
sessionId: "session-1",
allowHomeFallback: false,
});
assert.match(command, /ALLOW_HOME_FALLBACK=0/);
assert.match(command, /ALLOW_LOGIN_FALLBACK=0/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
resolveUnlockedEncryptedKeysForAuth,
applyAgentForwarding,
shouldOfferAgentForLogin,
shouldPrepareSystemAgentForLogin,
shouldPromoteCachedAuthMethod,
prepareAgentForwardingOptions,
} = require("./startSession.cjs");
test("forwarding agent selection is resolved before connection reuse", async () => {
const calls = [];
const prepared = await prepareAgentForwardingOptions(
{ agentForwarding: true, identityAgent: "none" },
async (identityAgent) => {
calls.push(identityAgent);
return "/Users/alice/.bitwarden-ssh-agent.sock";
},
);
assert.deepEqual(calls, ["none"]);
assert.equal(prepared._resolvedForwardingAgentSocket, "/Users/alice/.bitwarden-ssh-agent.sock");
assert.equal(prepared.forwardingAgentSocket, "/Users/alice/.bitwarden-ssh-agent.sock");
});
test("pre-resolved forwarding agent selection is reused during SSH setup", async () => {
const connectOptions = {};
await applyAgentForwarding(
{
agentForwarding: true,
_resolvedForwardingAgentSocket: "/Users/alice/.bitwarden-ssh-agent.sock",
},
connectOptions,
async () => {
throw new Error("forwarding socket should not be resolved twice");
},
);
assert.equal(connectOptions.agent, "/Users/alice/.bitwarden-ssh-agent.sock");
assert.equal(connectOptions.agentForward, true);
});
test("agent forwarding resolves the forwarding socket independently from login auth", async () => {
const connectOptions = { password: "login-password" };
const resolved = [];
await applyAgentForwarding(
{ agentForwarding: true, useSshAgent: false },
connectOptions,
async (identityAgent) => {
resolved.push(identityAgent);
return "/Users/alice/.bitwarden-ssh-agent.sock";
},
);
assert.deepEqual(resolved, [undefined]);
assert.equal(connectOptions.agent, "/Users/alice/.bitwarden-ssh-agent.sock");
assert.equal(connectOptions.agentForward, true);
});
test("agent forwarding replaces an automatically discovered empty login agent", async () => {
const connectOptions = { agent: "/private/tmp/com.apple.launchd.test/Listeners" };
await applyAgentForwarding(
{ agentForwarding: true },
connectOptions,
async () => "/Users/alice/.bitwarden-ssh-agent.sock",
{ replaceExistingAgent: true },
);
assert.equal(connectOptions.agent, "/Users/alice/.bitwarden-ssh-agent.sock");
assert.equal(connectOptions.agentForward, true);
});
test("agent forwarding configures ssh2 separately from an explicitly prepared login agent", async () => {
const explicitAgent = { kind: "selected-agent" };
const connectOptions = { agent: explicitAgent };
let resolutions = 0;
await applyAgentForwarding(
{ agentForwarding: true, identityAgent: "/tmp/selected-agent.sock" },
connectOptions,
async () => {
resolutions += 1;
return "/tmp/other-agent.sock";
},
);
assert.equal(connectOptions.agent, "/tmp/other-agent.sock");
assert.equal(connectOptions.agentForward, true);
assert.equal(resolutions, 1);
});
test("agent forwarding does not enable agent login after an explicit opt-out", () => {
assert.equal(shouldOfferAgentForLogin(
{ useSshAgent: false, agentForwarding: true },
{ agent: "/tmp/agent.sock", agentForward: true },
), false);
});
test("agent login remains available when it is not explicitly disabled", () => {
assert.equal(shouldOfferAgentForLogin(
{ agentForwarding: true },
{ agent: "/tmp/agent.sock", agentForward: true },
), true);
});
test("direct SSH allows only a restricted selected agent-backed key", () => {
const selectedAgentKey = {
authMethod: "key",
useSshAgent: true,
identitiesOnly: true,
agentPublicKeys: ["ssh-ed25519 AAAASELECTED"],
};
assert.equal(shouldPrepareSystemAgentForLogin(selectedAgentKey), true);
assert.equal(shouldOfferAgentForLogin(selectedAgentKey, { agent: {} }), true);
const selectedReferencedKey = {
...selectedAgentKey,
agentPublicKeys: [],
identityFilePaths: ["~/.ssh/id_work"],
};
assert.equal(shouldPrepareSystemAgentForLogin(selectedReferencedKey), true);
assert.equal(shouldOfferAgentForLogin(selectedReferencedKey, { agent: {} }), true);
assert.equal(shouldPrepareSystemAgentForLogin({
...selectedAgentKey,
agentPublicKeys: [],
identityFilePaths: [],
}), false);
assert.equal(shouldOfferAgentForLogin({
...selectedAgentKey,
identitiesOnly: false,
}, { agent: {} }), false);
});
test("strict agent selection excludes unlocked default keys", () => {
const unlocked = [{ keyName: "id_other", privateKey: "PRIVATE KEY" }];
assert.deepEqual(resolveUnlockedEncryptedKeysForAuth({
_unlockedEncryptedKeys: unlocked,
}, true), []);
assert.equal(resolveUnlockedEncryptedKeysForAuth({
_unlockedEncryptedKeys: unlocked,
}, false), unlocked);
});
test("explicit auth modes exclude unlocked unrelated default keys", () => {
const unlocked = [{ keyName: "id_other", privateKey: "PRIVATE KEY" }];
for (const authMethod of ["password", "key", "certificate"]) {
assert.deepEqual(resolveUnlockedEncryptedKeysForAuth({
authMethod,
_unlockedEncryptedKeys: unlocked,
}, false), []);
}
assert.equal(resolveUnlockedEncryptedKeysForAuth({
authMethod: "auto",
_unlockedEncryptedKeys: unlocked,
}, false), unlocked);
});
test("cached methods cannot override explicit authentication ordering", () => {
for (const authMethod of ["password", "key", "certificate"]) {
assert.equal(shouldPromoteCachedAuthMethod(authMethod, "password"), false);
assert.equal(shouldPromoteCachedAuthMethod(authMethod, "keyboard-interactive"), false);
}
assert.equal(shouldPromoteCachedAuthMethod("auto", "password"), false);
assert.equal(shouldPromoteCachedAuthMethod("auto", "keyboard-interactive"), false);
assert.equal(shouldPromoteCachedAuthMethod("auto", "agent"), true);
assert.equal(shouldPromoteCachedAuthMethod("auto", "publickey-default-id_work"), true);
assert.equal(shouldPromoteCachedAuthMethod(undefined, "password"), true);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,307 @@
/**
* System OpenSSH known_hosts trust source.
*
* Mosh sessions are bootstrapped by the *system* `ssh`, which records the
* server's host key in the user's OpenSSH known_hosts files (e.g.
* `~/.ssh/known_hosts`). That file — not Netcatty's in-app known-hosts vault —
* is the real trust source for a Mosh connection: the user vetted and accepted
* the key through OpenSSH's own prompt during the handshake.
*
* The stats companion (moshStatsConnection.cjs) opens a *second*, background
* ssh2 connection and must only ever ride on a host whose key is already
* trusted. Netcatty's vault snapshot does not get updated when OpenSSH accepts
* a key, so a host trusted purely via the system would be wrongly classified as
* "unknown" and the companion permanently disabled (issue: Mosh stats never
* appear unless the user manually imports/scans the host into Netcatty).
*
* This module parses the system known_hosts files and answers a single
* question: "does a non-revoked system entry for this (host, port) record the
* EXACT public key the server just presented?" — matched by the key's SHA-256
* fingerprint. It only ever *adds* trust for keys the user's own OpenSSH
* already trusts; it never accepts an unknown or mismatched key. Unknown /
* changed keys remain rejected by the caller.
*
* Format handling (OpenSSH known_hosts(5)):
* - comments (`#…`) and blank lines are ignored;
* - plain host tokens, comma-separated host lists, and `[host]:port`;
* - hashed entries `|1|<b64 salt>|<b64 HMAC-SHA1(salt, token)>` — the token
* hashed is the canonical name OpenSSH uses (`[host]:port` for a non-default
* port, the bare host otherwise), matched by recomputing the HMAC;
* - marker lines: `@revoked` entries are treated as explicitly NOT trusted
* (a revoked key never grants trust, even if its fingerprint matches);
* `@cert-authority` lines are skipped (they delegate to a CA, not a literal
* host key, which the fingerprint-equality check cannot model);
* - multiple key types / multiple lines per host.
*
* Negation patterns (`!pattern`) and wildcard patterns (`*`, `?`) are NOT
* honored for matching: a wildcard could make an unrelated entry vouch for a
* host whose key we have not actually seen. We only trust exact host-token
* matches, which is the safe subset for "has the user's OpenSSH seen THIS key
* for THIS host". This is intentionally conservative — failing to match just
* leaves the Mosh stats bar empty (graceful degradation), never weakens
* security.
*/
function createSystemKnownHostsApi(ctx) {
const { fs, path, os, crypto, log } = ctx;
const HASH_MARKER = "|1|";
const normalizeHostname = (value) => String(value || "").trim().toLowerCase();
const stripFingerprintPadding = (value) =>
String(value || "").replace(/=+$/g, "");
// SHA-256 base64 fingerprint (no padding) of an OpenSSH public-key blob,
// computed the same way the live host-key verifier does so the two values
// are directly comparable.
const fingerprintFromKeyBlob = (base64Key) => {
if (typeof base64Key !== "string" || base64Key.length === 0) return "";
let blob;
try {
blob = Buffer.from(base64Key, "base64");
} catch {
return "";
}
if (blob.length === 0) return "";
return stripFingerprintPadding(
crypto.createHash("sha256").update(blob).digest("base64"),
);
};
// The canonical host token OpenSSH uses as the hashed/plain lookup key:
// the bare host on the default port, `[host]:port` otherwise. Built for the
// host exactly as supplied and (when different) its lowercase form, so a
// case-insensitive hostname still matches a hashed entry hashed in either
// case. Only ever broadens matching against the user's own trusted file.
const buildLookupTokens = (hostname, port) => {
const raw = String(hostname || "").trim();
if (!raw) return [];
const variants = new Set([raw]);
const lower = raw.toLowerCase();
variants.add(lower);
const tokens = new Set();
const usePort = Number.isFinite(port) && Number(port) !== 22;
for (const variant of variants) {
tokens.add(usePort ? `[${variant}]:${Number(port)}` : variant);
}
return [...tokens];
};
// Does a plain (non-hashed) host field cover (hostname, port)? Handles
// comma-separated lists and `[host]:port`. Wildcards and negations are not
// honored (see module header).
const plainHostFieldMatches = (hostField, hostname, port) => {
const wantHost = normalizeHostname(hostname);
if (!wantHost) return false;
const wantPort = Number.isFinite(port) ? Number(port) : 22;
const patterns = String(hostField || "").split(",");
for (const pattern of patterns) {
const token = pattern.trim();
if (!token) continue;
// Skip negations and wildcard patterns — not a safe exact match.
if (token.startsWith("!") || token.includes("*") || token.includes("?")) {
continue;
}
const bracket = token.match(/^\[([^\]]+)\]:(\d+)$/);
if (bracket) {
if (
normalizeHostname(bracket[1]) === wantHost &&
Number.parseInt(bracket[2], 10) === wantPort
) {
return true;
}
continue;
}
// A bare token implies the default SSH port.
if (normalizeHostname(token) === wantHost && wantPort === 22) {
return true;
}
}
return false;
};
// Does a hashed host field (`|1|salt|hash`) cover (hostname, port)? Matches
// by recomputing HMAC-SHA1(salt, token) for each canonical lookup token.
const hashedHostFieldMatches = (hostField, hostname, port) => {
const field = String(hostField || "");
if (!field.startsWith(HASH_MARKER)) return false;
const rest = field.slice(HASH_MARKER.length);
const sep = rest.indexOf("|");
if (sep <= 0) return false;
const saltB64 = rest.slice(0, sep);
const expected = rest.slice(sep + 1);
if (!saltB64 || !expected) return false;
let salt;
try {
salt = Buffer.from(saltB64, "base64");
} catch {
return false;
}
if (salt.length === 0) return false;
let expectedBuf;
try {
expectedBuf = Buffer.from(expected, "base64");
} catch {
return false;
}
if (expectedBuf.length === 0) return false;
// Use the host string exactly as supplied (and its lowercase form) when
// building tokens — a hashed entry preserves the literal name OpenSSH saw.
for (const token of buildLookupTokens(hostname, port)) {
let computed;
try {
computed = crypto.createHmac("sha1", salt).update(token).digest();
} catch {
continue;
}
if (
computed.length === expectedBuf.length &&
crypto.timingSafeEqual(computed, expectedBuf)
) {
return true;
}
}
return false;
};
const hostFieldMatches = (hostField, hostname, port) => {
if (String(hostField || "").startsWith(HASH_MARKER)) {
return hashedHostFieldMatches(hostField, hostname, port);
}
return plainHostFieldMatches(hostField, hostname, port);
};
// Parse one known_hosts line into { revoked, certAuthority, hostField,
// keyType, fingerprint } or null when it is a comment / blank / malformed.
// The fingerprint is the SHA-256 of the line's key blob.
const parseKnownHostsLine = (rawLine) => {
const line = String(rawLine || "").trim();
if (!line || line.startsWith("#")) return null;
let rest = line;
let revoked = false;
let certAuthority = false;
// Leading markers: `@revoked` / `@cert-authority` (one per line in
// practice). Consume any leading `@…` token.
while (rest.startsWith("@")) {
const spaceIdx = rest.search(/\s/);
if (spaceIdx < 0) return null;
const marker = rest.slice(0, spaceIdx);
if (marker === "@revoked") revoked = true;
else if (marker === "@cert-authority") certAuthority = true;
// Unknown markers are ignored but still consumed.
rest = rest.slice(spaceIdx).trim();
}
const parts = rest.split(/\s+/);
if (parts.length < 3) return null;
const [hostField, keyType, keyBlob] = parts;
if (!hostField || !keyType || !keyBlob) return null;
const fingerprint = fingerprintFromKeyBlob(keyBlob);
if (!fingerprint) return null;
return { revoked, certAuthority, hostField, keyType, fingerprint };
};
// The OpenSSH default trust files, mirroring localFsBridge.readKnownHosts so
// the companion trusts exactly what the user's system ssh would.
const getSystemKnownHostsPaths = () => {
const homeDir = os.homedir();
const paths = [path.join(homeDir, ".ssh", "known_hosts")];
if (process.platform === "win32") {
paths.push(
path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts"),
);
} else {
paths.push("/etc/ssh/ssh_known_hosts");
}
return paths;
};
const readSystemKnownHostsContent = () => {
let combined = "";
for (const filePath of getSystemKnownHostsPaths()) {
let content;
try {
content = fs.readFileSync(filePath, "utf8");
} catch {
// Missing / unreadable file is expected (e.g. no /etc/ssh on macOS).
continue;
}
if (content && content.length > 0) {
combined += combined ? `\n${content}` : content;
}
}
return combined;
};
/**
* Is the host key the server just presented already trusted by the user's
* system OpenSSH known_hosts?
*
* Returns true ONLY when a non-revoked plain/hashed entry for (hostname,
* port) records a key whose SHA-256 fingerprint equals `fingerprint`. A
* `@revoked` entry that matches the fingerprint forces a hard `false` — a
* revoked key must never be trusted, even if an older non-revoked entry also
* lists it. Any read/parse error fails closed (returns false).
*
* @param {object} params
* @param {string} params.hostname - SSH host the companion targets.
* @param {number} [params.port=22] - SSH port.
* @param {string} params.fingerprint - SHA-256 base64 (no padding, no
* `SHA256:` prefix) of the live host key.
* @returns {boolean}
*/
const isHostKeyTrustedBySystem = ({ hostname, port = 22, fingerprint } = {}) => {
const wantFingerprint = stripFingerprintPadding(fingerprint);
if (!hostname || !wantFingerprint) return false;
let content;
try {
content = readSystemKnownHostsContent();
} catch (err) {
log?.(
"[Mosh] failed to read system known_hosts:",
err?.message || String(err),
);
return false;
}
if (!content) return false;
let trusted = false;
for (const rawLine of content.split(/\r?\n/)) {
const entry = parseKnownHostsLine(rawLine);
if (!entry) continue;
// @cert-authority delegates to a CA rather than pinning a literal host
// key; the fingerprint-equality model does not apply, so skip it.
if (entry.certAuthority) continue;
if (entry.fingerprint !== wantFingerprint) continue;
if (!hostFieldMatches(entry.hostField, hostname, port)) continue;
// A matching @revoked entry is an explicit "never trust this key" and
// overrides any non-revoked match.
if (entry.revoked) return false;
trusted = true;
}
return trusted;
};
return {
isHostKeyTrustedBySystem,
readSystemKnownHostsContent,
// Exposed for unit testing.
parseKnownHostsLine,
hostFieldMatches,
plainHostFieldMatches,
hashedHostFieldMatches,
fingerprintFromKeyBlob,
buildLookupTokens,
getSystemKnownHostsPaths,
};
}
module.exports = { createSystemKnownHostsApi };

View File

@@ -0,0 +1,393 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const path = require("node:path");
const os = require("node:os");
const { createSystemKnownHostsApi } = require("./systemKnownHosts.cjs");
// Build an api whose fs.readFileSync returns the given content for the FIRST
// system known_hosts path and throws (ENOENT-like) for the rest, mirroring the
// common case of only `~/.ssh/known_hosts` existing.
function makeApi(fileContents = {}) {
const reads = [];
const fs = {
readFileSync(filePath) {
reads.push(filePath);
if (Object.prototype.hasOwnProperty.call(fileContents, filePath)) {
return fileContents[filePath];
}
const err = new Error(`ENOENT: ${filePath}`);
err.code = "ENOENT";
throw err;
},
};
const logs = [];
const api = createSystemKnownHostsApi({
fs,
path,
os,
crypto,
log: (...args) => logs.push(args),
});
return { api, reads, logs };
}
// SHA-256 base64 (no padding) fingerprint of an OpenSSH public-key blob.
function fingerprintOf(base64Key) {
return crypto
.createHash("sha256")
.update(Buffer.from(base64Key, "base64"))
.digest("base64")
.replace(/=+$/g, "");
}
// A valid-looking ed25519 key blob seeded deterministically.
function keyBlob(seed) {
return (
"AAAAC3NzaC1lZDI1NTE5AAAAI" +
crypto.createHash("sha256").update(seed).digest("base64").slice(0, 27)
);
}
// Produce a hashed host field `|1|salt|HMAC-SHA1(salt, token)` for `token`,
// exactly as `ssh-keygen -H` would (verified empirically against ssh-keygen).
function hashedHostField(token, salt = crypto.randomBytes(20)) {
const hash = crypto.createHmac("sha1", salt).update(token).digest("base64");
return `|1|${salt.toString("base64")}|${hash}`;
}
const HOME_KH = path.join(os.homedir(), ".ssh", "known_hosts");
test("system known_hosts paths include the OpenSSH defaults for the platform", () => {
const { api } = makeApi();
const paths = api.getSystemKnownHostsPaths();
assert.ok(paths.includes(HOME_KH), "must include ~/.ssh/known_hosts");
if (process.platform === "win32") {
assert.ok(
paths.some((p) => /ssh[\\/]known_hosts$/i.test(p) && /ProgramData/i.test(p)),
"Windows must include %PROGRAMDATA%/ssh/known_hosts",
);
} else {
assert.ok(paths.includes("/etc/ssh/ssh_known_hosts"));
}
});
test("trusts a plain entry whose fingerprint matches (default port)", () => {
const blob = keyBlob("plain");
const { api } = makeApi({
[HOME_KH]: `example.com ssh-ed25519 ${blob}\n`,
});
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
port: 22,
fingerprint: fingerprintOf(blob),
}),
true,
);
});
test("matches hostnames case-insensitively", () => {
const blob = keyBlob("case");
const { api } = makeApi({ [HOME_KH]: `Example.COM ssh-ed25519 ${blob}\n` });
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
fingerprint: fingerprintOf(blob),
}),
true,
);
});
test("matches a comma-separated host list", () => {
const blob = keyBlob("list");
const { api } = makeApi({
[HOME_KH]: `alias.example.com,192.0.2.5 ssh-ed25519 ${blob}\n`,
});
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "192.0.2.5",
fingerprint: fingerprintOf(blob),
}),
true,
);
});
test("matches a [host]:port entry only on the right non-default port", () => {
const blob = keyBlob("port");
const { api } = makeApi({
[HOME_KH]: `[example.com]:2222 ssh-ed25519 ${blob}\n`,
});
const fingerprint = fingerprintOf(blob);
assert.equal(
api.isHostKeyTrustedBySystem({ hostname: "example.com", port: 2222, fingerprint }),
true,
);
// Same host, wrong port -> not trusted.
assert.equal(
api.isHostKeyTrustedBySystem({ hostname: "example.com", port: 22, fingerprint }),
false,
);
});
test("does NOT trust a fingerprint mismatch (different key for the same host)", () => {
const stored = keyBlob("stored");
const live = keyBlob("live-different");
const { api } = makeApi({ [HOME_KH]: `example.com ssh-ed25519 ${stored}\n` });
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
fingerprint: fingerprintOf(live),
}),
false,
);
});
test("does NOT trust when the host does not appear at all", () => {
const blob = keyBlob("other");
const { api } = makeApi({ [HOME_KH]: `other.example.com ssh-ed25519 ${blob}\n` });
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
fingerprint: fingerprintOf(blob),
}),
false,
);
});
test("trusts a hashed entry whose token + fingerprint match (default port)", () => {
const blob = keyBlob("hashed-default");
const { api } = makeApi({
[HOME_KH]: `${hashedHostField("example.com")} ssh-ed25519 ${blob}\n`,
});
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
port: 22,
fingerprint: fingerprintOf(blob),
}),
true,
);
});
test("trusts a hashed entry for a non-default port ([host]:port token)", () => {
const blob = keyBlob("hashed-port");
const { api } = makeApi({
[HOME_KH]: `${hashedHostField("[h.example.com]:2022")} ssh-ed25519 ${blob}\n`,
});
const fingerprint = fingerprintOf(blob);
assert.equal(
api.isHostKeyTrustedBySystem({ hostname: "h.example.com", port: 2022, fingerprint }),
true,
);
// The same hashed entry must NOT match the default-port (bare-host) token.
assert.equal(
api.isHostKeyTrustedBySystem({ hostname: "h.example.com", port: 22, fingerprint }),
false,
);
});
test("hashed entry does not match a different hostname (HMAC differs)", () => {
const blob = keyBlob("hashed-wrong-host");
const { api } = makeApi({
[HOME_KH]: `${hashedHostField("example.com")} ssh-ed25519 ${blob}\n`,
});
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "evil.example.com",
fingerprint: fingerprintOf(blob),
}),
false,
);
});
test("matches against ssh-keygen-generated hashed entries (real fixtures)", () => {
// These two lines were produced by `ssh-keygen -H` from:
// example.com ssh-ed25519 …KEYDATA0000…
// [example.com]:2222 ssh-ed25519 …KEYDATA1111…
// and pin the exact HMAC-SHA1 hashing OpenSSH uses (incl. the bracketed
// token for the non-default port).
const blobA = "AAAAC3NzaC1lZDI1NTE5AAAAITESTKEYDATA0000000000000000000000000";
const blobB = "AAAAC3NzaC1lZDI1NTE5AAAAITESTKEYDATA1111111111111111111111111";
const content =
`|1|GjfxyrxES8V34vZje/1Lt1hHg/Y=|ZEnB8OFqAbq3mcme43V+dukJ51I= ssh-ed25519 ${blobA}\n` +
`|1|uZT6RsKBJirh9q9ycDnQUhVSmqI=|auufYDNOuFA17oSrmJwneIyl9po= ssh-ed25519 ${blobB}\n`;
const { api } = makeApi({ [HOME_KH]: content });
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
port: 22,
fingerprint: fingerprintOf(blobA),
}),
true,
"default-port hashed entry must match",
);
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
port: 2222,
fingerprint: fingerprintOf(blobB),
}),
true,
"non-default-port hashed entry must match",
);
// The 2222 key must NOT be accepted for port 22 (token differs).
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
port: 22,
fingerprint: fingerprintOf(blobB),
}),
false,
);
});
test("a @revoked entry with a matching fingerprint forces NOT trusted", () => {
const blob = keyBlob("revoked");
const fingerprint = fingerprintOf(blob);
// Even if a non-revoked entry would also match, the revoked one wins.
const content =
`example.com ssh-ed25519 ${blob}\n` +
`@revoked example.com ssh-ed25519 ${blob}\n`;
const { api } = makeApi({ [HOME_KH]: content });
assert.equal(
api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint }),
false,
);
});
test("a @revoked hashed entry also forces NOT trusted", () => {
const blob = keyBlob("revoked-hashed");
const fingerprint = fingerprintOf(blob);
const content = `@revoked ${hashedHostField("example.com")} ssh-ed25519 ${blob}\n`;
const { api } = makeApi({ [HOME_KH]: content });
assert.equal(
api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint }),
false,
);
});
test("a @cert-authority line is skipped (not a literal host-key match)", () => {
const blob = keyBlob("ca");
const { api } = makeApi({
[HOME_KH]: `@cert-authority *.example.com ssh-ed25519 ${blob}\n`,
});
// Fingerprint matches the CA key, but CA delegation is not modeled -> not
// trusted via this path.
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "host.example.com",
fingerprint: fingerprintOf(blob),
}),
false,
);
});
test("comments, blank lines, and malformed lines are ignored", () => {
const blob = keyBlob("with-comments");
const content = [
"# a comment",
"",
" ",
"garbage-without-enough-fields",
`example.com ssh-ed25519 ${blob}`,
"# trailing comment",
].join("\n");
const { api } = makeApi({ [HOME_KH]: content });
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
fingerprint: fingerprintOf(blob),
}),
true,
);
});
test("wildcard / negation host patterns are not honored for trust", () => {
const blob = keyBlob("wild");
const fingerprint = fingerprintOf(blob);
const wildcard = makeApi({ [HOME_KH]: `*.example.com ssh-ed25519 ${blob}\n` });
assert.equal(
wildcard.api.isHostKeyTrustedBySystem({ hostname: "host.example.com", fingerprint }),
false,
"a wildcard entry must not vouch for a specific host's key we never saw",
);
const negated = makeApi({
[HOME_KH]: `!example.com,example.com ssh-ed25519 ${blob}\n`,
});
assert.equal(
negated.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint }),
true,
"the non-negated token in the list still matches",
);
});
test("combines multiple system files (home + /etc) into the trust set", () => {
const blob = keyBlob("etc");
const etcPath = process.platform === "win32"
? path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts")
: "/etc/ssh/ssh_known_hosts";
const { api } = makeApi({
[HOME_KH]: "# only comments here\n",
[etcPath]: `shared.example.com ssh-ed25519 ${blob}\n`,
});
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "shared.example.com",
fingerprint: fingerprintOf(blob),
}),
true,
);
});
test("returns false (fail-closed) when no system files exist", () => {
const { api, reads } = makeApi(); // every read throws ENOENT
assert.equal(
api.isHostKeyTrustedBySystem({
hostname: "example.com",
fingerprint: "anything",
}),
false,
);
assert.ok(reads.length >= 1, "should have attempted to read at least one path");
});
test("returns false on empty/whitespace fingerprint or hostname", () => {
const blob = keyBlob("guard");
const { api } = makeApi({ [HOME_KH]: `example.com ssh-ed25519 ${blob}\n` });
assert.equal(
api.isHostKeyTrustedBySystem({ hostname: "", fingerprint: fingerprintOf(blob) }),
false,
);
assert.equal(
api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: "" }),
false,
);
assert.equal(api.isHostKeyTrustedBySystem({}), false);
});
test("fingerprint comparison ignores base64 padding differences", () => {
const blob = keyBlob("padding");
const { api } = makeApi({ [HOME_KH]: `example.com ssh-ed25519 ${blob}\n` });
const padded = `${fingerprintOf(blob)}==`;
assert.equal(
api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: padded }),
true,
);
});
test("parseKnownHostsLine extracts markers and fingerprint", () => {
const blob = keyBlob("parse-line");
const { api } = makeApi();
const entry = api.parseKnownHostsLine(`@revoked example.com ssh-rsa ${blob}`);
assert.equal(entry.revoked, true);
assert.equal(entry.certAuthority, false);
assert.equal(entry.hostField, "example.com");
assert.equal(entry.keyType, "ssh-rsa");
assert.equal(entry.fingerprint, fingerprintOf(blob));
assert.equal(api.parseKnownHostsLine("# comment"), null);
assert.equal(api.parseKnownHostsLine(""), null);
assert.equal(api.parseKnownHostsLine("too few"), null);
});