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 <redacted> 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 <redacted> 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 = <redacted> signingkey = <redacted> [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 <KEY ID>` 3. Run `git config --global commit.gpgsign true` 4. Run & copy result from `gpg --armor --export <KEY ID>` 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 <lukas@zed.dev>
This commit is contained in:
1 parent
166f044fd0
commit
b76e4db15d
4 files changed
+150
No files matched your search
Generated
+1
@@ -954,6 +954,7 @@ dependencies = [
|
||||
"smol",
|
||||
"tempfile",
|
||||
"util",
|
||||
"which 6.0.3",
|
||||
"windows 0.61.3",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<std::path::PathBuf>,
|
||||
/// 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<OsStr> {
|
||||
&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<String> {
|
||||
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 <key>`). 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<<EOF
|
||||
${{passphrase}}
|
||||
EOF
|
||||
"#,
|
||||
))
|
||||
}
|
||||
|
||||
/// Finds the real `gpg` (or `gpg2`) executable on `PATH`.
|
||||
#[inline]
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn find_gpg_program() -> Option<std::path::PathBuf> {
|
||||
["gpg", "gpg2"]
|
||||
.into_iter()
|
||||
.find_map(|candidate| which::which(candidate).ok())
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
Reference in new issue
Block a user