windows: Encrypt SSH passwords stored in memory (#38427)

Release Notes:

- N/A

---------

Co-authored-by: Julia <julia@zed.dev>
This commit is contained in:
Piotr Osiewicz
2025-09-23 18:58:46 +02:00
committed by GitHub
co-authored by Julia
parent 6a24ad7d39
commit dc20a41e0d
14 changed files with 232 additions and 37 deletions
Generated
+6 -1
View File
@@ -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",
+2
View File
@@ -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",
+5 -1
View File
@@ -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
+23 -10
View File
@@ -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<String>)>,
tx: mpsc::UnboundedSender<(String, oneshot::Sender<EncryptedPassword>)>,
_task: Task<()>,
}
impl AskPassDelegate {
pub fn new(
cx: &mut AsyncApp,
password_prompt: impl Fn(String, oneshot::Sender<String>, &mut AsyncApp) + Send + Sync + 'static,
password_prompt: impl Fn(String, oneshot::Sender<EncryptedPassword>, &mut AsyncApp)
+ Send
+ Sync
+ 'static,
) -> Self {
let (tx, mut rx) = mpsc::unbounded::<(String, oneshot::Sender<String>)>();
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<String> {
pub async fn ask_password(&mut self, prompt: String) -> Result<EncryptedPassword> {
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<parking_lot::Mutex<String>>,
secret: std::sync::Arc<OnceLock<EncryptedPassword>>,
_askpass_task: Task<()>,
askpass_opened_rx: Option<oneshot::Receiver<()>>,
askpass_kill_master_rx: Option<oneshot::Receiver<()>>,
@@ -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<EncryptedPassword> {
self.secret.get().cloned()
}
}
+111
View File
@@ -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<u8>, 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<EncryptedPassword> for proto::AskPassResponse {
type Error = anyhow::Error;
fn try_from(pw: EncryptedPassword) -> Result<Self, Self::Error> {
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<EncryptedPassword> {
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::<Vec<_>>();
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<String> {
#[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))?)
}
+1
View File
@@ -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
+19 -6
View File
@@ -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<Editor>,
tx: Option<oneshot::Sender<String>>,
tx: Option<oneshot::Sender<EncryptedPassword>>,
}
impl EventEmitter<DismissEvent> for AskPassModal {}
@@ -28,7 +31,7 @@ impl AskPassModal {
pub fn new(
operation: SharedString,
prompt: SharedString,
tx: oneshot::Sender<String>,
tx: oneshot::Sender<EncryptedPassword>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -53,10 +56,20 @@ impl AskPassModal {
cx.emit(DismissEvent);
}
fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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);
}
+1
View File
@@ -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
+7 -3
View File
@@ -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);
+1
View File
@@ -14,6 +14,7 @@ doctest = false
[dependencies]
anyhow.workspace = true
askpass.workspace = true
auto_update.workspace = true
editor.workspace = true
extension_host.workspace = true
@@ -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<SharedString>,
is_wsl: bool,
status_message: Option<SharedString>,
prompt: Option<(Entity<Markdown>, oneshot::Sender<String>)>,
prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
cancellation: Option<oneshot::Sender<()>>,
editor: Entity<Editor>,
}
@@ -160,10 +161,10 @@ impl RemoteConnectionPrompt {
self.cancellation = Some(tx);
}
pub fn set_prompt(
fn set_prompt(
&mut self,
prompt: String,
tx: oneshot::Sender<String>,
tx: oneshot::Sender<EncryptedPassword>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -203,8 +204,12 @@ impl RemoteConnectionPrompt {
pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<RemoteConnectionPrompt>,
known_password: Option<String>,
known_password: Option<EncryptedPassword>,
}
impl remote::RemoteClientDelegate for RemoteClientDelegate {
fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
fn ask_password(
&self,
prompt: String,
tx: oneshot::Sender<EncryptedPassword>,
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<Result<Option<Entity<RemoteClient>>>> {
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<Result<Option<Entity<RemoteClient>>>> {
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
},
+1
View File
@@ -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"] }
+9 -2
View File
@@ -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<String>, cx: &mut AsyncApp);
fn ask_password(
&self,
prompt: String,
tx: oneshot::Sender<EncryptedPassword>,
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<String>, _: &mut AsyncApp) {
fn ask_password(&self, _: String, _: oneshot::Sender<EncryptedPassword>, _: &mut AsyncApp) {
unreachable!()
}
+18 -3
View File
@@ -74,6 +74,8 @@ struct SshSocket {
#[cfg(not(target_os = "windows"))]
socket_path: PathBuf,
envs: HashMap<String, String>,
#[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<Self> {
fn new(
options: SshConnectionOptions,
temp_dir: &TempDir,
password: askpass::EncryptedPassword,
) -> Result<Self> {
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.