From 1dcb33550e49323dfdeec103308be987f6aed02c Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:18:43 -0400 Subject: [PATCH] Expand SSH identity path tokens --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/bin/dosh-client.rs | 260 ++++++++++++++++++++++++++++++++--------- 3 files changed, 210 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c499174..9ec4132 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "dosh" -version = "1.0.0-rc27" +version = "1.0.0-rc28" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 4d4207e..29b91c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dosh" -version = "1.0.0-rc27" +version = "1.0.0-rc28" edition = "2024" license = "MIT" diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 259d646..db6a43f 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -4032,7 +4032,7 @@ async fn try_native_auth( let auth = sign_native_user_auth( config, cli_identity, - ssh_config, + &NativeIdentityContext::new(server, ssh_port, ssh_config), &hello, &server_hello.hello, requested_forwardings, @@ -4183,7 +4183,7 @@ async fn try_native_auth_check( let auth = sign_native_user_auth( config, cli_identity, - ssh_config, + &NativeIdentityContext::new(server, ssh_port, ssh_config), &hello, &server_hello.hello, Vec::new(), @@ -4218,13 +4218,14 @@ async fn try_native_auth_check( fn sign_native_user_auth( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, - ssh_config: &SshConfig, + identity_context: &NativeIdentityContext<'_>, hello: &dosh::native::NativeClientHello, server_hello: &dosh::native::NativeServerHello, requested_forwardings: Vec, allow_passphrase_prompt: bool, ) -> Result { let mut errors = Vec::new(); + let ssh_config = identity_context.ssh_config; if config.use_ssh_agent && !ssh_config.identities_only { match ssh_agent::sign_user_auth_with_agent( hello, @@ -4239,9 +4240,9 @@ fn sign_native_user_auth( } let identity = if allow_passphrase_prompt { - load_first_native_identity(config, cli_identity, ssh_config) + load_first_native_identity(config, cli_identity, identity_context) } else { - load_first_native_identity_noninteractive(config, cli_identity, ssh_config) + load_first_native_identity_noninteractive(config, cli_identity, identity_context) }; match identity { Ok(identity) => { @@ -4260,12 +4261,12 @@ fn sign_native_user_auth( fn load_first_native_identity( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, - ssh_config: &SshConfig, + identity_context: &NativeIdentityContext<'_>, ) -> Result { load_first_native_identity_with_prompt( config, cli_identity, - ssh_config, + identity_context, prompt_identity_passphrase, ) } @@ -4273,9 +4274,9 @@ fn load_first_native_identity( fn load_first_native_identity_noninteractive( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, - ssh_config: &SshConfig, + identity_context: &NativeIdentityContext<'_>, ) -> Result { - load_first_native_identity_with_prompt(config, cli_identity, ssh_config, |path| { + load_first_native_identity_with_prompt(config, cli_identity, identity_context, |path| { Err(anyhow!( "{} is encrypted; unlock it in ssh-agent or use an unencrypted key for proxy-stdio", path.display() @@ -4286,22 +4287,30 @@ fn load_first_native_identity_noninteractive( fn load_first_native_identity_with_prompt( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, - ssh_config: &SshConfig, + identity_context: &NativeIdentityContext<'_>, mut prompt: F, ) -> Result where F: FnMut(&Path) -> Result, { + let ssh_config = identity_context.ssh_config; + let token_context = &identity_context.path_tokens; let mut paths = Vec::new(); if let Some(path) = cli_identity { push_identity_path(&mut paths, path.to_path_buf()); } for path in &ssh_config.identity_files { - push_identity_path(&mut paths, expand_tilde(path)); + push_identity_path( + &mut paths, + expand_tilde(&expand_ssh_path_tokens(path, token_context)), + ); } if !ssh_config.identities_only { for path in &config.identity_files { - push_identity_path(&mut paths, expand_tilde(path)); + push_identity_path( + &mut paths, + expand_tilde(&expand_ssh_path_tokens(path, token_context)), + ); } } @@ -4339,6 +4348,98 @@ fn push_identity_path(paths: &mut Vec, path: PathBuf) { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct SshPathTokenContext { + original_host: String, + hostname: String, + port: u16, + remote_user: String, + local_user: String, + home_dir: Option, +} + +struct NativeIdentityContext<'a> { + ssh_config: &'a SshConfig, + path_tokens: SshPathTokenContext, +} + +impl<'a> NativeIdentityContext<'a> { + fn new(server: &str, ssh_port: Option, ssh_config: &'a SshConfig) -> Self { + Self { + ssh_config, + path_tokens: ssh_path_token_context(server, ssh_port, ssh_config), + } + } +} + +fn ssh_path_token_context( + server: &str, + ssh_port: Option, + ssh_config: &SshConfig, +) -> SshPathTokenContext { + let original_host = ssh_destination_host(server); + let hostname = ssh_config + .hostname + .clone() + .unwrap_or_else(|| original_host.clone()); + let port = ssh_config.port.or(ssh_port).unwrap_or(22); + let remote_user = ssh_username(server) + .or(ssh_config.user.clone()) + .unwrap_or_else(local_username); + let local_user = local_username(); + let home_dir = dirs::home_dir().map(|path| path.to_string_lossy().to_string()); + SshPathTokenContext { + original_host, + hostname, + port, + remote_user, + local_user, + home_dir, + } +} + +fn local_username() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "unknown".to_string()) +} + +fn expand_ssh_path_tokens(path: &str, context: &SshPathTokenContext) -> String { + let mut out = String::with_capacity(path.len()); + let mut chars = path.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + out.push(ch); + continue; + } + let Some(token) = chars.next() else { + out.push('%'); + break; + }; + match token { + '%' => out.push('%'), + 'd' => { + if let Some(home_dir) = &context.home_dir { + out.push_str(home_dir); + } else { + out.push('%'); + out.push(token); + } + } + 'h' => out.push_str(&context.hostname), + 'n' => out.push_str(&context.original_host), + 'p' => out.push_str(&context.port.to_string()), + 'r' => out.push_str(&context.remote_user), + 'u' => out.push_str(&context.local_user), + other => { + out.push('%'); + out.push(other); + } + } + } + out +} + fn prompt_identity_passphrase(path: &Path) -> Result { rpassword::prompt_password(format!("Enter passphrase for {}: ", path.display())) .with_context(|| format!("read passphrase for {}", path.display())) @@ -7941,27 +8042,27 @@ mod tests { use super::{ ALT_SCREEN_IDLE_REPAINT_AFTER, CachedCredential, DisconnectStatus, DynamicForward, FRAME_GAP_RESYNC_AFTER_MS, FrameBuffer, LocalForward, MAX_PENDING_USER_INPUT_BYTES, - POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, PredictMode, Predictor, - RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, SshConfig, StartupGateMode, - StatusAction, UpdateOptions, UpdateRole, auth_allows, cache_key, cache_server_prefix, - cleanup_stream_state, clear_cached_credentials, ensure_tui_safe_status_overlay, - input_contains_focus_in, input_matches_escape, is_local_status_target, - is_resume_response_for_client, latest_release_download_url, - load_first_native_identity_with_prompt, native_proxy_udp_warning, parse_dynamic_forward, - parse_escape_key, parse_local_forward, parse_remote_forward, parse_ssh_config, - parse_update_options, post_submit_hold_duration, queue_pending_user_input, - raw_contains_host_table, recv_response_until, refresh_live_addr, release_tag_download_url, - release_tag_from_effective_url, release_version_from_tag, render_status_clear, - render_status_overlay, requested_env, resolved_startup_command, retire_stream_state, - retransmit_stream_opens, rewrite_forward_command, selected_predict_mode, selected_udp_host, - server_version_mismatch, should_flush_terminal_input_after_contact, - should_hold_during_startup_gate, should_hold_post_submit_input, - should_repaint_idle_alternate_screen, split_after_command_submit, ssh_command_target, - ssh_config_uses_proxy, ssh_destination_host, ssh_username, ssh_with_user, startup_command, - status_ssh_target, strip_stale_mouse_reports, strip_terminal_focus_reports, - terminal_private_mode_transition, toml_bare_key_or_quoted, unix_update_script, - update_installer_url, update_version_status, upsert_managed_block, valid_forward_host, - vscode_safe_alias, windows_update_script, + NativeIdentityContext, POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, PredictMode, + Predictor, RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, SshConfig, + SshPathTokenContext, StartupGateMode, StatusAction, UpdateOptions, UpdateRole, auth_allows, + cache_key, cache_server_prefix, cleanup_stream_state, clear_cached_credentials, + ensure_tui_safe_status_overlay, expand_ssh_path_tokens, input_contains_focus_in, + input_matches_escape, is_local_status_target, is_resume_response_for_client, + latest_release_download_url, load_first_native_identity_with_prompt, + native_proxy_udp_warning, parse_dynamic_forward, parse_escape_key, parse_local_forward, + parse_remote_forward, parse_ssh_config, parse_update_options, post_submit_hold_duration, + queue_pending_user_input, raw_contains_host_table, recv_response_until, refresh_live_addr, + release_tag_download_url, release_tag_from_effective_url, release_version_from_tag, + render_status_clear, render_status_overlay, requested_env, resolved_startup_command, + retire_stream_state, retransmit_stream_opens, rewrite_forward_command, + selected_predict_mode, selected_udp_host, server_version_mismatch, + should_flush_terminal_input_after_contact, should_hold_during_startup_gate, + should_hold_post_submit_input, should_repaint_idle_alternate_screen, + split_after_command_submit, ssh_command_target, ssh_config_uses_proxy, + ssh_destination_host, ssh_username, ssh_with_user, startup_command, status_ssh_target, + strip_stale_mouse_reports, strip_terminal_focus_reports, terminal_private_mode_transition, + toml_bare_key_or_quoted, unix_update_script, update_installer_url, update_version_status, + upsert_managed_block, valid_forward_host, vscode_safe_alias, windows_update_script, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; @@ -8346,6 +8447,26 @@ mod tests { })); } + #[test] + fn ssh_identity_paths_expand_common_openssh_tokens() { + let context = SshPathTokenContext { + original_host: "prod".to_string(), + hostname: "10.0.0.5".to_string(), + port: 2222, + remote_user: "deploy".to_string(), + local_user: "palav".to_string(), + home_dir: Some("/home/palav".to_string()), + }; + assert_eq!( + expand_ssh_path_tokens("~/.ssh/%r@%h:%p-%n-%u-%%-%x", &context), + "~/.ssh/deploy@10.0.0.5:2222-prod-palav-%-%x" + ); + assert_eq!( + expand_ssh_path_tokens("%d/.ssh/%r_%h", &context), + "/home/palav/.ssh/deploy_10.0.0.5" + ); + } + #[test] fn native_proxy_udp_warning_requires_missing_explicit_dosh_host() { let proxied = SshConfig { @@ -8403,13 +8524,14 @@ mod tests { write_encrypted_identity(&path, &signing, "let-me-in"); let mut config = ClientConfig::default(); config.identity_files.clear(); - let loaded = load_first_native_identity_with_prompt( - &config, - Some(&path), - &SshConfig::default(), - |_| Ok("let-me-in".to_string()), - ) - .unwrap(); + let ssh_config = SshConfig::default(); + let identity_context = + NativeIdentityContext::new("alice@example.com", Some(2222), &ssh_config); + let loaded = + load_first_native_identity_with_prompt(&config, Some(&path), &identity_context, |_| { + Ok("let-me-in".to_string()) + }) + .unwrap(); assert_eq!( loaded.public_key().key_data().ed25519().unwrap().as_ref(), @@ -8425,13 +8547,14 @@ mod tests { write_encrypted_identity(&path, &signing, "correct"); let mut config = ClientConfig::default(); config.identity_files.clear(); - let err = load_first_native_identity_with_prompt( - &config, - Some(&path), - &SshConfig::default(), - |_| Ok("wrong".to_string()), - ) - .unwrap_err(); + let ssh_config = SshConfig::default(); + let identity_context = + NativeIdentityContext::new("alice@example.com", Some(2222), &ssh_config); + let err = + load_first_native_identity_with_prompt(&config, Some(&path), &identity_context, |_| { + Ok("wrong".to_string()) + }) + .unwrap_err(); assert!(err.to_string().contains("no usable native identity")); } @@ -8450,7 +8573,9 @@ mod tests { identities_only: true, ..SshConfig::default() }; - let err = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| { + let identity_context = + NativeIdentityContext::new("alice@example.com", Some(2222), &ssh_config); + let err = load_first_native_identity_with_prompt(&config, None, &identity_context, |_| { unreachable!("unencrypted key should not prompt") }) .unwrap_err(); @@ -8471,10 +8596,41 @@ mod tests { identities_only: true, ..SshConfig::default() }; - let loaded = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| { - unreachable!("unencrypted key should not prompt") - }) - .unwrap(); + let identity_context = + NativeIdentityContext::new("alice@example.com", Some(2222), &ssh_config); + let loaded = + load_first_native_identity_with_prompt(&config, None, &identity_context, |_| { + unreachable!("unencrypted key should not prompt") + }) + .unwrap(); + + assert_eq!( + loaded.public_key().key_data().ed25519().unwrap().as_ref(), + VerifyingKey::from(&signing).as_bytes() + ); + } + + #[test] + fn ssh_config_identity_file_tokens_are_expanded_for_native_auth() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("deploy_10.0.0.5_2222"); + let signing = SigningKey::from_bytes(&[65u8; 32]); + write_identity(&path, &signing); + let mut config = ClientConfig::default(); + config.identity_files.clear(); + let ssh_config = SshConfig { + hostname: Some("10.0.0.5".to_string()), + identity_files: vec![format!("{}/%r_%h_%p", dir.path().display())], + identities_only: true, + port: Some(2222), + ..SshConfig::default() + }; + let identity_context = NativeIdentityContext::new("deploy@prod", None, &ssh_config); + let loaded = + load_first_native_identity_with_prompt(&config, None, &identity_context, |_| { + unreachable!("unencrypted key should not prompt") + }) + .unwrap(); assert_eq!( loaded.public_key().key_data().ed25519().unwrap().as_ref(),