From dc20a41e0d73a5f8e9ce5ec42a79499aa229027c Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 23 Sep 2025 18:58:46 +0200 Subject: [PATCH] windows: Encrypt SSH passwords stored in memory (#38427) Release Notes: - N/A --------- Co-authored-by: Julia --- Cargo.lock | 7 +- Cargo.toml | 2 + crates/askpass/Cargo.toml | 6 +- crates/askpass/src/askpass.rs | 33 ++++-- crates/askpass/src/encrypted_password.rs | 111 ++++++++++++++++++ crates/git_ui/Cargo.toml | 1 + crates/git_ui/src/askpass_modal.rs | 25 +++- crates/project/Cargo.toml | 1 + crates/project/src/git_store.rs | 10 +- crates/recent_projects/Cargo.toml | 1 + .../recent_projects/src/remote_connections.rs | 39 ++++-- crates/remote/Cargo.toml | 1 + crates/remote/src/remote_client.rs | 11 +- crates/remote/src/transport/ssh.rs | 21 +++- 14 files changed, 232 insertions(+), 37 deletions(-) create mode 100644 crates/askpass/src/encrypted_password.rs diff --git a/Cargo.lock b/Cargo.lock index c57d06ef884b78d22042324d02a78d23ec485608..898533b5a531fb92e63a709cd30b022779f5c03a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -813,11 +813,13 @@ dependencies = [ "futures 0.3.31", "gpui", "net", - "parking_lot", + "proto", "smol", "tempfile", "util", + "windows 0.61.1", "workspace-hack", + "zeroize", ] [[package]] @@ -6946,6 +6948,7 @@ dependencies = [ "workspace", "workspace-hack", "zed_actions", + "zeroize", "zlog", ] @@ -13141,6 +13144,7 @@ dependencies = [ "which 6.0.3", "workspace-hack", "worktree", + "zeroize", "zlog", ] @@ -13839,6 +13843,7 @@ name = "recent_projects" version = "0.1.0" dependencies = [ "anyhow", + "askpass", "auto_update", "dap", "editor", diff --git a/Cargo.toml b/Cargo.toml index 8aabe6ad40c9d6b854d3453b08972dd8a4364e09..f49a0374bbe2f4be99d52c755b3438f7f6e31217 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -717,6 +717,7 @@ windows-core = "0.61" wit-component = "0.221" workspace-hack = "0.1.0" yawc = "0.2.5" +zeroize = "1.8" zstd = "0.11" [workspace.dependencies.windows] @@ -743,6 +744,7 @@ features = [ "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Credentials", + "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Com_StructuredStorage", diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index 0527399af8b6f45ef18650ee5c286c0b51a83608..297139545caeb1c23386cb9f5d8f97b84791dc10 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -16,8 +16,12 @@ anyhow.workspace = true futures.workspace = true gpui.workspace = true net.workspace = true -parking_lot.workspace = true +proto.workspace = true smol.workspace = true tempfile.workspace = true util.workspace = true workspace-hack.workspace = true +zeroize.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index 9e84a9fed03c8a620c7cb33cc76ef22c000c3fa6..6405be5966ba512d68a9ab638d1216d678200968 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -1,3 +1,9 @@ +mod encrypted_password; + +pub use encrypted_password::{EncryptedPassword, ProcessExt}; + +#[cfg(target_os = "windows")] +use std::sync::OnceLock; use std::{ffi::OsStr, time::Duration}; use anyhow::{Context as _, Result}; @@ -10,6 +16,8 @@ use gpui::{AsyncApp, BackgroundExecutor, Task}; use smol::fs; use util::ResultExt as _; +use crate::encrypted_password::decrypt; + #[derive(PartialEq, Eq)] pub enum AskPassResult { CancelledByUser, @@ -17,16 +25,19 @@ pub enum AskPassResult { } pub struct AskPassDelegate { - tx: mpsc::UnboundedSender<(String, oneshot::Sender)>, + tx: mpsc::UnboundedSender<(String, oneshot::Sender)>, _task: Task<()>, } impl AskPassDelegate { pub fn new( cx: &mut AsyncApp, - password_prompt: impl Fn(String, oneshot::Sender, &mut AsyncApp) + Send + Sync + 'static, + password_prompt: impl Fn(String, oneshot::Sender, &mut AsyncApp) + + Send + + Sync + + 'static, ) -> Self { - let (tx, mut rx) = mpsc::unbounded::<(String, oneshot::Sender)>(); + let (tx, mut rx) = mpsc::unbounded::<(String, oneshot::Sender<_>)>(); let task = cx.spawn(async move |cx: &mut AsyncApp| { while let Some((prompt, channel)) = rx.next().await { password_prompt(prompt, channel, cx); @@ -35,7 +46,7 @@ impl AskPassDelegate { Self { tx, _task: task } } - pub async fn ask_password(&mut self, prompt: String) -> Result { + pub async fn ask_password(&mut self, prompt: String) -> Result { let (tx, rx) = oneshot::channel(); self.tx.send((prompt, tx)).await?; Ok(rx.await?) @@ -48,7 +59,7 @@ pub struct AskPassSession { #[cfg(target_os = "windows")] askpass_helper: String, #[cfg(target_os = "windows")] - secret: std::sync::Arc>, + secret: std::sync::Arc>, _askpass_task: Task<()>, askpass_opened_rx: Option>, askpass_kill_master_rx: Option>, @@ -68,7 +79,7 @@ impl AskPassSession { use util::fs::make_file_executable; #[cfg(target_os = "windows")] - let secret = std::sync::Arc::new(parking_lot::Mutex::new(String::new())); + let secret = std::sync::Arc::new(OnceLock::new()); let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?; let askpass_socket = temp_dir.path().join("askpass.sock"); let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME); @@ -104,10 +115,12 @@ impl AskPassSession { .context("getting askpass password") .log_err() { - stream.write_all(password.as_bytes()).await.log_err(); #[cfg(target_os = "windows")] { - *askpass_secret.lock() = password; + askpass_secret.get_or_init(|| password.clone()); + } + if let Ok(decrypted) = decrypt(password) { + stream.write_all(decrypted.as_bytes()).await.log_err(); } } else { if let Some(kill_tx) = kill_tx.take() { @@ -188,8 +201,8 @@ impl AskPassSession { /// This will return the password that was last set by the askpass script. #[cfg(target_os = "windows")] - pub fn get_password(&self) -> String { - self.secret.lock().clone() + pub fn get_password(&self) -> Option { + self.secret.get().cloned() } } diff --git a/crates/askpass/src/encrypted_password.rs b/crates/askpass/src/encrypted_password.rs new file mode 100644 index 0000000000000000000000000000000000000000..f027faa2cda163e0aa5344623991e7082a0983ec --- /dev/null +++ b/crates/askpass/src/encrypted_password.rs @@ -0,0 +1,111 @@ +//! This module provides [EncryptedPassword] for storage of passwords in memory. +//! On Windows that's implemented with CryptProtectMemory/CryptUnprotectMemory; on other platforms it just falls through +//! to string for now. +//! +//! The "safety" of this module lies in exploiting visibility rules of Rust: +//! 1. No outside module has access to the internal representation of [EncryptedPassword]. +//! 2. [EncryptedPassword] cannot be converted into a [String] or any other plaintext representation. +//! All use cases that do need such functionality (of which we have two right now) are implemented within this module. +//! +//! Note that this is not bulletproof. +//! 1. [ProcessExt] is implemented for [smol::process::Command], which is a builder for smol processes. +//! Before the process itself is spawned the contents of [EncryptedPassword] are unencrypted in env var storage of said builder. +//! 2. We're also sending plaintext passwords over RPC with [proto::AskPassResponse]. Go figure how great that is. +//! +//! Still, the goal of this module is to not have passwords laying around nilly-willy in memory. +//! We do not claim that it is fool-proof. +use anyhow::Result; +use zeroize::Zeroize; + +type LengthWithoutPadding = u32; +#[derive(Clone)] +pub struct EncryptedPassword(Vec, LengthWithoutPadding); + +pub trait ProcessExt { + fn encrypted_env(&mut self, name: &str, value: EncryptedPassword) -> &mut Self; +} + +impl ProcessExt for smol::process::Command { + fn encrypted_env(&mut self, name: &str, value: EncryptedPassword) -> &mut Self { + if let Ok(password) = decrypt(value) { + self.env(name, password); + } + self + } +} + +impl TryFrom for proto::AskPassResponse { + type Error = anyhow::Error; + fn try_from(pw: EncryptedPassword) -> Result { + let pw = decrypt(pw)?; + Ok(Self { response: pw }) + } +} + +impl Drop for EncryptedPassword { + fn drop(&mut self) { + self.0.zeroize(); + self.1.zeroize(); + } +} + +impl TryFrom<&str> for EncryptedPassword { + type Error = anyhow::Error; + fn try_from(password: &str) -> Result { + let len: u32 = password.len().try_into()?; + #[cfg(windows)] + { + use windows::Win32::Security::Cryptography::{ + CRYPTPROTECTMEMORY_BLOCK_SIZE, CRYPTPROTECTMEMORY_SAME_PROCESS, CryptProtectMemory, + }; + let mut value = password.bytes().collect::>(); + let padded_length = len.next_multiple_of(CRYPTPROTECTMEMORY_BLOCK_SIZE); + if padded_length != len { + value.resize(padded_length as usize, 0); + } + unsafe { + CryptProtectMemory( + value.as_mut_ptr() as _, + len, + CRYPTPROTECTMEMORY_SAME_PROCESS, + )?; + } + Ok(Self(value, len)) + } + #[cfg(not(windows))] + Ok(Self(String::from(password).into(), len)) + } +} + +pub(crate) fn decrypt(mut password: EncryptedPassword) -> Result { + #[cfg(windows)] + { + use anyhow::Context; + use windows::Win32::Security::Cryptography::{ + CRYPTPROTECTMEMORY_BLOCK_SIZE, CRYPTPROTECTMEMORY_SAME_PROCESS, CryptUnprotectMemory, + }; + assert_eq!( + password.0.len() % CRYPTPROTECTMEMORY_BLOCK_SIZE as usize, + 0, + "Violated pre-condition (buffer size <{}> must be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE <{}>) for CryptUnprotectMemory.", + password.0.len(), + CRYPTPROTECTMEMORY_BLOCK_SIZE + ); + unsafe { + CryptUnprotectMemory( + password.0.as_mut_ptr() as _, + password.1, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + .context("while decrypting a SSH password")? + }; + + { + // Remove padding + _ = password.0.drain(password.1 as usize..); + } + Ok(String::from_utf8(std::mem::take(&mut password.0))?) + } + #[cfg(not(windows))] + Ok(String::from_utf8(std::mem::take(&mut password.0))?) +} diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 52577d033a94fb819b320d3b18fd384431fe5a1f..6905b3eb890b5ac96d9ccd75a08dd88f0288787b 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -61,6 +61,7 @@ watch.workspace = true workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true +zeroize.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/git_ui/src/askpass_modal.rs b/crates/git_ui/src/askpass_modal.rs index 1705ad6732ef57095a7e550a6c27978596a6b11e..bbd507cfc4cce55f02e3c77a4317a6ca69049987 100644 --- a/crates/git_ui/src/askpass_modal.rs +++ b/crates/git_ui/src/askpass_modal.rs @@ -1,3 +1,4 @@ +use askpass::EncryptedPassword; use editor::Editor; use futures::channel::oneshot; use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Styled}; @@ -7,13 +8,15 @@ use ui::{ LabelSize, ParentElement, Render, SharedString, StyledExt, StyledTypography, Window, div, h_flex, v_flex, }; +use util::maybe; use workspace::ModalView; +use zeroize::Zeroize; pub(crate) struct AskPassModal { operation: SharedString, prompt: SharedString, editor: Entity, - tx: Option>, + tx: Option>, } impl EventEmitter for AskPassModal {} @@ -28,7 +31,7 @@ impl AskPassModal { pub fn new( operation: SharedString, prompt: SharedString, - tx: oneshot::Sender, + tx: oneshot::Sender, window: &mut Window, cx: &mut Context, ) -> Self { @@ -53,10 +56,20 @@ impl AskPassModal { cx.emit(DismissEvent); } - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - if let Some(tx) = self.tx.take() { - tx.send(self.editor.read(cx).text(cx)).ok(); - } + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + maybe!({ + let tx = self.tx.take()?; + let mut text = self.editor.update(cx, |this, cx| { + let text = this.text(cx); + this.clear(window, cx); + text + }); + let pw = askpass::EncryptedPassword::try_from(text.as_ref()).ok()?; + text.zeroize(); + tx.send(pw).ok(); + Some(()) + }); + cx.emit(DismissEvent); } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 3d46a44770ec2504991899e98c1504116611c20b..1af622df351f05721c850debebfe5b3e84284dad 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -89,6 +89,7 @@ util.workspace = true watch.workspace = true which.workspace = true worktree.workspace = true +zeroize.workspace = true zlog.workspace = true workspace-hack.workspace = true diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index a3d777ac774216967b2a5ffab03c72cf51dd9e7d..401a610ba0a2d0e17657547553ed03d8b9fca38f 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -7,7 +7,7 @@ use crate::{ worktree_store::{WorktreeStore, WorktreeStoreEvent}, }; use anyhow::{Context as _, Result, anyhow, bail}; -use askpass::AskPassDelegate; +use askpass::{AskPassDelegate, EncryptedPassword}; use buffer_diff::{BufferDiff, BufferDiffEvent}; use client::ProjectId; use collections::HashMap; @@ -68,6 +68,7 @@ use worktree::{ File, PathChange, PathKey, PathProgress, PathSummary, PathTarget, ProjectEntryId, UpdatedGitRepositoriesSet, UpdatedGitRepository, Worktree, }; +use zeroize::Zeroize; pub struct GitStore { state: GitStoreState, @@ -2106,7 +2107,7 @@ impl GitStore { .lock() .insert(envelope.payload.askpass_id, askpass); - Ok(proto::AskPassResponse { response }) + response.try_into() } async fn handle_check_for_pushed_commits( @@ -2740,7 +2741,10 @@ fn make_remote_delegate( prompt, }); cx.spawn(async move |_, _| { - tx.send(response.await?.response).ok(); + let mut response = response.await?.response; + tx.send(EncryptedPassword::try_from(response.as_ref())?) + .ok(); + response.zeroize(); anyhow::Ok(()) }) .detach_and_log_err(cx); diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 1445b80fa6118bbc79fa83f113bc23bdf66c7dc4..d81ac89e7cdacb96198313b2b8cdea86430dd0b2 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -14,6 +14,7 @@ doctest = false [dependencies] anyhow.workspace = true +askpass.workspace = true auto_update.workspace = true editor.workspace = true extension_host.workspace = true diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index fba7b122dd7842edfcb5f66054040e378acbfc70..689b06da71e43cfa451113a87b2db46be3bcaa75 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -1,6 +1,7 @@ use std::{path::PathBuf, sync::Arc}; use anyhow::{Context as _, Result}; +use askpass::EncryptedPassword; use auto_update::AutoUpdater; use editor::Editor; use extension_host::ExtensionStore; @@ -118,7 +119,7 @@ pub struct RemoteConnectionPrompt { nickname: Option, is_wsl: bool, status_message: Option, - prompt: Option<(Entity, oneshot::Sender)>, + prompt: Option<(Entity, oneshot::Sender)>, cancellation: Option>, editor: Entity, } @@ -160,10 +161,10 @@ impl RemoteConnectionPrompt { self.cancellation = Some(tx); } - pub fn set_prompt( + fn set_prompt( &mut self, prompt: String, - tx: oneshot::Sender, + tx: oneshot::Sender, window: &mut Window, cx: &mut Context, ) { @@ -203,8 +204,12 @@ impl RemoteConnectionPrompt { pub fn confirm(&mut self, window: &mut Window, cx: &mut Context) { if let Some((_, tx)) = self.prompt.take() { self.status_message = Some("Connecting".into()); + self.editor.update(cx, |editor, cx| { - tx.send(editor.text(cx)).ok(); + let pw = editor.text(cx); + if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) { + tx.send(secure).ok(); + } editor.clear(window, cx); }); } @@ -438,11 +443,16 @@ impl ModalView for RemoteConnectionModal { pub struct RemoteClientDelegate { window: AnyWindowHandle, ui: WeakEntity, - known_password: Option, + known_password: Option, } impl remote::RemoteClientDelegate for RemoteClientDelegate { - fn ask_password(&self, prompt: String, tx: oneshot::Sender, cx: &mut AsyncApp) { + fn ask_password( + &self, + prompt: String, + tx: oneshot::Sender, + cx: &mut AsyncApp, + ) { let mut known_password = self.known_password.clone(); if let Some(password) = known_password.take() { tx.send(password).ok(); @@ -531,7 +541,10 @@ pub fn connect_over_ssh( cx: &mut App, ) -> Task>>> { let window = window.window_handle(); - let known_password = connection_options.password.clone(); + let known_password = connection_options + .password + .as_deref() + .and_then(|pw| EncryptedPassword::try_from(pw).ok()); let (tx, rx) = oneshot::channel(); ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx)); @@ -557,9 +570,10 @@ pub fn connect( ) -> Task>>> { let window = window.window_handle(); let known_password = match &connection_options { - RemoteConnectionOptions::Ssh(ssh_connection_options) => { - ssh_connection_options.password.clone() - } + RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options + .password + .as_deref() + .and_then(|pw| pw.try_into().ok()), _ => None, }; let (tx, rx) = oneshot::channel(); @@ -644,7 +658,10 @@ pub async fn open_remote_project( known_password: if let RemoteConnectionOptions::Ssh(options) = &connection_options { - options.password.clone() + options + .password + .as_deref() + .and_then(|pw| EncryptedPassword::try_from(pw).ok()) } else { None }, diff --git a/crates/remote/Cargo.toml b/crates/remote/Cargo.toml index 59d80206b0585d59df6c73e2fee44b9ba1fb70c1..545eb8d43dc445732e20615d4f8aad9b927531f4 100644 --- a/crates/remote/Cargo.toml +++ b/crates/remote/Cargo.toml @@ -44,6 +44,7 @@ util.workspace = true which.workspace = true workspace-hack.workspace = true + [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 0363fc721a2d51971f49112af520b5dd34b52cb1..46fd903fc310a3964ff6571b0bdf4369d0707238 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -8,6 +8,7 @@ use crate::{ }, }; use anyhow::{Context as _, Result, anyhow}; +use askpass::EncryptedPassword; use async_trait::async_trait; use collections::HashMap; use futures::{ @@ -60,7 +61,12 @@ pub struct CommandTemplate { } pub trait RemoteClientDelegate: Send + Sync { - fn ask_password(&self, prompt: String, tx: oneshot::Sender, cx: &mut AsyncApp); + fn ask_password( + &self, + prompt: String, + tx: oneshot::Sender, + cx: &mut AsyncApp, + ); fn get_download_params( &self, platform: RemotePlatform, @@ -1373,6 +1379,7 @@ mod fake { use super::{ChannelClient, RemoteClientDelegate, RemoteConnection, RemotePlatform}; use crate::remote_client::{CommandTemplate, RemoteConnectionOptions}; use anyhow::Result; + use askpass::EncryptedPassword; use async_trait::async_trait; use collections::HashMap; use futures::{ @@ -1517,7 +1524,7 @@ mod fake { pub(super) struct Delegate; impl RemoteClientDelegate for Delegate { - fn ask_password(&self, _: String, _: oneshot::Sender, _: &mut AsyncApp) { + fn ask_password(&self, _: String, _: oneshot::Sender, _: &mut AsyncApp) { unreachable!() } diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 85ebf4659ccfbda35933f8b8aa4bec25d44a489c..2afed3f900012e969135b82bae627b5e4a9ddac2 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -74,6 +74,8 @@ struct SshSocket { #[cfg(not(target_os = "windows"))] socket_path: PathBuf, envs: HashMap, + #[cfg(target_os = "windows")] + password: askpass::EncryptedPassword, } macro_rules! shell_script { @@ -347,7 +349,13 @@ impl SshRemoteConnection { #[cfg(not(target_os = "windows"))] let socket = SshSocket::new(connection_options, socket_path)?; #[cfg(target_os = "windows")] - let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?; + let socket = SshSocket::new( + connection_options, + &temp_dir, + askpass + .get_password() + .context("Failed to fetch askpass password")?, + )?; drop(askpass); let ssh_platform = socket.platform().await?; @@ -674,16 +682,21 @@ impl SshSocket { } #[cfg(target_os = "windows")] - fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result { + fn new( + options: SshConnectionOptions, + temp_dir: &TempDir, + password: askpass::EncryptedPassword, + ) -> Result { let askpass_script = temp_dir.path().join("askpass.bat"); std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?; let mut envs = HashMap::default(); envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into()); envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string()); - envs.insert("ZED_SSH_ASKPASS".into(), secret); + Ok(Self { connection_options: options, envs, + password, }) } @@ -737,12 +750,14 @@ impl SshSocket { #[cfg(target_os = "windows")] fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { + use askpass::ProcessExt; command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .args(self.connection_options.additional_args()) .envs(self.envs.clone()) + .encrypted_env("ZED_SSH_ASKPASS", self.password.clone()) } // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.