Files
NetMesh/electron/bridges/sshAuthHelper.kbdInteractive.test.cjs

1318 lines
47 KiB
JavaScript
Raw Normal View History

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