From b527355005c54f76a1320f2c5370c97c31e21105 Mon Sep 17 00:00:00 2001 From: rorychou Date: Thu, 26 Feb 2026 00:42:38 +0800 Subject: [PATCH 1/2] fix(auth): prefer keyboard-interactive for password flows --- components/KeyboardInteractiveModal.tsx | 48 +++++++++++++++++++++---- electron/bridges/sshAuthHelper.cjs | 27 +++++++++----- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/components/KeyboardInteractiveModal.tsx b/components/KeyboardInteractiveModal.tsx index 287c26cc..93a7320b 100644 --- a/components/KeyboardInteractiveModal.tsx +++ b/components/KeyboardInteractiveModal.tsx @@ -47,14 +47,50 @@ export const KeyboardInteractiveModal: React.FC = const [showPasswords, setShowPasswords] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); + const getAutofillPasswordIndex = useCallback((prompts: KeyboardInteractivePrompt[]) => { + const passwordPromptPattern = /password|passphrase|passwd|密码|口令/i; + const oneTimeCodePattern = /otp|token|verification|verify|\bcode\b|验证码|动态码|二次验证|双因素|2fa|mfa/i; + + for (let i = 0; i < prompts.length; i++) { + const prompt = prompts[i]; + if (prompt.echo) continue; + const text = (prompt.prompt || "").toLowerCase(); + if (passwordPromptPattern.test(text) && !oneTimeCodePattern.test(text)) { + return i; + } + } + return -1; + }, []); + + // Build auto-filled responses and check if all prompts are covered. + // Computed during render to prevent modal flash when auto-submitting. + const buildAutofilledResponses = useCallback( + (req: KeyboardInteractiveRequest): { responses: string[]; allFilled: boolean } => { + const filled = req.prompts.map(() => ""); + if (req.savedPassword) { + const idx = getAutofillPasswordIndex(req.prompts); + if (idx >= 0) filled[idx] = req.savedPassword; + } + return { responses: filled, allFilled: filled.length > 0 && filled.every((r) => r.length > 0) }; + }, + [getAutofillPasswordIndex] + ); + + // Auto-submit when all prompts are filled by savedPassword (e.g. single password prompt) + const canAutoSubmit = request ? buildAutofilledResponses(request).allFilled : false; + // Reset state when request changes useEffect(() => { - if (request) { - setResponses(request.prompts.map(() => "")); - setShowPasswords(request.prompts.map(() => false)); - setIsSubmitting(false); + if (!request) return; + const { responses: filled, allFilled } = buildAutofilledResponses(request); + if (allFilled) { + onSubmit(request.requestId, filled); + return; } - }, [request]); + setResponses(filled); + setShowPasswords(request.prompts.map(() => false)); + setIsSubmitting(false); + }, [request, buildAutofilledResponses, onSubmit]); const handleResponseChange = useCallback((index: number, value: string) => { setResponses((prev) => { @@ -93,7 +129,7 @@ export const KeyboardInteractiveModal: React.FC = [handleSubmit, isSubmitting] ); - if (!request) return null; + if (!request || canAutoSubmit) return null; const title = request.name?.trim() || t("keyboard.interactive.title"); const description = diff --git a/electron/bridges/sshAuthHelper.cjs b/electron/bridges/sshAuthHelper.cjs index 23d07161..851deb06 100644 --- a/electron/bridges/sshAuthHelper.cjs +++ b/electron/bridges/sshAuthHelper.cjs @@ -190,10 +190,15 @@ async function findAllDefaultPrivateKeys(options = {}) { // If only simple auth methods and no fallback keys needed, use array-based handler if (hasExplicitAuth && !hasFallbackOptions) { const authMethods = []; - if (effectiveAgent) authMethods.push("agent"); - if (privateKey) authMethods.push("publickey"); - if (password) authMethods.push("password"); - authMethods.push("keyboard-interactive"); + if (isPasswordOnly) { + authMethods.push("keyboard-interactive"); + authMethods.push("password"); + } else { + if (effectiveAgent) authMethods.push("agent"); + if (privateKey) authMethods.push("publickey"); + if (password) authMethods.push("password"); + authMethods.push("keyboard-interactive"); + } return { authHandler: authMethods, @@ -205,17 +210,19 @@ async function findAllDefaultPrivateKeys(options = {}) { // Build comprehensive authMethods array with all auth options // Order depends on what user explicitly configured: - // - Password-only: password -> agent -> default keys -> keyboard-interactive + // - Password-only: keyboard-interactive -> password -> agent -> default keys // - Key-only: user key -> password -> agent -> default keys -> keyboard-interactive // - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive // - No explicit auth: agent -> default keys -> keyboard-interactive const authMethods = []; if (isPasswordOnly) { - // Password-only: password first, then fallbacks + // keyboard-interactive first: many bastion/2FA setups expect password + OTP + // via interactive prompts. Starting with "password" can trigger duplicate challenges. + authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" }); authMethods.push({ type: "password", id: "password" }); - // Add agent and default keys AFTER password as fallback + // Add agent and default keys after password as fallback if (sshAgentSocket) { authMethods.push({ type: "agent", id: "agent" }); } @@ -308,8 +315,10 @@ async function findAllDefaultPrivateKeys(options = {}) { }); } - // Keyboard-interactive as last resort - authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" }); + // Keyboard-interactive as last resort (if not already present) + if (!authMethods.some((m) => m.type === "keyboard-interactive")) { + authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" }); + } console.log(`${logPrefix} Auth methods configured`, { isPasswordOnly, From c483285848de4c56ef9b08e799e5326684189e95 Mon Sep 17 00:00:00 2001 From: rorychou Date: Thu, 26 Feb 2026 01:02:13 +0800 Subject: [PATCH 2/2] fix(auth): avoid consuming unavailable auth methods --- electron/bridges/sshAuthHelper.cjs | 63 ++++++++++++++++-------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/electron/bridges/sshAuthHelper.cjs b/electron/bridges/sshAuthHelper.cjs index 851deb06..c7075a1d 100644 --- a/electron/bridges/sshAuthHelper.cjs +++ b/electron/bridges/sshAuthHelper.cjs @@ -329,47 +329,50 @@ async function findAllDefaultPrivateKeys(options = {}) { methods: authMethods.map(m => m.id), }); - // Use dynamic authHandler to try all keys - let authIndex = 0; - const attemptedMethodIds = new Set(); - - const authHandler = (methodsLeft, partialSuccess, callback) => { - const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"]; - - while (authIndex < authMethods.length) { - const method = authMethods[authIndex]; - authIndex++; - - if (attemptedMethodIds.has(method.id)) continue; - attemptedMethodIds.add(method.id); - + // Use dynamic authHandler to try all configured methods. + // Important: only mark a method as attempted when it is actually sent to ssh2. + // This avoids consuming methods that are unavailable in an earlier round but + // become available after partialSuccess (for example password -> keyboard-interactive MFA). + const attemptedMethodIds = new Set(); + + const authHandler = (methodsLeft, partialSuccess, callback) => { + const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"]; + + for (const method of authMethods) { + if (attemptedMethodIds.has(method.id)) continue; + if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) { + attemptedMethodIds.add(method.id); console.log(`${logPrefix} Trying agent auth`); return callback("agent"); } else if (method.type === "publickey" && availableMethods.includes("publickey")) { - console.log(`${logPrefix} Trying publickey auth:`, method.id); - const pubkeyAuth = { - type: "publickey", - username, - key: method.key, - }; - if (method.passphrase) { - pubkeyAuth.passphrase = method.passphrase; - } - return callback(pubkeyAuth); + attemptedMethodIds.add(method.id); + console.log(`${logPrefix} Trying publickey auth:`, method.id); + const pubkeyAuth = { + type: "publickey", + username, + key: method.key, + }; + if (method.passphrase) { + pubkeyAuth.passphrase = method.passphrase; + } + return callback(pubkeyAuth); } else if (method.type === "password" && availableMethods.includes("password")) { + attemptedMethodIds.add(method.id); console.log(`${logPrefix} Trying password auth`); return callback({ type: "password", username, password, }); - } else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) { - return callback("keyboard-interactive"); - } - } - return callback(false); - }; + } else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) { + attemptedMethodIds.add(method.id); + return callback("keyboard-interactive"); + } + } + + return callback(false); + }; // Determine the agent to return - if authMethods includes agent, we need to provide the socket // even if effectiveAgent is null (for fallback scenarios)