Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/apps/desktop/src/api/ssh_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<bool, String> {
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<SSHConnectionResult, String> {
log::info!("ssh_connect called: id={}, host={}, port={}, username={}",
config.id, config.host, config.port, config.username);
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions src/crates/core/src/service/remote_ssh/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -270,6 +271,7 @@ pub struct SSHConnectionManager {
/// Remote workspace persistence (multiple workspaces)
remote_workspaces: Arc<tokio::sync::RwLock<Vec<crate::service::remote_ssh::types::RemoteWorkspace>>>,
remote_workspace_path: std::path::PathBuf,
password_vault: std::sync::Arc<SSHPasswordVault>,
}

impl SSHConnectionManager {
Expand All @@ -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())),
Expand All @@ -286,6 +289,7 @@ impl SSHConnectionManager {
known_hosts_path,
remote_workspaces: Arc::new(tokio::sync::RwLock::new(Vec::new())),
remote_workspace_path,
password_vault,
}
}

Expand Down Expand Up @@ -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<Option<String>> {
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
}

Expand Down
1 change: 1 addition & 0 deletions src/crates/core/src/service/remote_ssh/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
181 changes: 181 additions & 0 deletions src/crates/core/src/service/remote_ssh/password_vault.rs
Original file line number Diff line number Diff line change
@@ -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<String, String>,
}

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<String> {
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<String> {
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<Option<String>> {
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(())
}
}
4 changes: 2 additions & 2 deletions src/crates/core/src/service/remote_ssh/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ pub struct SavedConnection {
pub last_connected: Option<u64>,
}

/// 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,
Expand Down
17 changes: 16 additions & 1 deletion src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,22 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
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 }
Expand Down
Loading
Loading