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
581 lines (489 loc) • 17.4 kB
JavaScript
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { promisify } = require('util');
const { spawn } = require('child_process');
/**
* Secure Credential Vault
*
* Implements encrypted storage for SSH keys, host info, and secrets using:
* - Argon2id for key derivation (configurable rounds, salt, memory)
* - XChaCha20-Poly1305 for encryption (via libsodium)
* - Secure memory management with zeroing
* - Rate limiting for unlock attempts
*/
class SecureVault {
constructor(options = {}) {
this.vaultPath = options.vaultPath || path.join(process.env.HOME || os.homedir(), '.sshbridge', 'vault.json.enc');
this.vaultDir = path.dirname(this.vaultPath);
this.maxUnlockAttempts = options.maxUnlockAttempts || 5;
this.lockoutDuration = options.lockoutDuration || 15 * 60 * 1000; // 15 minutes
this.argon2Config = {
timeCost: options.argon2TimeCost || 3,
memoryCost: options.argon2MemoryCost || 65536, // 64MB
parallelism: options.argon2Parallelism || 1,
hashLength: options.argon2HashLength || 32
};
// Secure memory tracking
this.secureBuffers = [];
this.unlockAttempts = 0;
this.lastFailedAttempt = 0;
this.isUnlocked = false;
this.masterKey = null;
// Ensure vault directory exists with correct permissions
this.ensureVaultDirectory();
}
/**
* Ensure vault directory exists with secure permissions
*/
async ensureVaultDirectory() {
try {
if (!fsSync.existsSync(this.vaultDir)) {
await fs.mkdir(this.vaultDir, { recursive: true, mode: 0o700 });
} else {
// Verify existing directory permissions
const stats = await fs.stat(this.vaultDir);
if ((stats.mode & 0o777) !== 0o700) {
await fs.chmod(this.vaultDir, 0o700);
}
}
} catch (error) {
throw new Error(`Failed to create secure vault directory: ${error.message}`);
}
}
/**
* Generate a cryptographically secure salt
*/
generateSalt(length = 32) {
return crypto.randomBytes(length);
}
/**
* Derive encryption key from passphrase using Argon2id
*/
async deriveKey(passphrase, salt) {
try {
// Use system argon2 if available, fallback to Node.js crypto
if (await this.hasArgon2()) {
return await this.deriveKeyWithArgon2(passphrase, salt);
} else {
return await this.deriveKeyWithNodeCrypto(passphrase, salt);
}
} catch (error) {
throw new Error(`Key derivation failed: ${error.message}`);
}
}
/**
* Check if system argon2 is available
*/
async hasArgon2() {
try {
await promisify(spawn)('which', ['argon2']);
return true;
} catch {
return false;
}
}
/**
* Derive key using system argon2 command
*/
async deriveKeyWithArgon2(passphrase, salt) {
return new Promise((resolve, reject) => {
const argon2 = spawn('argon2', [
passphrase,
salt.toString('base64'),
'-t', this.argon2Config.timeCost.toString(),
'-m', this.argon2Config.memoryCost.toString(),
'-p', this.argon2Config.parallelism.toString(),
'-l', this.argon2Config.hashLength.toString(),
'-id' // Use Argon2id variant
]);
let output = '';
let error = '';
argon2.stdout.on('data', (data) => {
output += data.toString();
});
argon2.stderr.on('data', (data) => {
error += data.toString();
});
argon2.on('close', (code) => {
if (code === 0) {
// Extract hash from argon2 output (format: $argon2id$v=19$m=65536,t=3,p=1$...)
const match = output.match(/\$argon2id\$[^$]*\$[^$]*\$([A-Za-z0-9+/]+)/);
if (match) {
resolve(Buffer.from(match[1], 'base64'));
} else {
reject(new Error('Failed to parse argon2 output'));
}
} else {
reject(new Error(`Argon2 failed with code ${code}: ${error}`));
}
});
});
}
/**
* Fallback key derivation using Node.js crypto (PBKDF2)
*/
async deriveKeyWithNodeCrypto(passphrase, salt) {
return new Promise((resolve, reject) => {
crypto.pbkdf2(passphrase, salt, 100000, this.argon2Config.hashLength, 'sha256', (err, derivedKey) => {
if (err) {
reject(err);
} else {
resolve(derivedKey);
}
});
});
}
/**
* Encrypt data using AES-256-GCM (fallback for compatibility)
*/
async encrypt(data, key) {
try {
// Generate IV for AES
const iv = crypto.randomBytes(16);
// Create cipher using AES-256-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', key.slice(0, 32), iv);
// Encrypt data
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
// Get auth tag
const authTag = cipher.getAuthTag();
// Return encrypted data with IV and auth tag
return {
encrypted: encrypted,
nonce: iv.toString('hex'),
authTag: authTag.toString('hex')
};
} catch (error) {
throw new Error(`Encryption failed: ${error.message}`);
}
}
/**
* Decrypt data using AES-256-GCM (fallback for compatibility)
*/
async decrypt(encryptedData, key) {
try {
const { encrypted, nonce, authTag } = encryptedData;
// Create decipher using AES-256-GCM
const decipher = crypto.createDecipheriv('aes-256-gcm', key.slice(0, 32), Buffer.from(nonce, 'hex'));
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
// Decrypt data
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
throw new Error(`Decryption failed: ${error.message}`);
}
}
/**
* Unlock vault with passphrase
*/
async unlock(passphrase) {
try {
// Validate passphrase input
if (!passphrase || typeof passphrase !== 'string') {
throw new Error('Passphrase must be a non-empty string');
}
// Enhanced passphrase validation for unlock
if (passphrase.length < 8) {
throw new Error('Passphrase must be at least 8 characters long');
}
// Check for common weak passwords during unlock
const weakPasswords = [
'password', '123456', 'qwerty', 'admin', 'root', 'test', 'guest', 'user',
'default', 'changeme', 'password123', 'admin123', 'root123', 'test123',
'guest123', 'user123', 'default123', 'changeme123'
];
if (weakPasswords.includes(passphrase.toLowerCase())) {
throw new Error('Passphrase is too weak. Choose a stronger password.');
}
// Check for common patterns during unlock
if (/^[a-zA-Z]+$/.test(passphrase)) {
throw new Error('Passphrase must contain numbers or special characters');
}
if (/^[0-9]+$/.test(passphrase)) {
throw new Error('Passphrase must contain letters or special characters');
}
// Check rate limiting
if (this.isRateLimited()) {
const remainingTime = Math.ceil((this.lastFailedAttempt + this.lockoutDuration - Date.now()) / 1000);
throw new Error(`Vault is locked due to too many failed attempts. Try again in ${remainingTime} seconds.`);
}
// Check if vault exists
if (!fsSync.existsSync(this.vaultPath)) {
throw new Error('Vault does not exist. Use createVault() to initialize.');
}
// Read encrypted vault
const encryptedVault = await fs.readFile(this.vaultPath, 'utf8');
const vaultData = JSON.parse(encryptedVault);
// Derive key from passphrase
const salt = Buffer.from(vaultData.salt, 'base64');
const derivedKey = await this.deriveKey(passphrase, salt);
// Decrypt vault
const decryptedData = await this.decrypt(vaultData.data, derivedKey);
const vault = JSON.parse(decryptedData);
// Store master key securely
this.masterKey = derivedKey;
this.isUnlocked = true;
this.unlockAttempts = 0;
// Track for secure cleanup
this.secureBuffers.push(derivedKey);
// Store the decrypted vault data for this session
this.sessionVaultData = vault;
return vault;
} catch (error) {
this.unlockAttempts++;
this.lastFailedAttempt = Date.now();
if (this.unlockAttempts >= this.maxUnlockAttempts) {
console.warn(`⚠️ Vault locked due to ${this.maxUnlockAttempts} failed attempts. Try again in ${Math.ceil(this.lockoutDuration / 1000)} seconds.`);
}
throw error;
}
}
/**
* Create new vault with passphrase
*/
async createVault(passphrase, initialData = {}) {
try {
// Validate passphrase strength
if (!passphrase || typeof passphrase !== 'string') {
throw new Error('Passphrase must be a non-empty string');
}
if (passphrase.length < 8) {
throw new Error('Passphrase must be at least 8 characters long');
}
// Check for common weak passwords
const weakPasswords = [
'password', '123456', 'qwerty', 'admin', 'root', 'test', 'guest', 'user',
'default', 'changeme', 'password123', 'admin123', 'root123', 'test123',
'guest123', 'user123', 'default123', 'changeme123'
];
if (weakPasswords.includes(passphrase.toLowerCase())) {
throw new Error('Passphrase is too weak. Choose a stronger password.');
}
// Check for common patterns
if (/^[a-zA-Z]+$/.test(passphrase)) {
throw new Error('Passphrase must contain numbers or special characters');
}
if (/^[0-9]+$/.test(passphrase)) {
throw new Error('Passphrase must contain letters or special characters');
}
// Check for excessive data size (prevent memory issues)
if (JSON.stringify(initialData).length > 1000000) { // 1MB limit
throw new Error('Initial data too large. Maximum allowed: 1MB');
}
// Generate salt
const salt = this.generateSalt();
// Derive key
const derivedKey = await this.deriveKey(passphrase, salt);
// Encrypt initial data
const encryptedData = await this.encrypt(JSON.stringify(initialData), derivedKey);
// Create vault structure
const vault = {
version: '1.0.0',
salt: salt.toString('base64'),
data: encryptedData,
created: new Date().toISOString(),
lastModified: new Date().toISOString()
};
// Write encrypted vault
await this.writeVaultSecurely(vault);
// Store master key for current session
this.masterKey = derivedKey;
this.isUnlocked = true;
this.secureBuffers.push(derivedKey);
return true;
} catch (error) {
throw new Error(`Failed to create vault: ${error.message}`);
}
}
/**
* Store data in vault
*/
async store(key, value) {
if (!this.isUnlocked || !this.masterKey || !this.sessionVaultData) {
throw new Error('Vault must be unlocked before storing data');
}
try {
// Update session data
this.sessionVaultData[key] = value;
this.sessionVaultData.lastModified = new Date().toISOString();
// Re-encrypt and write to disk
const newEncryptedData = await this.encrypt(JSON.stringify(this.sessionVaultData), this.masterKey);
// Read current vault structure
const encryptedVault = await fs.readFile(this.vaultPath, 'utf8');
const vaultData = JSON.parse(encryptedVault);
// Update encrypted data
vaultData.data = newEncryptedData;
vaultData.lastModified = new Date().toISOString();
// Write updated vault
await this.writeVaultSecurely(vaultData);
return true;
} catch (error) {
throw new Error(`Failed to store data: ${error.message}`);
}
}
/**
* Retrieve data from vault
*/
async retrieve(key) {
if (!this.isUnlocked || !this.masterKey || !this.sessionVaultData) {
throw new Error('Vault must be unlocked before retrieving data');
}
try {
return this.sessionVaultData[key];
} catch (error) {
throw new Error(`Failed to retrieve data: ${error.message}`);
}
}
/**
* List all keys in vault
*/
async listKeys() {
if (!this.isUnlocked || !this.masterKey || !this.sessionVaultData) {
throw new Error('Vault must be unlocked before listing keys');
}
try {
// Filter out metadata keys
const metadataKeys = ['version', 'created', 'lastModified'];
return Object.keys(this.sessionVaultData).filter(key => !metadataKeys.includes(key));
} catch (error) {
throw new Error(`Failed to list keys: ${error.message}`);
}
}
/**
* Remove data from vault
*/
async remove(key) {
if (!this.isUnlocked || !this.masterKey) {
throw new Error('Vault must be unlocked before removing data');
}
try {
// Read current vault
const encryptedVault = await fs.readFile(this.vaultPath, 'utf8');
const vaultData = JSON.parse(encryptedVault);
// Decrypt current data
const decryptedData = await this.decrypt(vaultData.data, this.masterKey);
const vault = JSON.parse(decryptedData);
// Remove key
delete vault.data[key];
vault.lastModified = new Date().toISOString();
// Re-encrypt
const newEncryptedData = await this.encrypt(JSON.stringify(vault), this.masterKey);
vaultData.data = newEncryptedData;
vaultData.lastModified = new Date().toISOString();
// Write updated vault
await this.writeVaultSecurely(vaultData);
return true;
} catch (error) {
throw new Error(`Failed to remove data: ${error.message}`);
}
}
/**
* Write vault securely using atomic operations
*/
async writeVaultSecurely(vaultData) {
const tempPath = `${this.vaultPath}.tmp`;
try {
// Write to temporary file
await fs.writeFile(tempPath, JSON.stringify(vaultData, null, 2), { mode: 0o600 });
// Atomically rename to target file
await fs.rename(tempPath, this.vaultPath);
// Ensure correct permissions
await fs.chmod(this.vaultPath, 0o600);
} catch (error) {
// Clean up temp file on error
try {
await fs.unlink(tempPath);
} catch {}
throw error;
}
}
/**
* Check if vault is rate limited
*/
isRateLimited() {
if (this.unlockAttempts >= this.maxUnlockAttempts) {
const timeSinceLastAttempt = Date.now() - this.lastFailedAttempt;
return timeSinceLastAttempt < this.lockoutDuration;
}
return false;
}
/**
* Lock vault and clear sensitive data from memory
*/
lock() {
this.isUnlocked = false;
this.sessionVaultData = null;
this.secureCleanup();
}
/**
* Securely clean up sensitive data from memory
*/
secureCleanup() {
// Zero out all secure buffers
this.secureBuffers.forEach(buffer => {
if (Buffer.isBuffer(buffer)) {
buffer.fill(0);
}
});
// Clear arrays
this.secureBuffers = [];
this.masterKey = null;
}
/**
* Get vault status
*/
getStatus() {
return {
isUnlocked: this.isUnlocked,
unlockAttempts: this.unlockAttempts,
isRateLimited: this.isRateLimited(),
remainingLockoutTime: this.isRateLimited() ?
Math.max(0, this.lastFailedAttempt + this.lockoutDuration - Date.now()) : 0,
vaultPath: this.vaultPath,
vaultExists: fsSync.existsSync(this.vaultPath)
};
}
/**
* Change vault passphrase
*/
async changePassphrase(oldPassphrase, newPassphrase) {
if (!this.isUnlocked || !this.masterKey) {
throw new Error('Vault must be unlocked before changing passphrase');
}
try {
// Verify old passphrase
const oldKey = await this.deriveKey(oldPassphrase, Buffer.from(this.masterKey));
if (!oldKey.equals(this.masterKey)) {
throw new Error('Old passphrase is incorrect');
}
// Read current vault
const encryptedVault = await fs.readFile(this.vaultPath, 'utf8');
const vaultData = JSON.parse(encryptedVault);
// Decrypt current data
const decryptedData = await this.decrypt(vaultData.data, this.masterKey);
const vault = JSON.parse(decryptedData);
// Generate new salt and key
const newSalt = this.generateSalt();
const newKey = await this.deriveKey(newPassphrase, newSalt);
// Re-encrypt with new key
const newEncryptedData = await this.encrypt(JSON.stringify(vault), newKey);
// Update vault
vaultData.salt = newSalt.toString('base64');
vaultData.data = newEncryptedData;
vaultData.lastModified = new Date().toISOString();
// Write updated vault
await this.writeVaultSecurely(vaultData);
// Update current session
this.masterKey = newKey;
this.secureBuffers.push(newKey);
return true;
} catch (error) {
throw new Error(`Failed to change passphrase: ${error.message}`);
}
}
}
module.exports = { SecureVault };