Fix SSH known host verification

This commit is contained in:
bincxz
2026-05-09 19:44:21 +08:00
parent b6c59b9683
commit bce33f34ee
13 changed files with 728 additions and 10 deletions

11
App.tsx
View File

@@ -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 });
}}

View File

@@ -111,6 +111,23 @@ export const useTerminalBackend = () => {
return bridge?.onChainProgress?.(cb);
}, []);
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[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,
};
};

View File

@@ -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<KnownHostConfirmDialogProps> = ({
onContinue,
onAddAndContinue,
}) => {
const isChanged = hostKeyInfo.status === 'changed';
return (
<div className="flex flex-col items-center justify-center h-full p-8 max-w-2xl mx-auto">
{/* Header with host info */}
@@ -60,11 +64,19 @@ const KnownHostConfirmDialog: React.FC<KnownHostConfirmDialogProps> = ({
{/* Warning message */}
<div className="text-center mb-6">
<h3 className="text-lg font-semibold text-amber-500 mb-2">
Are you sure you want to connect?
<h3 className={`text-lg font-semibold mb-2 ${isChanged ? 'text-destructive' : 'text-amber-500'}`}>
{isChanged ? 'Host key has changed' : 'Are you sure you want to connect?'}
</h3>
<p className="text-sm text-muted-foreground">
The authenticity of <span className="font-mono font-medium text-foreground">{hostKeyInfo.hostname}</span> can not be established.
{isChanged ? (
<>
The saved key for <span className="font-mono font-medium text-foreground">{hostKeyInfo.hostname}</span> no longer matches this server.
</>
) : (
<>
The authenticity of <span className="font-mono font-medium text-foreground">{hostKeyInfo.hostname}</span> can not be established.
</>
)}
</p>
</div>
@@ -78,8 +90,18 @@ const KnownHostConfirmDialog: React.FC<KnownHostConfirmDialogProps> = ({
{hostKeyInfo.fingerprint}
</code>
</div>
{isChanged && hostKeyInfo.knownFingerprint && (
<div className="bg-destructive/10 rounded-lg p-3 border border-destructive/30">
<p className="text-xs text-destructive mb-1">Saved fingerprint</p>
<code className="text-sm font-mono text-foreground break-all">
{hostKeyInfo.knownFingerprint}
</code>
</div>
)}
<p className="text-sm text-muted-foreground">
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?'}
</p>
</div>
@@ -103,7 +125,7 @@ const KnownHostConfirmDialog: React.FC<KnownHostConfirmDialogProps> = ({
className="min-w-[140px]"
onClick={onAddAndContinue}
>
Add and continue
{isChanged ? 'Update and continue' : 'Add and continue'}
</Button>
</div>
</div>

View File

@@ -84,7 +84,7 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
hostname,
port,
keyType,
publicKey: publicKey.slice(0, 64) + "...",
publicKey: `${keyType} ${publicKey}`,
discoveredAt: Date.now(),
});
} catch {

View File

@@ -218,7 +218,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
chainHosts = [],
themePreviewId,
knownHosts: _knownHosts = [],
knownHosts = [],
isVisible,
inWorkspace,
isResizing,
@@ -639,6 +639,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [needsHostKeyVerification, setNeedsHostKeyVerification] = useState(false);
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
const [pendingHostKeyRequestId, setPendingHostKeyRequestId] = useState<string | null>(null);
const pendingConnectionRef = useRef<(() => void) | null>(null);
// OSC-52 clipboard read prompt
@@ -662,6 +663,35 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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<HTMLDivElement>) => {
if (e.button !== 0) return;
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
@@ -755,6 +785,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
identities,
knownHosts,
resolvedChainHosts,
sessionId,
startupCommand,
@@ -1493,12 +1524,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
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<TerminalProps> = ({
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<TerminalProps> = ({
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 = () => {

View File

@@ -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<string, unknown> | 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<string, unknown>) => {
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<string, unknown> | null = null;

View File

@@ -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,
});
};

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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);
});

View File

@@ -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 = {

View File

@@ -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);

21
global.d.ts vendored
View File

@@ -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<string, string>;
// 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: {