943 lines
34 KiB
JavaScript
943 lines
34 KiB
JavaScript
|
|
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);
|
||
|
|
});
|