diff --git a/App.tsx b/App.tsx index fdff0f4..2a38924 100755 --- a/App.tsx +++ b/App.tsx @@ -19,6 +19,7 @@ import { Input } from './components/ui/input'; import { Label } from './components/ui/label'; import { ToastProvider, toast } from './components/ui/toast'; import { VaultView, VaultSection } from './components/VaultView'; +import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal'; import { cn } from './lib/utils'; import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types'; import { LogView as LogViewType } from './application/state/useSessionState'; @@ -150,6 +151,8 @@ function App({ settings }: { settings: SettingsState }) { const [protocolSelectHost, setProtocolSelectHost] = useState(null); // Navigation state for VaultView sections const [navigateToSection, setNavigateToSection] = useState(null); + // Keyboard-interactive authentication state (2FA/MFA) + const [keyboardInteractiveRequest, setKeyboardInteractiveRequest] = useState(null); const { theme, @@ -291,6 +294,45 @@ function App({ settings }: { settings: SettingsState }) { keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })), }); + // Keyboard-interactive authentication (2FA/MFA) event listener + useEffect(() => { + const bridge = netcattyBridge.get(); + if (!bridge?.onKeyboardInteractive) return; + + const unsubscribe = bridge.onKeyboardInteractive((request) => { + console.log('[App] Keyboard-interactive request received:', request); + setKeyboardInteractiveRequest({ + requestId: request.requestId, + name: request.name, + instructions: request.instructions, + prompts: request.prompts, + hostname: request.hostname, + }); + }); + + return () => { + unsubscribe?.(); + }; + }, []); + + // Handle keyboard-interactive submit + const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => { + const bridge = netcattyBridge.get(); + if (bridge?.respondKeyboardInteractive) { + void bridge.respondKeyboardInteractive(requestId, responses, false); + } + setKeyboardInteractiveRequest(null); + }, []); + + // Handle keyboard-interactive cancel + const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { + const bridge = netcattyBridge.get(); + if (bridge?.respondKeyboardInteractive) { + void bridge.respondKeyboardInteractive(requestId, [], true); + } + setKeyboardInteractiveRequest(null); + }, []); + // Debounce ref for moveFocus to prevent double-triggering when focus switches const lastMoveFocusTimeRef = useRef(0); const MOVE_FOCUS_DEBOUNCE_MS = 200; @@ -989,6 +1031,13 @@ function App({ settings }: { settings: SettingsState }) { /> )} + + {/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) */} + ); } diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index f5207ff..e46ff69 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -1117,6 +1117,16 @@ const en: Messages = { 'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno', 'serial.connectAndSave': 'Connect & Save', 'serial.edit.title': 'Serial Port Settings', + + // Keyboard Interactive Authentication (2FA/MFA) + 'keyboard.interactive.title': 'Authentication Required', + 'keyboard.interactive.desc': 'The server requires additional authentication.', + 'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.', + 'keyboard.interactive.response': 'Response', + 'keyboard.interactive.enterCode': 'Enter verification code', + 'keyboard.interactive.enterResponse': 'Enter response', + 'keyboard.interactive.submit': 'Submit', + 'keyboard.interactive.verifying': 'Verifying...', }; export default en; diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index b656e97..4af7ee0 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -1106,6 +1106,16 @@ const zhCN: Messages = { 'serial.field.configLabelPlaceholder': '例如 Arduino Uno', 'serial.connectAndSave': '连接并保存', 'serial.edit.title': '串口设置', + + // Keyboard Interactive Authentication (2FA/MFA) + 'keyboard.interactive.title': '需要验证', + 'keyboard.interactive.desc': '服务器需要额外的身份验证。', + 'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。', + 'keyboard.interactive.response': '响应', + 'keyboard.interactive.enterCode': '输入验证码', + 'keyboard.interactive.enterResponse': '输入响应', + 'keyboard.interactive.submit': '提交', + 'keyboard.interactive.verifying': '验证中...', }; export default zhCN; diff --git a/components/KeyboardInteractiveModal.tsx b/components/KeyboardInteractiveModal.tsx new file mode 100644 index 0000000..1a3ee37 --- /dev/null +++ b/components/KeyboardInteractiveModal.tsx @@ -0,0 +1,189 @@ +/** + * Keyboard Interactive Authentication Modal + * Global modal for handling SSH keyboard-interactive authentication (2FA/MFA) + * This modal displays prompts from the SSH server and collects user responses. + */ +import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; +import { useI18n } from "../application/i18n/I18nProvider"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; + +export interface KeyboardInteractivePrompt { + prompt: string; + echo: boolean; +} + +export interface KeyboardInteractiveRequest { + requestId: string; + name: string; + instructions: string; + prompts: KeyboardInteractivePrompt[]; + hostname?: string; +} + +interface KeyboardInteractiveModalProps { + request: KeyboardInteractiveRequest | null; + onSubmit: (requestId: string, responses: string[]) => void; + onCancel: (requestId: string) => void; +} + +export const KeyboardInteractiveModal: React.FC = ({ + request, + onSubmit, + onCancel, +}) => { + const { t } = useI18n(); + const [responses, setResponses] = useState([]); + const [showPasswords, setShowPasswords] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Reset state when request changes + useEffect(() => { + if (request) { + setResponses(request.prompts.map(() => "")); + setShowPasswords(request.prompts.map(() => false)); + setIsSubmitting(false); + } + }, [request]); + + const handleResponseChange = useCallback((index: number, value: string) => { + setResponses((prev) => { + const updated = [...prev]; + updated[index] = value; + return updated; + }); + }, []); + + const toggleShowPassword = useCallback((index: number) => { + setShowPasswords((prev) => { + const updated = [...prev]; + updated[index] = !updated[index]; + return updated; + }); + }, []); + + const handleSubmit = useCallback(() => { + if (!request || isSubmitting) return; + setIsSubmitting(true); + onSubmit(request.requestId, responses); + }, [request, responses, onSubmit, isSubmitting]); + + const handleCancel = useCallback(() => { + if (!request) return; + onCancel(request.requestId); + }, [request, onCancel]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !isSubmitting) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit, isSubmitting] + ); + + if (!request) return null; + + const title = request.name?.trim() || t("keyboard.interactive.title"); + const description = + request.instructions?.trim() || + (request.hostname + ? t("keyboard.interactive.descWithHost", { hostname: request.hostname }) + : t("keyboard.interactive.desc")); + + return ( + !open && handleCancel()}> + + +
+
+ +
+
+ {title} + + {description} + +
+
+
+ +
+ {request.prompts.map((prompt, index) => { + const isPassword = !prompt.echo; + const showPassword = showPasswords[index]; + // Clean up prompt text (remove trailing colon and whitespace) + const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim(); + + return ( +
+ +
+ handleResponseChange(index, e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + isPassword + ? t("keyboard.interactive.enterCode") + : t("keyboard.interactive.enterResponse") + } + className={isPassword ? "pr-10" : undefined} + autoFocus={index === 0} + disabled={isSubmitting} + /> + {isPassword && ( + + )} +
+
+ ); + })} +
+ +
+ + +
+
+
+ ); +}; + +export default KeyboardInteractiveModal; diff --git a/electron/bridges/keyboardInteractiveHandler.cjs b/electron/bridges/keyboardInteractiveHandler.cjs new file mode 100644 index 0000000..0df3860 --- /dev/null +++ b/electron/bridges/keyboardInteractiveHandler.cjs @@ -0,0 +1,104 @@ +/** + * Keyboard Interactive Handler - Shared state for keyboard-interactive authentication + * This module provides a centralized storage for keyboard-interactive auth requests + * used by SSH, SFTP, and Port Forwarding bridges. + */ + +// Keyboard-interactive authentication pending requests +// Map of requestId -> { finishCallback, webContentsId, sessionId, createdAt, timeoutId } +const keyboardInteractiveRequests = new Map(); + +// TTL for abandoned requests (5 minutes) +const REQUEST_TTL_MS = 5 * 60 * 1000; + +/** + * Generate a unique request ID for keyboard-interactive requests + */ +function generateRequestId(prefix = 'ki') { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +/** + * Store a keyboard-interactive request with TTL cleanup + */ +function storeRequest(requestId, finishCallback, webContentsId, sessionId) { + // Set up TTL timeout to clean up abandoned requests + const timeoutId = setTimeout(() => { + const pending = keyboardInteractiveRequests.get(requestId); + if (pending) { + console.warn(`[KeyboardInteractive] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s, cleaning up`); + keyboardInteractiveRequests.delete(requestId); + // Call finish with empty responses to abort the authentication + try { + pending.finishCallback([]); + } catch (err) { + console.warn(`[KeyboardInteractive] Failed to call finishCallback for timed out request:`, err.message); + } + } + }, REQUEST_TTL_MS); + + keyboardInteractiveRequests.set(requestId, { + finishCallback, + webContentsId, + sessionId, + createdAt: Date.now(), + timeoutId, + }); +} + +/** + * Handle keyboard-interactive authentication response from renderer + */ +function handleResponse(_event, payload) { + console.log(`[KeyboardInteractive] handleResponse called with payload:`, JSON.stringify(payload)); + + const { requestId, responses, cancelled } = payload; + const pending = keyboardInteractiveRequests.get(requestId); + + console.log(`[KeyboardInteractive] Looking for request ${requestId}, found:`, !!pending); + console.log(`[KeyboardInteractive] Current pending requests:`, Array.from(keyboardInteractiveRequests.keys())); + + if (!pending) { + console.warn(`[KeyboardInteractive] No pending request for ${requestId}`); + return { success: false, error: 'Request not found' }; + } + + // Clear the TTL timeout since we received a response + if (pending.timeoutId) { + clearTimeout(pending.timeoutId); + } + + keyboardInteractiveRequests.delete(requestId); + + if (cancelled) { + console.log(`[KeyboardInteractive] Auth cancelled for ${requestId}`); + pending.finishCallback([]); // Empty responses to cancel + } else { + console.log(`[KeyboardInteractive] Auth response received for ${requestId}, responses count:`, responses?.length); + pending.finishCallback(responses); + } + + return { success: true }; +} + +/** + * Get the requests map (for debugging/testing) + */ +function getRequests() { + return keyboardInteractiveRequests; +} + +/** + * Register IPC handler for keyboard-interactive responses + */ +function registerHandler(ipcMain) { + ipcMain.handle("netcatty:keyboard-interactive:respond", handleResponse); +} + +module.exports = { + generateRequestId, + storeRequest, + handleResponse, + getRequests, + registerHandler, +}; diff --git a/electron/bridges/portForwardingBridge.cjs b/electron/bridges/portForwardingBridge.cjs index fd92c83..b1cb3ab 100644 --- a/electron/bridges/portForwardingBridge.cjs +++ b/electron/bridges/portForwardingBridge.cjs @@ -5,18 +5,31 @@ const net = require("node:net"); const { Client: SSHClient } = require("ssh2"); +const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs"); // Active port forwarding tunnels const portForwardingTunnels = new Map(); +/** + * Send message to renderer safely + */ +function safeSend(sender, channel, payload) { + try { + if (!sender || sender.isDestroyed()) return; + sender.send(channel, payload); + } catch { + // Ignore destroyed webContents during shutdown. + } +} + /** * Start a port forwarding tunnel */ async function startPortForward(event, payload) { - const { - tunnelId, + const { + tunnelId, type, // 'local' | 'remote' | 'dynamic' - localPort, + localPort, bindAddress = '127.0.0.1', remoteHost, remotePort, @@ -26,34 +39,125 @@ async function startPortForward(event, payload) { password, privateKey, } = payload; - + return new Promise((resolve, reject) => { const conn = new SSHClient(); const sender = event.sender; - + const sendStatus = (status, error = null) => { if (!sender.isDestroyed()) { sender.send("netcatty:portforward:status", { tunnelId, status, error }); } }; - + const connectOpts = { host: hostname, port: port, username: username || 'root', - readyTimeout: 30000, + readyTimeout: 120000, // 2 minutes for 2FA input keepaliveInterval: 10000, + // Enable keyboard-interactive authentication (required for 2FA/MFA) + tryKeyboard: true, }; - + if (privateKey) { connectOpts.privateKey = privateKey; - } else if (password) { + } + if (password) { connectOpts.password = password; } - + + // Build auth handler with keyboard-interactive support + const authMethods = []; + if (privateKey) authMethods.push("publickey"); + if (password) authMethods.push("password"); + authMethods.push("keyboard-interactive"); + connectOpts.authHandler = authMethods; + + // Handle keyboard-interactive authentication (2FA/MFA) + conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => { + console.log(`[PortForward] ${hostname} keyboard-interactive auth requested`, { + name, + instructions, + promptCount: prompts?.length || 0, + prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })), + }); + + // If there are no prompts, just call finish with empty array + if (!prompts || prompts.length === 0) { + console.log(`[PortForward] No prompts, finishing keyboard-interactive`); + finish([]); + return; + } + + // Check if all prompts are password prompts that we can auto-answer + const responses = []; + const promptsNeedingUserInput = []; + + for (let i = 0; i < prompts.length; i++) { + const prompt = prompts[i]; + const promptText = (prompt.prompt || '').toLowerCase().trim(); + + // Auto-answer password prompts if we have a configured password + if (password && ( + promptText.includes('password') || + promptText === 'password:' || + promptText === 'password' + )) { + console.log(`[PortForward] Auto-answering password prompt at index ${i}`); + responses[i] = password; + } else { + // This prompt needs user input (likely 2FA) + promptsNeedingUserInput.push({ index: i, prompt: prompt }); + responses[i] = null; // Placeholder + } + } + + // If all prompts were auto-answered, finish immediately + if (promptsNeedingUserInput.length === 0) { + console.log(`[PortForward] All prompts auto-answered, finishing keyboard-interactive`); + finish(responses); + return; + } + + // If some prompts need user input, show the modal + const requestId = keyboardInteractiveHandler.generateRequestId('pf'); + + // Store finish callback with context about which responses are already filled + keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => { + // Merge user responses with auto-filled responses + let userResponseIndex = 0; + for (let i = 0; i < prompts.length; i++) { + if (responses[i] === null) { + responses[i] = userResponses[userResponseIndex] || ''; + userResponseIndex++; + } + } + console.log(`[PortForward] Merged responses, finishing keyboard-interactive`); + finish(responses); + }, sender.id, tunnelId); + + // Send only the prompts that need user input + const promptsData = promptsNeedingUserInput.map((item) => ({ + prompt: item.prompt.prompt, + echo: item.prompt.echo, + })); + + console.log(`[PortForward] Showing modal for ${promptsData.length} prompts that need user input`); + + safeSend(sender, "netcatty:keyboard-interactive", { + requestId, + sessionId: tunnelId, + name: name || "", + instructions: instructions || "", + prompts: promptsData, + hostname: hostname, + }); + }); + conn.on('ready', () => { console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`); - + if (type === 'local') { // LOCAL FORWARDING: Listen on local port, forward to remote const server = net.createServer((socket) => { @@ -69,13 +173,13 @@ async function startPortForward(event, payload) { return; } socket.pipe(stream).pipe(socket); - + socket.on('error', (e) => console.warn('[PortForward] Socket error:', e.message)); stream.on('error', (e) => console.warn('[PortForward] Stream error:', e.message)); } ); }); - + server.on('error', (err) => { console.error(`[PortForward] Server error:`, err.message); sendStatus('error', err.message); @@ -83,19 +187,19 @@ async function startPortForward(event, payload) { portForwardingTunnels.delete(tunnelId); reject(err); }); - + server.listen(localPort, bindAddress, () => { console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`); - portForwardingTunnels.set(tunnelId, { - type: 'local', - conn, + portForwardingTunnels.set(tunnelId, { + type: 'local', + conn, server, - webContentsId: sender.id + webContentsId: sender.id }); sendStatus('active'); resolve({ tunnelId, success: true }); }); - + } else if (type === 'remote') { // REMOTE FORWARDING: Listen on remote port, forward to local conn.forwardIn(bindAddress, localPort, (err) => { @@ -106,24 +210,24 @@ async function startPortForward(event, payload) { reject(err); return; } - + console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`); - portForwardingTunnels.set(tunnelId, { - type: 'remote', + portForwardingTunnels.set(tunnelId, { + type: 'remote', conn, - webContentsId: sender.id + webContentsId: sender.id }); sendStatus('active'); resolve({ tunnelId, success: true }); }); - + // Handle incoming connections from remote conn.on('tcp connection', (info, accept, rejectConn) => { const stream = accept(); const socket = net.connect(remotePort, remoteHost || '127.0.0.1', () => { stream.pipe(socket).pipe(stream); }); - + socket.on('error', (e) => { console.warn('[PortForward] Local socket error:', e.message); stream.end(); @@ -133,7 +237,7 @@ async function startPortForward(event, payload) { socket.end(); }); }); - + } else if (type === 'dynamic') { // DYNAMIC FORWARDING (SOCKS5 Proxy) const server = net.createServer((socket) => { @@ -143,10 +247,10 @@ async function startPortForward(event, payload) { socket.end(); return; } - + // Reply: version, no auth required socket.write(Buffer.from([0x05, 0x00])); - + // Wait for connection request socket.once('data', (request) => { if (request[0] !== 0x05 || request[1] !== 0x01) { @@ -154,10 +258,10 @@ async function startPortForward(event, payload) { socket.end(); return; } - + let targetHost, targetPort; const addressType = request[3]; - + if (addressType === 0x01) { // IPv4 targetHost = `${request[4]}.${request[5]}.${request[6]}.${request[7]}`; @@ -177,7 +281,7 @@ async function startPortForward(event, payload) { socket.end(); return; } - + // Forward through SSH tunnel conn.forwardOut( bindAddress, @@ -190,7 +294,7 @@ async function startPortForward(event, payload) { socket.end(); return; } - + // Success reply const reply = Buffer.alloc(10); reply[0] = 0x05; @@ -199,9 +303,9 @@ async function startPortForward(event, payload) { reply[3] = 0x01; reply.writeUInt16BE(0, 8); socket.write(reply); - + socket.pipe(stream).pipe(socket); - + socket.on('error', () => stream.end()); stream.on('error', () => socket.end()); } @@ -209,7 +313,7 @@ async function startPortForward(event, payload) { }); }); }); - + server.on('error', (err) => { console.error(`[PortForward] SOCKS server error:`, err.message); sendStatus('error', err.message); @@ -217,14 +321,14 @@ async function startPortForward(event, payload) { portForwardingTunnels.delete(tunnelId); reject(err); }); - + server.listen(localPort, bindAddress, () => { console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`); - portForwardingTunnels.set(tunnelId, { - type: 'dynamic', - conn, + portForwardingTunnels.set(tunnelId, { + type: 'dynamic', + conn, server, - webContentsId: sender.id + webContentsId: sender.id }); sendStatus('active'); resolve({ tunnelId, success: true }); @@ -233,26 +337,26 @@ async function startPortForward(event, payload) { reject(new Error(`Unknown forwarding type: ${type}`)); } }); - + conn.on('error', (err) => { console.error(`[PortForward] SSH error:`, err.message); sendStatus('error', err.message); portForwardingTunnels.delete(tunnelId); reject(err); }); - + conn.on('close', () => { console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`); const tunnel = portForwardingTunnels.get(tunnelId); if (tunnel) { if (tunnel.server) { - try { tunnel.server.close(); } catch {} + try { tunnel.server.close(); } catch { } } sendStatus('inactive'); portForwardingTunnels.delete(tunnelId); } }); - + sendStatus('connecting'); conn.connect(connectOpts); }); @@ -264,11 +368,11 @@ async function startPortForward(event, payload) { async function stopPortForward(event, payload) { const { tunnelId } = payload; const tunnel = portForwardingTunnels.get(tunnelId); - + if (!tunnel) { return { tunnelId, success: false, error: 'Tunnel not found' }; } - + try { if (tunnel.server) { tunnel.server.close(); @@ -277,7 +381,7 @@ async function stopPortForward(event, payload) { tunnel.conn.end(); } portForwardingTunnels.delete(tunnelId); - + return { tunnelId, success: true }; } catch (err) { return { tunnelId, success: false, error: err.message }; @@ -290,11 +394,11 @@ async function stopPortForward(event, payload) { async function getPortForwardStatus(event, payload) { const { tunnelId } = payload; const tunnel = portForwardingTunnels.get(tunnelId); - + if (!tunnel) { return { tunnelId, status: 'inactive' }; } - + return { tunnelId, status: 'active', type: tunnel.type }; } diff --git a/electron/bridges/proxyUtils.cjs b/electron/bridges/proxyUtils.cjs new file mode 100644 index 0000000..ccc1b0b --- /dev/null +++ b/electron/bridges/proxyUtils.cjs @@ -0,0 +1,135 @@ +/** + * Proxy Utilities - Shared proxy socket creation for SSH connections + * Extracted from sshBridge.cjs and sftpBridge.cjs to eliminate code duplication + */ + +const net = require("node:net"); + +/** + * Create a socket through a proxy (HTTP CONNECT or SOCKS5) + * @param {Object} proxy - Proxy configuration + * @param {string} proxy.type - 'http' or 'socks5' + * @param {string} proxy.host - Proxy host + * @param {number} proxy.port - Proxy port + * @param {string} [proxy.username] - Optional username for auth + * @param {string} [proxy.password] - Optional password for auth + * @param {string} targetHost - Target host to connect through proxy + * @param {number} targetPort - Target port to connect through proxy + * @returns {Promise} Connected socket through proxy + */ +function createProxySocket(proxy, targetHost, targetPort) { + return new Promise((resolve, reject) => { + if (proxy.type === 'http') { + // HTTP CONNECT proxy + const socket = net.connect(proxy.port, proxy.host, () => { + let authHeader = ''; + if (proxy.username && proxy.password) { + const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64'); + authHeader = `Proxy-Authorization: Basic ${auth}\r\n`; + } + const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`; + socket.write(connectRequest); + + let response = ''; + const onData = (data) => { + response += data.toString(); + if (response.includes('\r\n\r\n')) { + socket.removeListener('data', onData); + if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) { + resolve(socket); + } else { + socket.destroy(); + reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`)); + } + } + }; + socket.on('data', onData); + }); + socket.on('error', reject); + } else if (proxy.type === 'socks5') { + // SOCKS5 proxy + const socket = net.connect(proxy.port, proxy.host, () => { + // SOCKS5 greeting + const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00]; + socket.write(Buffer.from([0x05, authMethods.length, ...authMethods])); + + let step = 'greeting'; + const onData = (data) => { + if (step === 'greeting') { + if (data[0] !== 0x05) { + socket.destroy(); + reject(new Error('Invalid SOCKS5 response')); + return; + } + const method = data[1]; + if (method === 0x02 && proxy.username && proxy.password) { + // Username/password auth + step = 'auth'; + const userBuf = Buffer.from(proxy.username); + const passBuf = Buffer.from(proxy.password); + socket.write(Buffer.concat([ + Buffer.from([0x01, userBuf.length]), + userBuf, + Buffer.from([passBuf.length]), + passBuf + ])); + } else if (method === 0x00) { + // No auth, proceed to connect + step = 'connect'; + sendConnectRequest(); + } else { + socket.destroy(); + reject(new Error('SOCKS5 authentication method not supported')); + } + } else if (step === 'auth') { + if (data[1] !== 0x00) { + socket.destroy(); + reject(new Error('SOCKS5 authentication failed')); + return; + } + step = 'connect'; + sendConnectRequest(); + } else if (step === 'connect') { + socket.removeListener('data', onData); + if (data[1] === 0x00) { + resolve(socket); + } else { + const errors = { + 0x01: 'General failure', + 0x02: 'Connection not allowed', + 0x03: 'Network unreachable', + 0x04: 'Host unreachable', + 0x05: 'Connection refused', + 0x06: 'TTL expired', + 0x07: 'Command not supported', + 0x08: 'Address type not supported', + }; + socket.destroy(); + reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`)); + } + } + }; + + const sendConnectRequest = () => { + // SOCKS5 connect request + const hostBuf = Buffer.from(targetHost); + const request = Buffer.concat([ + Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]), + hostBuf, + Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff]) + ]); + socket.write(request); + }; + + socket.on('data', onData); + }); + socket.on('error', reject); + } else { + reject(new Error(`Unknown proxy type: ${proxy.type}`)); + } + }); +} + +module.exports = { + createProxySocket, +}; diff --git a/electron/bridges/sftpBridge.cjs b/electron/bridges/sftpBridge.cjs index 7aee179..07be627 100644 --- a/electron/bridges/sftpBridge.cjs +++ b/electron/bridges/sftpBridge.cjs @@ -11,6 +11,8 @@ const SftpClient = require("ssh2-sftp-client"); const { Client: SSHClient } = require("ssh2"); const { NetcattyAgent } = require("./netcattyAgent.cjs"); const fileWatcherBridge = require("./fileWatcherBridge.cjs"); +const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs"); +const { createProxySocket } = require("./proxyUtils.cjs"); // SFTP clients storage - shared reference passed from main let sftpClients = null; @@ -20,128 +22,23 @@ let electronModule = null; const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream } /** - * Initialize the SFTP bridge with dependencies + * Send message to renderer safely */ -function init(deps) { - sftpClients = deps.sftpClients; - electronModule = deps.electronModule; +function safeSend(sender, channel, payload) { + try { + if (!sender || sender.isDestroyed()) return; + sender.send(channel, payload); + } catch { + // Ignore destroyed webContents during shutdown. + } } /** - * Create a socket through a proxy (HTTP CONNECT or SOCKS5) - * Reused from sshBridge.cjs + * Initialize the SFTP bridge with dependencies */ -function createProxySocket(proxy, targetHost, targetPort) { - return new Promise((resolve, reject) => { - if (proxy.type === 'http') { - // HTTP CONNECT proxy - const socket = net.connect(proxy.port, proxy.host, () => { - let authHeader = ''; - if (proxy.username && proxy.password) { - const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64'); - authHeader = `Proxy-Authorization: Basic ${auth}\r\n`; - } - const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`; - socket.write(connectRequest); - - let response = ''; - const onData = (data) => { - response += data.toString(); - if (response.includes('\r\n\r\n')) { - socket.removeListener('data', onData); - if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) { - resolve(socket); - } else { - socket.destroy(); - reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`)); - } - } - }; - socket.on('data', onData); - }); - socket.on('error', reject); - } else if (proxy.type === 'socks5') { - // SOCKS5 proxy - const socket = net.connect(proxy.port, proxy.host, () => { - // SOCKS5 greeting - const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00]; - socket.write(Buffer.from([0x05, authMethods.length, ...authMethods])); - - let step = 'greeting'; - const onData = (data) => { - if (step === 'greeting') { - if (data[0] !== 0x05) { - socket.destroy(); - reject(new Error('Invalid SOCKS5 response')); - return; - } - const method = data[1]; - if (method === 0x02 && proxy.username && proxy.password) { - // Username/password auth - step = 'auth'; - const userBuf = Buffer.from(proxy.username); - const passBuf = Buffer.from(proxy.password); - socket.write(Buffer.concat([ - Buffer.from([0x01, userBuf.length]), - userBuf, - Buffer.from([passBuf.length]), - passBuf - ])); - } else if (method === 0x00) { - // No auth, proceed to connect - step = 'connect'; - sendConnectRequest(); - } else { - socket.destroy(); - reject(new Error('SOCKS5 authentication method not supported')); - } - } else if (step === 'auth') { - if (data[1] !== 0x00) { - socket.destroy(); - reject(new Error('SOCKS5 authentication failed')); - return; - } - step = 'connect'; - sendConnectRequest(); - } else if (step === 'connect') { - socket.removeListener('data', onData); - if (data[1] === 0x00) { - resolve(socket); - } else { - const errors = { - 0x01: 'General failure', - 0x02: 'Connection not allowed', - 0x03: 'Network unreachable', - 0x04: 'Host unreachable', - 0x05: 'Connection refused', - 0x06: 'TTL expired', - 0x07: 'Command not supported', - 0x08: 'Address type not supported', - }; - socket.destroy(); - reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`)); - } - } - }; - - const sendConnectRequest = () => { - // SOCKS5 connect request - const hostBuf = Buffer.from(targetHost); - const request = Buffer.concat([ - Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]), - hostBuf, - Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff]) - ]); - socket.write(request); - }; - - socket.on('data', onData); - }); - socket.on('error', reject); - } else { - reject(new Error(`Unknown proxy type: ${proxy.type}`)); - } - }); +function init(deps) { + sftpClients = deps.sftpClients; + electronModule = deps.electronModule; } /** @@ -150,7 +47,7 @@ function createProxySocket(proxy, targetHost, targetPort) { async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) { const connections = []; let currentSocket = null; - + try { // Connect through each jump host for (let i = 0; i < jumpHosts.length; i++) { @@ -158,14 +55,14 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, const isFirst = i === 0; const isLast = i === jumpHosts.length - 1; const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`; - + console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`); - + const conn = new SSHClient(); // Increase max listeners to prevent Node.js warning // Set to 0 (unlimited) since complex operations add many temp listeners conn.setMaxListeners(0); - + // Build connection options const connOpts = { host: jump.hostname, @@ -174,13 +71,15 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, readyTimeout: 20000, keepaliveInterval: 10000, keepaliveCountMax: 3, + // Enable keyboard-interactive authentication (required for 2FA/MFA) + tryKeyboard: true, algorithms: { cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'], kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'], compress: ['none'], }, }; - + // Auth - support agent (certificate), key, and password fallback const hasCertificate = typeof jump.certificate === "string" && jump.certificate.trim().length > 0; @@ -210,7 +109,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, if (connOpts.password) order.push("password"); connOpts.authHandler = order; } - + // If first hop and proxy is configured, connect through proxy if (isFirst && options.proxy) { currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22); @@ -223,7 +122,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, delete connOpts.host; delete connOpts.port; } - + // Connect this hop await new Promise((resolve, reject) => { conn.on('ready', () => { @@ -240,9 +139,9 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, }); conn.connect(connOpts); }); - + connections.push(conn); - + // Determine next target let nextHost, nextPort; if (isLast) { @@ -255,7 +154,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, nextHost = nextJump.hostname; nextPort = nextJump.port || 22; } - + // Create forward stream to next hop console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Forwarding to ${nextHost}:${nextPort}...`); currentSocket = await new Promise((resolve, reject) => { @@ -270,10 +169,10 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, }); }); } - + // Return the final forwarded stream and all connections for cleanup - return { - socket: currentSocket, + return { + socket: currentSocket, connections }; } catch (err) { @@ -292,15 +191,15 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, async function openSftp(event, options) { const client = new SftpClient(); const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`; - + // Check if we need to connect through jump hosts const jumpHosts = options.jumpHosts || []; const hasJumpHosts = jumpHosts.length > 0; const hasProxy = !!options.proxy; - + let chainConnections = []; let connectionSocket = null; - + // Handle chain/proxy connections if (hasJumpHosts) { console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`); @@ -321,13 +220,16 @@ async function openSftp(event, options) { options.port || 22 ); } - + const connectOpts = { host: options.hostname, port: options.port || 22, username: options.username || "root", + // Enable keyboard-interactive authentication (required for 2FA/MFA) + tryKeyboard: true, + readyTimeout: 120000, // 2 minutes for 2FA input }; - + // Use the tunneled socket if we have one if (connectionSocket) { connectOpts.sock = connectionSocket; @@ -335,7 +237,7 @@ async function openSftp(event, options) { delete connectOpts.host; delete connectOpts.port; } - + const hasCertificate = typeof options.certificate === "string" && options.certificate.trim().length > 0; let authAgent = null; @@ -363,19 +265,122 @@ async function openSftp(event, options) { if (connectOpts.password) order.push("password"); connectOpts.authHandler = order; } - + + // Add keyboard-interactive authentication support + // ssh2-sftp-client exposes the underlying ssh2 Client through its `on` method + const kiHandler = (name, instructions, instructionsLang, prompts, finish) => { + console.log(`[SFTP] ${options.hostname} keyboard-interactive auth requested`, { + name, + instructions, + promptCount: prompts?.length || 0, + prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })), + }); + + // If there are no prompts, just call finish with empty array + if (!prompts || prompts.length === 0) { + console.log(`[SFTP] No prompts, finishing keyboard-interactive`); + finish([]); + return; + } + + // Check if all prompts are password prompts that we can auto-answer + const responses = []; + const promptsNeedingUserInput = []; + + for (let i = 0; i < prompts.length; i++) { + const prompt = prompts[i]; + const promptText = (prompt.prompt || '').toLowerCase().trim(); + + // Auto-answer password prompts if we have a configured password + if (options.password && ( + promptText.includes('password') || + promptText === 'password:' || + promptText === 'password' + )) { + console.log(`[SFTP] Auto-answering password prompt at index ${i}`); + responses[i] = options.password; + } else { + // This prompt needs user input (likely 2FA) + promptsNeedingUserInput.push({ index: i, prompt: prompt }); + responses[i] = null; // Placeholder + } + } + + // If all prompts were auto-answered, finish immediately + if (promptsNeedingUserInput.length === 0) { + console.log(`[SFTP] All prompts auto-answered, finishing keyboard-interactive`); + finish(responses); + return; + } + + // If some prompts need user input, show the modal + const requestId = keyboardInteractiveHandler.generateRequestId('sftp'); + + // Store finish callback with context about which responses are already filled + keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => { + // Merge user responses with auto-filled responses + let userResponseIndex = 0; + for (let i = 0; i < prompts.length; i++) { + if (responses[i] === null) { + responses[i] = userResponses[userResponseIndex] || ''; + userResponseIndex++; + } + } + console.log(`[SFTP] Merged responses, finishing keyboard-interactive`); + finish(responses); + }, event.sender.id, connId); + + // Send only the prompts that need user input + const promptsData = promptsNeedingUserInput.map((item) => ({ + prompt: item.prompt.prompt, + echo: item.prompt.echo, + })); + + console.log(`[SFTP] Showing modal for ${promptsData.length} prompts that need user input`); + + safeSend(event.sender, "netcatty:keyboard-interactive", { + requestId, + sessionId: connId, + name: name || "", + instructions: instructions || "", + prompts: promptsData, + hostname: options.hostname, + }); + }; + + // Add keyboard-interactive listener BEFORE connecting + client.on("keyboard-interactive", kiHandler); + + // Enable keyboard-interactive authentication in authHandler + if (connectOpts.authHandler) { + // Add keyboard-interactive after the existing methods + if (!connectOpts.authHandler.includes("keyboard-interactive")) { + connectOpts.authHandler.push("keyboard-interactive"); + } + } else { + // Create authHandler with keyboard-interactive support + const authMethods = []; + if (connectOpts.privateKey) authMethods.push("publickey"); + if (connectOpts.password) authMethods.push("password"); + authMethods.push("keyboard-interactive"); + connectOpts.authHandler = authMethods; + } + + // Increase timeout to allow for keyboard-interactive auth + connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input + try { await client.connect(connectOpts); - + // Increase max listeners AFTER connect, when the internal ssh2 Client exists // This prevents Node.js MaxListenersExceededWarning when performing many operations // ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit if (client.client && typeof client.client.setMaxListeners === 'function') { client.client.setMaxListeners(0); // 0 means unlimited } - + sftpClients.set(connId, client); - + // Store jump connections for cleanup when SFTP is closed if (chainConnections.length > 0) { jumpConnectionsMap.set(connId, { @@ -383,7 +388,7 @@ async function openSftp(event, options) { socket: connectionSocket }); } - + console.log(`[SFTP] Connection established: ${connId}`); return { sftpId: connId }; } catch (err) { @@ -402,15 +407,15 @@ async function openSftp(event, options) { async function listSftp(event, payload) { const client = sftpClients.get(payload.sftpId); if (!client) throw new Error("SFTP session not found"); - + const list = await client.list(payload.path || "."); const basePath = payload.path || "."; - + // Process items and resolve symlinks const results = await Promise.all(list.map(async (item) => { let type; let linkTarget = null; - + if (item.type === "d") { type = "directory"; } else if (item.type === "l") { @@ -433,7 +438,7 @@ async function listSftp(event, payload) { } else { type = "file"; } - + // Extract permissions from longname or rights let permissions = undefined; if (item.rights) { @@ -446,7 +451,7 @@ async function listSftp(event, payload) { permissions = match[1]; } } - + return { name: item.name, type, @@ -456,7 +461,7 @@ async function listSftp(event, payload) { permissions, }; })); - + return results; } @@ -489,7 +494,7 @@ async function readSftpBinary(event, payload) { async function writeSftp(event, payload) { const client = sftpClients.get(payload.sftpId); if (!client) throw new Error("SFTP session not found"); - + await client.put(Buffer.from(payload.content, "utf-8"), payload.path); return true; } @@ -500,14 +505,14 @@ async function writeSftp(event, payload) { async function writeSftpBinaryWithProgress(event, payload) { const client = sftpClients.get(payload.sftpId); if (!client) throw new Error("SFTP session not found"); - + const { sftpId, path: remotePath, content, transferId } = payload; const buffer = Buffer.from(content); const totalBytes = buffer.length; let transferredBytes = 0; let lastProgressTime = Date.now(); let lastTransferredBytes = 0; - + const { Readable } = require("stream"); const readableStream = new Readable({ read() { @@ -516,7 +521,7 @@ async function writeSftpBinaryWithProgress(event, payload) { const end = Math.min(transferredBytes + chunkSize, totalBytes); const chunk = buffer.slice(transferredBytes, end); transferredBytes = end; - + const now = Date.now(); const elapsed = (now - lastProgressTime) / 1000; let speed = 0; @@ -525,7 +530,7 @@ async function writeSftpBinaryWithProgress(event, payload) { lastProgressTime = now; lastTransferredBytes = transferredBytes; } - + const contents = electronModule.webContents.fromId(event.sender.id); contents?.send("netcatty:upload:progress", { transferId, @@ -533,20 +538,20 @@ async function writeSftpBinaryWithProgress(event, payload) { totalBytes, speed, }); - + this.push(chunk); } else { this.push(null); } } }); - + try { await client.put(readableStream, remotePath); - + const contents = electronModule.webContents.fromId(event.sender.id); contents?.send("netcatty:upload:complete", { transferId }); - + return { success: true, transferId }; } catch (err) { const contents = electronModule.webContents.fromId(event.sender.id); @@ -562,21 +567,21 @@ async function writeSftpBinaryWithProgress(event, payload) { async function closeSftp(event, payload) { const client = sftpClients.get(payload.sftpId); if (!client) return; - + // Stop file watchers and clean up temp files for this SFTP session try { fileWatcherBridge.stopWatchersForSession(payload.sftpId, true); } catch (err) { console.warn("[SFTP] Error stopping file watchers:", err.message); } - + try { await client.end(); } catch (err) { console.warn("SFTP close failed", err); } sftpClients.delete(payload.sftpId); - + // Clean up jump connections if any const jumpData = jumpConnectionsMap.get(payload.sftpId); if (jumpData) { @@ -594,7 +599,7 @@ async function closeSftp(event, payload) { async function mkdirSftp(event, payload) { const client = sftpClients.get(payload.sftpId); if (!client) throw new Error("SFTP session not found"); - + await client.mkdir(payload.path, true); return true; } @@ -605,7 +610,7 @@ async function mkdirSftp(event, payload) { async function deleteSftp(event, payload) { const client = sftpClients.get(payload.sftpId); if (!client) throw new Error("SFTP session not found"); - + const stat = await client.stat(payload.path); if (stat.isDirectory) { await client.rmdir(payload.path, true); @@ -621,7 +626,7 @@ async function deleteSftp(event, payload) { async function renameSftp(event, payload) { const client = sftpClients.get(payload.sftpId); if (!client) throw new Error("SFTP session not found"); - + await client.rename(payload.oldPath, payload.newPath); return true; } @@ -632,7 +637,7 @@ async function renameSftp(event, payload) { async function statSftp(event, payload) { const client = sftpClients.get(payload.sftpId); if (!client) throw new Error("SFTP session not found"); - + const stat = await client.stat(payload.path); return { name: path.basename(payload.path), @@ -649,7 +654,7 @@ async function statSftp(event, payload) { async function chmodSftp(event, payload) { const client = sftpClients.get(payload.sftpId); if (!client) throw new Error("SFTP session not found"); - + await client.chmod(payload.path, parseInt(payload.mode, 8)); return true; } diff --git a/electron/bridges/sshBridge.cjs b/electron/bridges/sshBridge.cjs index ac19f7a..d6750b3 100644 --- a/electron/bridges/sshBridge.cjs +++ b/electron/bridges/sshBridge.cjs @@ -8,6 +8,8 @@ const fs = require("node:fs"); const path = require("node:path"); const { Client: SSHClient, utils: sshUtils } = require("ssh2"); const { NetcattyAgent } = require("./netcattyAgent.cjs"); +const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs"); +const { createProxySocket } = require("./proxyUtils.cjs"); // Simple file logger for debugging const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log"); @@ -49,122 +51,6 @@ function init(deps) { electronModule = deps.electronModule; } -/** - * Create a socket through a proxy (HTTP CONNECT or SOCKS5) - */ -function createProxySocket(proxy, targetHost, targetPort) { - return new Promise((resolve, reject) => { - if (proxy.type === 'http') { - // HTTP CONNECT proxy - const socket = net.connect(proxy.port, proxy.host, () => { - let authHeader = ''; - if (proxy.username && proxy.password) { - const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64'); - authHeader = `Proxy-Authorization: Basic ${auth}\r\n`; - } - const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`; - socket.write(connectRequest); - - let response = ''; - const onData = (data) => { - response += data.toString(); - if (response.includes('\r\n\r\n')) { - socket.removeListener('data', onData); - if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) { - resolve(socket); - } else { - socket.destroy(); - reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`)); - } - } - }; - socket.on('data', onData); - }); - socket.on('error', reject); - } else if (proxy.type === 'socks5') { - // SOCKS5 proxy - const socket = net.connect(proxy.port, proxy.host, () => { - // SOCKS5 greeting - const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00]; - socket.write(Buffer.from([0x05, authMethods.length, ...authMethods])); - - let step = 'greeting'; - const onData = (data) => { - if (step === 'greeting') { - if (data[0] !== 0x05) { - socket.destroy(); - reject(new Error('Invalid SOCKS5 response')); - return; - } - const method = data[1]; - if (method === 0x02 && proxy.username && proxy.password) { - // Username/password auth - step = 'auth'; - const userBuf = Buffer.from(proxy.username); - const passBuf = Buffer.from(proxy.password); - socket.write(Buffer.concat([ - Buffer.from([0x01, userBuf.length]), - userBuf, - Buffer.from([passBuf.length]), - passBuf - ])); - } else if (method === 0x00) { - // No auth, proceed to connect - step = 'connect'; - sendConnectRequest(); - } else { - socket.destroy(); - reject(new Error('SOCKS5 authentication method not supported')); - } - } else if (step === 'auth') { - if (data[1] !== 0x00) { - socket.destroy(); - reject(new Error('SOCKS5 authentication failed')); - return; - } - step = 'connect'; - sendConnectRequest(); - } else if (step === 'connect') { - socket.removeListener('data', onData); - if (data[1] === 0x00) { - resolve(socket); - } else { - const errors = { - 0x01: 'General failure', - 0x02: 'Connection not allowed', - 0x03: 'Network unreachable', - 0x04: 'Host unreachable', - 0x05: 'Connection refused', - 0x06: 'TTL expired', - 0x07: 'Command not supported', - 0x08: 'Address type not supported', - }; - socket.destroy(); - reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`)); - } - } - }; - - const sendConnectRequest = () => { - // SOCKS5 connect request - const hostBuf = Buffer.from(targetHost); - const request = Buffer.concat([ - Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]), - hostBuf, - Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff]) - ]); - socket.write(request); - }; - - socket.on('data', onData); - }); - socket.on('error', reject); - } else { - reject(new Error(`Unknown proxy type: ${proxy.type}`)); - } - }); -} - /** * Connect through a chain of jump hosts */ @@ -203,6 +89,8 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target // If 0 or not provided, use 10000ms as default keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000, keepaliveCountMax: 3, + // Enable keyboard-interactive authentication (required for 2FA/MFA) + tryKeyboard: true, algorithms: { // Prioritize fastest ciphers (GCM modes are hardware-accelerated) cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'], @@ -361,6 +249,8 @@ async function startSSHSession(event, options) { // If 0 or not provided, use 10000ms as default keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000, keepaliveCountMax: 3, + // Enable keyboard-interactive authentication (required for 2FA/MFA) + tryKeyboard: true, algorithms: { // Prioritize fastest ciphers (GCM modes are hardware-accelerated) cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'], @@ -616,6 +506,106 @@ async function startSSHSession(event, options) { } }); + // Handle keyboard-interactive authentication (2FA/MFA) + conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => { + console.log(`${logPrefix} ${options.hostname} keyboard-interactive auth requested`, { + name, + instructions, + promptCount: prompts?.length || 0, + prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })), + }); + + // If there are no prompts, just call finish with empty array + if (!prompts || prompts.length === 0) { + console.log(`${logPrefix} No prompts, finishing keyboard-interactive`); + finish([]); + return; + } + + // Check if all prompts are password prompts that we can auto-answer + const responses = []; + const promptsNeedingUserInput = []; + + for (let i = 0; i < prompts.length; i++) { + const prompt = prompts[i]; + const promptText = (prompt.prompt || '').toLowerCase().trim(); + + // Auto-answer password prompts if we have a configured password + if (options.password && ( + promptText.includes('password') || + promptText === 'password:' || + promptText === 'password' + )) { + console.log(`${logPrefix} Auto-answering password prompt at index ${i}`); + responses[i] = options.password; + } else { + // This prompt needs user input (likely 2FA) + promptsNeedingUserInput.push({ index: i, prompt: prompt }); + responses[i] = null; // Placeholder + } + } + + // If all prompts were auto-answered, finish immediately + if (promptsNeedingUserInput.length === 0) { + console.log(`${logPrefix} All prompts auto-answered, finishing keyboard-interactive`); + finish(responses); + return; + } + + // If some prompts need user input, show the modal + // But only send the prompts that need user input + const requestId = keyboardInteractiveHandler.generateRequestId('ssh'); + + // Store finish callback with context about which responses are already filled + keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => { + // Merge user responses with auto-filled responses + let userResponseIndex = 0; + for (let i = 0; i < prompts.length; i++) { + if (responses[i] === null) { + responses[i] = userResponses[userResponseIndex] || ''; + userResponseIndex++; + } + } + console.log(`${logPrefix} Merged responses, finishing keyboard-interactive`); + finish(responses); + }, sender.id, sessionId); + + // Send only the prompts that need user input + const promptsData = promptsNeedingUserInput.map((item) => ({ + prompt: item.prompt.prompt, + echo: item.prompt.echo, + })); + + console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts that need user input`); + + safeSend(sender, "netcatty:keyboard-interactive", { + requestId, + sessionId, + name: name || "", + instructions: instructions || "", + prompts: promptsData, + hostname: options.hostname, + }); + }); + + // Enable keyboard-interactive authentication in authHandler + if (connectOpts.authHandler) { + // Add keyboard-interactive after the existing methods + if (!connectOpts.authHandler.includes("keyboard-interactive")) { + connectOpts.authHandler.push("keyboard-interactive"); + } + } else { + // Create authHandler with keyboard-interactive support + const authMethods = []; + if (connectOpts.privateKey) authMethods.push("publickey"); + if (connectOpts.password) authMethods.push("password"); + authMethods.push("keyboard-interactive"); + connectOpts.authHandler = authMethods; + } + + // Increase timeout to allow for keyboard-interactive auth + connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input + console.log(`${logPrefix} Connecting to ${options.hostname}...`); conn.connect(connectOpts); }); @@ -879,6 +869,8 @@ function registerHandlers(ipcMain) { ipcMain.handle("netcatty:ssh:exec", execCommand); ipcMain.handle("netcatty:ssh:pwd", getSessionPwd); ipcMain.handle("netcatty:key:generate", generateKeyPair); + // Register the shared keyboard-interactive response handler + keyboardInteractiveHandler.registerHandler(ipcMain); } module.exports = { diff --git a/electron/preload.cjs b/electron/preload.cjs index 3ca9422..68911eb 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -9,6 +9,7 @@ const chainProgressListeners = new Map(); const authFailedListeners = new Map(); const languageChangeListeners = new Set(); const fullscreenChangeListeners = new Set(); +const keyboardInteractiveListeners = new Set(); ipcRenderer.on("netcatty:data", (_event, payload) => { const set = dataListeners.get(payload.sessionId); @@ -86,6 +87,17 @@ ipcRenderer.on("netcatty:auth:failed", (_event, payload) => { } }); +// Keyboard-interactive authentication events (2FA/MFA) +ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => { + keyboardInteractiveListeners.forEach((cb) => { + try { + cb(payload); + } catch (err) { + console.error("Keyboard-interactive callback failed", err); + } + }); +}); + // Transfer progress events ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => { const cb = transferProgressListeners.get(payload.transferId); @@ -285,6 +297,18 @@ const api = { authFailedListeners.get(sessionId).add(cb); return () => authFailedListeners.get(sessionId)?.delete(cb); }, + // Keyboard-interactive authentication (2FA/MFA) + onKeyboardInteractive: (cb) => { + keyboardInteractiveListeners.add(cb); + return () => keyboardInteractiveListeners.delete(cb); + }, + respondKeyboardInteractive: async (requestId, responses, cancelled = false) => { + return ipcRenderer.invoke("netcatty:keyboard-interactive:respond", { + requestId, + responses, + cancelled, + }); + }, openSftp: async (options) => { const result = await ipcRenderer.invoke("netcatty:sftp:open", options); return result.sftpId; diff --git a/global.d.ts b/global.d.ts index 9a969ef..bc13d26 100644 --- a/global.d.ts +++ b/global.d.ts @@ -185,6 +185,23 @@ interface NetcattyBridge { cb: (evt: { sessionId: string; error: string; hostname: string }) => void ): () => void; + // Keyboard-interactive authentication (2FA/MFA) + onKeyboardInteractive?( + cb: (request: { + requestId: string; + sessionId: string; + name: string; + instructions: string; + prompts: Array<{ prompt: string; echo: boolean }>; + hostname: string; + }) => void + ): () => void; + respondKeyboardInteractive?( + requestId: string, + responses: string[], + cancelled?: boolean + ): Promise<{ success: boolean; error?: string }>; + // SFTP operations openSftp(options: NetcattySSHOptions): Promise; listSftp(sftpId: string, path: string): Promise;