ssh-bridge-ai
Version:
AI-Powered SSH Tool with Bulletproof Connections & Enterprise Sandbox Security + Cursor-like Confirmation - Enable AI assistants to securely SSH into your servers with persistent sessions, keepalive, automatic recovery, sandbox command testing, and user c
618 lines (523 loc) • 19.2 kB
JavaScript
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execSync, spawn } = require('child_process');
const { SSHClient } = require('./ssh');
const { Config } = require('./config');
const logger = require('./utils/logger');
/**
* SSH Key Manager - Handles key generation, copying, and management
*/
class SSHKeyManager {
constructor() {
this.sshDir = path.join(process.env.HOME || os.homedir(), '.ssh');
this.config = new Config();
}
/**
* Generate a new SSH key pair
*/
async generateKey(options = {}) {
const {
type = 'ed25519',
bits = '4096',
output = '',
email = '',
passphrase = false,
force = false
} = options;
// Validate key type
const validTypes = ['ed25519', 'rsa', 'ecdsa', 'dsa'];
if (!validTypes.includes(type)) {
throw new Error(`Invalid key type: ${type}. Supported types: ${validTypes.join(', ')}`);
}
// Determine output path
let keyPath = output;
if (!keyPath) {
const defaultName = `id_${type}`;
keyPath = path.join(this.sshDir, defaultName);
} else {
// Expand ~ to home directory
if (keyPath.startsWith('~')) {
keyPath = path.join(process.env.HOME || os.homedir(), keyPath.slice(1));
}
// Ensure .ssh directory exists
const keyDir = path.dirname(keyPath);
if (keyDir !== this.sshDir) {
await fs.mkdir(keyDir, { recursive: true, mode: 0o700 });
}
}
// Check if key already exists
if (fsSync.existsSync(keyPath) && !force) {
throw new Error(`Key already exists: ${keyPath}. Use --force to overwrite.`);
}
// Ensure .ssh directory exists with correct permissions
await this.ensureSSHDirectory();
// Build ssh-keygen command
const args = ['-t', type];
if (type === 'rsa' && bits) {
args.push('-b', bits);
}
if (email) {
args.push('-C', email);
}
if (passphrase) {
args.push('-N', passphrase); // Use provided passphrase
} else {
args.push('-N', '""'); // No passphrase (empty string)
}
// Use quiet mode for clean generation
args.push('-q');
args.push('-f', keyPath);
try {
// Generate the key
console.log(`Generating ${type.toUpperCase()} key...`);
// Use spawn for better error handling
return new Promise((resolve, reject) => {
const child = spawn('ssh-keygen', args, {
stdio: 'pipe'
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', async (code) => {
if (code === 0) {
try {
// Set correct permissions
await fs.chmod(keyPath, 0o600);
await fs.chmod(`${keyPath}.pub`, 0o644);
// Verify key was created
if (!fsSync.existsSync(keyPath) || !fsSync.existsSync(`${keyPath}.pub`)) {
reject(new Error('Key generation failed - files not created'));
return;
}
// Get key fingerprint and randomart after generation
const fingerprint = await this.getKeyFingerprint(`${keyPath}.pub`);
const randomart = await this.getKeyRandomart(keyPath);
logger.info('SSH key generated successfully', {
type,
path: keyPath,
publicKey: `${keyPath}.pub`,
randomart: randomart ? 'generated' : 'not found'
});
resolve({
privateKeyPath: keyPath,
publicKeyPath: `${keyPath}.pub`,
type: type,
email: email,
randomart: randomart,
fingerprint: fingerprint
});
} catch (error) {
reject(error);
}
} else {
reject(new Error(`ssh-keygen failed with exit code ${code}: ${stderr}`));
}
});
child.on('error', (error) => {
reject(new Error(`Failed to spawn ssh-keygen: ${error.message}`));
});
});
} catch (error) {
// Clean up partial files
try {
if (fsSync.existsSync(keyPath)) await fs.unlink(keyPath);
if (fsSync.existsSync(`${keyPath}.pub`)) await fs.unlink(`${keyPath}.pub`);
} catch (cleanupError) {
logger.warn('Failed to cleanup partial key files', { error: cleanupError.message });
}
// ssh-keygen returns status 1 for various reasons, not just user cancellation
// Let's provide a more helpful error message
if (error.status === 1) {
throw new Error(`Key generation failed with exit code 1: ${error.message}`);
}
throw new Error(`Key generation failed: ${error.message}`);
}
}
/**
* Extract randomart from ssh-keygen output
*/
extractRandomart(output) {
try {
// Look for the randomart pattern in the output
// Pattern for ssh-keygen -lv output - matches both formats:
// +---[RSA 2048]----+ and +--[ED25519 256]--+
const randomartMatch = output.match(/(\+-+\[[^\]]+\]-+\+[\s\S]*?\+----\[[^\]]+\]-----)/);
if (randomartMatch && randomartMatch[1]) {
return randomartMatch[1].trim();
}
// Alternative pattern for some ssh-keygen versions
const altMatch = output.match(/randomart image is:\s*\n([\s\S]*?)(?=\n\n|\n$|$)/);
if (altMatch && altMatch[1]) {
return altMatch[1].trim();
}
return null;
} catch (error) {
logger.warn('Failed to extract randomart', { error: error.message });
return null;
}
}
/**
* Get key fingerprint using ssh-keygen
*/
async getKeyFingerprint(publicKeyPath) {
try {
const { execSync } = require('child_process');
const output = execSync(`ssh-keygen -lf "${publicKeyPath}"`, { encoding: 'utf8' });
const match = output.match(/^\d+\s+([a-f0-9:]+)\s+/);
return match ? match[1] : null;
} catch (error) {
logger.warn('Failed to get key fingerprint', { error: error.message });
return null;
}
}
/**
* Copy SSH public key to remote server
*/
async copyKeyToServer(server, options = {}) {
const {
key = '',
port = '22',
force = false,
output = '',
passphrase = null
} = options;
// Parse server string
let username, hostname;
if (server.includes('@')) {
[username, hostname] = server.split('@');
} else {
// Try to get from config
const config = this.config.getConfig();
const serverConfig = config[server];
if (!serverConfig) {
throw new Error(`Server '${server}' not found in config. Use user@hostname format or add to config.`);
}
username = serverConfig.username;
hostname = serverConfig.hostname;
}
// Determine which key to use
let keyPath = key;
if (!keyPath) {
// Auto-detect best available key
const keys = await this.listKeys();
if (keys.length === 0) {
throw new Error('No SSH keys found. Generate one with: sshbridge keygen');
}
keyPath = keys[0].path; // Use first available key
}
// Validate key exists
if (!fsSync.existsSync(keyPath)) {
throw new Error(`SSH key not found: ${keyPath}`);
}
// Read public key
const publicKeyPath = `${keyPath}.pub`;
if (!fsSync.existsSync(publicKeyPath)) {
throw new Error(`Public key not found: ${publicKeyPath}`);
}
const publicKey = await fs.readFile(publicKeyPath, 'utf8');
const publicKeyContent = publicKey.trim();
// Connect to server and copy key
const connection = `${username}@${hostname}`;
const sshOptions = { port: parseInt(port) };
// Add key and passphrase if provided
if (keyPath) {
sshOptions.key = keyPath;
if (passphrase) {
sshOptions.passphrase = passphrase;
logger.debug('Passphrase provided for encrypted key', { keyPath });
} else {
logger.debug('No passphrase provided, key may be unencrypted', { keyPath });
}
}
logger.debug('SSH connection options', {
connection,
port: sshOptions.port,
hasKey: !!sshOptions.key,
hasPassphrase: !!sshOptions.passphrase
});
// Test key decryption before connection
if (keyPath && passphrase) {
try {
logger.debug('Testing key decryption before connection...');
// Test key loading without actual connection
const testKey = await fs.readFile(keyPath);
logger.debug('Key decryption test passed');
} catch (keyError) {
logger.error('Key decryption test failed', { error: keyError.message });
if (keyError.message.includes('integrity check failed') || keyError.message.includes('bad passphrase')) {
throw new Error(`SSH key decryption failed: ${keyError.message}. Please verify your passphrase is correct.`);
}
throw keyError;
}
}
const ssh = new SSHClient(connection, sshOptions);
try {
await ssh.connect();
// Determine authorized_keys path
const authKeysPath = output || '~/.ssh/authorized_keys';
// Check if authorized_keys exists
const checkResult = await ssh.exec(`test -f ${authKeysPath} && echo "exists" || echo "missing"`);
const authKeysExists = checkResult.stdout.trim() === 'exists';
if (authKeysExists && !force) {
// Check if key already exists
const grepResult = await ssh.exec(`grep -F "${publicKeyContent}" ${authKeysPath} || echo "not_found"`);
if (grepResult.stdout.trim() !== 'not_found') {
return {
server: connection,
keyPath: keyPath,
status: 'Key already exists on server',
action: 'none'
};
}
}
// Create .ssh directory if it doesn't exist
await ssh.exec(`mkdir -p ~/.ssh`);
await ssh.exec(`chmod 700 ~/.ssh`);
// Add key to authorized_keys
if (authKeysExists) {
// Append to existing file
await ssh.exec(`echo "${publicKeyContent}" >> ${authKeysPath}`);
} else {
// Create new file
await ssh.exec(`echo "${publicKeyContent}" > ${authKeysPath}`);
}
// Set correct permissions
await ssh.exec(`chmod 600 ${authKeysPath}`);
logger.info('SSH key copied to server successfully', {
server: connection,
keyPath,
authKeysPath
});
return {
server: connection,
keyPath: keyPath,
status: 'Key copied successfully',
action: authKeysExists ? 'appended' : 'created'
};
} catch (error) {
// Log the actual error for debugging
logger.error('SSH connection failed in copyKeyToServer', {
error: error.message,
stack: error.stack,
connection,
hasPassphrase: !!passphrase
});
throw new Error(`Failed to copy key to server: ${error.message}`);
} finally {
await ssh.dispose();
}
}
/**
* List all available SSH keys
*/
async listKeys() {
const keys = [];
const defaultKey = this.config.getDefaultSSHKey();
try {
// Ensure .ssh directory exists
if (!fsSync.existsSync(this.sshDir)) {
return keys;
}
const files = await fs.readdir(this.sshDir);
for (const file of files) {
if (file.endsWith('.pub')) continue; // Skip public keys
if (file.includes('.pub.')) continue; // Skip backup files
const keyPath = path.join(this.sshDir, file);
const publicKeyPath = `${keyPath}.pub`;
// Check if this is a valid key pair
if (!fsSync.existsSync(publicKeyPath)) continue;
try {
const stats = await fs.stat(keyPath);
const publicKeyStats = await fs.stat(publicKeyPath);
// Check permissions (private key should be 600, public key 644)
const privateMode = stats.mode & 0o777;
const publicMode = publicKeyStats.mode & 0o777;
if (privateMode !== 0o600 || publicMode !== 0o644) continue;
// Get key type and fingerprint
const keyInfo = await this.getKeyInfo(keyPath);
keys.push({
name: file,
path: keyPath,
type: keyInfo.type,
bits: keyInfo.bits,
fingerprint: keyInfo.fingerprint,
isDefault: keyPath === defaultKey,
size: stats.size,
modified: stats.mtime
});
} catch (error) {
logger.debug(`Skipping invalid key file: ${file}`, { error: error.message });
continue;
}
}
// Sort keys: default first, then by type (ed25519 preferred), then by name
keys.sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
const typePriority = { 'ed25519': 1, 'ecdsa': 2, 'rsa': 3, 'dsa': 4 };
const aPriority = typePriority[a.type] || 5;
const bPriority = typePriority[b.type] || 5;
if (aPriority !== bPriority) return aPriority - bPriority;
return a.name.localeCompare(b.name);
});
} catch (error) {
logger.error('Failed to list SSH keys', { error: error.message });
}
return keys;
}
/**
* Get key information (type, bits, fingerprint)
*/
async getKeyInfo(keyPath) {
try {
// Try to get fingerprint using ssh-keygen
const fingerprint = execSync(`ssh-keygen -lf "${keyPath}"`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
// Parse fingerprint output: "2048 SHA256:abc123... user@host (RSA)"
const match = fingerprint.match(/^(\d+)\s+(\w+):([a-f0-9]+)\s+(.+?)\s+\((\w+)\)$/);
if (match) {
return {
bits: parseInt(match[1]),
hash: match[2],
fingerprint: match[3],
comment: match[4],
type: match[5].toLowerCase()
};
} else {
// Fallback: just return the fingerprint
return {
type: 'unknown',
bits: null,
fingerprint: fingerprint.split(/\s+/)[1] || fingerprint
};
}
} catch (error) {
logger.warn('Failed to get key info using ssh-keygen', { error: error.message });
// Fallback: try to determine type from file content
try {
const keyContent = await fs.readFile(keyPath, 'utf8');
if (keyContent.includes('BEGIN OPENSSH PRIVATE KEY')) {
return { type: 'openssh', bits: null, fingerprint: 'unknown' };
} else if (keyContent.includes('BEGIN RSA PRIVATE KEY')) {
return { type: 'rsa', bits: null, fingerprint: 'unknown' };
} else if (keyContent.includes('BEGIN EC PRIVATE KEY')) {
return { type: 'ecdsa', bits: null, fingerprint: 'unknown' };
} else if (keyContent.includes('BEGIN DSA PRIVATE KEY')) {
return { type: 'dsa', bits: null, fingerprint: 'unknown' };
} else {
return { type: 'unknown', bits: null, fingerprint: 'unknown' };
}
} catch (readError) {
return { type: 'unknown', bits: null, fingerprint: 'unknown' };
}
}
}
/**
* Get key fingerprint
*/
async getKeyFingerprint(keyPath) {
const keyInfo = await this.getKeyInfo(keyPath);
return keyInfo.fingerprint;
}
/**
* Remove SSH key
*/
async removeKey(keyPath) {
// Validate key path
if (!fsSync.existsSync(keyPath)) {
throw new Error(`SSH key not found: ${keyPath}`);
}
// Check if it's a private key
if (!keyPath.endsWith('.pub') && !keyPath.includes('id_')) {
throw new Error(`Invalid SSH key path: ${keyPath}`);
}
// Determine public key path
const publicKeyPath = keyPath.endsWith('.pub') ? keyPath : `${keyPath}.pub`;
const privateKeyPath = keyPath.endsWith('.pub') ? keyPath.replace('.pub', '') : keyPath;
// Remove both files
const filesToRemove = [];
if (fsSync.existsSync(privateKeyPath)) filesToRemove.push(privateKeyPath);
if (fsSync.existsSync(publicKeyPath)) filesToRemove.push(publicKeyPath);
for (const file of filesToRemove) {
await fs.unlink(file);
logger.info('SSH key file removed', { path: file });
}
return {
removed: filesToRemove,
message: `Removed ${filesToRemove.length} key file(s)`
};
}
/**
* Ensure .ssh directory exists with correct permissions
*/
async ensureSSHDirectory() {
if (!fsSync.existsSync(this.sshDir)) {
await fs.mkdir(this.sshDir, { recursive: true, mode: 0o700 });
} else {
// Check and fix permissions
const stats = await fs.stat(this.sshDir);
const mode = stats.mode & 0o777;
if (mode !== 0o700) {
await fs.chmod(this.sshDir, 0o700);
logger.info('Fixed .ssh directory permissions', { oldMode: mode.toString(8), newMode: '700' });
}
}
}
/**
* Get randomart for an existing SSH key
*/
async getKeyRandomart(keyPath) {
try {
const { execSync } = require('child_process');
// Determine if it's a public or private key
const isPublic = keyPath.endsWith('.pub');
const publicKeyPath = isPublic ? keyPath : `${keyPath}.pub`;
if (!fsSync.existsSync(publicKeyPath)) {
throw new Error(`Public key not found: ${publicKeyPath}`);
}
// Use ssh-keygen -lv to get verbose output including randomart
const output = execSync(`ssh-keygen -lv -f "${publicKeyPath}"`, { encoding: 'utf8' });
// Debug: log the output
logger.debug('ssh-keygen -lv output', {
keyPath: publicKeyPath,
output: output.substring(0, 200) + '...'
});
// Extract randomart from the output
const randomart = this.extractRandomart(output);
if (!randomart) {
logger.warn('No randomart found in ssh-keygen output', {
keyPath: publicKeyPath,
outputLength: output.length
});
}
return randomart;
} catch (error) {
logger.warn('Failed to get key randomart', { error: error.message });
return null;
}
}
/**
* Test SSH key authentication
*/
async testKeyAuthentication(server, keyPath) {
try {
const ssh = new SSHClient(server, { key: keyPath });
await ssh.connect();
await ssh.dispose();
return { success: true, message: 'Authentication successful' };
} catch (error) {
return { success: false, error: error.message };
}
}
}
module.exports = { SSHKeyManager };