From b76e4db15df16b79f8ab8383904a92d7ebc60fca Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Wed, 15 Jul 2026 10:27:31 +0200 Subject: [PATCH] git: Add a gpg wrapper script to route GnuPG prompts via the askpass UI (#58791) This PR adds support for entering your git GPG passphrase through the askpass UI. As a daily Zed user I'm really missing this feature, this forces me to switch to a terminal or an external application. which is not ideal for me and most people that are forced to use GPG signing. Right now Zed can show a similar error (redacted) when you are trying to commit with GPG signing enabled: ``` error: gpg failed to sign the data: [GNUPG:] KEY_CONSIDERED 2 [GNUPG:] BEGIN_SIGNING H8 [GNUPG:] PINENTRY_LAUNCHED 4625 curses 1.3.2 - xterm-ghostty - - 501/20 0 gpg: signing failed: Inappropriate ioctl for device [GNUPG:] FAILURE sign gpg: signing failed: Inappropriate ioctl for device fatal: failed to write commit object ``` Here is the key configurion parts of my **global** git config: ``` [user] name = Remco Smits email = signingkey = [commit] gpgsign = true [tag] gpgsign = true ``` **Before** https://github.com/user-attachments/assets/60a06574-92f1-45df-a29a-8ae11db6751d **After** https://github.com/user-attachments/assets/92b648e9-b538-4ce5-af4c-136689d14e1a **How to test this?** 1. Create a key `gpg --full-generate-key` 2. Run `git config --global user.signingkey ` 3. Run `git config --global commit.gpgsign true` 4. Run & copy result from `gpg --armor --export ` and submit your public key to github [https://github.com/settings/gpg/new](https://github.com/settings/gpg/new) 5. Make a change and try committing 6. See that it promts for your passphrase :) **Note**: I used AI as an assistant to write this PR Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Git: Passphrase prompts from GPG to unlock commit signing keys are now shown in Zed. Co-authored-by: Lukas Wirth --- Cargo.lock | 1 + crates/askpass/Cargo.toml | 3 + crates/askpass/src/askpass.rs | 136 ++++++++++++++++++++++++++++++++++ crates/git/src/repository.rs | 10 +++ 4 files changed, 150 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 31c56677ac..b48dfe2dd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,6 +954,7 @@ dependencies = [ "smol", "tempfile", "util", + "which 6.0.3", "windows 0.61.3", "zeroize", ] diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index 298d1a7369..671ab34df2 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -22,6 +22,9 @@ tempfile.workspace = true util.workspace = true zeroize.workspace = true +[target.'cfg(not(target_os = "windows"))'.dependencies] +which.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 e887841b58..dc5488ac11 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -91,6 +91,9 @@ pub struct AskPassSession { #[cfg(not(target_os = "windows"))] const ASKPASS_SCRIPT_NAME: &str = "askpass.sh"; +#[cfg(not(target_os = "windows"))] +const GPG_WRAPPER_SCRIPT_NAME: &str = "gpg-wrapper.sh"; + impl AskPassSession { /// This will create a new AskPassSession. /// You must retain this session until the master process exits. @@ -189,6 +192,15 @@ impl AskPassSession { self.askpass_task.script_path() } + /// Path to a script suitable for git's `gpg.program`, routing GnuPG + /// passphrase prompts through Zed's askpass UI. `None` if unavailable. + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + #[cfg(not(target_os = "windows"))] + return self.askpass_task.gpg_wrapper_path(); + #[cfg(target_os = "windows")] + return None; + } + /// Returns the socket path to set as ZED_ASKPASS_SOCKET. /// /// On Windows, SSH_ASKPASS points directly to cli.exe. SSH passes only @@ -206,6 +218,8 @@ pub struct PasswordProxy { /// On Unix: path to the generated .sh askpass script (set as SSH_ASKPASS). /// On Windows: path to cli.exe (set as SSH_ASKPASS directly — no script needed). askpass_script_path: std::path::PathBuf, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path: Option, /// On Windows only: path to the Unix socket, passed as ZED_ASKPASS_SOCKET /// so cli.exe can find it without --askpass argument parsing. #[cfg(target_os = "windows")] @@ -238,6 +252,24 @@ impl PasswordProxy { let askpass_socket_path = askpass_socket.clone(); + // Create a gpg wrapper script that routes GnuPG passphrase prompts through + // the same socket (and thus through Zed's askpass UI). This only works on + // Unix where we control the pinentry via loopback mode. We compute the path + // before the socket task takes ownership of `temp_dir`, and write the file + // afterwards. + #[cfg(not(target_os = "windows"))] + let (gpg_wrapper_script_path, gpg_wrapper_script) = + match generate_gpg_wrapper_script(askpass_program, &askpass_socket_path) { + Ok(script) => ( + Some(temp_dir.path().join(GPG_WRAPPER_SCRIPT_NAME)), + Some(script), + ), + Err(err) => { + log::warn!("could not create gpg askpass wrapper: {err:#}"); + (None, None) + } + }; + let _task = executor.spawn(async move { maybe!(async move { let listener = @@ -291,9 +323,36 @@ impl PasswordProxy { })?; } + // Write the gpg wrapper script (computed above) and mark it executable. + #[cfg(not(target_os = "windows"))] + let gpg_wrapper_script_path = + if let Some((path, script)) = gpg_wrapper_script_path.zip(gpg_wrapper_script) { + match async { + fs::write(&path, script) + .await + .with_context(|| format!("creating gpg wrapper script at {path:?}"))?; + make_file_executable(&path).await.with_context(|| { + format!("marking gpg wrapper script executable at {path:?}") + })?; + anyhow::Ok(()) + } + .await + { + Ok(()) => Some(path), + Err(err) => { + log::warn!("could not write gpg askpass wrapper: {err:#}"); + None + } + } + } else { + None + }; + Ok(Self { _task, askpass_script_path, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path, #[cfg(target_os = "windows")] askpass_socket_path, }) @@ -307,6 +366,11 @@ impl PasswordProxy { pub fn socket_path(&self) -> impl AsRef { &self.askpass_socket_path } + + #[cfg(not(target_os = "windows"))] + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + self.gpg_wrapper_script_path.as_deref() + } } /// Runs Zed in netcat mode for use in askpass. @@ -398,3 +462,75 @@ fn generate_askpass_script( "{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n", )) } + +#[inline] +#[cfg(not(target_os = "windows"))] +fn generate_gpg_wrapper_script( + askpass_program: &std::path::Path, + askpass_socket: &std::path::Path, +) -> Result { + let shell_kind = ShellKind::Posix; + let gpg_program = find_gpg_program().context("could not find a gpg binary on PATH")?; + let gpg_program = gpg_program + .to_str() + .context("gpg program is on a non-utf8 path")?; + let gpg_program = shell_kind + .try_quote_prefix_aware(gpg_program) + .context("Failed to shell-escape gpg program path")?; + + let askpass_program = shell_kind.prepend_command_prefix( + askpass_program + .to_str() + .context("Askpass program is on a non-utf8 path")?, + ); + let askpass_program = shell_kind + .try_quote_prefix_aware(&askpass_program) + .context("Failed to shell-escape Askpass program path")?; + let askpass_socket = askpass_socket + .try_shell_safe(shell_kind) + .context("Failed to shell-escape Askpass socket path")?; + + let prompt = shell_kind + .try_quote_prefix_aware("Enter passphrase for your Git signing key:") + .context("Failed to shell-escape gpg passphrase prompt")?; + + // The wrapper inspects gpg's arguments and only prompts for a passphrase when + // git asks it to *sign* (e.g. `gpg -bsau `). Other invocations such as + // signature verification (`--verify`) run gpg unchanged so we never pop a + // spurious modal. When signing, we feed the passphrase to gpg on fd 3 via + // `--passphrase-fd 3` in loopback mode, so no pinentry/terminal is required. + Ok(format!( + r#"#!/bin/sh +for arg in "$@"; do + case "$arg" in + # Long-form signing options. + --sign|--detach-sign|--clearsign|--clear-sign) is_signing=1 ;; + # Skip other long options so flags like `--status-fd` don't match below. + --*) ;; + # Short-flag clusters containing `s`, e.g. git's `-bsau`. + -*s*) is_signing=1 ;; + esac +done + +# Not a signing request: run gpg as-is, leaving its prompting untouched. +if [ -z "${{is_signing}}" ]; then + exec {gpg_program} "$@" +fi + +# Signing: ask Zed for the passphrase, then hand it to gpg on fd 3. +passphrase=$(printf '%s\0' {prompt} | {askpass_program} --askpass={askpass_socket} 2>/dev/null) +exec {gpg_program} --pinentry-mode loopback --passphrase-fd 3 "$@" 3< Option { + ["gpg", "gpg2"] + .into_iter() + .find_map(|candidate| which::which(candidate).ok()) +} diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 94d3fac855..0ff60eade2 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -3744,6 +3744,16 @@ async fn run_git_command( .env("GIT_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS_REQUIRE", "force"); + + if !env.contains_key("GIT_CONFIG_COUNT") + && let Some(gpg_wrapper) = ask_pass.gpg_wrapper_path() + { + command + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "gpg.program") + .env("GIT_CONFIG_VALUE_0", gpg_wrapper); + } + #[cfg(target_os = "windows")] command.env("ZED_ASKPASS_SOCKET", ask_pass.socket_path()); let git_process = command.spawn()?;