From 841e229dd3c52f2a9ec3f9c009b6de83d384d719 Mon Sep 17 00:00:00 2001 From: Ryan Whitworth Date: Tue, 31 Mar 2026 22:10:57 -0400 Subject: [PATCH] Add conditional SSH host key verification when fingerprint available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the gateway provides a host_key_fingerprint in CreateSshSessionResponse, write a temporary known_hosts file and enable StrictHostKeyChecking=yes. When the fingerprint is empty (current default), behavior is unchanged. This is scaffolding for F10/F11 — once the gateway populates the fingerprint field, MITM protection activates automatically in both CLI and TUI. --- crates/openshell-cli/src/ssh.rs | 62 +++++++++++++++++++++-------- crates/openshell-tui/Cargo.toml | 1 + crates/openshell-tui/src/lib.rs | 69 ++++++++++++++++++++++----------- 3 files changed, 94 insertions(+), 38 deletions(-) diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 4b284bff..111a6a8b 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -59,6 +59,7 @@ struct SshSessionConfig { sandbox_id: String, gateway_url: String, token: String, + host_key_fingerprint: String, } async fn ssh_session_config( @@ -127,22 +128,51 @@ async fn ssh_session_config( sandbox_id: session.sandbox_id.clone(), gateway_url, token: session.token, + host_key_fingerprint: session.host_key_fingerprint, }) } -fn ssh_base_command(proxy_command: &str) -> Command { +fn ssh_base_command(proxy_command: &str, host_key_fingerprint: &str) -> Command { let mut command = Command::new("ssh"); command .arg("-o") - .arg(format!("ProxyCommand={proxy_command}")) - .arg("-o") - .arg("StrictHostKeyChecking=no") - .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null") - .arg("-o") - .arg("LogLevel=ERROR"); + .arg(format!("ProxyCommand={proxy_command}")); + if host_key_fingerprint.is_empty() { + command + .arg("-o") + .arg("StrictHostKeyChecking=no") + .arg("-o") + .arg("UserKnownHostsFile=/dev/null") + .arg("-o") + .arg("GlobalKnownHostsFile=/dev/null"); + } else { + // Write a temporary known_hosts with the gateway-provided fingerprint + // and enable strict host key checking to detect MITM attacks. + let known_hosts = format!("[sandbox]:2222 {host_key_fingerprint}"); + let applied = (|| -> Option<()> { + let dir = tempfile::tempdir().ok()?; + let path = dir.into_path().join("known_hosts"); + std::fs::write(&path, &known_hosts).ok()?; + command + .arg("-o") + .arg("StrictHostKeyChecking=yes") + .arg("-o") + .arg(format!("UserKnownHostsFile={}", path.display())) + .arg("-o") + .arg("GlobalKnownHostsFile=/dev/null"); + Some(()) + })(); + if applied.is_none() { + command + .arg("-o") + .arg("StrictHostKeyChecking=no") + .arg("-o") + .arg("UserKnownHostsFile=/dev/null") + .arg("-o") + .arg("GlobalKnownHostsFile=/dev/null"); + } + } + command.arg("-o").arg("LogLevel=ERROR"); command } @@ -236,7 +266,7 @@ async fn sandbox_connect_with_mode( ) -> Result<()> { let session = ssh_session_config(server, name, tls).await?; - let mut command = ssh_base_command(&session.proxy_command); + let mut command = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); command .arg("-tt") .arg("-o") @@ -315,7 +345,7 @@ pub async fn sandbox_forward( let session = ssh_session_config(server, name, tls).await?; - let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command)); + let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command, &session.host_key_fingerprint)); command .arg("-N") .arg("-o") @@ -395,7 +425,7 @@ async fn sandbox_exec_with_mode( } let session = ssh_session_config(server, name, tls).await?; - let mut ssh = ssh_base_command(&session.proxy_command); + let mut ssh = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); if tty { ssh.arg("-tt") @@ -465,7 +495,7 @@ pub async fn sandbox_sync_up_files( let session = ssh_session_config(server, name, tls).await?; - let mut ssh = ssh_base_command(&session.proxy_command); + let mut ssh = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); ssh.arg("-T") .arg("-o") .arg("RequestTTY=no") @@ -534,7 +564,7 @@ pub async fn sandbox_sync_up( ) -> Result<()> { let session = ssh_session_config(server, name, tls).await?; - let mut ssh = ssh_base_command(&session.proxy_command); + let mut ssh = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); ssh.arg("-T") .arg("-o") .arg("RequestTTY=no") @@ -627,7 +657,7 @@ pub async fn sandbox_sync_down( ), ); - let mut ssh = ssh_base_command(&session.proxy_command); + let mut ssh = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); ssh.arg("-T") .arg("-o") .arg("RequestTTY=no") diff --git a/crates/openshell-tui/Cargo.toml b/crates/openshell-tui/Cargo.toml index b0ac0c7c..bb9cb0af 100644 --- a/crates/openshell-tui/Cargo.toml +++ b/crates/openshell-tui/Cargo.toml @@ -26,6 +26,7 @@ miette = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true } tracing = { workspace = true } +tempfile = "3" url = { workspace = true } [lints] diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 04fdf568..1c186d08 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -778,6 +778,44 @@ async fn fetch_sandbox_detail(app: &mut App) { // Shell connect (suspend TUI, launch SSH, resume) // --------------------------------------------------------------------------- +/// Apply SSH host key verification arguments to an SSH command. +/// +/// When the gateway provides a host key fingerprint, a temporary `known_hosts` file +/// is written and strict checking is enabled. Otherwise, host key checking is +/// disabled (current behavior until the gateway populates the fingerprint field). +fn apply_host_key_args(cmd: &mut std::process::Command, host_key_fingerprint: &str) { + if host_key_fingerprint.is_empty() { + cmd.arg("-o") + .arg("StrictHostKeyChecking=no") + .arg("-o") + .arg("UserKnownHostsFile=/dev/null") + .arg("-o") + .arg("GlobalKnownHostsFile=/dev/null"); + } else { + let known_hosts = format!("[sandbox]:2222 {host_key_fingerprint}"); + let applied = (|| -> Option<()> { + let dir = tempfile::tempdir().ok()?; + let path = dir.into_path().join("known_hosts"); + std::fs::write(&path, &known_hosts).ok()?; + cmd.arg("-o") + .arg("StrictHostKeyChecking=yes") + .arg("-o") + .arg(format!("UserKnownHostsFile={}", path.display())) + .arg("-o") + .arg("GlobalKnownHostsFile=/dev/null"); + Some(()) + })(); + if applied.is_none() { + cmd.arg("-o") + .arg("StrictHostKeyChecking=no") + .arg("-o") + .arg("UserKnownHostsFile=/dev/null") + .arg("-o") + .arg("GlobalKnownHostsFile=/dev/null"); + } + } +} + /// Suspend the TUI, launch an interactive SSH shell to the sandbox, resume on exit. /// /// This replicates the `openshell sandbox connect` flow but uses `Command::status()` @@ -863,13 +901,9 @@ async fn handle_shell_connect( let mut command = std::process::Command::new("ssh"); command .arg("-o") - .arg(format!("ProxyCommand={proxy_command}")) - .arg("-o") - .arg("StrictHostKeyChecking=no") - .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null") + .arg(format!("ProxyCommand={proxy_command}")); + apply_host_key_args(&mut command, &session.host_key_fingerprint); + command .arg("-o") .arg("LogLevel=ERROR") .arg("-tt") @@ -1012,14 +1046,9 @@ async fn handle_exec_command( .join(" "); let mut ssh = std::process::Command::new("ssh"); ssh.arg("-o") - .arg(format!("ProxyCommand={proxy_command}")) - .arg("-o") - .arg("StrictHostKeyChecking=no") - .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null") - .arg("-o") + .arg(format!("ProxyCommand={proxy_command}")); + apply_host_key_args(&mut ssh, &session.host_key_fingerprint); + ssh.arg("-o") .arg("LogLevel=ERROR") .arg("-tt") .arg("-o") @@ -1434,13 +1463,9 @@ async fn start_port_forwards( let mut command = std::process::Command::new("ssh"); command .arg("-o") - .arg(format!("ProxyCommand={proxy_command}")) - .arg("-o") - .arg("StrictHostKeyChecking=no") - .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null") + .arg(format!("ProxyCommand={proxy_command}")); + apply_host_key_args(&mut command, &session.host_key_fingerprint); + command .arg("-o") .arg("LogLevel=ERROR") .arg("-o")