diff --git a/App.tsx b/App.tsx index de680c1d..6a5ea0d5 100755 --- a/App.tsx +++ b/App.tsx @@ -2077,7 +2077,16 @@ function App({ settings }: { settings: SettingsState }) { onUpdateSessionStatus={handleSessionStatusChange} onUpdateHostDistro={updateHostDistro} onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))} - onAddKnownHost={(kh) => updateKnownHosts([...knownHosts, kh])} + onAddKnownHost={(kh) => updateKnownHosts([ + ...knownHosts.filter((existing) => + !( + existing.hostname.toLowerCase() === kh.hostname.toLowerCase() && + existing.port === kh.port && + existing.keyType === kh.keyType + ) + ), + kh, + ])} onCommandExecuted={(command, hostId, hostLabel, sessionId) => { addShellHistoryEntry({ command, hostId, hostLabel, sessionId }); }} diff --git a/application/state/useTerminalBackend.ts b/application/state/useTerminalBackend.ts index aec7d438..668633ea 100644 --- a/application/state/useTerminalBackend.ts +++ b/application/state/useTerminalBackend.ts @@ -111,6 +111,23 @@ export const useTerminalBackend = () => { return bridge?.onChainProgress?.(cb); }, []); + const onHostKeyVerification = useCallback((cb: Parameters>[0]) => { + const bridge = netcattyBridge.get(); + return bridge?.onHostKeyVerification?.(cb); + }, []); + + const respondHostKeyVerification = useCallback(async ( + requestId: string, + accept: boolean, + addToKnownHosts?: boolean, + ) => { + const bridge = netcattyBridge.get(); + if (!bridge?.respondHostKeyVerification) { + return { success: false, error: "respondHostKeyVerification unavailable" }; + } + return bridge.respondHostKeyVerification(requestId, accept, addToKnownHosts); + }, []); + const openExternal = useCallback(async (url: string) => { const bridge = netcattyBridge.get(); await bridge?.openExternal?.(url); @@ -188,6 +205,8 @@ export const useTerminalBackend = () => { onTelnetAutoLoginComplete, onTelnetAutoLoginCancelled, onChainProgress, + onHostKeyVerification, + respondHostKeyVerification, openExternal, }; }; diff --git a/components/KnownHostConfirmDialog.tsx b/components/KnownHostConfirmDialog.tsx index 5eef43fe..010448fa 100644 --- a/components/KnownHostConfirmDialog.tsx +++ b/components/KnownHostConfirmDialog.tsx @@ -10,6 +10,8 @@ export interface HostKeyInfo { keyType: string; // ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256, etc. fingerprint: string; // SHA256 fingerprint publicKey?: string; // Full public key + status?: 'unknown' | 'changed'; + knownFingerprint?: string; } interface KnownHostConfirmDialogProps { @@ -27,6 +29,8 @@ const KnownHostConfirmDialog: React.FC = ({ onContinue, onAddAndContinue, }) => { + const isChanged = hostKeyInfo.status === 'changed'; + return (
{/* Header with host info */} @@ -60,11 +64,19 @@ const KnownHostConfirmDialog: React.FC = ({ {/* Warning message */}
-

- Are you sure you want to connect? +

+ {isChanged ? 'Host key has changed' : 'Are you sure you want to connect?'}

- The authenticity of {hostKeyInfo.hostname} can not be established. + {isChanged ? ( + <> + The saved key for {hostKeyInfo.hostname} no longer matches this server. + + ) : ( + <> + The authenticity of {hostKeyInfo.hostname} can not be established. + + )}

@@ -78,8 +90,18 @@ const KnownHostConfirmDialog: React.FC = ({ {hostKeyInfo.fingerprint}
+ {isChanged && hostKeyInfo.knownFingerprint && ( +
+

Saved fingerprint

+ + {hostKeyInfo.knownFingerprint} + +
+ )}

- Do you want to add it to the list of known hosts? + {isChanged + ? 'Only continue if you expected this host to change.' + : 'Do you want to add it to the list of known hosts?'}

@@ -103,7 +125,7 @@ const KnownHostConfirmDialog: React.FC = ({ className="min-w-[140px]" onClick={onAddAndContinue} > - Add and continue + {isChanged ? 'Update and continue' : 'Add and continue'} diff --git a/components/KnownHostsManager.tsx b/components/KnownHostsManager.tsx index a5e845ff..d3721554 100644 --- a/components/KnownHostsManager.tsx +++ b/components/KnownHostsManager.tsx @@ -84,7 +84,7 @@ const parseKnownHostsFile = (content: string): KnownHost[] => { hostname, port, keyType, - publicKey: publicKey.slice(0, 64) + "...", + publicKey: `${keyType} ${publicKey}`, discoveredAt: Date.now(), }); } catch { diff --git a/components/Terminal.tsx b/components/Terminal.tsx index 901dfdb9..95223e92 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -218,7 +218,7 @@ const TerminalComponent: React.FC = ({ snippets, chainHosts = [], themePreviewId, - knownHosts: _knownHosts = [], + knownHosts = [], isVisible, inWorkspace, isResizing, @@ -639,6 +639,7 @@ const TerminalComponent: React.FC = ({ const [needsHostKeyVerification, setNeedsHostKeyVerification] = useState(false); const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState(null); + const [pendingHostKeyRequestId, setPendingHostKeyRequestId] = useState(null); const pendingConnectionRef = useRef<(() => void) | null>(null); // OSC-52 clipboard read prompt @@ -662,6 +663,35 @@ const TerminalComponent: React.FC = ({ termRef.current?.focus(); }, []); + useEffect(() => { + const dispose = terminalBackend.onHostKeyVerification?.((request) => { + if (request.sessionId !== sessionId) return; + + setPendingHostKeyRequestId(request.requestId); + setPendingHostKeyInfo({ + hostname: request.hostname, + port: request.port, + keyType: request.keyType, + fingerprint: request.fingerprint, + publicKey: request.publicKey, + status: request.status, + knownFingerprint: request.knownFingerprint, + }); + setNeedsHostKeyVerification(true); + setError(null); + setProgressLogs((prev) => [ + ...prev, + request.status === 'changed' + ? `Host key changed for ${request.hostname}. Waiting for confirmation...` + : `Host key verification required for ${request.hostname}.`, + ]); + }); + + return () => { + dispose?.(); + }; + }, [sessionId, terminalBackend.onHostKeyVerification]); + const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return; @@ -755,6 +785,7 @@ const TerminalComponent: React.FC = ({ host, keys, identities, + knownHosts, resolvedChainHosts, sessionId, startupCommand, @@ -1493,12 +1524,16 @@ const TerminalComponent: React.FC = ({ }; const handleCancelConnect = () => { + if (pendingHostKeyRequestId) { + void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false); + } retryTokenRef.current = null; setIsCancelling(true); auth.setNeedsAuth(false); auth.setAuthRetryMessage(null); setNeedsHostKeyVerification(false); setPendingHostKeyInfo(null); + setPendingHostKeyRequestId(null); setError("Connection cancelled"); setProgressLogs((prev) => [...prev, "Cancelled by user."]); cleanupSession(); @@ -1520,16 +1555,21 @@ const TerminalComponent: React.FC = ({ const handleHostKeyClose = () => { setNeedsHostKeyVerification(false); setPendingHostKeyInfo(null); + setPendingHostKeyRequestId(null); handleCancelConnect(); }; const handleHostKeyContinue = () => { + if (pendingHostKeyRequestId) { + void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, false); + } setNeedsHostKeyVerification(false); if (pendingConnectionRef.current) { pendingConnectionRef.current(); pendingConnectionRef.current = null; } setPendingHostKeyInfo(null); + setPendingHostKeyRequestId(null); }; const handleHostKeyAddAndContinue = () => { @@ -1539,17 +1579,22 @@ const TerminalComponent: React.FC = ({ hostname: pendingHostKeyInfo.hostname, port: pendingHostKeyInfo.port || host.port || 22, keyType: pendingHostKeyInfo.keyType, - publicKey: pendingHostKeyInfo.fingerprint, + publicKey: pendingHostKeyInfo.publicKey || `SHA256:${pendingHostKeyInfo.fingerprint}`, + fingerprint: pendingHostKeyInfo.fingerprint, discoveredAt: Date.now(), }; onAddKnownHost(newKnownHost); } + if (pendingHostKeyRequestId) { + void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, true); + } setNeedsHostKeyVerification(false); if (pendingConnectionRef.current) { pendingConnectionRef.current(); pendingConnectionRef.current = null; } setPendingHostKeyInfo(null); + setPendingHostKeyRequestId(null); }; const handleRetry = () => { diff --git a/components/terminal/runtime/createTerminalSessionStarters.test.ts b/components/terminal/runtime/createTerminalSessionStarters.test.ts index 78704a72..9f03bd7f 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.test.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.test.ts @@ -189,6 +189,88 @@ test("startSSH omits identity file paths when password auth is selected", async assert.equal(capturedOptions.identityFilePaths, undefined); }); +test("startSSH passes known host records to the SSH bridge", async () => { + let capturedOptions: Record | null = null; + const knownHosts = [{ + id: "kh-1", + hostname: "target.example.test", + port: 22, + keyType: "ssh-ed25519", + publicKey: "SHA256:trusted-key", + discoveredAt: 1, + }]; + + const terminalBackend = { + backendAvailable: () => true, + telnetAvailable: () => true, + moshAvailable: () => true, + localAvailable: () => true, + serialAvailable: () => true, + execAvailable: () => true, + startSSHSession: async (options: Record) => { + capturedOptions = options; + return "ssh-session"; + }, + startTelnetSession: async () => "telnet-session", + startMoshSession: async () => "mosh-session", + startLocalSession: async () => "local-session", + startSerialSession: async () => "serial-session", + execCommand: async () => ({}), + onSessionData: () => noop, + onSessionExit: () => noop, + onChainProgress: () => noop, + writeToSession: noop, + resizeSession: noop, + }; + + const ctx = { + host: { + id: "host-1", + label: "Target", + hostname: "target.example.test", + username: "alice", + authMethod: "password", + password: "secret", + }, + keys: [], + knownHosts, + resolvedChainHosts: [], + sessionId: "session-1", + terminalSettings: {}, + terminalBackend, + sessionRef: { current: null }, + hasConnectedRef: { current: false }, + hasRunStartupCommandRef: { current: false }, + disposeDataRef: { current: null }, + disposeExitRef: { current: null }, + fitAddonRef: { current: null }, + serializeAddonRef: { current: null }, + pendingAuthRef: { current: null }, + updateStatus: noop, + setStatus: noop, + setError: noop, + setNeedsAuth: noop, + setAuthRetryMessage: noop, + setAuthPassword: noop, + setProgressLogs: noop, + setProgressValue: noop, + setChainProgress: noop, + }; + + const term = { + cols: 120, + rows: 32, + write: noop, + writeln: noop, + scrollToBottom: noop, + }; + + await createTerminalSessionStarters(ctx as never).startSSH(term as never); + + assert.ok(capturedOptions); + assert.equal(capturedOptions.knownHosts, knownHosts); +}); + test("startSSH omits jump host identity file paths when password auth is selected", async () => { let capturedOptions: Record | null = null; diff --git a/components/terminal/runtime/createTerminalSessionStarters.ts b/components/terminal/runtime/createTerminalSessionStarters.ts index 973c91b2..a5dee3dc 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.ts @@ -4,7 +4,7 @@ import type { Terminal as XTerm } from "@xterm/xterm"; import type { Dispatch, RefObject, SetStateAction } from "react"; import { shouldScrollOnTerminalOutput } from "../../../domain/terminalScroll"; import { logger } from "../../../lib/logger"; -import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types"; +import type { Host, Identity, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types"; import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue, @@ -112,6 +112,7 @@ export type TerminalSessionStartersContext = { host: Host; keys: SSHKey[]; identities?: Identity[]; + knownHosts?: KnownHost[]; resolvedChainHosts: Host[]; sessionId: string; startupCommand?: string; @@ -689,6 +690,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex keepaliveInterval: ctx.terminalSettings?.keepaliveInterval, sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined, identityFilePaths: attempt.password ? undefined : targetIdentityFilePaths, + knownHosts: ctx.knownHosts, }); }; diff --git a/domain/models.ts b/domain/models.ts index 752efef3..9add8340 100755 --- a/domain/models.ts +++ b/domain/models.ts @@ -870,6 +870,7 @@ export interface KnownHost { port: number; keyType: string; // ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256, etc. publicKey: string; // The host's public key fingerprint or full key + fingerprint?: string; // SHA256 fingerprint without the SHA256: prefix discoveredAt: number; lastSeen?: number; convertedToHostId?: string; // If converted to managed host diff --git a/electron/bridges/hostKeyVerifier.cjs b/electron/bridges/hostKeyVerifier.cjs new file mode 100644 index 00000000..5f1ba723 --- /dev/null +++ b/electron/bridges/hostKeyVerifier.cjs @@ -0,0 +1,252 @@ +const crypto = require("node:crypto"); +const { randomUUID } = require("node:crypto"); +const { utils: sshUtils } = require("ssh2"); + +const REQUEST_TTL_MS = 2 * 60 * 1000; +const hostKeyRequests = new Map(); + +const normalizeFingerprint = (value) => { + if (typeof value !== "string") return ""; + return value + .trim() + .replace(/^SHA256:/i, "") + .replace(/=+$/g, ""); +}; + +const normalizeHostname = (value) => String(value || "").trim().toLowerCase(); + +const parseKnownHostPattern = (hostname) => { + const value = String(hostname || "").trim(); + if (!value) return { hostname: "", port: undefined }; + const first = value.split(",")[0]; + const bracketMatch = first.match(/^\[([^\]]+)\]:(\d+)$/); + if (bracketMatch) { + return { + hostname: normalizeHostname(bracketMatch[1]), + port: Number.parseInt(bracketMatch[2], 10), + }; + } + return { hostname: normalizeHostname(first), port: undefined }; +}; + +const getKnownHostPort = (knownHost) => { + const parsed = parseKnownHostPattern(knownHost?.hostname); + if (Number.isFinite(knownHost?.port)) return Number(knownHost.port); + if (Number.isFinite(parsed.port)) return Number(parsed.port); + return 22; +}; + +const matchesHostAndPort = (knownHost, hostname, port) => { + const parsed = parseKnownHostPattern(knownHost?.hostname); + if (!parsed.hostname || parsed.hostname === "(hashed)") return false; + return parsed.hostname === normalizeHostname(hostname) && getKnownHostPort(knownHost) === (port || 22); +}; + +const describeRawPublicKeyBlob = (key) => { + if (!Buffer.isBuffer(key) || key.length < 8) return null; + const typeLength = key.readUInt32BE(0); + if (typeLength <= 0 || typeLength > 128 || 4 + typeLength > key.length) return null; + + const keyType = key.subarray(4, 4 + typeLength).toString("ascii"); + if (!/^[A-Za-z0-9@._+-]+$/.test(keyType)) return null; + + return { + keyType, + publicKey: `${keyType} ${key.toString("base64")}`, + }; +}; + +const fingerprintFromPublicKey = (publicKey) => { + if (typeof publicKey !== "string") return ""; + const trimmed = publicKey.trim(); + if (!trimmed) return ""; + if (/^SHA256:/i.test(trimmed)) return normalizeFingerprint(trimmed); + + const parts = trimmed.split(/\s+/); + if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) { + try { + return crypto.createHash("sha256") + .update(Buffer.from(parts[1], "base64")) + .digest("base64") + .replace(/=+$/g, ""); + } catch { + return ""; + } + } + return normalizeFingerprint(trimmed); +}; + +const getKnownHostFingerprint = (knownHost) => { + return normalizeFingerprint(knownHost?.fingerprint) + || fingerprintFromPublicKey(knownHost?.publicKey); +}; + +const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, fingerprint }) => { + const normalizedFingerprint = normalizeFingerprint(fingerprint); + const candidates = Array.isArray(knownHosts) + ? knownHosts.filter((knownHost) => matchesHostAndPort(knownHost, hostname, port)) + : []; + + if (candidates.length === 0) { + return { status: "unknown" }; + } + + const comparableCandidates = candidates + .map((knownHost) => ({ + knownHost, + fingerprint: getKnownHostFingerprint(knownHost), + })) + .filter((entry) => entry.fingerprint); + + const match = comparableCandidates.find((entry) => entry.fingerprint === normalizedFingerprint); + if (match) { + return { status: "trusted", knownHost: match.knownHost }; + } + + const sameTypeMismatch = comparableCandidates.find((entry) => + !keyType || keyType === "unknown" || !entry.knownHost.keyType || entry.knownHost.keyType === keyType + ); + if (sameTypeMismatch) { + return { + status: "changed", + knownHost: sameTypeMismatch.knownHost, + expectedFingerprint: sameTypeMismatch.fingerprint, + }; + } + + return { status: "unknown" }; +}; + +const describeHostKey = (rawKey) => { + const key = Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || ""); + const fingerprint = crypto.createHash("sha256") + .update(key) + .digest("base64") + .replace(/=+$/g, ""); + let keyType = "unknown"; + let publicKey; + + const rawPublicKey = describeRawPublicKeyBlob(key); + if (rawPublicKey) { + keyType = rawPublicKey.keyType; + publicKey = rawPublicKey.publicKey; + } + + try { + const parsed = sshUtils.parseKey(key); + const parsedKey = Array.isArray(parsed) ? parsed[0] : parsed; + if (parsedKey && !(parsedKey instanceof Error)) { + keyType = parsedKey.type || keyType; + const publicSsh = parsedKey.getPublicSSH?.(); + if (publicSsh) publicKey = publicSsh.toString("utf8"); + } + } catch { + // Keep the fingerprint; key type/public key are best-effort metadata. + } + + return { keyType, fingerprint, publicKey }; +}; + +const generateRequestId = () => `hostkey-${randomUUID()}`; + +const settleRequest = (requestId, response) => { + const pending = hostKeyRequests.get(requestId); + if (!pending) return { success: false, error: "Request not found" }; + if (pending.timeoutId) clearTimeout(pending.timeoutId); + hostKeyRequests.delete(requestId); + pending.resolve(response); + return { success: true }; +}; + +const requestHostKeyVerification = (sender, info) => new Promise((resolve) => { + if (!sender || sender.isDestroyed?.()) { + resolve({ accept: false }); + return; + } + + const requestId = generateRequestId(); + const timeoutId = setTimeout(() => { + settleRequest(requestId, { accept: false, timeout: true }); + }, REQUEST_TTL_MS); + + hostKeyRequests.set(requestId, { + resolve, + timeoutId, + createdAt: Date.now(), + webContentsId: sender.id, + sessionId: info.sessionId, + }); + + try { + sender.send("netcatty:host-key:verify", { + requestId, + ...info, + }); + } catch { + settleRequest(requestId, { accept: false }); + } +}); + +const createHostVerifier = ({ + sender, + sessionId, + hostname, + port = 22, + knownHosts = [], +}) => (rawKey, callback) => { + const keyInfo = describeHostKey(rawKey); + const decision = classifyHostKey({ + knownHosts, + hostname, + port, + keyType: keyInfo.keyType, + fingerprint: keyInfo.fingerprint, + }); + + if (decision.status === "trusted") { + callback(true); + return; + } + + void requestHostKeyVerification(sender, { + sessionId, + hostname, + port, + status: decision.status, + keyType: keyInfo.keyType, + fingerprint: keyInfo.fingerprint, + publicKey: keyInfo.publicKey, + knownHostId: decision.knownHost?.id, + knownFingerprint: decision.expectedFingerprint, + }).then((response) => { + callback(Boolean(response?.accept)); + }).catch(() => { + callback(false); + }); +}; + +const handleResponse = (_event, payload) => { + const { requestId, accept, addToKnownHosts } = payload || {}; + return settleRequest(requestId, { + accept: Boolean(accept), + addToKnownHosts: Boolean(addToKnownHosts), + }); +}; + +const registerHandler = (ipcMain) => { + ipcMain.handle("netcatty:host-key:respond", handleResponse); +}; + +const getRequests = () => hostKeyRequests; + +module.exports = { + classifyHostKey, + createHostVerifier, + describeHostKey, + getKnownHostFingerprint, + handleResponse, + normalizeFingerprint, + registerHandler, + requestHostKeyVerification, + getRequests, +}; diff --git a/electron/bridges/hostKeyVerifier.test.cjs b/electron/bridges/hostKeyVerifier.test.cjs new file mode 100644 index 00000000..cddc0102 --- /dev/null +++ b/electron/bridges/hostKeyVerifier.test.cjs @@ -0,0 +1,232 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); + +const { + classifyHostKey, + createHostVerifier, + describeHostKey, + handleResponse, + normalizeFingerprint, +} = require("./hostKeyVerifier.cjs"); + +const makeRawPublicKey = (keyType, body = "trusted imported 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)]); +}; + +test("classifyHostKey prompts for unknown hosts", () => { + const result = classifyHostKey({ + knownHosts: [], + hostname: "switch.local", + port: 22, + keyType: "ssh-ed25519", + fingerprint: "new-key", + }); + + assert.equal(result.status, "unknown"); +}); + +test("classifyHostKey trusts a matching known host fingerprint", () => { + const result = classifyHostKey({ + knownHosts: [{ + id: "kh-1", + hostname: "switch.local", + port: 22, + keyType: "ssh-ed25519", + publicKey: "SHA256:trusted-key", + discoveredAt: 1, + }], + hostname: "switch.local", + port: 22, + keyType: "ssh-ed25519", + fingerprint: "trusted-key", + }); + + assert.equal(result.status, "trusted"); +}); + +test("classifyHostKey trusts a matching full known_hosts public key", () => { + const rawKey = makeRawPublicKey("ssh-ed25519"); + const fingerprint = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, ""); + const result = classifyHostKey({ + knownHosts: [{ + id: "kh-1", + hostname: "switch.local", + port: 22, + keyType: "ssh-ed25519", + publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`, + discoveredAt: 1, + }], + hostname: "switch.local", + port: 22, + keyType: "ssh-ed25519", + fingerprint, + }); + + assert.equal(result.status, "trusted"); +}); + +test("describeHostKey preserves the full public key from raw SSH key blobs", () => { + const rawKey = makeRawPublicKey("ssh-ed25519"); + const result = describeHostKey(rawKey); + + assert.equal(result.keyType, "ssh-ed25519"); + assert.equal(result.publicKey, `ssh-ed25519 ${rawKey.toString("base64")}`); +}); + +test("classifyHostKey warns when a known host fingerprint changes", () => { + const result = classifyHostKey({ + knownHosts: [{ + id: "kh-1", + hostname: "192.0.2.10", + port: 22, + keyType: "ssh-ed25519", + publicKey: "SHA256:old-key", + discoveredAt: 1, + }], + hostname: "192.0.2.10", + port: 22, + keyType: "ssh-ed25519", + fingerprint: "new-key", + }); + + assert.equal(result.status, "changed"); + assert.equal(result.knownHost?.id, "kh-1"); + assert.equal(result.expectedFingerprint, "old-key"); +}); + +test("classifyHostKey treats the same hostname on a different port as unknown", () => { + const result = classifyHostKey({ + knownHosts: [{ + id: "kh-1", + hostname: "switch.local", + port: 2222, + keyType: "ssh-ed25519", + publicKey: "SHA256:trusted-key", + discoveredAt: 1, + }], + hostname: "switch.local", + port: 22, + keyType: "ssh-ed25519", + fingerprint: "trusted-key", + }); + + assert.equal(result.status, "unknown"); +}); + +test("classifyHostKey treats unknown incoming key types as comparable for changed hosts", () => { + const result = classifyHostKey({ + knownHosts: [{ + id: "kh-1", + hostname: "switch.local", + port: 22, + keyType: "ssh-ed25519", + publicKey: "SHA256:trusted-key", + discoveredAt: 1, + }], + hostname: "switch.local", + port: 22, + keyType: "unknown", + fingerprint: "new-key", + }); + + assert.equal(result.status, "changed"); +}); + +test("normalizeFingerprint accepts SHA256-prefixed values", () => { + assert.equal(normalizeFingerprint("SHA256:abc123==="), "abc123"); +}); + +test("createHostVerifier accepts trusted host keys without prompting", async () => { + const rawKey = Buffer.from("trusted server key"); + const fingerprint = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, ""); + const sent = []; + const sender = { + id: 1, + isDestroyed: () => false, + send: (channel, payload) => sent.push({ channel, payload }), + }; + const verifier = createHostVerifier({ + sender, + sessionId: "session-1", + hostname: "switch.local", + port: 22, + knownHosts: [{ + id: "kh-1", + hostname: "switch.local", + port: 22, + keyType: "unknown", + publicKey: `SHA256:${fingerprint}`, + discoveredAt: 1, + }], + }); + + const accepted = await new Promise((resolve) => verifier(rawKey, resolve)); + + assert.equal(accepted, true); + assert.deepEqual(sent, []); +}); + +test("createHostVerifier accepts imported full known_hosts public keys without prompting", async () => { + const rawKey = makeRawPublicKey("ssh-ed25519"); + const sent = []; + const sender = { + id: 1, + isDestroyed: () => false, + send: (channel, payload) => sent.push({ channel, payload }), + }; + const verifier = createHostVerifier({ + sender, + sessionId: "session-1", + hostname: "switch.local", + port: 22, + knownHosts: [{ + id: "kh-1", + hostname: "switch.local", + port: 22, + keyType: "ssh-ed25519", + publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`, + discoveredAt: 1, + }], + }); + + const accepted = await new Promise((resolve) => verifier(rawKey, resolve)); + + assert.equal(accepted, true); + assert.deepEqual(sent, []); +}); + +test("createHostVerifier prompts for unknown host keys and waits for user response", async () => { + const rawKey = Buffer.from("new server key"); + const sent = []; + const sender = { + id: 1, + isDestroyed: () => false, + send: (channel, payload) => sent.push({ channel, payload }), + }; + const verifier = createHostVerifier({ + sender, + sessionId: "session-1", + hostname: "switch.local", + port: 22, + knownHosts: [], + }); + + const acceptedPromise = new Promise((resolve) => verifier(rawKey, resolve)); + + assert.equal(sent.length, 1); + assert.equal(sent[0].channel, "netcatty:host-key:verify"); + assert.equal(sent[0].payload.hostname, "switch.local"); + assert.equal(sent[0].payload.status, "unknown"); + + handleResponse(null, { + requestId: sent[0].payload.requestId, + accept: true, + addToKnownHosts: true, + }); + + assert.equal(await acceptedPromise, true); +}); diff --git a/electron/bridges/sshBridge.cjs b/electron/bridges/sshBridge.cjs index 17519b20..9d06075b 100644 --- a/electron/bridges/sshBridge.cjs +++ b/electron/bridges/sshBridge.cjs @@ -14,6 +14,7 @@ const { Client: SSHClient, utils: sshUtils } = require("ssh2"); const { NetcattyAgent } = require("./netcattyAgent.cjs"); const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs"); const passphraseHandler = require("./passphraseHandler.cjs"); +const hostKeyVerifier = require("./hostKeyVerifier.cjs"); const { createProxySocket } = require("./proxyUtils.cjs"); const { attachX11Forwarding } = require("./x11Forwarding.cjs"); const { @@ -745,6 +746,14 @@ async function startSSHSession(event, options) { algorithms: buildAlgorithms(options.legacyAlgorithms), }; + connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({ + sender, + sessionId, + hostname: options.hostname, + port: options.port || 22, + knownHosts: options.knownHosts, + }); + // Authentication for final target const hasCertificate = typeof options.certificate === "string" && options.certificate.trim().length > 0; const effectivePassphrase = options.passphrase; @@ -2712,6 +2721,8 @@ function registerHandlers(ipcMain) { keyboardInteractiveHandler.registerHandler(ipcMain); // Register the passphrase response handler passphraseHandler.registerHandler(ipcMain); + // Register the SSH host key verification response handler + hostKeyVerifier.registerHandler(ipcMain); } module.exports = { diff --git a/electron/preload.cjs b/electron/preload.cjs index eb4187f7..d65913ed 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -17,6 +17,7 @@ const telnetAutoLoginCancelledListeners = new Map(); const languageChangeListeners = new Set(); const fullscreenChangeListeners = new Set(); const keyboardInteractiveListeners = new Set(); +const hostKeyVerificationListeners = new Set(); const passphraseListeners = new Set(); const passphraseTimeoutListeners = new Set(); const passphraseCancelledListeners = new Set(); @@ -287,6 +288,16 @@ ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => { }); }); +ipcRenderer.on("netcatty:host-key:verify", (_event, payload) => { + hostKeyVerificationListeners.forEach((cb) => { + try { + cb(payload); + } catch (err) { + console.error("Host key verification callback failed", err); + } + }); +}); + // Passphrase request events for encrypted SSH keys ipcRenderer.on("netcatty:passphrase-request", (_event, payload) => { passphraseListeners.forEach((cb) => { @@ -712,6 +723,17 @@ const api = { cancelled, }); }, + onHostKeyVerification: (cb) => { + hostKeyVerificationListeners.add(cb); + return () => hostKeyVerificationListeners.delete(cb); + }, + respondHostKeyVerification: async (requestId, accept, addToKnownHosts = false) => { + return ipcRenderer.invoke("netcatty:host-key:respond", { + requestId, + accept, + addToKnownHosts, + }); + }, // Passphrase request for encrypted SSH keys onPassphraseRequest: (cb) => { passphraseListeners.add(cb); diff --git a/global.d.ts b/global.d.ts index 489437ec..2db642b2 100644 --- a/global.d.ts +++ b/global.d.ts @@ -81,6 +81,7 @@ declare global { extraArgs?: string[]; startupCommand?: string; passphrase?: string; + knownHosts?: import("./domain/models").KnownHost[]; // Environment variables to set in the remote shell env?: Record; // Proxy configuration @@ -366,6 +367,26 @@ declare global { cancelled?: boolean ): Promise<{ success: boolean; error?: string }>; + onHostKeyVerification?( + cb: (request: { + requestId: string; + sessionId: string; + hostname: string; + port: number; + status: 'unknown' | 'changed'; + keyType: string; + fingerprint: string; + publicKey?: string; + knownHostId?: string; + knownFingerprint?: string; + }) => void + ): () => void; + respondHostKeyVerification?( + requestId: string, + accept: boolean, + addToKnownHosts?: boolean + ): Promise<{ success: boolean; error?: string }>; + // Passphrase request for encrypted SSH keys onPassphraseRequest?( cb: (request: {