[codex] Fix known host fingerprint coverage (#1442)
* Fix known host fingerprint coverage * Tighten SFTP host key verification
This commit is contained in:
2
App.tsx
2
App.tsx
@@ -194,7 +194,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keysRef = useRef(keys);
|
||||
keysRef.current = keys;
|
||||
const knownHostsRef = useRef(knownHosts);
|
||||
knownHostsRef.current = knownHosts;
|
||||
// Bridge the gap while useVaultState hydrates: its async init awaits
|
||||
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
|
||||
// so the state is briefly [] at boot even when localStorage has entries.
|
||||
@@ -205,6 +204,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
knownHostsRef.current = effectiveKnownHosts;
|
||||
|
||||
const {
|
||||
sessions,
|
||||
|
||||
@@ -214,9 +214,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface SftpPane {
|
||||
id: string;
|
||||
@@ -15,6 +15,22 @@ export interface SftpPane {
|
||||
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
|
||||
export interface SftpSideTabs {
|
||||
tabs: SftpPane[];
|
||||
@@ -70,4 +86,6 @@ export interface SftpStateOptions {
|
||||
* is honored for SFTP browsing too (not just the terminal session).
|
||||
*/
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
}
|
||||
|
||||
@@ -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 { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
@@ -12,6 +12,8 @@ interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
leftTabsRef: 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>;
|
||||
listLocalFiles: (path: string) => 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 = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
@@ -76,8 +114,76 @@ export const useSftpConnections = ({
|
||||
createEmptyPane,
|
||||
autoConnectLocalOnMount = true,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
|
||||
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(
|
||||
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
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
@@ -336,7 +443,7 @@ export const useSftpConnections = ({
|
||||
if (hasKey) {
|
||||
try {
|
||||
const keyFirstCredentials = {
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
};
|
||||
@@ -347,7 +454,7 @@ export const useSftpConnections = ({
|
||||
} catch (err) {
|
||||
if (hasPassword && isAuthError(err)) {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
privateKey: undefined,
|
||||
@@ -363,7 +470,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
} else {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
});
|
||||
@@ -544,6 +651,10 @@ export const useSftpConnections = ({
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
activeHostKeySessionsRef.current.delete(sftpSessionId);
|
||||
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
|
||||
setPendingHostKeyVerification(null);
|
||||
}
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
@@ -558,6 +669,7 @@ export const useSftpConnections = ({
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
setPendingHostKeyVerification,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -643,5 +755,9 @@ export const useSftpConnections = ({
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
hostKeyVerification,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
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 => ({
|
||||
id: "host-1",
|
||||
@@ -102,6 +102,28 @@ test("buildSftpHostCredentials passes reference keys as identity file paths", ()
|
||||
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", () => {
|
||||
const key: SSHKey = {
|
||||
id: "jump-key",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
@@ -14,6 +14,7 @@ interface UseSftpHostCredentialsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export const buildSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
|
||||
@@ -165,6 +167,7 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
knownHosts,
|
||||
// Algorithm settings — must reach the SFTP bridge or hosts that need
|
||||
// legacy mode / the ECDSA skip / advanced overrides would still hit
|
||||
// the original negotiation failure when opening their SFTP pane,
|
||||
@@ -179,9 +182,10 @@ export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
|
||||
[hosts, identities, keys, terminalSettings],
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, knownHosts, terminalSettings }),
|
||||
[hosts, identities, keys, knownHosts, terminalSettings],
|
||||
);
|
||||
|
||||
@@ -170,10 +170,21 @@ export const useSftpState = (
|
||||
useSftpSessionCleanup(sftpSessionsRef);
|
||||
useSftpFileWatch(options);
|
||||
|
||||
const { connect, disconnect, listLocalFiles, listRemoteFiles } = useSftpConnections({
|
||||
const {
|
||||
connect,
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
hostKeyVerification,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
} = useSftpConnections({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts: options?.knownHosts,
|
||||
onAddKnownHost: options?.onAddKnownHost,
|
||||
terminalSettings: options?.terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
@@ -402,6 +413,9 @@ export const useSftpState = (
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
});
|
||||
methodsRef.current = {
|
||||
getFilteredFiles,
|
||||
@@ -460,6 +474,9 @@ export const useSftpState = (
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
};
|
||||
|
||||
// Create stable method wrappers that call through methodsRef
|
||||
@@ -532,6 +549,9 @@ export const useSftpState = (
|
||||
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...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 is a stable ref
|
||||
|
||||
@@ -546,6 +566,7 @@ export const useSftpState = (
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
|
||||
// Stable methods - never change reference
|
||||
...stableMethods,
|
||||
@@ -566,6 +587,7 @@ export const useSftpState = (
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
stableMethods,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ import { getParentPath, isConcreteTransferTargetPath } from "../application/stat
|
||||
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
|
||||
import { logger } from "../lib/logger";
|
||||
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 { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -47,7 +47,9 @@ interface SftpSidePanelProps {
|
||||
writableHosts?: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
@@ -87,7 +89,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
writableHosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
updateHosts,
|
||||
onAddKnownHost,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
@@ -134,7 +138,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
autoConnectLocalOnMount: false,
|
||||
terminalSettings,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
const {
|
||||
@@ -964,7 +970,9 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.writableHosts === next.writableHosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
prev.onAddKnownHost === next.onAddKnownHost &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.activeHost === next.activeHost &&
|
||||
prev.activeSessionId === next.activeSessionId &&
|
||||
|
||||
@@ -24,7 +24,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
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 { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
@@ -54,9 +54,11 @@ interface SftpViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
groupConfigs?: import('../domain/models').GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
@@ -73,9 +75,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
updateHosts,
|
||||
onAddKnownHost,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
@@ -110,7 +114,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
terminalSettings,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() => {
|
||||
@@ -612,9 +618,11 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.proxyProfiles === next.proxyProfiles &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.onAddKnownHost === next.onAddKnownHost &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
|
||||
@@ -7,9 +7,11 @@ import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWin
|
||||
import { useVaultState } from '../application/state/useVaultState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { upsertKnownHost } from '../domain/knownHosts';
|
||||
import type { TerminalPopupPayload } from '../domain/systemManager/types';
|
||||
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';
|
||||
|
||||
const Terminal = lazy(() => import('./Terminal'));
|
||||
@@ -195,11 +197,22 @@ function TerminalPopupPageInner() {
|
||||
const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow();
|
||||
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
|
||||
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 [terminalReady, setTerminalReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
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(
|
||||
() => buildPopupThemeVars(settings.currentTerminalTheme),
|
||||
[settings.currentTerminalTheme],
|
||||
@@ -307,7 +320,8 @@ function TerminalPopupPageInner() {
|
||||
snippetPackages={snippetPackages}
|
||||
compactToolbar
|
||||
lineTimestampsAvailable={false}
|
||||
knownHosts={knownHosts}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
isVisible
|
||||
isFocused
|
||||
fontFamilyId={settings.terminalFontFamilyId}
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { TransferTask } from "../../types";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import type { TextEditorModalSnapshot } from "../TextEditorModal";
|
||||
import { TerminalHostKeyVerification } from "../terminal/TerminalHostKeyVerification";
|
||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
|
||||
import { SftpConflictDialog } from "./SftpConflictDialog";
|
||||
import { SftpHostPicker } from "./SftpHostPicker";
|
||||
import { SftpPermissionsDialog } from "./SftpPermissionsDialog";
|
||||
@@ -139,6 +141,27 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
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
|
||||
open={!!permissionsState}
|
||||
onOpenChange={(open) => !open && setPermissionsState(null)}
|
||||
|
||||
@@ -89,6 +89,7 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleCloseSidePanel,
|
||||
handleHistoryPaste,
|
||||
handleHistoryRun,
|
||||
handleAddKnownHost,
|
||||
handleOpenHistory,
|
||||
handleFontFamilyChangeForFocusedSession,
|
||||
handleFontFamilyResetForFocusedSession,
|
||||
@@ -116,6 +117,7 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
|
||||
identities,
|
||||
keyBindings,
|
||||
keys,
|
||||
knownHosts,
|
||||
mountedAiTabIds,
|
||||
mountedSftpTabIds,
|
||||
scriptsMountedTabIds,
|
||||
@@ -460,7 +462,9 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
|
||||
writableHosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
knownHosts={knownHosts}
|
||||
updateHosts={updateHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={panelActiveHost}
|
||||
activeSessionId={isVisibleSftpPanel ? activeTerminalSessionIdForSftp : null}
|
||||
|
||||
@@ -24,6 +24,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
@@ -888,6 +889,7 @@ const openConnectionApi = createOpenConnectionApi({
|
||||
get sessions() { return sessions; },
|
||||
get electronModule() { return electronModule; },
|
||||
jumpConnectionsMap, SftpClient, SSHClient, NetcattyAgent, keyboardInteractiveHandler, passphraseHandler,
|
||||
hostKeyVerifier,
|
||||
fs, path, net, Buffer, process, console, setTimeout, clearTimeout,
|
||||
SFTPWrapper, createProxySocket, buildSftpAlgorithms, getAvailableAgentSocket,
|
||||
preparePrivateKeyForAuth, loadFirstIdentityFileForAuth, findAllDefaultPrivateKeysFromHelper,
|
||||
|
||||
313
electron/bridges/sftpBridge.hostKeyVerification.test.cjs
Normal file
313
electron/bridges/sftpBridge.hostKeyVerification.test.cjs
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
const hasCertificate =
|
||||
@@ -640,6 +647,13 @@ function createOpenConnectionApi(ctx) {
|
||||
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
|
||||
if (connectionSocket) {
|
||||
|
||||
@@ -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);
|
||||
logSshAlgorithms("Jump host", connOpts.algorithms, {
|
||||
hostname: jump.hostname,
|
||||
|
||||
196
electron/bridges/sshBridge.hostKeyChain.test.cjs
Normal file
196
electron/bridges/sshBridge.hostKeyChain.test.cjs
Normal 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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user