[codex] Fix known host fingerprint coverage (#1442)

* Fix known host fingerprint coverage

* Tighten SFTP host key verification
This commit is contained in:
陈大猫
2026-06-12 16:09:29 +08:00
committed by GitHub
parent 550a37b379
commit 910ef72205
17 changed files with 794 additions and 21 deletions

View File

@@ -194,7 +194,6 @@ function App({ settings }: { settings: SettingsState }) {
const keysRef = useRef(keys); const keysRef = useRef(keys);
keysRef.current = keys; keysRef.current = keys;
const knownHostsRef = useRef(knownHosts); const knownHostsRef = useRef(knownHosts);
knownHostsRef.current = knownHosts;
// Bridge the gap while useVaultState hydrates: its async init awaits // Bridge the gap while useVaultState hydrates: its async init awaits
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts, // hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
// so the state is briefly [] at boot even when localStorage has entries. // so the state is briefly [] at boot even when localStorage has entries.
@@ -205,6 +204,7 @@ function App({ settings }: { settings: SettingsState }) {
() => getEffectiveKnownHosts(knownHosts) ?? [], () => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts], [knownHosts],
); );
knownHostsRef.current = effectiveKnownHosts;
const { const {
sessions, sessions,

View File

@@ -214,9 +214,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
hosts={hosts} hosts={hosts}
keys={keys} keys={keys}
identities={identities} identities={identities}
knownHosts={effectiveKnownHosts}
proxyProfiles={proxyProfiles} proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs} groupConfigs={groupConfigs}
updateHosts={updateHosts} updateHosts={updateHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode} sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior} sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync} sftpAutoSync={sftpAutoSync}

View File

@@ -1,4 +1,4 @@
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models"; import { KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface SftpPane { export interface SftpPane {
id: string; id: string;
@@ -15,6 +15,22 @@ export interface SftpPane {
transferMutationToken: number; transferMutationToken: number;
} }
export interface SftpHostKeyInfo {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
status?: "unknown" | "changed";
knownHostId?: string;
knownFingerprint?: string;
}
export interface SftpHostKeyVerificationState {
hostKeyInfo: SftpHostKeyInfo;
progressLogs: string[];
}
// Multi-tab state for left and right sides // Multi-tab state for left and right sides
export interface SftpSideTabs { export interface SftpSideTabs {
tabs: SftpPane[]; tabs: SftpPane[];
@@ -70,4 +86,6 @@ export interface SftpStateOptions {
* is honored for SFTP browsing too (not just the terminal session). * is honored for SFTP browsing too (not just the terminal session).
*/ */
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number }; terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
knownHosts?: KnownHost[];
onAddKnownHost?: (knownHost: KnownHost) => void;
} }

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useRef } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react"; import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"; import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models"; import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpPane } from "./types"; import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing"; import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials"; import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache"; import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
@@ -12,6 +12,8 @@ interface UseSftpConnectionsParams {
hosts: Host[]; hosts: Host[];
keys: SSHKey[]; keys: SSHKey[];
identities: Identity[]; identities: Identity[];
knownHosts?: KnownHost[];
onAddKnownHost?: (knownHost: KnownHost) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number }; terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>; leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>; rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
@@ -48,12 +50,48 @@ interface UseSftpConnectionsResult {
disconnect: (side: "left" | "right") => Promise<void>; disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>; listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>; listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
hostKeyVerification: SftpHostKeyVerificationState | null;
rejectHostKeyVerification: () => void;
acceptHostKeyVerification: () => void;
acceptAndSaveHostKeyVerification: () => void;
} }
type HostKeyVerificationRequest = SftpHostKeyInfo & {
requestId: string;
sessionId?: string;
};
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
hostname: request.hostname,
port: request.port || 22,
keyType: request.keyType,
fingerprint: request.fingerprint,
publicKey: request.publicKey,
status: request.status,
knownHostId: request.knownHostId,
knownFingerprint: request.knownFingerprint,
});
const createKnownHostFromSftpHostKeyInfo = (
hostKeyInfo: SftpHostKeyInfo,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});
export const useSftpConnections = ({ export const useSftpConnections = ({
hosts, hosts,
keys, keys,
identities, identities,
knownHosts,
onAddKnownHost,
terminalSettings, terminalSettings,
leftTabsRef, leftTabsRef,
rightTabsRef, rightTabsRef,
@@ -76,8 +114,76 @@ export const useSftpConnections = ({
createEmptyPane, createEmptyPane,
autoConnectLocalOnMount = true, autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => { }: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings }); const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing(); const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
const setPendingHostKeyVerification = useCallback((
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
) => {
hostKeyVerificationRef.current = next;
setHostKeyVerification(next ? {
hostKeyInfo: next.hostKeyInfo,
progressLogs: next.progressLogs,
} : null);
}, []);
useEffect(() => {
const dispose = netcattyBridge.get()?.onHostKeyVerification?.((request: HostKeyVerificationRequest) => {
const sessionId = request.sessionId;
if (!sessionId) return;
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
if (!activeSession) return;
const hostKeyInfo = toSftpHostKeyInfo(request);
const logLine = request.status === "changed"
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
: `Host key verification required for ${request.hostname}.`;
updateTab(activeSession.side, activeSession.tabId, (prev) => ({
...prev,
connectionLogs: [...prev.connectionLogs, logLine],
}));
setPendingHostKeyVerification({
requestId: request.requestId,
sessionId,
hostKeyInfo,
progressLogs: [logLine],
});
});
return () => {
dispose?.();
};
}, [setPendingHostKeyVerification, updateTab]);
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
const pending = hostKeyVerificationRef.current;
if (!pending) return;
if (accept && addToKnownHosts) {
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
}
void netcattyBridge.get()?.respondHostKeyVerification?.(
pending.requestId,
accept,
addToKnownHosts,
);
setPendingHostKeyVerification(null);
}, [onAddKnownHost, setPendingHostKeyVerification]);
const rejectHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(false);
}, [respondToHostKeyVerification]);
const acceptHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(true, false);
}, [respondToHostKeyVerification]);
const acceptAndSaveHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(true, true);
}, [respondToHostKeyVerification]);
const connect = useCallback( const connect = useCallback(
async (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => { async (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => {
@@ -271,6 +377,7 @@ export const useSftpConnections = ({
// Subscribe to SFTP connection progress events for auth logging // Subscribe to SFTP connection progress events for auth logging
const sftpSessionId = `sftp-${connectionId}`; const sftpSessionId = `sftp-${connectionId}`;
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
let unsubSftpProgress: (() => void) | undefined; let unsubSftpProgress: (() => void) | undefined;
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
if (bridge?.onSftpConnectionProgress) { if (bridge?.onSftpConnectionProgress) {
@@ -336,7 +443,7 @@ export const useSftpConnections = ({
if (hasKey) { if (hasKey) {
try { try {
const keyFirstCredentials = { const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`, sessionId: sftpSessionId,
...credentials, ...credentials,
sourceSessionId: options?.sourceSessionId, sourceSessionId: options?.sourceSessionId,
}; };
@@ -347,7 +454,7 @@ export const useSftpConnections = ({
} catch (err) { } catch (err) {
if (hasPassword && isAuthError(err)) { if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({ sftpId = await openSftp({
sessionId: `sftp-${connectionId}`, sessionId: sftpSessionId,
...credentials, ...credentials,
sourceSessionId: options?.sourceSessionId, sourceSessionId: options?.sourceSessionId,
privateKey: undefined, privateKey: undefined,
@@ -363,7 +470,7 @@ export const useSftpConnections = ({
} }
} else { } else {
sftpId = await openSftp({ sftpId = await openSftp({
sessionId: `sftp-${connectionId}`, sessionId: sftpSessionId,
...credentials, ...credentials,
sourceSessionId: options?.sourceSessionId, sourceSessionId: options?.sourceSessionId,
}); });
@@ -544,6 +651,10 @@ export const useSftpConnections = ({
reconnecting: false, reconnecting: false,
})); }));
} finally { } finally {
activeHostKeySessionsRef.current.delete(sftpSessionId);
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
setPendingHostKeyVerification(null);
}
unsubSftpProgress?.(); unsubSftpProgress?.();
} }
} }
@@ -558,6 +669,7 @@ export const useSftpConnections = ({
makeCacheKey, makeCacheKey,
listLocalFiles, listLocalFiles,
listRemoteFiles, listRemoteFiles,
setPendingHostKeyVerification,
], ],
); );
@@ -643,5 +755,9 @@ export const useSftpConnections = ({
disconnect, disconnect,
listLocalFiles, listLocalFiles,
listRemoteFiles, listRemoteFiles,
hostKeyVerification,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
}; };
}; };

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts"; import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host, SSHKey } from "../../../domain/models.ts"; import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({ const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1", id: "host-1",
@@ -102,6 +102,28 @@ test("buildSftpHostCredentials passes reference keys as identity file paths", ()
assert.equal(credentials.passphrase, "saved-passphrase"); assert.equal(credentials.passphrase, "saved-passphrase");
}); });
test("buildSftpHostCredentials forwards known hosts for SFTP host-key checks", () => {
const knownHosts: KnownHost[] = [{
id: "kh-1",
hostname: "example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: "SHA256:abc",
fingerprint: "abc",
discoveredAt: 1,
}];
const credentials = buildSftpHostCredentials({
host: host(),
hosts: [],
keys: [],
identities: [],
knownHosts,
});
assert.equal(credentials.knownHosts, knownHosts);
});
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => { test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
const key: SSHKey = { const key: SSHKey = {
id: "jump-key", id: "jump-key",

View File

@@ -1,5 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models"; import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials"; import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth"; import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host"; import { resolveHostKeepalive } from "../../../domain/host";
@@ -14,6 +14,7 @@ interface UseSftpHostCredentialsParams {
hosts: Host[]; hosts: Host[];
keys: SSHKey[]; keys: SSHKey[];
identities: Identity[]; identities: Identity[];
knownHosts?: KnownHost[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>; terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
} }
@@ -22,6 +23,7 @@ export const buildSftpHostCredentials = ({
hosts, hosts,
keys, keys,
identities, identities,
knownHosts,
terminalSettings, terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => { }: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE; const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
@@ -165,6 +167,7 @@ export const buildSftpHostCredentials = ({
identityFilePaths: keyAuth.identityFilePaths, identityFilePaths: keyAuth.identityFilePaths,
keepaliveInterval: targetKeepalive.interval, keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax, keepaliveCountMax: targetKeepalive.countMax,
knownHosts,
// Algorithm settings — must reach the SFTP bridge or hosts that need // Algorithm settings — must reach the SFTP bridge or hosts that need
// legacy mode / the ECDSA skip / advanced overrides would still hit // legacy mode / the ECDSA skip / advanced overrides would still hit
// the original negotiation failure when opening their SFTP pane, // the original negotiation failure when opening their SFTP pane,
@@ -179,9 +182,10 @@ export const useSftpHostCredentials = ({
hosts, hosts,
keys, keys,
identities, identities,
knownHosts,
terminalSettings, terminalSettings,
}: UseSftpHostCredentialsParams) => }: UseSftpHostCredentialsParams) =>
useCallback( useCallback(
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }), (host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, knownHosts, terminalSettings }),
[hosts, identities, keys, terminalSettings], [hosts, identities, keys, knownHosts, terminalSettings],
); );

View File

@@ -170,10 +170,21 @@ export const useSftpState = (
useSftpSessionCleanup(sftpSessionsRef); useSftpSessionCleanup(sftpSessionsRef);
useSftpFileWatch(options); useSftpFileWatch(options);
const { connect, disconnect, listLocalFiles, listRemoteFiles } = useSftpConnections({ const {
connect,
disconnect,
listLocalFiles,
listRemoteFiles,
hostKeyVerification,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
} = useSftpConnections({
hosts, hosts,
keys, keys,
identities, identities,
knownHosts: options?.knownHosts,
onAddKnownHost: options?.onAddKnownHost,
terminalSettings: options?.terminalSettings, terminalSettings: options?.terminalSettings,
leftTabsRef, leftTabsRef,
rightTabsRef, rightTabsRef,
@@ -402,6 +413,9 @@ export const useSftpState = (
resolveConflict: resolveAnyConflict, resolveConflict: resolveAnyConflict,
getSftpIdForConnection, getSftpIdForConnection,
reportSessionError: handleSessionError, reportSessionError: handleSessionError,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
}); });
methodsRef.current = { methodsRef.current = {
getFilteredFiles, getFilteredFiles,
@@ -460,6 +474,9 @@ export const useSftpState = (
resolveConflict: resolveAnyConflict, resolveConflict: resolveAnyConflict,
getSftpIdForConnection, getSftpIdForConnection,
reportSessionError: handleSessionError, reportSessionError: handleSessionError,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
}; };
// Create stable method wrappers that call through methodsRef // Create stable method wrappers that call through methodsRef
@@ -532,6 +549,9 @@ export const useSftpState = (
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args), resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args), getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args), reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
activeFileWatchCountRef, activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref }), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
@@ -546,6 +566,7 @@ export const useSftpState = (
transfers, transfers,
activeTransfersCount, activeTransfersCount,
conflicts, conflicts,
hostKeyVerification,
// Stable methods - never change reference // Stable methods - never change reference
...stableMethods, ...stableMethods,
@@ -566,6 +587,7 @@ export const useSftpState = (
transfers, transfers,
activeTransfersCount, activeTransfersCount,
conflicts, conflicts,
hostKeyVerification,
stableMethods, stableMethods,
]); ]);
}; };

View File

@@ -24,7 +24,7 @@ import { getParentPath, isConcreteTransferTargetPath } from "../application/stat
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache"; import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { logger } from "../lib/logger"; import { logger } from "../lib/logger";
import type { DropEntry } from "../lib/sftpFileUtils"; import type { DropEntry } from "../lib/sftpFileUtils";
import { Host, Identity, SSHKey } from "../types"; import { Host, Identity, KnownHost, SSHKey } from "../types";
import type { TransferTask } from "../types"; import type { TransferTask } from "../types";
import { toast } from "./ui/toast"; import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -47,7 +47,9 @@ interface SftpSidePanelProps {
writableHosts?: Host[]; writableHosts?: Host[];
keys: SSHKey[]; keys: SSHKey[];
identities: Identity[]; identities: Identity[];
knownHosts?: KnownHost[];
updateHosts: (hosts: Host[]) => void; updateHosts: (hosts: Host[]) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
sftpDefaultViewMode: "list" | "tree"; sftpDefaultViewMode: "list" | "tree";
/** The host to connect to (follows focused terminal) */ /** The host to connect to (follows focused terminal) */
activeHost: Host | null; activeHost: Host | null;
@@ -87,7 +89,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
writableHosts, writableHosts,
keys, keys,
identities, identities,
knownHosts = [],
updateHosts, updateHosts,
onAddKnownHost,
sftpDefaultViewMode, sftpDefaultViewMode,
activeHost, activeHost,
activeSessionId, activeSessionId,
@@ -134,7 +138,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
defaultShowHiddenFiles: sftpShowHiddenFiles, defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false, autoConnectLocalOnMount: false,
terminalSettings, terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]); knownHosts,
onAddKnownHost,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions); const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const { const {
@@ -964,7 +970,9 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.writableHosts === next.writableHosts && prev.writableHosts === next.writableHosts &&
prev.keys === next.keys && prev.keys === next.keys &&
prev.identities === next.identities && prev.identities === next.identities &&
prev.knownHosts === next.knownHosts &&
prev.updateHosts === next.updateHosts && prev.updateHosts === next.updateHosts &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode && prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost && prev.activeHost === next.activeHost &&
prev.activeSessionId === next.activeSessionId && prev.activeSessionId === next.activeSessionId &&

View File

@@ -24,7 +24,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger"; import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker"; import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types"; import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig"; import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles"; import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations"; import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
@@ -54,9 +54,11 @@ interface SftpViewProps {
hosts: Host[]; hosts: Host[];
keys: SSHKey[]; keys: SSHKey[];
identities: Identity[]; identities: Identity[];
knownHosts?: KnownHost[];
groupConfigs?: import('../domain/models').GroupConfig[]; groupConfigs?: import('../domain/models').GroupConfig[];
proxyProfiles?: ProxyProfile[]; proxyProfiles?: ProxyProfile[];
updateHosts: (hosts: Host[]) => void; updateHosts: (hosts: Host[]) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
sftpDefaultViewMode: "list" | "tree"; sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer"; sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean; sftpAutoSync: boolean;
@@ -73,9 +75,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
hosts, hosts,
keys, keys,
identities, identities,
knownHosts = [],
groupConfigs = [], groupConfigs = [],
proxyProfiles = [], proxyProfiles = [],
updateHosts, updateHosts,
onAddKnownHost,
sftpDefaultViewMode, sftpDefaultViewMode,
sftpDoubleClickBehavior, sftpDoubleClickBehavior,
sftpAutoSync, sftpAutoSync,
@@ -110,7 +114,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
useCompressedUpload: sftpUseCompressedUpload, useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles, defaultShowHiddenFiles: sftpShowHiddenFiles,
terminalSettings, terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]); knownHosts,
onAddKnownHost,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
// Pre-resolve group defaults so SFTP connections inherit group config // Pre-resolve group defaults so SFTP connections inherit group config
const effectiveHosts = useMemo(() => { const effectiveHosts = useMemo(() => {
@@ -612,9 +618,11 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts && prev.hosts === next.hosts &&
prev.keys === next.keys && prev.keys === next.keys &&
prev.identities === next.identities && prev.identities === next.identities &&
prev.knownHosts === next.knownHosts &&
prev.groupConfigs === next.groupConfigs && prev.groupConfigs === next.groupConfigs &&
prev.proxyProfiles === next.proxyProfiles && prev.proxyProfiles === next.proxyProfiles &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode && prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior && prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync && prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles && prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&

View File

@@ -7,9 +7,11 @@ import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWin
import { useVaultState } from '../application/state/useVaultState'; import { useVaultState } from '../application/state/useVaultState';
import { useWindowControls } from '../application/state/useWindowControls'; import { useWindowControls } from '../application/state/useWindowControls';
import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent'; import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent';
import { upsertKnownHost } from '../domain/knownHosts';
import type { TerminalPopupPayload } from '../domain/systemManager/types'; import type { TerminalPopupPayload } from '../domain/systemManager/types';
import type { TerminalTheme } from '../domain/models'; import type { TerminalTheme } from '../domain/models';
import type { Host } from '../types'; import type { Host, KnownHost } from '../types';
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
const Terminal = lazy(() => import('./Terminal')); const Terminal = lazy(() => import('./Terminal'));
@@ -195,11 +197,22 @@ function TerminalPopupPageInner() {
const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow(); const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow();
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls(); const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
const settings = useSettingsState(); const settings = useSettingsState();
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages } = useVaultState(); const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages, updateKnownHosts } = useVaultState();
const [config, setConfig] = useState<TerminalPopupPayload | null>(null); const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
const [terminalReady, setTerminalReady] = useState(false); const [terminalReady, setTerminalReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null); const [startupError, setStartupError] = useState<string | null>(null);
const sessionId = useMemo(() => crypto.randomUUID(), []); const sessionId = useMemo(() => crypto.randomUUID(), []);
const knownHostsRef = React.useRef(knownHosts);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
knownHostsRef.current = effectiveKnownHosts;
const handleAddKnownHost = useCallback((knownHost: KnownHost) => {
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, knownHost);
knownHostsRef.current = nextKnownHosts;
updateKnownHosts(nextKnownHosts);
}, [updateKnownHosts]);
const popupThemeVars = useMemo( const popupThemeVars = useMemo(
() => buildPopupThemeVars(settings.currentTerminalTheme), () => buildPopupThemeVars(settings.currentTerminalTheme),
[settings.currentTerminalTheme], [settings.currentTerminalTheme],
@@ -307,7 +320,8 @@ function TerminalPopupPageInner() {
snippetPackages={snippetPackages} snippetPackages={snippetPackages}
compactToolbar compactToolbar
lineTimestampsAvailable={false} lineTimestampsAvailable={false}
knownHosts={knownHosts} knownHosts={effectiveKnownHosts}
onAddKnownHost={handleAddKnownHost}
isVisible isVisible
isFocused isFocused
fontFamilyId={settings.terminalFontFamilyId} fontFamilyId={settings.terminalFontFamilyId}

View File

@@ -7,6 +7,8 @@ import type { TransferTask } from "../../types";
import FileOpenerDialog from "../FileOpenerDialog"; import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal"; import TextEditorModal from "../TextEditorModal";
import type { TextEditorModalSnapshot } from "../TextEditorModal"; import type { TextEditorModalSnapshot } from "../TextEditorModal";
import { TerminalHostKeyVerification } from "../terminal/TerminalHostKeyVerification";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { SftpConflictDialog } from "./SftpConflictDialog"; import { SftpConflictDialog } from "./SftpConflictDialog";
import { SftpHostPicker } from "./SftpHostPicker"; import { SftpHostPicker } from "./SftpHostPicker";
import { SftpPermissionsDialog } from "./SftpPermissionsDialog"; import { SftpPermissionsDialog } from "./SftpPermissionsDialog";
@@ -139,6 +141,27 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
formatFileSize={sftp.formatFileSize} formatFileSize={sftp.formatFileSize}
/> />
<Dialog
open={!!sftp.hostKeyVerification}
onOpenChange={(open) => {
if (!open) sftp.rejectHostKeyVerification();
}}
>
<DialogContent className="max-w-lg" hideCloseButton>
<DialogTitle className="sr-only">Confirm host key</DialogTitle>
{sftp.hostKeyVerification && (
<TerminalHostKeyVerification
hostKeyInfo={sftp.hostKeyVerification.hostKeyInfo}
showLogs={sftp.hostKeyVerification.progressLogs.length > 0}
progressLogs={sftp.hostKeyVerification.progressLogs}
onClose={sftp.rejectHostKeyVerification}
onContinue={sftp.acceptHostKeyVerification}
onAddAndContinue={sftp.acceptAndSaveHostKeyVerification}
/>
)}
</DialogContent>
</Dialog>
<SftpPermissionsDialog <SftpPermissionsDialog
open={!!permissionsState} open={!!permissionsState}
onOpenChange={(open) => !open && setPermissionsState(null)} onOpenChange={(open) => !open && setPermissionsState(null)}

View File

@@ -89,6 +89,7 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
handleCloseSidePanel, handleCloseSidePanel,
handleHistoryPaste, handleHistoryPaste,
handleHistoryRun, handleHistoryRun,
handleAddKnownHost,
handleOpenHistory, handleOpenHistory,
handleFontFamilyChangeForFocusedSession, handleFontFamilyChangeForFocusedSession,
handleFontFamilyResetForFocusedSession, handleFontFamilyResetForFocusedSession,
@@ -116,6 +117,7 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
identities, identities,
keyBindings, keyBindings,
keys, keys,
knownHosts,
mountedAiTabIds, mountedAiTabIds,
mountedSftpTabIds, mountedSftpTabIds,
scriptsMountedTabIds, scriptsMountedTabIds,
@@ -460,7 +462,9 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
writableHosts={hosts} writableHosts={hosts}
keys={keys} keys={keys}
identities={identities} identities={identities}
knownHosts={knownHosts}
updateHosts={updateHosts} updateHosts={updateHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode} sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={panelActiveHost} activeHost={panelActiveHost}
activeSessionId={isVisibleSftpPanel ? activeTerminalSessionIdForSftp : null} activeSessionId={isVisibleSftpPanel ? activeTerminalSessionIdForSftp : null}

View File

@@ -24,6 +24,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs"); const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs"); const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs"); const passphraseHandler = require("./passphraseHandler.cjs");
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
const tempDirBridge = require("./tempDirBridge.cjs"); const tempDirBridge = require("./tempDirBridge.cjs");
const { createProxySocket } = require("./proxyUtils.cjs"); const { createProxySocket } = require("./proxyUtils.cjs");
const { const {
@@ -888,6 +889,7 @@ const openConnectionApi = createOpenConnectionApi({
get sessions() { return sessions; }, get sessions() { return sessions; },
get electronModule() { return electronModule; }, get electronModule() { return electronModule; },
jumpConnectionsMap, SftpClient, SSHClient, NetcattyAgent, keyboardInteractiveHandler, passphraseHandler, jumpConnectionsMap, SftpClient, SSHClient, NetcattyAgent, keyboardInteractiveHandler, passphraseHandler,
hostKeyVerifier,
fs, path, net, Buffer, process, console, setTimeout, clearTimeout, fs, path, net, Buffer, process, console, setTimeout, clearTimeout,
SFTPWrapper, createProxySocket, buildSftpAlgorithms, getAvailableAgentSocket, SFTPWrapper, createProxySocket, buildSftpAlgorithms, getAvailableAgentSocket,
preparePrivateKeyForAuth, loadFirstIdentityFileForAuth, findAllDefaultPrivateKeysFromHelper, preparePrivateKeyForAuth, loadFirstIdentityFileForAuth, findAllDefaultPrivateKeysFromHelper,

View File

@@ -0,0 +1,313 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
function makeRawPublicKey(keyType, body) {
const type = Buffer.from(keyType);
const length = Buffer.alloc(4);
length.writeUInt32BE(type.length, 0);
return Buffer.concat([length, type, Buffer.from(body)]);
}
function makeKnownHost(id, hostname, rawKey) {
return {
id,
hostname,
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint: crypto.createHash("sha256")
.update(rawKey)
.digest("base64")
.replace(/=+$/g, ""),
discoveredAt: 1,
};
}
function loadSftpBridgeWithMockedClients(t) {
const bridgePath = require.resolve("./sftpBridge.cjs");
const originalLoad = Module._load;
class MockJumpClient extends EventEmitter {
constructor() {
super();
MockJumpClient.instances.push(this);
this.connectOpts = null;
this.ended = false;
this.hostVerifierCalls = 0;
}
connect(opts) {
this.connectOpts = opts;
const rawKey = MockJumpClient.hostKeysByHost.get(opts.host) || MockJumpClient.defaultHostKey;
setImmediate(() => {
const accept = () => {
this.emit("handshake");
this.emit("ready");
};
if (typeof opts.hostVerifier !== "function") {
accept();
return;
}
this.hostVerifierCalls += 1;
opts.hostVerifier(rawKey, (accepted) => {
if (accepted) {
accept();
return;
}
const err = new Error(`Host key rejected for ${opts.host || "tunneled host"}`);
err.level = "client-socket";
this.emit("error", err);
});
});
}
forwardOut(_srcIP, _srcPort, _dstHost, _dstPort, cb) {
const stream = new EventEmitter();
stream.end = () => {};
stream.destroy = () => {};
setImmediate(() => cb(null, stream));
}
end() {
this.ended = true;
}
destroy() {
this.ended = true;
}
}
MockJumpClient.instances = [];
MockJumpClient.hostKeysByHost = new Map();
MockJumpClient.defaultHostKey = makeRawPublicKey("ssh-ed25519", "default untrusted jump key");
class MockSftpClient extends EventEmitter {
constructor() {
super();
MockSftpClient.instances.push(this);
this.hostVerifierCalls = 0;
this.client = new EventEmitter();
this.client.setMaxListeners = () => {};
this.client.connectOpts = null;
this.client.connect = (opts) => {
this.client.connectOpts = opts;
const rawKey = MockSftpClient.hostKeysByHost.get(opts.host)
|| MockSftpClient.tunneledHostKey
|| MockSftpClient.defaultHostKey;
setImmediate(() => {
const accept = () => {
this.client.emit("handshake");
this.client.emit("ready");
};
if (typeof opts.hostVerifier !== "function") {
accept();
return;
}
this.hostVerifierCalls += 1;
opts.hostVerifier(rawKey, (accepted) => {
if (accepted) {
accept();
return;
}
const err = new Error(`Host key rejected for ${opts.host || "tunneled host"}`);
err.level = "client-socket";
this.client.emit("error", err);
});
});
};
this.client.sftp = (cb) => {
setImmediate(() => cb(null, new EventEmitter()));
};
this.client.end = () => {};
this.client.destroy = () => {};
}
end() {}
}
MockSftpClient.instances = [];
MockSftpClient.hostKeysByHost = new Map();
MockSftpClient.defaultHostKey = makeRawPublicKey("ssh-ed25519", "default untrusted target key");
MockSftpClient.tunneledHostKey = null;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2") {
return {
Client: MockJumpClient,
utils: { parseKey: () => new Error("no key") },
};
}
if (request === "ssh2-sftp-client") {
return MockSftpClient;
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
const bridge = require("./sftpBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
Module._load = originalLoad;
});
return { bridge, MockJumpClient, MockSftpClient };
}
function makeSender({ rejectHostKeyPrompts = false } = {}) {
return {
id: 1,
isDestroyed: () => false,
sent: [],
send(channel, payload) {
this.sent.push({ channel, payload });
if (rejectHostKeyPrompts && channel === "netcatty:host-key:verify") {
const { handleResponse } = require("./hostKeyVerifier.cjs");
queueMicrotask(() => {
handleResponse(null, {
requestId: payload.requestId,
accept: false,
});
});
}
},
};
}
test("SFTP direct connections verify target host keys against known hosts", async (t) => {
const { bridge, MockSftpClient } = loadSftpBridgeWithMockedClients(t);
const sender = makeSender();
const rawTargetKey = makeRawPublicKey("ssh-ed25519", "trusted sftp target key");
MockSftpClient.hostKeysByHost.set("target.example.com", rawTargetKey);
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
await bridge.openSftp(
{ sender },
{
sessionId: "sftp-direct-host-key",
hostname: "target.example.com",
port: 22,
username: "alice",
knownHosts: [makeKnownHost("kh-target", "target.example.com", rawTargetKey)],
},
);
const connectOpts = MockSftpClient.instances[0].client.connectOpts;
assert.equal(typeof connectOpts.hostVerifier, "function");
assert.equal(MockSftpClient.instances[0].hostVerifierCalls, 1);
assert.deepEqual(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify"),
[],
);
});
test("SFTP jump-host chains verify hop and target host keys against known hosts", async (t) => {
const { bridge, MockJumpClient, MockSftpClient } = loadSftpBridgeWithMockedClients(t);
const sender = makeSender();
const rawJumpKey = makeRawPublicKey("ssh-ed25519", "trusted sftp jump key");
const rawTargetKey = makeRawPublicKey("ssh-ed25519", "trusted sftp target key");
MockJumpClient.hostKeysByHost.set("bastion.example.com", rawJumpKey);
MockSftpClient.tunneledHostKey = rawTargetKey;
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
await bridge.openSftp(
{ sender },
{
sessionId: "sftp-chain-host-key",
hostname: "target.example.com",
port: 22,
username: "alice",
knownHosts: [
makeKnownHost("kh-jump", "bastion.example.com", rawJumpKey),
makeKnownHost("kh-target", "target.example.com", rawTargetKey),
],
jumpHosts: [{
hostname: "bastion.example.com",
port: 22,
username: "jump",
password: "secret",
label: "Bastion",
}],
},
);
const jumpConnectOpts = MockJumpClient.instances[0].connectOpts;
assert.equal(typeof jumpConnectOpts.hostVerifier, "function");
assert.equal(MockJumpClient.instances[0].hostVerifierCalls, 1);
const targetConnectOpts = MockSftpClient.instances[0].client.connectOpts;
assert.equal(typeof targetConnectOpts.hostVerifier, "function");
assert.equal(MockSftpClient.instances[0].hostVerifierCalls, 1);
assert.deepEqual(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify"),
[],
);
});
test("SFTP direct connections stop when target host keys are rejected", async (t) => {
const { bridge, MockSftpClient } = loadSftpBridgeWithMockedClients(t);
const sender = makeSender({ rejectHostKeyPrompts: true });
MockSftpClient.hostKeysByHost.set(
"target.example.com",
makeRawPublicKey("ssh-ed25519", "unknown sftp target key"),
);
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
await assert.rejects(
bridge.openSftp(
{ sender },
{
sessionId: "sftp-direct-host-key-rejected",
hostname: "target.example.com",
port: 22,
username: "alice",
knownHosts: [],
},
),
/Host key rejected/,
);
assert.equal(MockSftpClient.instances[0].hostVerifierCalls, 1);
assert.equal(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify").length,
1,
);
});
test("SFTP jump-host chains stop when hop host keys are rejected", async (t) => {
const { bridge, MockJumpClient } = loadSftpBridgeWithMockedClients(t);
const sender = makeSender({ rejectHostKeyPrompts: true });
MockJumpClient.hostKeysByHost.set(
"bastion.example.com",
makeRawPublicKey("ssh-ed25519", "unknown sftp jump key"),
);
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
await assert.rejects(
bridge.openSftp(
{ sender },
{
sessionId: "sftp-chain-host-key-rejected",
hostname: "target.example.com",
port: 22,
username: "alice",
knownHosts: [],
jumpHosts: [{
hostname: "bastion.example.com",
port: 22,
username: "jump",
password: "secret",
label: "Bastion",
}],
},
),
/Host key rejected/,
);
assert.equal(MockJumpClient.instances[0].hostVerifierCalls, 1);
assert.equal(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify").length,
1,
);
});

View File

@@ -81,6 +81,13 @@ function createOpenConnectionApi(ctx) {
}, },
), ),
}; };
connOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender,
sessionId: connId,
hostname: jump.hostname,
port: jump.port || 22,
knownHosts: options.knownHosts,
});
// Auth - support agent (certificate), key, and password fallback // Auth - support agent (certificate), key, and password fallback
const hasCertificate = const hasCertificate =
@@ -640,6 +647,13 @@ function createOpenConnectionApi(ctx) {
algorithmOverrides: options.algorithmOverrides, algorithmOverrides: options.algorithmOverrides,
}), }),
}; };
connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender: event.sender,
sessionId: connId,
hostname: options.hostname,
port: options.port || 22,
knownHosts: options.knownHosts,
});
// Use the tunneled socket if we have one // Use the tunneled socket if we have one
if (connectionSocket) { if (connectionSocket) {

View File

@@ -545,6 +545,13 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
}, },
), ),
}; };
connOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender,
sessionId,
hostname: jump.hostname,
port: jump.port || 22,
knownHosts: options.knownHosts,
});
attachSshDebugLogger(connOpts, sshDiagnosticLogger); attachSshDebugLogger(connOpts, sshDiagnosticLogger);
logSshAlgorithms("Jump host", connOpts.algorithms, { logSshAlgorithms("Jump host", connOpts.algorithms, {
hostname: jump.hostname, hostname: jump.hostname,

View File

@@ -0,0 +1,196 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
function makeRawPublicKey(keyType, body = "trusted jump host key") {
const type = Buffer.from(keyType);
const length = Buffer.alloc(4);
length.writeUInt32BE(type.length, 0);
return Buffer.concat([length, type, Buffer.from(body)]);
}
function loadBridgeWithMockedSsh2(t) {
const bridgePath = require.resolve("./sshBridge.cjs");
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
const originalLoad = Module._load;
class MockSSHClient extends EventEmitter {
constructor() {
super();
MockSSHClient.instances.push(this);
this.ended = false;
this.connectOpts = null;
this.hostVerifierCalls = 0;
}
connect(opts) {
this.connectOpts = opts;
const rawKey = MockSSHClient.hostKeysByHost.get(opts.host) || MockSSHClient.defaultHostKey;
setImmediate(() => {
const accept = () => {
this.emit("connect");
this.emit("handshake");
this.emit("ready");
};
if (typeof opts.hostVerifier !== "function") {
accept();
return;
}
this.hostVerifierCalls += 1;
opts.hostVerifier(rawKey, (accepted) => {
if (accepted) {
accept();
return;
}
const err = new Error(`Host key rejected for ${opts.host || "tunneled host"}`);
err.level = "client-socket";
this.emit("error", err);
});
});
}
forwardOut(_srcIP, _srcPort, _dstHost, _dstPort, cb) {
const stream = new EventEmitter();
stream.destroy = () => {};
setImmediate(() => cb(null, stream));
}
end() {
this.ended = true;
}
destroy() {
this.ended = true;
}
}
MockSSHClient.instances = [];
MockSSHClient.hostKeysByHost = new Map();
MockSSHClient.defaultHostKey = makeRawPublicKey("ssh-ed25519", "default untrusted key");
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2") {
return {
Client: MockSSHClient,
utils: { parseKey: () => new Error("no key") },
};
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
const bridge = require("./sshBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
Module._load = originalLoad;
});
return { bridge, MockSSHClient };
}
function makeSender({ rejectHostKeyPrompts = false } = {}) {
return {
id: 1,
isDestroyed: () => false,
sent: [],
send(channel, payload) {
this.sent.push({ channel, payload });
if (rejectHostKeyPrompts && channel === "netcatty:host-key:verify") {
const { handleResponse } = require("./hostKeyVerifier.cjs");
queueMicrotask(() => {
handleResponse(null, {
requestId: payload.requestId,
accept: false,
});
});
}
},
};
}
test("jump-host chain connections verify hop host keys against known hosts", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
const sender = makeSender();
const rawKey = makeRawPublicKey("ssh-ed25519");
MockSSHClient.hostKeysByHost.set("bastion.example.com", rawKey);
const fingerprint = crypto.createHash("sha256")
.update(rawKey)
.digest("base64")
.replace(/=+$/g, "");
await bridge.connectThroughChain(
{ sender },
{
knownHosts: [{
id: "kh-jump",
hostname: "bastion.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint,
discoveredAt: 1,
}],
_defaultKeys: [],
},
[{
hostname: "bastion.example.com",
port: 22,
username: "alice",
password: "secret",
label: "Bastion",
}],
"target.example.com",
22,
"session-1",
);
assert.equal(MockSSHClient.instances.length, 1);
const connectOpts = MockSSHClient.instances[0].connectOpts;
assert.equal(typeof connectOpts.hostVerifier, "function");
assert.equal(MockSSHClient.instances[0].hostVerifierCalls, 1);
assert.deepEqual(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify"),
[],
);
});
test("jump-host chain connections stop when hop host keys are rejected", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
const sender = makeSender({ rejectHostKeyPrompts: true });
MockSSHClient.hostKeysByHost.set(
"bastion.example.com",
makeRawPublicKey("ssh-ed25519", "unknown jump host key"),
);
await assert.rejects(
bridge.connectThroughChain(
{ sender },
{
knownHosts: [],
_defaultKeys: [],
},
[{
hostname: "bastion.example.com",
port: 22,
username: "alice",
password: "secret",
label: "Bastion",
}],
"target.example.com",
22,
"session-1",
),
/Host key rejected/,
);
assert.equal(MockSSHClient.instances.length, 1);
assert.equal(MockSSHClient.instances[0].hostVerifierCalls, 1);
assert.equal(
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify").length,
1,
);
});