Fix SSH known host verification
This commit is contained in:
11
App.tsx
11
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 });
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -84,7 +84,7 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
|
||||
hostname,
|
||||
port,
|
||||
keyType,
|
||||
publicKey: publicKey.slice(0, 64) + "...",
|
||||
publicKey: `${keyType} ${publicKey}`,
|
||||
discoveredAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
252
electron/bridges/hostKeyVerifier.cjs
Normal file
252
electron/bridges/hostKeyVerifier.cjs
Normal 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,
|
||||
};
|
||||
232
electron/bridges/hostKeyVerifier.test.cjs
Normal file
232
electron/bridges/hostKeyVerifier.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
21
global.d.ts
vendored
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user