diff --git a/src/apps/desktop/src/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index ab177059..edb80e45 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -5,7 +5,7 @@ use tauri::State; use bitfun_core::service::remote_ssh::{ - SSHConnectionConfig, SSHConnectionResult, SavedConnection, RemoteTreeNode, + SSHAuthMethod, SSHConnectionConfig, SSHConnectionResult, SavedConnection, RemoteTreeNode, SSHConfigLookupResult, SSHConfigEntry, ServerInfo, }; use crate::api::app_state::SSHServiceError; @@ -54,10 +54,19 @@ pub async fn ssh_delete_connection( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn ssh_has_stored_password( + state: State<'_, AppState>, + connection_id: String, +) -> Result { + let manager = state.get_ssh_manager_async().await?; + Ok(manager.has_stored_password(&connection_id).await) +} + #[tauri::command] pub async fn ssh_connect( state: State<'_, AppState>, - config: SSHConnectionConfig, + mut config: SSHConnectionConfig, ) -> Result { log::info!("ssh_connect called: id={}, host={}, port={}, username={}", config.id, config.host, config.port, config.username); @@ -73,6 +82,22 @@ pub async fn ssh_connect( } }; + if let SSHAuthMethod::Password { ref password } = config.auth { + if password.is_empty() { + match manager.load_stored_password(&config.id).await { + Ok(Some(pwd)) => { + config.auth = SSHAuthMethod::Password { password: pwd }; + } + Ok(None) => { + return Err( + "SSH password is required (no saved password for this connection)".to_string(), + ); + } + Err(e) => return Err(e.to_string()), + } + } + } + // First save the connection config so it persists across restarts log::info!("ssh_connect: about to save connection config"); if let Err(e) = manager.save_connection(&config).await { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 3780f50b..e1cf7fd6 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -674,6 +674,7 @@ pub async fn run() { api::ssh_api::ssh_list_saved_connections, api::ssh_api::ssh_save_connection, api::ssh_api::ssh_delete_connection, + api::ssh_api::ssh_has_stored_password, api::ssh_api::ssh_connect, api::ssh_api::ssh_disconnect, api::ssh_api::ssh_disconnect_all, diff --git a/src/crates/core/src/service/remote_ssh/manager.rs b/src/crates/core/src/service/remote_ssh/manager.rs index 396a5ddb..f66bec53 100644 --- a/src/crates/core/src/service/remote_ssh/manager.rs +++ b/src/crates/core/src/service/remote_ssh/manager.rs @@ -2,6 +2,7 @@ //! //! This module manages SSH connections using the pure-Russ SSH implementation +use crate::service::remote_ssh::password_vault::SSHPasswordVault; use crate::service::remote_ssh::types::{ SavedConnection, ServerInfo, SSHConnectionConfig, SSHConnectionResult, SSHAuthMethod, SSHConfigEntry, SSHConfigLookupResult, @@ -270,6 +271,7 @@ pub struct SSHConnectionManager { /// Remote workspace persistence (multiple workspaces) remote_workspaces: Arc>>, remote_workspace_path: std::path::PathBuf, + password_vault: std::sync::Arc, } impl SSHConnectionManager { @@ -278,6 +280,7 @@ impl SSHConnectionManager { let config_path = data_dir.join("ssh_connections.json"); let known_hosts_path = data_dir.join("known_hosts"); let remote_workspace_path = data_dir.join("remote_workspace.json"); + let password_vault = std::sync::Arc::new(SSHPasswordVault::new(data_dir)); Self { connections: Arc::new(tokio::sync::RwLock::new(HashMap::new())), saved_connections: Arc::new(tokio::sync::RwLock::new(Vec::new())), @@ -286,6 +289,7 @@ impl SSHConnectionManager { known_hosts_path, remote_workspaces: Arc::new(tokio::sync::RwLock::new(Vec::new())), remote_workspace_path, + password_vault, } } @@ -708,14 +712,46 @@ impl SSHConnectionManager { }); drop(guard); + + match &config.auth { + SSHAuthMethod::Password { password } => { + if !password.is_empty() { + self.password_vault + .store(&config.id, password) + .await + .with_context(|| format!("store ssh password vault for {}", config.id))?; + } + } + SSHAuthMethod::PrivateKey { .. } | SSHAuthMethod::Agent => { + self.password_vault.remove(&config.id).await?; + } + } + self.save_connections().await } + /// Decrypt stored password for password-based saved connections (auto-reconnect). + pub async fn load_stored_password(&self, connection_id: &str) -> anyhow::Result> { + self.password_vault.load(connection_id).await + } + + /// Whether the vault has a stored password for this connection (skip auto-reconnect when false). + pub async fn has_stored_password(&self, connection_id: &str) -> bool { + match self.load_stored_password(connection_id).await { + Ok(opt) => opt.is_some(), + Err(e) => { + log::warn!("has_stored_password failed for {}: {}", connection_id, e); + false + } + } + } + /// Delete a saved connection pub async fn delete_saved_connection(&self, connection_id: &str) -> anyhow::Result<()> { let mut guard = self.saved_connections.write().await; guard.retain(|c| c.id != connection_id); drop(guard); + self.password_vault.remove(connection_id).await?; self.save_connections().await } diff --git a/src/crates/core/src/service/remote_ssh/mod.rs b/src/crates/core/src/service/remote_ssh/mod.rs index 5522676e..5d749c1d 100644 --- a/src/crates/core/src/service/remote_ssh/mod.rs +++ b/src/crates/core/src/service/remote_ssh/mod.rs @@ -5,6 +5,7 @@ //! similar to VSCode's Remote SSH extension. pub mod manager; +mod password_vault; pub mod remote_fs; pub mod remote_terminal; pub mod types; diff --git a/src/crates/core/src/service/remote_ssh/password_vault.rs b/src/crates/core/src/service/remote_ssh/password_vault.rs new file mode 100644 index 00000000..6fdc052a --- /dev/null +++ b/src/crates/core/src/service/remote_ssh/password_vault.rs @@ -0,0 +1,181 @@ +//! Encrypted file-backed storage for SSH password authentication. +//! +//! A random 32-byte key lives in `data_dir/.ssh_password_vault.key` (0600 on Unix). +//! Ciphertext map is stored in `data_dir/ssh_password_vault.json`. + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use tokio::sync::Mutex; + +const NONCE_LEN: usize = 12; + +#[derive(Serialize, Deserialize, Default)] +struct VaultFile { + entries: HashMap, +} + +pub struct SSHPasswordVault { + key_path: PathBuf, + vault_path: PathBuf, + lock: Mutex<()>, +} + +impl SSHPasswordVault { + pub fn new(data_dir: PathBuf) -> Self { + Self { + key_path: data_dir.join(".ssh_password_vault.key"), + vault_path: data_dir.join("ssh_password_vault.json"), + lock: Mutex::new(()), + } + } + + async fn ensure_key(&self) -> Result<[u8; 32]> { + if self.key_path.exists() { + let bytes = tokio::fs::read(&self.key_path) + .await + .context("read ssh password vault key")?; + if bytes.len() != 32 { + anyhow::bail!("invalid ssh password vault key length"); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + return Ok(key); + } + if let Some(p) = self.key_path.parent() { + tokio::fs::create_dir_all(p).await?; + } + let mut key = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut key); + tokio::fs::write(&self.key_path, key.as_slice()) + .await + .context("write ssh password vault key")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + &self.key_path, + std::fs::Permissions::from_mode(0o600), + ); + } + Ok(key) + } + + fn encrypt_password(key: &[u8; 32], plaintext: &str) -> Result { + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| anyhow::anyhow!("{}", e))?; + let mut nonce = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let ct = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext.as_bytes()) + .map_err(|e| anyhow::anyhow!("encrypt: {}", e))?; + let mut blob = Vec::with_capacity(NONCE_LEN + ct.len()); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ct); + Ok(B64.encode(blob)) + } + + fn decrypt_password(key: &[u8; 32], blob_b64: &str) -> Result { + let blob = B64 + .decode(blob_b64) + .context("base64 decode ssh vault entry")?; + if blob.len() <= NONCE_LEN { + anyhow::bail!("ssh vault entry too short"); + } + let (nonce, ct) = blob.split_at(NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| anyhow::anyhow!("{}", e))?; + let pt = cipher + .decrypt(Nonce::from_slice(nonce), ct) + .map_err(|e| anyhow::anyhow!("decrypt: {}", e))?; + String::from_utf8(pt).context("utf8 decode ssh vault password") + } + + pub async fn store(&self, connection_id: &str, password: &str) -> Result<()> { + let _g = self.lock.lock().await; + let key = self.ensure_key().await?; + let mut file: VaultFile = if self.vault_path.exists() { + let s = tokio::fs::read_to_string(&self.vault_path) + .await + .unwrap_or_default(); + serde_json::from_str(&s).unwrap_or_default() + } else { + VaultFile::default() + }; + let enc = Self::encrypt_password(&key, password)?; + file.entries.insert(connection_id.to_string(), enc); + if let Some(p) = self.vault_path.parent() { + tokio::fs::create_dir_all(p).await?; + } + let body = serde_json::to_string_pretty(&file)?; + tokio::fs::write(&self.vault_path, body) + .await + .context("write ssh password vault")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + &self.vault_path, + std::fs::Permissions::from_mode(0o600), + ); + } + Ok(()) + } + + pub async fn load(&self, connection_id: &str) -> Result> { + let _g = self.lock.lock().await; + if !self.vault_path.exists() || !self.key_path.exists() { + return Ok(None); + } + let bytes = tokio::fs::read(&self.key_path) + .await + .context("read ssh vault key")?; + if bytes.len() != 32 { + anyhow::bail!("invalid ssh password vault key length"); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + + let s = tokio::fs::read_to_string(&self.vault_path) + .await + .unwrap_or_default(); + let file: VaultFile = serde_json::from_str(&s).unwrap_or_default(); + let Some(entry) = file.entries.get(connection_id) else { + return Ok(None); + }; + match Self::decrypt_password(&key, entry) { + Ok(p) => Ok(Some(p)), + Err(e) => { + log::warn!( + "Failed to decrypt SSH password vault entry for {}: {}", + connection_id, + e + ); + Ok(None) + } + } + } + + pub async fn remove(&self, connection_id: &str) -> Result<()> { + let _g = self.lock.lock().await; + if !self.vault_path.exists() { + return Ok(()); + } + let s = tokio::fs::read_to_string(&self.vault_path).await.unwrap_or_default(); + let mut file: VaultFile = serde_json::from_str(&s).unwrap_or_default(); + file.entries.remove(connection_id); + if file.entries.is_empty() { + let _ = tokio::fs::remove_file(&self.vault_path).await; + } else { + tokio::fs::write( + &self.vault_path, + serde_json::to_string_pretty(&file)?, + ) + .await?; + } + Ok(()) + } +} diff --git a/src/crates/core/src/service/remote_ssh/types.rs b/src/crates/core/src/service/remote_ssh/types.rs index 156097fb..51c5a0cd 100644 --- a/src/crates/core/src/service/remote_ssh/types.rs +++ b/src/crates/core/src/service/remote_ssh/types.rs @@ -95,11 +95,11 @@ pub struct SavedConnection { pub last_connected: Option, } -/// Saved auth type (excludes sensitive credentials) +/// Saved auth type (excludes sensitive credentials; password ciphertext is in `ssh_password_vault.json`) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum SavedAuthType { - Password, // Password is stored in system keychain + Password, PrivateKey { #[serde(rename = "keyPath")] key_path: String, diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index 64dc4e0f..a7155b0d 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -216,7 +216,22 @@ export const SSHConnectionDialog: React.FC = ({ setLocalError(null); if (conn.authType.type === 'Password') { - setCredentialsPrompt({ kind: 'saved', connection: conn }); + setIsConnecting(true); + setLocalError(null); + try { + await connect(conn.id, { + id: conn.id, + name: conn.name, + host: conn.host, + port: conn.port, + username: conn.username, + auth: { type: 'Password', password: '' }, + }); + } catch { + setCredentialsPrompt({ kind: 'saved', connection: conn }); + } finally { + setIsConnecting(false); + } } else { const auth: SSHAuthMethod = conn.authType.type === 'PrivateKey' ? { type: 'PrivateKey', keyPath: conn.authType.keyPath } diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index c870ee77..e6ee840b 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -121,17 +121,15 @@ export const SSHRemoteProvider: React.FC = ({ children } return false; } - // Determine auth method from tagged enum + // Determine auth method from tagged enum (password uses empty string; backend fills from vault) let authMethod: SSHConnectionConfig['auth'] | null = null; if (savedConn.authType.type === 'PrivateKey') { authMethod = { type: 'PrivateKey', keyPath: savedConn.authType.keyPath }; } else if (savedConn.authType.type === 'Agent') { authMethod = { type: 'Agent' }; } else { - // Password auth cannot auto-reconnect because BitFun intentionally does not - // persist passwords. The user must reconnect manually after restarting the app. - log.warn('Skipping auto-reconnect: password auth requires user input', { connectionId: workspace.connectionId }); - return false; + // Caller must only invoke password reconnect when vault has a password (see checkRemoteWorkspace). + authMethod = { type: 'Password', password: '' }; } const reconnectConfig: SSHConnectionConfig = { @@ -273,16 +271,33 @@ export const SSHRemoteProvider: React.FC = ({ children } log.info(`checkRemoteWorkspace: found ${toReconnect.size} remote workspace(s)`); - // Mark all as 'connecting' immediately so the UI shows the pending state + const reconnectList = Array.from(toReconnect.values()); + const savedConnectionsList = await sshApi.listSavedConnections(); + + const skipPasswordAutoReconnect = new Set(); + for (const ws of reconnectList) { + const sc = savedConnectionsList.find(c => c.id === ws.connectionId); + if (sc?.authType.type === 'Password') { + let hasVault = false; + try { + hasVault = await sshApi.hasStoredPassword(sc.id); + } catch { + hasVault = false; + } + if (!hasVault) { + skipPasswordAutoReconnect.add(ws.connectionId); + } + } + } + const initialStatuses: Record = {}; for (const [, ws] of toReconnect) { - initialStatuses[ws.connectionId] = 'connecting'; + initialStatuses[ws.connectionId] = skipPasswordAutoReconnect.has(ws.connectionId) + ? 'error' + : 'connecting'; } setWorkspaceStatuses(prev => ({ ...prev, ...initialStatuses })); - // ── Process each workspace in parallel (slow servers no longer block others) ── - const reconnectList = Array.from(toReconnect.values()); - type ConnectedEntry = { workspace: RemoteWorkspace; connectionId: string }; const results = await Promise.all( reconnectList.map(async workspace => { @@ -315,6 +330,13 @@ export const SSHRemoteProvider: React.FC = ({ children } return { ok: true as const, connected: { workspace, connectionId: workspace.connectionId } }; } + if (skipPasswordAutoReconnect.has(workspace.connectionId)) { + log.info('Skipping auto-reconnect: password auth but no stored password', { + connectionId: workspace.connectionId, + }); + return { ok: false as const }; + } + log.info('Remote workspace disconnected, attempting auto-reconnect', { connectionId: workspace.connectionId, remotePath: workspace.remotePath, @@ -346,10 +368,19 @@ export const SSHRemoteProvider: React.FC = ({ children } }; } - log.warn('Auto-reconnect failed, removing workspace from sidebar', { + const savedConn = savedConnectionsList.find(c => c.id === workspace.connectionId); + log.warn('Auto-reconnect failed', { connectionId: workspace.connectionId, + auth: savedConn?.authType.type, }); - await workspaceManager.removeRemoteWorkspace(workspace.connectionId, workspace.remotePath).catch(() => {}); + if (savedConn?.authType.type === 'Password') { + // Keep workspace in sidebar; user reconnects manually. No auto password dialog. + setWorkspaceStatus(workspace.connectionId, 'error'); + } else { + await workspaceManager + .removeRemoteWorkspace(workspace.connectionId, workspace.remotePath) + .catch(() => {}); + } return { ok: false as const }; }) ); diff --git a/src/web-ui/src/features/ssh-remote/sshApi.ts b/src/web-ui/src/features/ssh-remote/sshApi.ts index 9e22bee8..8d292170 100644 --- a/src/web-ui/src/features/ssh-remote/sshApi.ts +++ b/src/web-ui/src/features/ssh-remote/sshApi.ts @@ -41,6 +41,13 @@ export const sshApi = { return api.invoke('ssh_delete_connection', { connectionId }); }, + /** + * Whether a password is stored in the local vault for this saved connection (password auth auto-reconnect). + */ + async hasStoredPassword(connectionId: string): Promise { + return api.invoke('ssh_has_stored_password', { connectionId }); + }, + /** * Connect to remote SSH server */