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