[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);
|
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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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
|
// 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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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