Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7355e29b89 | ||
|
|
64686cc237 | ||
|
|
d65440ace7 | ||
|
|
2dbeddd9aa | ||
|
|
4758345448 | ||
|
|
4d3fa93083 | ||
|
|
2746aae274 | ||
|
|
a7b22b3580 |
81
App.tsx
81
App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
169
components/PassphraseModal.tsx
Normal file
169
components/PassphraseModal.tsx
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
141
electron/bridges/passphraseHandler.cjs
Normal file
141
electron/bridges/passphraseHandler.cjs
Normal 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,
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
533
electron/bridges/sshAuthHelper.cjs
Normal file
533
electron/bridges/sshAuthHelper.cjs
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
21
global.d.ts
vendored
@@ -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[]>;
|
||||
|
||||
Reference in New Issue
Block a user