import { execSync, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { logger } from './logger.js';
// Path to known_hosts file
const KNOWN_HOSTS_PATH = path.join(process.env.HOME, '.ssh', 'known_hosts');
const KNOWN_HOSTS_BACKUP = path.join(process.env.HOME, '.ssh', 'known_hosts.mcp-backup');
/**
* Parse a known_hosts entry
*/
function parseKnownHostEntry(line) {
if (!line || line.startsWith('#')) return null;
const parts = line.split(' ');
if (parts.length < 3) return null;
return {
host: parts[0],
keyType: parts[1],
key: parts[2],
comment: parts.slice(3).join(' ') || ''
};
}
/**
* Get the SSH host key fingerprint for a server
*/
export async function getHostKeyFingerprint(host, port = 22) {
return new Promise((resolve, reject) => {
const cmd = spawn('ssh-keyscan', ['-p', port.toString(), '-t', 'ed25519,rsa,ecdsa', host]);
let stdout = '';
let stderr = '';
cmd.stdout.on('data', (data) => {
stdout += data.toString();
});
cmd.stderr.on('data', (data) => {
stderr += data.toString();
});
cmd.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Failed to get host key: ${stderr}`));
return;
}
const lines = stdout.trim().split('\n').filter(l => l && !l.startsWith('#'));
const fingerprints = [];
for (const line of lines) {
const entry = parseKnownHostEntry(line);
if (entry) {
// Calculate SHA256 fingerprint
const keyData = Buffer.from(entry.key, 'base64');
const hash = crypto.createHash('sha256').update(keyData).digest('base64');
fingerprints.push({
host: entry.host,
type: entry.keyType,
fingerprint: `SHA256:${hash}`,
fullKey: line
});
}
}
resolve(fingerprints);
});
});
}
/**
* Check if a host key exists in known_hosts
*/
export function isHostKnown(host, port = 22) {
if (!fs.existsSync(KNOWN_HOSTS_PATH)) {
return false;
}
const content = fs.readFileSync(KNOWN_HOSTS_PATH, 'utf8');
const lines = content.split('\n');
// Format host entry as SSH does
const hostEntry = port === 22 ? host : `[${host}]:${port}`;
for (const line of lines) {
if (line.includes(hostEntry)) {
return true;
}
}
return false;
}
/**
* Get current host key from known_hosts
*/
export function getCurrentHostKey(host, port = 22) {
if (!fs.existsSync(KNOWN_HOSTS_PATH)) {
return null;
}
const content = fs.readFileSync(KNOWN_HOSTS_PATH, 'utf8');
const lines = content.split('\n');
// Format host entry as SSH does
const hostEntry = port === 22 ? host : `[${host}]:${port}`;
const keys = [];
for (const line of lines) {
if (line.includes(hostEntry)) {
const entry = parseKnownHostEntry(line);
if (entry) {
const keyData = Buffer.from(entry.key, 'base64');
const hash = crypto.createHash('sha256').update(keyData).digest('base64');
keys.push({
host: entry.host,
type: entry.keyType,
fingerprint: `SHA256:${hash}`,
fullKey: line
});
}
}
}
return keys.length > 0 ? keys : null;
}
/**
* Remove a host from known_hosts
*/
export function removeHostKey(host, port = 22) {
try {
const hostEntry = port === 22 ? host : `[${host}]:${port}`;
// Use ssh-keygen to remove the host
execSync(`ssh-keygen -R "${hostEntry}"`, { stdio: 'ignore' });
logger.info('Host key removed', { host, port });
return true;
} catch (error) {
logger.error('Failed to remove host key', { host, port, error: error.message });
throw new Error(`Failed to remove host key: ${error.message}`);
}
}
/**
* Add a host key to known_hosts
*/
export async function addHostKey(host, port = 22, keyData = null) {
try {
// Backup current known_hosts
if (fs.existsSync(KNOWN_HOSTS_PATH)) {
fs.copyFileSync(KNOWN_HOSTS_PATH, KNOWN_HOSTS_BACKUP);
}
// If no key data provided, fetch it
if (!keyData) {
const fingerprints = await getHostKeyFingerprint(host, port);
if (fingerprints.length === 0) {
throw new Error('No host keys found');
}
keyData = fingerprints.map(fp => fp.fullKey).join('\n');
}
// Ensure .ssh directory exists
const sshDir = path.dirname(KNOWN_HOSTS_PATH);
if (!fs.existsSync(sshDir)) {
fs.mkdirSync(sshDir, { mode: 0o700, recursive: true });
}
// Append to known_hosts
fs.appendFileSync(KNOWN_HOSTS_PATH, keyData + '\n');
logger.info('Host key added', { host, port });
return true;
} catch (error) {
logger.error('Failed to add host key', { host, port, error: error.message });
throw new Error(`Failed to add host key: ${error.message}`);
}
}
/**
* Update a host key (remove old, add new)
*/
export async function updateHostKey(host, port = 22) {
try {
// Remove old key
removeHostKey(host, port);
// Add new key
await addHostKey(host, port);
logger.info('Host key updated', { host, port });
return true;
} catch (error) {
logger.error('Failed to update host key', { host, port, error: error.message });
throw new Error(`Failed to update host key: ${error.message}`);
}
}
/**
* Verify if host key has changed
*/
export async function hasHostKeyChanged(host, port = 22) {
try {
const currentKeys = getCurrentHostKey(host, port);
if (!currentKeys || currentKeys.length === 0) {
// No key in known_hosts
return { changed: false, reason: 'not_in_known_hosts' };
}
const newKeys = await getHostKeyFingerprint(host, port);
if (!newKeys || newKeys.length === 0) {
return { changed: false, reason: 'cannot_fetch_key' };
}
// Check if any current key matches any new key
for (const currentKey of currentKeys) {
for (const newKey of newKeys) {
if (currentKey.fingerprint === newKey.fingerprint) {
return { changed: false, reason: 'key_matches' };
}
}
}
// Keys don't match
return {
changed: true,
reason: 'key_mismatch',
currentFingerprints: currentKeys.map(k => k.fingerprint),
newFingerprints: newKeys.map(k => k.fingerprint)
};
} catch (error) {
logger.error('Failed to verify host key', { host, port, error: error.message });
return { changed: false, reason: 'verification_error', error: error.message };
}
}
/**
* List all known hosts
*/
export function listKnownHosts() {
if (!fs.existsSync(KNOWN_HOSTS_PATH)) {
return [];
}
const content = fs.readFileSync(KNOWN_HOSTS_PATH, 'utf8');
const lines = content.split('\n');
const hosts = new Map();
for (const line of lines) {
if (line && !line.startsWith('#')) {
const entry = parseKnownHostEntry(line);
if (entry) {
// Extract host and port
let host = entry.host;
let port = 22;
if (host.startsWith('[')) {
const match = host.match(/\[([^\]]+)\]:(\d+)/);
if (match) {
host = match[1];
port = parseInt(match[2]);
}
}
const keyData = Buffer.from(entry.key, 'base64');
const hash = crypto.createHash('sha256').update(keyData).digest('base64');
const hostKey = `${host}:${port}`;
if (!hosts.has(hostKey)) {
hosts.set(hostKey, {
host,
port,
keys: []
});
}
hosts.get(hostKey).keys.push({
type: entry.keyType,
fingerprint: `SHA256:${hash}`
});
}
}
}
return Array.from(hosts.values());
}
/**
* Detect SSH key error in command output
*/
export function detectSSHKeyError(stderr) {
const keyErrorPatterns = [
'WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED',
'Host key verification failed',
'The authenticity of host',
'ECDSA host key for .* has changed',
'RSA host key for .* has changed',
'ED25519 host key for .* has changed',
'Offending key in',
'Add correct host key in'
];
for (const pattern of keyErrorPatterns) {
if (stderr.includes(pattern)) {
return true;
}
}
return false;
}
/**
* Extract host info from SSH error
*/
export function extractHostFromSSHError(stderr) {
// Try to extract host and port from error message
const patterns = [
/Offending (?:RSA|ECDSA|ED25519) key in .+:(\d+)/i,
/Host key for \[([^\]]+)\]:(\d+) has changed/i,
/Host key for ([^\s]+) has changed/i,
/The authenticity of host '\[([^\]]+)\]:(\d+)'/i,
/The authenticity of host '([^\s]+) \(/i
];
for (const pattern of patterns) {
const match = stderr.match(pattern);
if (match) {
if (match[2]) {
// Host and port
return { host: match[1], port: parseInt(match[2]) };
} else {
// Just host
return { host: match[1], port: 22 };
}
}
}
return null;
}
/**
* Handle SSH key error automatically
*/
export async function handleSSHKeyError(stderr, options = {}) {
const { autoAccept = false, interactive = true } = options;
const hostInfo = extractHostFromSSHError(stderr);
if (!hostInfo) {
throw new Error('Could not extract host information from SSH error');
}
logger.warn('SSH host key verification failed', hostInfo);
if (autoAccept) {
// Automatically update the key
await updateHostKey(hostInfo.host, hostInfo.port);
return { action: 'updated', ...hostInfo };
}
if (!interactive) {
throw new Error(`Host key verification failed for ${hostInfo.host}:${hostInfo.port}. Use ssh_key_manage tool to update the key.`);
}
// In interactive mode, we would prompt the user
// For now, just return the error info
return {
action: 'prompt_required',
...hostInfo,
message: `Host key has changed for ${hostInfo.host}:${hostInfo.port}. Use ssh_key_manage tool to verify and update the key.`
};
}