Files
NetMesh/electron/bridges/sshAuthHelper.kbdInteractive.test.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1318 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require("node:test");
const assert = require("node:assert/strict");
const {
createKeyboardInteractiveHandler,
createOrderedStringAuthHandler,
createAuthPhase,
canRepeatKeyboardInteractive,
shouldSkipKiPasswordAutoFill,
isAutoFillablePasswordChallenge,
shouldPrefillSavedPassword,
buildAuthHandler,
} = require("./sshAuthHelper.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const createSender = () => {
const sent = [];
return {
sent,
sender: {
id: 42,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
},
};
};
// Settles any modal requests that the handler queued via storeRequest so the
// 5-minute TTL timer doesn't keep the test process alive.
const drainPendingRequests = (sent, senderId = 42) => {
for (const event of sent) {
if (event.channel !== "netcatty:keyboard-interactive") continue;
const requestId = event.payload?.requestId;
if (requestId) {
keyboardInteractiveHandler.handleResponse({ sender: { id: senderId } }, { requestId, cancelled: true });
}
}
};
const passwordPrompt = { prompt: "Password:", echo: false };
const linuxPasswordPrompt = { prompt: "[sudo] password for alice:", echo: false };
const verificationCodePrompt = { prompt: "Verification code:", echo: true };
const otpPrompt = { prompt: "Verification code:", echo: false }; // Google Auth / TOTP
const duoPrompt = { prompt: "Duo two-factor login\nPasscode or option (1-1):", echo: false };
const cjkPasswordPrompt = { prompt: "密码:", echo: false };
const customizedAuthPrompt = { prompt: "Please authenticate:", echo: false };
// OTP prompts that DO mention the word "password" or "口令" — the literal
// keyword should not be enough to trigger auto-fill (#969 PR review round 2).
const oneTimePasswordPrompt = { prompt: "Enter your one-time password:", echo: false };
const currentPasswordPrompt = { prompt: "Current password:", echo: false };
const newPasswordPrompt = { prompt: "New password:", echo: false };
const confirmPasswordPrompt = { prompt: "Confirm password:", echo: false };
const cjkDynamicPasswordPrompt = { prompt: "动态密码:", echo: false };
const cjkDynamicTokenPrompt = { prompt: "动态口令:", echo: false };
const cjkOneTimePasswordPrompt = { prompt: "一次性密码:", echo: false };
// --- isAutoFillablePasswordChallenge ---------------------------------------
test("isAutoFillablePasswordChallenge accepts a single hidden-echo prompt with a saved password", () => {
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], "hunter2"), true);
});
test("isAutoFillablePasswordChallenge rejects multi-prompt challenges (likely 2FA)", () => {
assert.equal(
isAutoFillablePasswordChallenge([passwordPrompt, verificationCodePrompt], "hunter2"),
false,
);
});
test("isAutoFillablePasswordChallenge rejects echo=true prompts (could be username / OTP)", () => {
assert.equal(isAutoFillablePasswordChallenge([verificationCodePrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects when no saved password is available", () => {
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], ""), false);
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], undefined), false);
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], null), false);
});
test("isAutoFillablePasswordChallenge rejects empty / non-array prompts", () => {
assert.equal(isAutoFillablePasswordChallenge([], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge(undefined, "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects OTP-style hidden prompts (Google Authenticator, TOTP)", () => {
// Single prompt, echo=false, but the text says "Verification code" — that's
// a 2FA challenge, not a password. Submitting the saved password here would
// burn an auth attempt on the server. (#969 PR review)
assert.equal(isAutoFillablePasswordChallenge([otpPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects Duo-style passcode prompts", () => {
// "Passcode" is the term Duo uses for the OTP, not a reusable password.
// Treat it as a 2FA challenge.
assert.equal(isAutoFillablePasswordChallenge([duoPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge accepts CJK password prompts", () => {
// PAM on Chinese-locale Linux often renders "密码:" — the user still
// expects the saved password to work.
assert.equal(isAutoFillablePasswordChallenge([cjkPasswordPrompt], "hunter2"), true);
});
test("isAutoFillablePasswordChallenge falls through to the modal for unrecognized prompt text", () => {
// Custom prompts that don't mention a known keyword stay on the safe side
// — the user sees the modal as before. No regression from the old
// always-prompt baseline.
assert.equal(isAutoFillablePasswordChallenge([customizedAuthPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects 'One-time password' even though it contains the word 'password'", () => {
// PR review round 2: the OTP vocabulary check must run before the password
// keyword check, otherwise "password" in "One-time password" triggers a
// false-positive auto-fill that burns a 2FA attempt.
assert.equal(isAutoFillablePasswordChallenge([oneTimePasswordPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects Chinese OTP prompts ('动态密码', '动态口令', '一次性密码')", () => {
// The Chinese "动态密码" / "动态口令" / "一次性密码" idioms specifically
// mean OTP. Mustn't auto-fill the reusable password into them.
assert.equal(isAutoFillablePasswordChallenge([cjkDynamicPasswordPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([cjkDynamicTokenPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([cjkOneTimePasswordPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge accepts a sudo-style password prompt", () => {
// Regression guard: the OTP deny-list should not over-block normal Linux
// PAM prompts that legitimately mention a username after "password".
assert.equal(isAutoFillablePasswordChallenge([linuxPasswordPrompt], "hunter2"), true);
});
// Corporate EDR / bastion step-up password prompts (#2150). These contain the
// word "密码" / "password" but must never be auto-filled with the login password.
const secondaryPasswordPrompt = { prompt: "二次密码:", echo: false };
const secondaryAuthPasswordPrompt = { prompt: "请输入二次认证密码", echo: false };
const securityPasswordPrompt = { prompt: "安全密码:", echo: false };
const secondaryPasswordEnPrompt = { prompt: "Secondary password:", echo: false };
const secondPasswordEnPrompt = { prompt: "Second password:", echo: false };
test("isAutoFillablePasswordChallenge rejects EDR secondary password prompts (#2150)", () => {
assert.equal(isAutoFillablePasswordChallenge([secondaryPasswordPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([secondaryAuthPasswordPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([securityPasswordPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([secondaryPasswordEnPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([secondPasswordEnPrompt], "hunter2"), false);
});
// Real-world EDR banner from issue #2150 / reporter screenshot: Chinese
// instruction + English "Secondary Authentication Password:" field label.
const edrSecondaryAuthPasswordPrompt = {
prompt: "Secondary Authentication Password:",
echo: false,
};
const edrSecondaryAuthInstructions =
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。";
test("isAutoFillablePasswordChallenge rejects Secondary Authentication Password (#2150)", () => {
// English field label alone must not auto-fill — words between "Secondary"
// and "Password" are common in corporate EDR prompts.
assert.equal(
isAutoFillablePasswordChallenge([edrSecondaryAuthPasswordPrompt], "hunter2"),
false,
);
});
test("isAutoFillablePasswordChallenge rejects when 二次认证 is only in instructions (#2150)", () => {
// Even if the prompt field were a generic "Password:", the instruction
// banner carrying "二次认证密码" must still block auto-fill.
assert.equal(
isAutoFillablePasswordChallenge(
[passwordPrompt],
"hunter2",
edrSecondaryAuthInstructions,
),
false,
);
assert.equal(
isAutoFillablePasswordChallenge(
[edrSecondaryAuthPasswordPrompt],
"hunter2",
edrSecondaryAuthInstructions,
),
false,
);
});
// --- createKeyboardInteractiveHandler --------------------------------------
test("createKeyboardInteractiveHandler auto-fills the saved password for a single password prompt", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
const finishCalls = [];
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push(responses));
// The handler answered without sending any IPC and without showing a prompt.
assert.deepEqual(sent, []);
assert.deepEqual(promptEvents, []);
assert.deepEqual(autoFillEvents, ["auto-fill"]);
assert.deepEqual(finishCalls, [["hunter2"]]);
});
test("createKeyboardInteractiveHandler falls back to the modal on the retry after a failed auto-fill", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "wrong-password",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
const finishCalls = [];
// First call — auto-fill fires, no modal shown.
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push({ first: responses }));
// ssh2 re-invokes after auth failure — this time the user must see the modal.
// savedPassword is intentionally omitted so a multi-round second factor that
// also says "Password:" cannot re-submit the login secret on Enter (#2150).
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push({ second: responses }));
assert.deepEqual(autoFillEvents, ["auto-fill"]);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.deepEqual(finishCalls, [{ first: ["wrong-password"] }]);
assert.equal(sent.length, 1);
assert.equal(sent[0].channel, "netcatty:keyboard-interactive");
// Do not re-prefill the stale value, but still allow saving a corrected one.
assert.equal(sent[0].payload.savedPassword, null);
assert.equal(sent[0].payload.allowSavePassword, true);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler carries the SSH auth banner into the modal when instructions are empty", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-edr.example.com",
password: "login-password",
getAuthBanner: () => "为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
});
handler("", "", "", [edrSecondaryAuthPasswordPrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(
sent[0].payload.instructions,
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler does not classify generic auth banners as secondary prompts", () => {
const { sender, sent } = createSender();
const finishCalls = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-linux.example.com",
password: "login-password",
getAuthBanner: () => edrSecondaryAuthInstructions,
});
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push(responses));
assert.deepEqual(finishCalls, [["login-password"]]);
assert.equal(sent.length, 0);
});
test("createKeyboardInteractiveHandler adds the EDR fallback text for bare Secondary Authentication Password prompts", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "192.168.9.138",
password: "login-password",
});
handler("", "", "", [edrSecondaryAuthPasswordPrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(
sent[0].payload.instructions,
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。",
);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler does not prefill after a prior auto-fill round (#2150)", () => {
// Multi-round keyboard-interactive without partialSuccess between rounds:
// round 1 auto-fills the login password; round 2 looks like Password: again
// (EDR secondary) and must open empty.
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-edr.example.com",
password: "login-password",
});
handler("", "", "", [passwordPrompt], () => {}); // auto-fill
handler("", "", "", [passwordPrompt], () => {}); // modal, no prefill
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler shows the modal when the challenge is real 2FA (multiple prompts)", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
handler("Two-factor", "", "", [passwordPrompt, verificationCodePrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.prompts.length, 2);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler includes the request scope in modal payloads", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
scope: "terminal",
});
handler("Two-factor", "", "", [passwordPrompt, verificationCodePrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.scope, "terminal");
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler forwards bootEpoch on modal payloads", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
scope: "terminal",
bootEpoch: 11,
});
handler("Two-factor", "", "", [passwordPrompt, verificationCodePrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.bootEpoch, 11);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler does not auto-fill when no saved password is configured", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: undefined,
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
handler("", "", "", [passwordPrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler shows the modal for OTP-style hidden prompts even with a saved password", () => {
// Regression guard for the #969 PR review: a single hidden-echo prompt
// that doesn't mention "password" must not auto-submit the saved value.
const { sender, sent } = createSender();
const autoFillEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
});
handler("", "", "", [otpPrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.equal(sent.length, 1, "modal IPC should fire instead of auto-fill");
assert.equal(sent[0].channel, "netcatty:keyboard-interactive");
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler short-circuits when the server sends zero prompts", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
const finishCalls = [];
handler("", "", "", [], (responses) => finishCalls.push(responses));
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, []);
assert.deepEqual(sent, []);
assert.deepEqual(finishCalls, [[]]);
});
test("createKeyboardInteractiveHandler shows the modal for EDR secondary password prompts (#2150)", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-edr.example.com",
password: "login-password",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
handler("EDR", "", "", [secondaryPasswordPrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.equal(sent.length, 1);
assert.equal(sent[0].channel, "netcatty:keyboard-interactive");
assert.equal(sent[0].payload.prompts[0].prompt, "二次密码:");
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler skips auto-fill after password partialSuccess (#2150)", () => {
// password method already succeeded as first factor; a later KI challenge
// that merely says "Password:" must still show the modal so the user can
// enter the distinct secondary secret.
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const authPhase = createAuthPhase();
authPhase.passwordAlreadySucceeded = true;
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-edr.example.com",
password: "login-password",
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
handler("", "", "", [passwordPrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.equal(sent.length, 1);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler still auto-fills after publickey partialSuccess (#2151 P2)", () => {
// publickey succeeded first; KI Password: is still the account password and
// should auto-fill from the saved host credential.
const { sender, sent } = createSender();
const autoFillEvents = [];
const authPhase = createAuthPhase();
authPhase.hadPartialSuccess = true;
// passwordAlreadySucceeded remains false
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
onAutoFill: () => autoFillEvents.push("auto-fill"),
});
const finishCalls = [];
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push(responses));
assert.deepEqual(autoFillEvents, ["auto-fill"]);
assert.deepEqual(finishCalls, [["hunter2"]]);
assert.deepEqual(sent, []);
});
test("createKeyboardInteractiveHandler still auto-fills before any partialSuccess", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const authPhase = createAuthPhase();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
onAutoFill: () => autoFillEvents.push("auto-fill"),
});
const finishCalls = [];
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push(responses));
assert.deepEqual(autoFillEvents, ["auto-fill"]);
assert.deepEqual(finishCalls, [["hunter2"]]);
assert.deepEqual(sent, []);
});
test("createKeyboardInteractiveHandler omits savedPassword on post-password-partialSuccess modal (#2150)", () => {
const { sender, sent } = createSender();
const authPhase = createAuthPhase();
authPhase.passwordAlreadySucceeded = true;
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-edr.example.com",
password: "login-password",
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
});
handler("", "", "", [passwordPrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler omits savedPassword for secondary password prompts", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-edr.example.com",
password: "login-password",
});
handler("EDR", "", "", [secondaryPasswordPrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
drainPendingRequests(sent);
});
test("createOrderedStringAuthHandler sets hadPartialSuccess on partialSuccess", () => {
const authPhase = { hadPartialSuccess: false };
const handler = createOrderedStringAuthHandler(
["none", "password", "keyboard-interactive"],
authPhase,
);
const offered = [];
handler(null, false, (method) => offered.push(method)); // none
handler(["password", "keyboard-interactive"], false, (method) => offered.push(method)); // password
handler(["keyboard-interactive"], true, (method) => offered.push(method)); // KI after partial
assert.deepEqual(offered, ["none", "password", "keyboard-interactive"]);
assert.equal(authPhase.hadPartialSuccess, true);
});
test("createOrderedStringAuthHandler can use a login agent that differs from forwarding", () => {
const loginAgent = { kind: "login-agent" };
const authPhase = createAuthPhase();
const handler = createOrderedStringAuthHandler(
["none", "agent"],
authPhase,
undefined,
{ username: "alice", agent: loginAgent },
);
const offered = [];
handler(null, false, (method) => offered.push(method));
handler(["publickey"], false, (method) => offered.push(method));
assert.deepEqual(offered, [
"none",
{ type: "agent", username: "alice", agent: loginAgent },
]);
});
test("createOrderedStringAuthHandler re-offers methods skipped as unavailable after partialSuccess (#2151 P2)", () => {
// Server first only advertises publickey. password sits in our order before
// publickey but is not advertised yet — it must NOT be permanently skipped.
// After publickey partialSuccess, the server asks for password and we offer it.
// (agent is also allowed when publickey is advertised, so it is tried first.)
const authPhase = { hadPartialSuccess: false };
const handler = createOrderedStringAuthHandler(
["none", "agent", "password", "publickey", "keyboard-interactive"],
authPhase,
);
const offered = [];
handler(null, false, (method) => offered.push(method)); // none
handler(["publickey"], false, (method) => offered.push(method)); // agent
handler(["publickey"], false, (method) => offered.push(method)); // publickey (password still not advertised)
// publickey partially succeeds; server now allows password + KI.
// Prefer keyboard-interactive automatically after any partial success.
handler(["password", "keyboard-interactive"], true, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "agent", "publickey", "keyboard-interactive"]);
assert.equal(authPhase.hadPartialSuccess, true);
// If KI only partially succeeds again, password remains available.
handler(["password"], true, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "agent", "publickey", "keyboard-interactive", "password"]);
});
test("createOrderedStringAuthHandler does not retry a rejected credential in a later factor", () => {
const authPhase = createAuthPhase();
const handler = createOrderedStringAuthHandler(
["none", "agent", "publickey", "password", "keyboard-interactive"],
authPhase,
);
const offered = [];
handler(null, false, (method) => offered.push(method));
handler(["publickey"], false, (method) => offered.push(method));
handler(["publickey"], false, (method) => offered.push(method));
handler(["publickey", "password"], true, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "agent", "publickey", "password"]);
});
test("createOrderedStringAuthHandler allows consecutive keyboard-interactive factors (#2150)", () => {
const authPhase = createAuthPhase();
const handler = createOrderedStringAuthHandler(
["none", "password", "keyboard-interactive"],
authPhase,
);
const offered = [];
handler(null, null, (method) => offered.push(method));
handler(["keyboard-interactive"], false, (method) => offered.push(method));
handler(["keyboard-interactive"], true, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "keyboard-interactive", "keyboard-interactive"]);
assert.equal(authPhase.hadPartialSuccess, true);
});
test("consecutive keyboard-interactive factors do not offer to save a generic second password (#2150)", () => {
const { sender, sent } = createSender();
const authPhase = createAuthPhase();
const authHandler = createOrderedStringAuthHandler(
["none", "password", "keyboard-interactive"],
authPhase,
);
const keyboardHandler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-edr.example.com",
password: "login-password",
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
});
const offered = [];
authHandler(null, null, (method) => offered.push(method));
authHandler(["keyboard-interactive"], false, (method) => offered.push(method));
const firstResponses = [];
keyboardHandler("", "", "", [passwordPrompt], (responses) => firstResponses.push(responses));
assert.deepEqual(firstResponses, [["login-password"]]);
assert.deepEqual(sent, []);
authHandler(["keyboard-interactive"], true, (method) => offered.push(method));
keyboardHandler("", "", "", [passwordPrompt], () => {});
assert.deepEqual(offered, ["none", "keyboard-interactive", "keyboard-interactive"]);
assert.equal(authPhase.keyboardInteractiveSuccessCount, 1);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
assert.equal(sent[0].payload.allowSavePassword, false);
drainPendingRequests(sent);
});
test("an intervening factor does not make a later keyboard-interactive password reusable (#2150)", () => {
const authPhase = createAuthPhase();
const authHandler = createOrderedStringAuthHandler(
["none", "keyboard-interactive", "publickey"],
authPhase,
);
const offered = [];
authHandler(null, null, (method) => offered.push(method));
authHandler(["keyboard-interactive"], false, (method) => offered.push(method));
authHandler(["publickey"], true, (method) => offered.push(method));
assert.equal(shouldSkipKiPasswordAutoFill(authPhase), true);
authHandler(["keyboard-interactive"], true, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "keyboard-interactive", "publickey", "keyboard-interactive"]);
assert.equal(authPhase.keyboardInteractiveSuccessCount, 1);
assert.equal(shouldSkipKiPasswordAutoFill(authPhase), true);
});
test("buildAuthHandler allows consecutive keyboard-interactive factors on the dynamic path (#2150)", () => {
const auth = buildAuthHandler({
authMethod: "auto",
username: "alice",
password: "login-password",
allowAgentFallback: false,
defaultKeys: [{ keyName: "unused-test-key", privateKey: "unused" }],
});
const offered = [];
auth.authHandler(null, null, (method) => offered.push(method));
auth.authHandler(["keyboard-interactive"], false, (method) => offered.push(method));
auth.authHandler(["keyboard-interactive"], true, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "keyboard-interactive", "keyboard-interactive"]);
assert.equal(auth.authPhase.hadPartialSuccess, true);
});
test("buildAuthHandler prefers password over keyboard-interactive by default", () => {
const auth = buildAuthHandler({
authMethod: "password",
username: "alice",
password: "login-password",
});
const offered = [];
auth.authHandler(null, null, (method) => offered.push(method));
auth.authHandler(["password", "keyboard-interactive"], false, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "password"]);
});
test("buildAuthHandler requiresMfa prefers keyboard-interactive before password", () => {
const auth = buildAuthHandler({
authMethod: "password",
username: "alice",
password: "login-password",
requiresMfa: true,
});
const offered = [];
auth.authHandler(null, null, (method) => offered.push(method));
auth.authHandler(["password", "keyboard-interactive"], false, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "keyboard-interactive"]);
});
test("buildAuthHandler requiresMfa keeps selected key before keyboard-interactive", () => {
const auth = buildAuthHandler({
authMethod: "key",
username: "alice",
privateKey: "selected-key",
password: "login-password",
requiresMfa: true,
allowAgentFallback: false,
});
const offered = [];
const record = (method) => offered.push(
method && typeof method === "object" ? method.type : method,
);
auth.authHandler(null, null, record);
auth.authHandler(["publickey", "password", "keyboard-interactive"], false, record);
auth.authHandler(["password", "keyboard-interactive"], false, record);
assert.deepEqual(offered, ["none", "publickey", "keyboard-interactive"]);
});
test("buildAuthHandler requiresMfa prefers keyboard-interactive before automatic discovery without a saved password", () => {
const auth = buildAuthHandler({
authMethod: "auto",
username: "alice",
requiresMfa: true,
allowAgentFallback: false,
defaultKeys: [{ keyName: "id_ed25519", privateKey: "default-key" }],
});
const offered = [];
auth.authHandler(null, null, (method) => offered.push(method));
auth.authHandler(
["publickey", "keyboard-interactive"],
false,
(method) => offered.push(method && typeof method === "object" ? method.type : method),
);
assert.deepEqual(offered, ["none", "keyboard-interactive"]);
});
test("buildAuthHandler skipPasswordMethod retries keyboard-interactive before password", () => {
const auth = buildAuthHandler({
authMethod: "password",
username: "alice",
password: "login-password",
skipPasswordMethod: true,
});
const offered = [];
auth.authHandler(null, null, (method) => offered.push(method));
auth.authHandler(["publickey", "password", "keyboard-interactive"], false, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "keyboard-interactive"]);
});
test("buildAuthHandler prefers keyboard-interactive after partial success without host MFA flag", () => {
const auth = buildAuthHandler({
authMethod: "password",
username: "alice",
password: "login-password",
});
const offered = [];
auth.authHandler(null, null, (method) => offered.push(method));
auth.authHandler(["password", "keyboard-interactive"], false, (method) => offered.push(method));
auth.authHandler(["password", "keyboard-interactive"], true, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "password", "keyboard-interactive"]);
});
test("buildAuthHandler skipPasswordMethod omits password for 2FA_RETRY", () => {
const auth = buildAuthHandler({
authMethod: "password",
username: "alice",
password: "login-password",
skipPasswordMethod: true,
});
const offered = [];
auth.authHandler(null, null, (method) => offered.push(method));
auth.authHandler(["password", "keyboard-interactive"], false, (method) => offered.push(method));
assert.deepEqual(offered, ["none", "keyboard-interactive"]);
});
test("buildAuthHandler keeps unavailable keyboard-interactive eligible after password rejection", () => {
const auth = buildAuthHandler({
authMethod: "auto",
username: "alice",
password: "stale-password",
allowAgentFallback: false,
defaultKeys: [{ keyName: "unused-test-key", privateKey: "unused" }],
});
const offered = [];
const record = (method) => offered.push(
method && typeof method === "object" ? method.type : method,
);
auth.authHandler(null, null, record);
auth.authHandler(["password"], false, record);
auth.authHandler(["keyboard-interactive"], false, record);
assert.deepEqual(offered, ["none", "password", "keyboard-interactive"]);
});
test("buildAuthHandler reconsiders dynamic methods between authentication factors (#2150)", () => {
const auth = buildAuthHandler({
authMethod: "auto",
username: "alice",
password: "login-password",
allowAgentFallback: false,
defaultKeys: [{ keyName: "id_ed25519", privateKey: "test-key" }],
});
const offered = [];
const record = (method) => offered.push(
method && typeof method === "object" ? method.type : method,
);
auth.authHandler(null, null, record);
auth.authHandler(["keyboard-interactive"], false, record);
auth.authHandler(["publickey"], true, record);
auth.authHandler(["keyboard-interactive"], true, record);
assert.deepEqual(
offered,
["none", "keyboard-interactive", "publickey", "keyboard-interactive"],
);
assert.equal(auth.authPhase.keyboardInteractiveSuccessCount, 1);
assert.equal(shouldSkipKiPasswordAutoFill(auth.authPhase), true);
});
test("createOrderedStringAuthHandler caps repeated keyboard-interactive factors (#2150)", () => {
const authPhase = createAuthPhase();
const handler = createOrderedStringAuthHandler(
["none", "keyboard-interactive"],
authPhase,
);
const offered = [];
handler(null, null, (method) => offered.push(method));
handler(["keyboard-interactive"], false, (method) => offered.push(method));
handler(["keyboard-interactive"], true, (method) => offered.push(method));
handler(["keyboard-interactive"], true, (method) => offered.push(method));
assert.deepEqual(
offered,
["none", "keyboard-interactive", "keyboard-interactive", false],
);
assert.equal(authPhase.keyboardInteractiveSuccessCount, 2);
});
test("createOrderedStringAuthHandler does not re-offer a rejected second interactive factor", () => {
const authPhase = createAuthPhase();
const handler = createOrderedStringAuthHandler(
["none", "keyboard-interactive", "publickey"],
authPhase,
);
const offered = [];
handler(null, null, (method) => offered.push(method));
handler(["keyboard-interactive"], false, (method) => offered.push(method));
handler(["keyboard-interactive"], true, (method) => offered.push(method));
handler(["publickey"], false, (method) => offered.push(method));
handler(["keyboard-interactive"], true, (method) => offered.push(method));
assert.deepEqual(
offered,
["none", "keyboard-interactive", "keyboard-interactive", "publickey", false],
);
assert.equal(authPhase.keyboardInteractiveSuccessCount, 1);
});
test("canRepeatKeyboardInteractive blocks an interactive method already rejected in this connection", () => {
const authPhase = createAuthPhase();
authPhase.keyboardInteractiveSuccessCount = 1;
assert.equal(canRepeatKeyboardInteractive(authPhase, new Set()), true);
assert.equal(
canRepeatKeyboardInteractive(authPhase, new Set(["keyboard-interactive"])),
false,
);
});
test("buildAuthHandler caps repeated keyboard-interactive factors on the dynamic path (#2150)", () => {
const auth = buildAuthHandler({
authMethod: "auto",
username: "alice",
password: "login-password",
allowAgentFallback: false,
defaultKeys: [{ keyName: "unused-test-key", privateKey: "unused" }],
});
const offered = [];
auth.authHandler(null, null, (method) => offered.push(method));
auth.authHandler(["keyboard-interactive"], false, (method) => offered.push(method));
auth.authHandler(["keyboard-interactive"], true, (method) => offered.push(method));
auth.authHandler(["keyboard-interactive"], true, (method) => offered.push(method));
assert.deepEqual(
offered,
["none", "keyboard-interactive", "keyboard-interactive", false],
);
assert.equal(auth.authPhase.keyboardInteractiveSuccessCount, 2);
});
test("buildAuthHandler does not retry a rejected credential in a later factor", () => {
const auth = buildAuthHandler({
authMethod: "auto",
username: "alice",
password: "login-password",
allowAgentFallback: false,
defaultKeys: [
{ keyName: "key-one", privateKey: "private-key-one" },
{ keyName: "key-two", privateKey: "private-key-two" },
],
});
const offered = [];
const record = (method) => offered.push(
method && typeof method === "object"
? `${method.type}:${method.key || "password"}`
: method,
);
auth.authHandler(null, null, record);
auth.authHandler(["publickey"], false, record);
auth.authHandler(["publickey"], false, record);
auth.authHandler(["publickey", "password"], true, record);
assert.deepEqual(offered, [
"none",
"publickey:private-key-one",
"publickey:private-key-two",
"password:password",
]);
});
test("buildAuthHandler simple password path tracks partialSuccess via function handler", () => {
const auth = buildAuthHandler({
password: "hunter2",
username: "alice",
// No default keys / agent: simple explicit password-only path.
defaultKeys: [],
allowAgentFallback: false,
});
assert.equal(typeof auth.authHandler, "function");
assert.ok(auth.authPhase);
assert.equal(auth.authPhase.hadPartialSuccess, false);
const offered = [];
auth.authHandler(null, false, (method) => offered.push(method));
auth.authHandler(["password", "keyboard-interactive"], false, (method) => offered.push(method));
auth.authHandler(["keyboard-interactive"], true, (method) => offered.push(method));
// Default: password first; after partial success KI can still run as second factor.
assert.deepEqual(offered, ["none", "password", "keyboard-interactive"]);
assert.equal(auth.authPhase.hadPartialSuccess, true);
});
test("shouldPrefillSavedPassword is false after skipAutoFill and for secondary prompts", () => {
assert.equal(
shouldPrefillSavedPassword([passwordPrompt], "hunter2", { skipAutoFill: true }),
false,
);
assert.equal(
shouldPrefillSavedPassword([secondaryPasswordPrompt], "hunter2", { skipAutoFill: false }),
false,
);
assert.equal(
shouldPrefillSavedPassword([passwordPrompt], "hunter2", { skipAutoFill: false }),
true,
);
});
test("shouldPrefillSavedPassword keeps multi-prompt Duo/two-factor password prefill (#2151 P3)", () => {
// Challenge name/instructions often say "Duo" / "two-factor" even when the
// form still includes a first-factor Password: slot next to an OTP field.
assert.equal(
shouldPrefillSavedPassword(
[passwordPrompt, verificationCodePrompt],
"hunter2",
{ skipAutoFill: false, contextText: "Duo two-factor login" },
),
true,
);
assert.equal(
shouldPrefillSavedPassword(
[passwordPrompt, verificationCodePrompt],
"hunter2",
{ skipAutoFill: true, contextText: "Duo two-factor login" },
),
false,
);
});
test("createKeyboardInteractiveHandler suggests enabling host MFA for Secondary Authentication Password (#2150)", () => {
const { handler, sent } = (() => {
const sent = [];
const handler = createKeyboardInteractiveHandler({
sender: {
id: 1,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
},
sessionId: "s1",
hostname: "host",
password: "saved",
});
return { handler, sent };
})();
handler(
"Keyboard-interactive authentication prompts from server",
"为保障主机安全,请输入二次认证密码",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
() => {},
);
assert.equal(sent[0].payload.allowSavePassword, false);
assert.equal(sent[0].payload.allowSavePassword, false);
drainPendingRequests(sent, 1);
});
test("createKeyboardInteractiveHandler does not suggest host MFA for password-change prompts", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "password-expired.example.com",
password: "saved",
});
handler(
"Password expired",
"You are required to change your password immediately.",
"",
[currentPasswordPrompt, newPasswordPrompt, confirmPasswordPrompt],
() => {},
);
assert.ok(sent[0].payload);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler includes owning hostId in modal payload", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "sftp-connection-1",
hostId: "host-1",
hostname: "host",
password: "saved",
});
handler(
"Keyboard-interactive authentication prompts from server",
"为保障主机安全,请输入二次认证密码",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
() => {},
);
assert.equal(sent[0].payload.hostId, "host-1");
drainPendingRequests(sent, sender.id);
});
test("createKeyboardInteractiveHandler does not suggest MFA when host already has requiresMfa", () => {
const sent = [];
const handler = createKeyboardInteractiveHandler({
sender: {
id: 1,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
},
sessionId: "s1",
hostname: "host",
password: "saved",
});
handler(
"Keyboard-interactive authentication prompts from server",
"为保障主机安全,请输入二次认证密码",
"",
[{ prompt: "Secondary Authentication Password:", echo: false }],
() => {},
);
assert.ok(sent[0].payload);
drainPendingRequests(sent, 1);
});
test("createKeyboardInteractiveHandler shows modal for Secondary Authentication Password banner (#2150)", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "192.168.9.128",
password: "login-password",
onAutoFill: () => autoFillEvents.push("auto-fill"),
});
handler(
"Keyboard-interactive authentication prompts from server",
edrSecondaryAuthInstructions,
"",
[edrSecondaryAuthPasswordPrompt],
() => {},
);
assert.deepEqual(autoFillEvents, []);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
assert.equal(sent[0].payload.allowSavePassword, false);
assert.equal(sent[0].payload.prompts[0].prompt, "Secondary Authentication Password:");
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler disables save on post-password-partialSuccess Password prompt", () => {
const { sender, sent } = createSender();
const authPhase = createAuthPhase();
authPhase.passwordAlreadySucceeded = true;
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-edr.example.com",
password: "login-password",
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
});
handler("", "", "", [passwordPrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
assert.equal(sent[0].payload.allowSavePassword, false);
drainPendingRequests(sent);
});
test("createOrderedStringAuthHandler sets passwordAlreadySucceeded only for password factor", () => {
const authPhase = createAuthPhase();
const handler = createOrderedStringAuthHandler(
["none", "publickey", "password", "keyboard-interactive"],
authPhase,
);
handler(null, false, () => {}); // none
handler(["publickey", "password", "keyboard-interactive"], false, () => {}); // publickey
handler(["password", "keyboard-interactive"], true, () => {}); // after publickey PS
assert.equal(authPhase.hadPartialSuccess, true);
assert.equal(authPhase.passwordAlreadySucceeded, false);
assert.equal(shouldSkipKiPasswordAutoFill(authPhase), false);
const authPhasePw = createAuthPhase();
const handlerPw = createOrderedStringAuthHandler(
["none", "password", "keyboard-interactive"],
authPhasePw,
);
handlerPw(null, false, () => {});
handlerPw(["password", "keyboard-interactive"], false, () => {}); // password
handlerPw(["keyboard-interactive"], true, () => {}); // after password PS
assert.equal(authPhasePw.passwordAlreadySucceeded, true);
assert.equal(shouldSkipKiPasswordAutoFill(authPhasePw), true);
});
test("createKeyboardInteractiveHandler allows save on first-factor password modal", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
// No saved password → modal with save checkbox for first login
});
handler("", "", "", [passwordPrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.allowSavePassword, true);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler allows save on multi-prompt Password + OTP (#2151 P3)", () => {
// PAM/Duo style: one keyboard-interactive challenge with a password slot
// and a verification-code slot. Saving should still target the password
// field only — do not disable allowSavePassword just because OTP wording
// appears in another prompt of the same challenge.
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "duo.example.com",
password: "login-password",
});
handler(
"Duo two-factor login",
"Enter password and passcode",
"",
[passwordPrompt, verificationCodePrompt],
() => {},
);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.allowSavePassword, true);
// Prefill still OK for the password slot even when name/instructions say Duo.
assert.equal(sent[0].payload.savedPassword, "login-password");
drainPendingRequests(sent);
});