Compare commits

...

8 Commits

Author SHA1 Message Date
陈大猫
7355e29b89 Merge pull request #137 from Nightsuki/fix/ssh-jump-host-default-key-auth
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: add default SSH key fallback for jump host connections
2026-01-27 17:07:50 +08:00
bincxz
64686cc237 fix: pass unlocked encrypted keys to jump host auth handler
When auth failure triggers the passphrase flow and user unlocks
encrypted default keys, the retry connection now correctly passes
these unlocked keys to connectThroughChain/connectThroughChainForSftp.

Previously, options._unlockedEncryptedKeys was only used for the
final target host, so jump hosts requiring encrypted default keys
would still fail even after successful passphrase entry.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-27 16:54:54 +08:00
bincxz
d65440ace7 feat: add passphrase modal for encrypted SSH key authentication
- Add PassphraseModal component for interactive passphrase input
- Add passphraseHandler bridge to manage passphrase requests/responses
- Add sshAuthHelper for centralized SSH key decryption with passphrase support
- Update sshBridge, sftpBridge, and portForwardingBridge to use new auth helper
- Add passphrase-related IPC channels in preload and type definitions
- Add i18n translations for passphrase modal UI (en/zh-CN)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-27 16:35:03 +08:00
Nightsuki
2dbeddd9aa fix: add default SSH key fallback for jump host connections
Previously, jump host connections (connectThroughChain) did not try
default SSH keys from ~/.ssh/ when no explicit auth was configured.
This caused authentication failures when using jump hosts without
manually specifying SSH keys.

Changes:
- Add ssh-agent support for jump host connections
- Try all default SSH keys (id_ed25519, id_ecdsa, id_rsa) for jump hosts
- Use dynamic authHandler to try each key in sequence
- Match the same fallback behavior as direct connections
2026-01-27 11:55:18 +08:00
陈大猫
4758345448 Merge pull request #136 from Nightsuki/fix/ssh-default-key-fallback-all-keys
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: try all default SSH keys for fallback authentication
2026-01-26 23:33:38 +08:00
Nightsuki
4d3fa93083 fix: try all default SSH keys for fallback authentication
Previously, when no explicit auth method was configured, the code would
only try the first available key (id_ed25519) even if the server only
accepted a different key (id_rsa). This caused authentication failures
when users had multiple SSH keys but only some were authorized.

Changes:
- Add findAllDefaultPrivateKeys() to discover all available keys
- Try ssh-agent first (matching regular SSH behavior)
- Try ALL default keys (id_ed25519, id_ecdsa, id_rsa) in order
- Add debug logging for ssh2 auth flow diagnostics
- Improve auth method ordering: agent -> keys -> password -> keyboard
2026-01-26 23:11:26 +08:00
陈大猫
2746aae274 Merge pull request #135 from binaricat/fix/sftp-local-files-freeze
fix: use async exec for Windows hidden file check to prevent UI freeze
2026-01-26 19:39:22 +08:00
bincxz
a7b22b3580 fix: use async exec for Windows hidden file check to prevent UI freeze
The isWindowsHiddenFile function was using execSync which blocks the
main process. When listing directories with many files on Windows,
this caused the app to freeze and show "No response" until all attrib
commands completed.

Changed to async exec with promisify to allow non-blocking execution.

Fixes #134

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 19:36:44 +08:00
12 changed files with 1294 additions and 156 deletions

81
App.tsx
View File

@@ -21,6 +21,7 @@ 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 { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
@@ -155,6 +156,8 @@ function App({ settings }: { settings: SettingsState }) {
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const {
theme,
@@ -349,6 +352,76 @@ function App({ settings }: { settings: SettingsState }) {
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Passphrase request event listener for encrypted SSH keys
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseRequest) return;
const unsubscribe = bridge.onPassphraseRequest((request) => {
console.log('[App] Passphrase request received:', request);
setPassphraseQueue(prev => [...prev, {
requestId: request.requestId,
keyPath: request.keyPath,
keyName: request.keyName,
hostname: request.hostname,
}]);
});
return () => {
unsubscribe?.();
};
}, []);
// Handle passphrase submit
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
void bridge.respondPassphrase(requestId, passphrase, false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase cancel
const handlePassphraseCancel = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
// Cancel = stop the entire passphrase flow
void bridge.respondPassphrase(requestId, '', true);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase skip (skip this key, continue with others)
const handlePassphraseSkip = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphraseSkip) {
// Skip = skip this key but continue asking for others
void bridge.respondPassphraseSkip(requestId);
} else if (bridge?.respondPassphrase) {
// Fallback for older API
void bridge.respondPassphrase(requestId, '', false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase timeout (request expired on backend)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseTimeout) return;
const unsubscribe = bridge.onPassphraseTimeout((event) => {
console.log('[App] Passphrase request timed out:', event.requestId);
// Remove from queue - the modal will close automatically
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
// Show a toast notification to inform user
toast.error('Passphrase request timed out. Please try connecting again.');
});
return () => {
unsubscribe?.();
};
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -1083,6 +1156,14 @@ function App({ settings }: { settings: SettingsState }) {
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
{/* Global Passphrase Modal for encrypted SSH keys */}
<PassphraseModal
request={passphraseQueue[0] || null}
onSubmit={handlePassphraseSubmit}
onCancel={handlePassphraseCancel}
onSkip={handlePassphraseSkip}
/>
</div>
);
}

View File

@@ -1216,6 +1216,16 @@ const en: Messages = {
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
'passphrase.desc': 'Enter the passphrase for {keyName}',
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
'passphrase.label': 'Passphrase',
'passphrase.keyPath': 'Key',
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
};
export default en;

View File

@@ -1205,6 +1205,16 @@ const zhCN: Messages = {
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',
'passphrase.desc': '请输入 {keyName} 的密码',
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
'passphrase.label': '密码',
'passphrase.keyPath': '密钥',
'passphrase.unlock': '解锁',
'passphrase.unlocking': '解锁中...',
'passphrase.skip': '跳过',
};
export default zhCN;

View File

@@ -0,0 +1,169 @@
/**
* Passphrase Modal
* Modal for requesting passphrase for encrypted SSH keys
*/
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 PassphraseRequest {
requestId: string;
keyPath: string;
keyName: string;
hostname?: string;
}
interface PassphraseModalProps {
request: PassphraseRequest | null;
onSubmit: (requestId: string, passphrase: string) => void;
onCancel: (requestId: string) => void;
onSkip?: (requestId: string) => void;
}
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
request,
onSubmit,
onCancel,
onSkip,
}) => {
const { t } = useI18n();
const [passphrase, setPassphrase] = useState("");
const [showPassphrase, setShowPassphrase] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset state when request changes
useEffect(() => {
if (request) {
setPassphrase("");
setShowPassphrase(false);
setIsSubmitting(false);
}
}, [request]);
const handleSubmit = useCallback(() => {
if (!request || isSubmitting || !passphrase) return;
setIsSubmitting(true);
onSubmit(request.requestId, passphrase);
}, [request, passphrase, onSubmit, isSubmitting]);
const handleCancel = useCallback(() => {
if (!request) return;
onCancel(request.requestId);
}, [request, onCancel]);
const handleSkip = useCallback(() => {
if (!request || !onSkip) return;
onSkip(request.requestId);
}, [request, onSkip]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting && passphrase) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit, isSubmitting, passphrase]
);
if (!request) return null;
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
return (
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{t("passphrase.title")}</DialogTitle>
<DialogDescription className="mt-1">
{request.hostname
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
: t("passphrase.desc", { keyName: keyDisplayName })}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="passphrase-input">
{t("passphrase.label")}
</Label>
<div className="relative">
<Input
id="passphrase-input"
type={showPassphrase ? "text" : "password"}
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
onKeyDown={handleKeyDown}
placeholder=""
className="pr-10"
autoFocus
disabled={isSubmitting}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
onClick={() => setShowPassphrase(!showPassphrase)}
disabled={isSubmitting}
>
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p className="text-xs text-muted-foreground">
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
</p>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
{t("common.cancel")}
</Button>
{onSkip && (
<Button
variant="ghost"
onClick={handleSkip}
disabled={isSubmitting}
>
{t("passphrase.skip")}
</Button>
)}
</div>
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("passphrase.unlocking")}
</>
) : (
t("passphrase.unlock")
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default PassphraseModal;

View File

@@ -6,19 +6,23 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { execSync } = require("node:child_process");
const { exec } = require("node:child_process");
const { promisify } = require("node:util");
const execAsync = promisify(exec);
/**
* Check if a file is hidden on Windows using the attrib command
* Returns true if the file has the hidden attribute set
* Uses async exec to avoid blocking the main process
*/
function isWindowsHiddenFile(filePath) {
async function isWindowsHiddenFile(filePath) {
if (process.platform !== "win32") return false;
try {
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
const { stdout } = await execAsync(`attrib "${filePath}"`);
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
// The attributes appear in the first ~10 characters before the path
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
return attrPart.includes("H");
} catch (err) {
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
@@ -67,7 +71,7 @@ async function listLocalDir(event, payload) {
}
// Check for Windows hidden attribute
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
@@ -86,7 +90,7 @@ async function listLocalDir(event, payload) {
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: brokenEntry.name,
type: "symlink",

View File

@@ -0,0 +1,141 @@
/**
* Passphrase Handler - Handles passphrase requests for encrypted SSH keys
* This module provides a mechanism to request passphrase input from the user
* when encountering encrypted default SSH keys in ~/.ssh
*/
// Passphrase request pending map
// Map of requestId -> { resolveCallback, rejectCallback, webContentsId, keyPath, createdAt, timeoutId }
const passphraseRequests = new Map();
// TTL for abandoned requests (2 minutes)
const REQUEST_TTL_MS = 2 * 60 * 1000;
/**
* Generate a unique request ID for passphrase requests
*/
function generateRequestId(prefix = 'pp') {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
/**
* Request passphrase from user via IPC
* @param {Object} sender - Electron webContents sender
* @param {string} keyPath - Path to the encrypted key
* @param {string} keyName - Name of the key (e.g., id_rsa)
* @param {string} [hostname] - Optional hostname for context
* @returns {Promise<{ passphrase?: string, cancelled?: boolean, skipped?: boolean } | null>}
*/
function requestPassphrase(sender, keyPath, keyName, hostname) {
return new Promise((resolve) => {
if (!sender || sender.isDestroyed()) {
console.warn('[Passphrase] Sender is destroyed, cannot request passphrase');
resolve(null);
return;
}
const requestId = generateRequestId();
// Set up TTL timeout to clean up abandoned requests
const timeoutId = setTimeout(() => {
const pending = passphraseRequests.get(requestId);
if (pending) {
console.warn(`[Passphrase] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s`);
passphraseRequests.delete(requestId);
// Notify renderer to close the modal
try {
if (!sender.isDestroyed()) {
sender.send('netcatty:passphrase-timeout', { requestId });
}
} catch (err) {
console.warn('[Passphrase] Failed to send timeout notification:', err.message);
}
resolve(null);
}
}, REQUEST_TTL_MS);
passphraseRequests.set(requestId, {
resolveCallback: resolve,
webContentsId: sender.id,
keyPath,
keyName,
createdAt: Date.now(),
timeoutId,
});
console.log(`[Passphrase] Requesting passphrase for ${keyName} (${requestId})`);
try {
sender.send('netcatty:passphrase-request', {
requestId,
keyPath,
keyName,
hostname,
});
} catch (err) {
console.error('[Passphrase] Failed to send passphrase request:', err);
passphraseRequests.delete(requestId);
clearTimeout(timeoutId);
resolve(null);
}
});
}
/**
* Handle passphrase response from renderer
*/
function handleResponse(_event, payload) {
const { requestId, passphrase, cancelled, skipped } = payload;
const pending = passphraseRequests.get(requestId);
if (!pending) {
console.warn(`[Passphrase] No pending request for ${requestId}`);
return { success: false, error: 'Request not found' };
}
// Clear the TTL timeout
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
passphraseRequests.delete(requestId);
if (cancelled) {
// User clicked Cancel - stop the entire passphrase flow
console.log(`[Passphrase] Request ${requestId} cancelled by user`);
pending.resolveCallback({ cancelled: true });
} else if (skipped) {
// User clicked Skip - skip this key but continue with others
console.log(`[Passphrase] Request ${requestId} skipped by user`);
pending.resolveCallback({ skipped: true });
} else {
console.log(`[Passphrase] Received passphrase for ${requestId}`);
pending.resolveCallback({ passphrase: passphrase || null });
}
return { success: true };
}
/**
* Register IPC handler for passphrase responses
*/
function registerHandler(ipcMain) {
ipcMain.handle('netcatty:passphrase:respond', handleResponse);
}
/**
* Get pending requests (for debugging)
*/
function getRequests() {
return passphraseRequests;
}
module.exports = {
generateRequestId,
requestPassphrase,
handleResponse,
registerHandler,
getRequests,
};

View File

@@ -6,6 +6,11 @@
const net = require("node:net");
const { Client: SSHClient } = require("ssh2");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
} = require("./sshAuthHelper.cjs");
// Active port forwarding tunnels
const portForwardingTunnels = new Map();
@@ -38,6 +43,7 @@ async function startPortForward(event, payload) {
username,
password,
privateKey,
passphrase,
} = payload;
return new Promise((resolve, reject) => {
@@ -63,59 +69,31 @@ async function startPortForward(event, payload) {
if (privateKey) {
connectOpts.privateKey = privateKey;
}
if (passphrase) {
connectOpts.passphrase = passphrase;
}
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;
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
privateKey,
password,
passphrase,
username: connectOpts.username,
logPrefix: "[PortForward]",
});
applyAuthToConnOpts(connectOpts, authConfig);
// 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;
}
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, tunnelId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: tunnelId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
savedPassword: password || null,
});
});
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
sender,
sessionId: tunnelId,
hostname,
password,
logPrefix: "[PortForward]",
}));
conn.on('ready', () => {

View File

@@ -23,6 +23,12 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
} = require("./sshAuthHelper.cjs");
// SFTP clients storage - shared reference passed from main
let sftpClients = null;
@@ -258,7 +264,8 @@ function init(deps) {
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
@@ -282,7 +289,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 20000,
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
keepaliveInterval: 10000,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
@@ -318,11 +325,18 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
if (jump.password) connOpts.password = jump.password;
if (authAgent) {
const order = ["agent"];
if (connOpts.password) order.push("password");
connOpts.authHandler = order;
}
// Build auth handler using shared helper
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
const authConfig = buildAuthHandler({
privateKey: connOpts.privateKey,
password: connOpts.password,
passphrase: connOpts.passphrase,
agent: connOpts.agent,
username: connOpts.username,
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
});
applyAuthToConnOpts(connOpts, authConfig);
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
@@ -351,6 +365,14 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
sender,
sessionId: connId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
}));
conn.connect(connOpts);
});
@@ -648,7 +670,8 @@ async function openSftp(event, options) {
options,
jumpHosts,
options.hostname,
options.port || 22
options.port || 22,
connId
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
@@ -700,78 +723,29 @@ async function openSftp(event, options) {
if (options.password) connectOpts.password = options.password;
if (authAgent) {
const order = ["agent"];
if (connectOpts.password) order.push("password");
connectOpts.authHandler = order;
} else if (options.privateKey && connectOpts.password) {
// Prefer key auth when both key and password are present (password still needed for sudo)
connectOpts.authHandler = ["publickey", "password"];
}
// 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;
}
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, event.sender.id, connId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
safeSend(event.sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: connId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
savedPassword: options.password || null,
});
};
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
privateKey: connectOpts.privateKey,
password: connectOpts.password,
passphrase: connectOpts.passphrase,
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[SFTP]",
});
applyAuthToConnOpts(connectOpts, authConfig);
// Create keyboard-interactive handler using shared helper
const kiHandler = createKeyboardInteractiveHandler({
sender: event.sender,
sessionId: connId,
hostname: options.hostname,
password: options.password,
logPrefix: "[SFTP]",
});
// 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

View File

@@ -0,0 +1,533 @@
/**
* SSH Authentication Helper - Shared authentication logic for SSH connections
* Used by sshBridge, sftpBridge, and portForwardingBridge
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
/**
* Check if an SSH private key is encrypted (requires passphrase)
* @param {string} keyContent - The content of the private key file
* @returns {boolean} - True if the key is encrypted
*/
function isKeyEncrypted(keyContent) {
if (!keyContent || typeof keyContent !== "string") return false;
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
return true;
}
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
return true;
}
// Check for DEK-Info header (legacy PEM encryption indicator)
if (keyContent.includes("DEK-Info:")) return true;
// Check for OpenSSH format keys
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
try {
// Extract the base64 content between the markers
const base64Match = keyContent.match(
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
);
if (base64Match) {
const base64Content = base64Match[1].replace(/\s/g, "");
const keyBuffer = Buffer.from(base64Content, "base64");
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
// If ciphername is "none", the key is not encrypted
const authMagic = "openssh-key-v1\0";
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
// After magic, read ciphername (length-prefixed string)
let offset = authMagic.length;
const cipherNameLen = keyBuffer.readUInt32BE(offset);
offset += 4;
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
return cipherName !== "none";
}
}
} catch {
// If parsing fails, assume it might be encrypted to be safe
return true;
}
}
return false;
}
/**
* Find default SSH private key from user's ~/.ssh directory
* Skips encrypted keys that require a passphrase
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
*/
function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
if (isKeyEncrypted(privateKey)) {
continue;
}
return { privateKey, keyPath, keyName: name };
} catch {
continue;
}
}
}
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* @param {Object} [options]
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>}
*/
function findAllDefaultPrivateKeys(options = {}) {
const { includeEncrypted = false } = options;
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (encrypted && !includeEncrypted) {
continue; // Skip encrypted keys when not including them
}
keys.push({
privateKey,
keyPath,
keyName: name,
...(includeEncrypted ? { isEncrypted: encrypted } : {})
});
} catch {
continue;
}
}
}
return keys;
}
/**
* Get ssh-agent socket path based on platform
* @returns {string|null}
*/
function getSshAgentSocket() {
if (process.platform === "win32") {
return "\\\\.\\pipe\\openssh-ssh-agent";
}
return process.env.SSH_AUTH_SOCK || null;
}
/**
* Build authentication handler with default key fallback support
* @param {Object} options
* @param {string} [options.privateKey] - Explicitly configured private key
* @param {string} [options.password] - Password for authentication
* @param {string} [options.passphrase] - Passphrase for encrypted private key
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
* @param {string} options.username - SSH username
* @param {string} [options.logPrefix] - Log prefix for debugging
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [] } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
const hasExplicitPassword = !!password;
const hasExplicitAgent = !!agent;
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
// Determine if this is a password-only or key-only connection
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
const sshAgentSocket = getSshAgentSocket();
const defaultKeys = findAllDefaultPrivateKeys();
// Only use system ssh-agent BEFORE user's auth when:
// - User explicitly configured agent, OR
// - No explicit auth is configured (pure fallback mode)
// When user configured key/password, system agent should only be used AFTER as fallback
const useAgentFirst = hasExplicitAgent || !hasExplicitAuth;
// Determine effective agent
const effectiveAgent = agent || (useAgentFirst ? sshAgentSocket : null);
// Determine effective privateKey (user-provided takes priority)
const effectivePrivateKey = privateKey || (!hasExplicitAuth && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
// Determine fallback keys (keys to try after user's primary auth fails)
// - If user provided a key: all default keys are fallbacks
// - If no explicit auth: first default key is primary, rest are fallbacks
// - If password-only or agent-only: all default keys are fallbacks (tried after primary)
const fallbackKeys = hasExplicitKey
? defaultKeys
: !hasExplicitAuth
? defaultKeys.slice(1)
: defaultKeys;
// Check if we need dynamic handler (have fallback options)
const hasFallbackOptions = fallbackKeys.length > 0 ||
(!hasExplicitAgent && sshAgentSocket) ||
(isPasswordOnly && defaultKeys.length > 0);
// 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");
return {
authHandler: authMethods,
privateKey: effectivePrivateKey,
agent: effectiveAgent,
usedDefaultKeys: false,
};
}
// Build comprehensive authMethods array with all auth options
// Order depends on what user explicitly configured:
// - Password-only: password -> agent -> default keys -> keyboard-interactive
// - 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
authMethods.push({ type: "password", id: "password" });
// Add agent and default keys AFTER password as fallback
if (sshAgentSocket) {
authMethods.push({ type: "agent", id: "agent" });
}
for (const keyInfo of defaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else if (isKeyOnly) {
// Key-only: user key first, then password (if any), then agent/default keys as fallback
// 1. User-provided key first
authMethods.push({
type: "publickey",
key: privateKey,
passphrase: passphrase,
id: "publickey-user"
});
// 2. Password (if configured alongside key)
if (password) {
authMethods.push({ type: "password", id: "password" });
}
// 3. System agent as fallback (AFTER user's key)
if (sshAgentSocket) {
authMethods.push({ type: "agent", id: "agent" });
}
// 4. Default keys as fallback
for (const keyInfo of fallbackKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else {
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
// 1. Agent (user-provided or system)
if (effectiveAgent) {
authMethods.push({ type: "agent", id: "agent" });
}
// 2. User-provided key
if (privateKey) {
authMethods.push({
type: "publickey",
key: privateKey,
passphrase: passphrase,
id: "publickey-user"
});
}
// 3. Password (if configured)
if (password) {
authMethods.push({ type: "password", id: "password" });
}
// 4. Default keys as fallback
for (const keyInfo of fallbackKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
// 5. If no user key provided, add first default key at the beginning (after agent)
if (!privateKey && defaultKeys.length > 0) {
const insertIndex = effectiveAgent ? 1 : 0;
authMethods.splice(insertIndex, 0, {
type: "publickey",
key: defaultKeys[0].privateKey,
id: `publickey-default-${defaultKeys[0].keyName}`
});
}
}
// Add unlocked encrypted default keys (user provided passphrases for these)
for (const keyInfo of unlockedEncryptedKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
passphrase: keyInfo.passphrase,
id: `publickey-encrypted-${keyInfo.keyName}`
});
}
// Keyboard-interactive as last resort
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
console.log(`${logPrefix} Auth methods configured`, {
isPasswordOnly,
hasUserKey: !!privateKey,
hasPassword: !!password,
hasAgent: !!effectiveAgent,
methodCount: authMethods.length,
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);
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
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);
} else if (method.type === "password" && availableMethods.includes("password")) {
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);
};
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
// even if effectiveAgent is null (for fallback scenarios)
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
return {
authHandler,
privateKey: effectivePrivateKey,
agent: returnAgent,
usedDefaultKeys: true,
};
}
/**
* Create a keyboard-interactive event handler
* @param {Object} options
* @param {Object} options.sender - Electron webContents sender
* @param {string} options.sessionId - Session/connection ID
* @param {string} options.hostname - Host being connected to
* @param {string} [options.password] - Saved password for fill button
* @param {string} [options.logPrefix] - Log prefix for debugging
* @returns {Function} - Event handler for 'keyboard-interactive' event
*/
function createKeyboardInteractiveHandler(options) {
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
return (name, instructions, instructionsLang, prompts, finish) => {
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
});
// 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;
}
// Forward prompts to user via IPC
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, sessionId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId,
name: name || hostname,
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
savedPassword: password || null,
});
};
}
/**
* 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.
}
}
/**
* Apply auth configuration to connection options
* Convenience function that combines buildAuthHandler results with connOpts
* @param {Object} connOpts - SSH connection options to modify
* @param {Object} authConfig - Auth configuration from buildAuthHandler
*/
function applyAuthToConnOpts(connOpts, authConfig) {
connOpts.authHandler = authConfig.authHandler;
if (authConfig.privateKey) {
connOpts.privateKey = authConfig.privateKey;
}
if (authConfig.agent) {
connOpts.agent = authConfig.agent;
}
}
/**
* Request passphrases for encrypted default keys
* Shows a modal for each encrypted key and collects passphrases
* @param {Object} sender - Electron webContents sender
* @param {string} [hostname] - Optional hostname for context
* @returns {Promise<{ keys: Array<{ privateKey: string, keyPath: string, keyName: string, passphrase: string }>, cancelled: boolean }>}
*/
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
const allKeys = findAllDefaultPrivateKeys({ includeEncrypted: true });
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
if (encryptedKeys.length === 0) {
return { keys: [], cancelled: false };
}
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
const unlockedKeys = [];
let wasCancelled = false;
for (const keyInfo of encryptedKeys) {
const result = await passphraseHandler.requestPassphrase(
sender,
keyInfo.keyPath,
keyInfo.keyName,
hostname
);
// Handle different response types
if (!result) {
// Timeout or error - continue with next key
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
continue;
}
if (result.cancelled) {
// User clicked Cancel - stop the entire flow
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
wasCancelled = true;
break;
}
if (result.skipped) {
// User clicked Skip - continue with next key
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
continue;
}
if (result.passphrase) {
// User provided passphrase
unlockedKeys.push({
privateKey: keyInfo.privateKey,
keyPath: keyInfo.keyPath,
keyName: keyInfo.keyName,
passphrase: result.passphrase,
});
}
}
return { keys: unlockedKeys, cancelled: wasCancelled };
}
module.exports = {
DEFAULT_KEY_NAMES,
isKeyEncrypted,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
getSshAgentSocket,
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend,
requestPassphrasesForEncryptedKeys,
};

View File

@@ -11,7 +11,16 @@ const { exec } = require("node:child_process");
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
requestPassphrasesForEncryptedKeys,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
} = require("./sshAuthHelper.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
@@ -98,6 +107,36 @@ function findDefaultPrivateKey() {
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* Returns all non-encrypted keys for fallback authentication
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string }>}
*/
function findAllDefaultPrivateKeys() {
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (!encrypted) {
keys.push({ privateKey, keyPath, keyName: name });
log("Found default key for fallback", { keyPath, keyName: name });
} else {
log("Skipping encrypted key", { keyPath, keyName: name });
}
} catch (e) {
log("Failed to read key", { keyPath, error: e.message });
}
}
}
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
return keys;
}
/**
* Check if Windows SSH Agent service is running
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
@@ -199,7 +238,7 @@ function init(deps) {
/**
* Connect through a chain of jump hosts
*/
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort) {
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
@@ -229,7 +268,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 20000, // Reduced from 60s for faster failure detection
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
// If 0 or not provided, use 10000ms as default
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
@@ -245,7 +284,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
},
};
// Auth - support agent (certificate), key, and password fallback
// Auth - support agent (certificate), key, password, and default key fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
@@ -269,11 +308,18 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
if (jump.password) connOpts.password = jump.password;
if (authAgent) {
const order = ["agent"];
if (connOpts.password) order.push("password");
connOpts.authHandler = order;
}
// Build auth handler using shared helper
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
const authConfig = buildAuthHandler({
privateKey: connOpts.privateKey,
password: connOpts.password,
passphrase: connOpts.passphrase,
agent: connOpts.agent,
username: connOpts.username,
logPrefix: `[Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
});
applyAuthToConnOpts(connOpts, authConfig);
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
@@ -304,6 +350,14 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
sender,
sessionId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
}));
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
conn.connect(connOpts);
});
@@ -449,22 +503,47 @@ async function startSSHSession(event, options) {
connectOpts.password = options.password;
}
// Always try to find default SSH key for fallback authentication
// Always try to find default SSH keys for fallback authentication
// This allows fallback even when password auth fails
let defaultKeyInfo = null;
let allDefaultKeys = [];
let usedDefaultKeyAsPrimary = false;
const defaultKey = findDefaultPrivateKey();
if (defaultKey) {
defaultKeyInfo = defaultKey;
log("Found default SSH key for fallback", { keyPath: defaultKey.keyPath, keyName: defaultKey.keyName });
}
// Also find ALL default keys for comprehensive fallback
allDefaultKeys = findAllDefaultPrivateKeys();
// If no primary auth method configured, use default key as primary
// Use unlocked encrypted keys if provided (from retry after auth failure)
// These are passed via _unlockedEncryptedKeys from startSSHSessionWrapper
const unlockedEncryptedKeys = options._unlockedEncryptedKeys || [];
if (unlockedEncryptedKeys.length > 0) {
log("Using unlocked encrypted keys from retry", {
count: unlockedEncryptedKeys.length,
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
});
}
// If no primary auth method configured, try ssh-agent first, then ALL default keys
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
log("No auth method configured, using default SSH key as primary auth");
if (defaultKeyInfo) {
connectOpts.privateKey = defaultKeyInfo.privateKey;
usedDefaultKeyAsPrimary = true; // Track that we promoted default key to primary
// First, try to use ssh-agent if available (this is what regular SSH does)
const sshAgentSocket = process.platform === "win32"
? "\\\\.\\pipe\\openssh-ssh-agent"
: process.env.SSH_AUTH_SOCK;
if (sshAgentSocket) {
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
connectOpts.agent = sshAgentSocket;
}
// Mark that we need to try all default keys (handled in authMethods below)
if (allDefaultKeys.length > 0) {
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
// Set first key for connectOpts.privateKey (required for ssh2 to allow publickey auth)
connectOpts.privateKey = allDefaultKeys[0].privateKey;
usedDefaultKeyAsPrimary = true;
} else {
log("No default SSH key found in ~/.ssh directory");
}
@@ -515,34 +594,54 @@ async function startSSHSession(event, options) {
const authMethods = [];
// First try user-configured key if available (explicit user choice)
if (connectOpts.privateKey) {
if (connectOpts.privateKey && !usedDefaultKeyAsPrimary) {
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
}
// Then try password if available (explicit user choice)
// Password before agent because agent may be auto-set via SSH_AUTH_SOCK
// and on servers with low MaxAuthTries, agent attempt could exhaust tries
if (connectOpts.password) {
authMethods.push({ type: "password", id: "password" });
}
// Then try agent if configured (agentForwarding or SSH_AUTH_SOCK)
// Agent after password since it may be auto-configured rather than explicit
// Then try agent if configured (try agent before password since it's usually faster)
if (connectOpts.agent) {
authMethods.push({ type: "agent", id: "agent" });
}
// Then try default SSH key as fallback (if not already used as primary)
if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
// Then try password if available (explicit user choice)
if (connectOpts.password) {
authMethods.push({ type: "password", id: "password" });
}
// Then try ALL default SSH keys as fallback (not just the first one!)
// This is critical because different servers may have different keys in authorized_keys
if (usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
for (const keyInfo of allDefaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
isDefault: true,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
// Single default key fallback (when user has configured other auth methods)
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
}
// Add unlocked encrypted default keys (user provided passphrases for these)
for (const keyInfo of unlockedEncryptedKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
passphrase: keyInfo.passphrase,
isDefault: true,
id: `publickey-encrypted-${keyInfo.keyName}`
});
}
// Finally try keyboard-interactive
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
log("Auth methods configured", {
methods: authMethods.map(m => ({ type: m.type, id: m.id, isDefault: m.isDefault || false })),
cachedMethod
cachedMethod,
usedDefaultKeyAsPrimary
});
// Reorder methods based on cached successful method
@@ -720,7 +819,8 @@ async function startSSHSession(event, options) {
options,
jumpHosts,
options.hostname,
options.port || 22
options.port || 22,
sessionId
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
@@ -959,17 +1059,30 @@ async function startSSHSession(event, options) {
}
} else if (typeof connectOpts.authHandler !== "function") {
// Create authHandler with keyboard-interactive support
// This path is taken when usedDefaultKeyAsPrimary=true (only keyboard-interactive in authMethods)
// Using array format is more reliable - ssh2 uses connectOpts credentials directly
const authMethods = [];
// Try agent FIRST (this is what regular SSH does - it checks ssh-agent before key files)
if (connectOpts.agent) authMethods.push("agent");
if (connectOpts.privateKey) authMethods.push("publickey");
if (connectOpts.password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
log("Using simple array authHandler", { authMethods, usedDefaultKeyAsPrimary });
}
// If authHandler is a function, it already handles keyboard-interactive
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
// Enable debug logging for ssh2 to diagnose auth issues
connectOpts.debug = (msg) => {
// Only log auth-related messages to avoid noise
if (msg.includes('Auth') || msg.includes('auth') || msg.includes('publickey') || msg.includes('keyboard')) {
log("ssh2 debug", { msg });
}
};
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
conn.connect(connectOpts);
});
@@ -1141,6 +1254,57 @@ async function startSSHSessionWrapper(event, options) {
err.level === 'client-authentication';
if (isAuthError) {
// Check if there are encrypted default keys we haven't tried yet
// Only offer retry if no unlocked keys were provided in this attempt
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
const allKeysWithEncrypted = findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
if (encryptedKeys.length > 0) {
console.log('[SSH] Auth failed, found encrypted default keys. Requesting passphrases for retry...');
// Request passphrases from user
const passphraseResult = await requestPassphrasesForEncryptedKeys(
event.sender,
options.hostname
);
// If user cancelled, don't retry even if some keys were unlocked
if (passphraseResult.cancelled) {
console.log('[SSH] User cancelled passphrase flow, not retrying');
} else if (passphraseResult.keys.length > 0) {
console.log('[SSH] User unlocked keys, retrying connection...', {
count: passphraseResult.keys.length,
keyNames: passphraseResult.keys.map(k => k.keyName)
});
// Retry connection with unlocked keys
// Wrap in try-catch to ensure consistent error handling for retry failures
try {
return await startSSHSession(event, {
...options,
_unlockedEncryptedKeys: passphraseResult.keys,
});
} catch (retryErr) {
// Re-wrap retry errors the same way as initial errors
const isRetryAuthError = retryErr.message?.toLowerCase().includes('authentication') ||
retryErr.message?.toLowerCase().includes('auth') ||
retryErr.level === 'client-authentication';
if (isRetryAuthError) {
const authError = new Error(retryErr.message);
authError.level = 'client-authentication';
authError.isAuthError = true;
throw authError;
}
throw retryErr;
}
} else {
console.log('[SSH] User did not unlock any keys, not retrying');
}
}
}
// Re-throw with a clean error to avoid Electron printing full stack trace
// The frontend will handle this as a normal auth failure for fallback
const authError = new Error(err.message);
@@ -1564,6 +1728,8 @@ function registerHandlers(ipcMain) {
});
// Register the shared keyboard-interactive response handler
keyboardInteractiveHandler.registerHandler(ipcMain);
// Register the passphrase response handler
passphraseHandler.registerHandler(ipcMain);
}
module.exports = {
@@ -1577,4 +1743,8 @@ module.exports = {
generateKeyPair,
checkWindowsSshAgent,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
isKeyEncrypted,
findAllDefaultPrivateKeys,
isKeyEncrypted,
};

View File

@@ -10,6 +10,8 @@ const authFailedListeners = new Map();
const languageChangeListeners = new Set();
const fullscreenChangeListeners = new Set();
const keyboardInteractiveListeners = new Set();
const passphraseListeners = new Set();
const passphraseTimeoutListeners = new Set();
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
@@ -98,6 +100,28 @@ ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
});
});
// Passphrase request events for encrypted SSH keys
ipcRenderer.on("netcatty:passphrase-request", (_event, payload) => {
passphraseListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Passphrase request callback failed", err);
}
});
});
// Passphrase timeout events (request expired)
ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
passphraseTimeoutListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Passphrase timeout callback failed", err);
}
});
});
// Transfer progress events
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
const cb = transferProgressListeners.get(payload.transferId);
@@ -318,6 +342,29 @@ const api = {
cancelled,
});
},
// Passphrase request for encrypted SSH keys
onPassphraseRequest: (cb) => {
passphraseListeners.add(cb);
return () => passphraseListeners.delete(cb);
},
respondPassphrase: async (requestId, passphrase, cancelled = false) => {
return ipcRenderer.invoke("netcatty:passphrase:respond", {
requestId,
passphrase,
cancelled,
});
},
respondPassphraseSkip: async (requestId) => {
return ipcRenderer.invoke("netcatty:passphrase:respond", {
requestId,
passphrase: '',
skipped: true,
});
},
onPassphraseTimeout: (cb) => {
passphraseTimeoutListeners.add(cb);
return () => passphraseTimeoutListeners.delete(cb);
},
openSftp: async (options) => {
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
return result.sftpId;

21
global.d.ts vendored
View File

@@ -245,6 +245,27 @@ declare global {
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
// Passphrase request for encrypted SSH keys
onPassphraseRequest?(
cb: (request: {
requestId: string;
keyPath: string;
keyName: string;
hostname?: string;
}) => void
): () => void;
respondPassphrase?(
requestId: string,
passphrase: string,
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
respondPassphraseSkip?(
requestId: string
): Promise<{ success: boolean; error?: string }>;
onPassphraseTimeout?(
cb: (event: { requestId: string }) => void
): () => void;
// SFTP operations
openSftp(options: NetcattySSHOptions): Promise<string>;
listSftp(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<RemoteFile[]>;