From 263f31c96d9a3b04e4bafce35315787edf4a21fc Mon Sep 17 00:00:00 2001 From: SWork <20725343+swork9@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:08:14 +0000 Subject: [PATCH 1/4] Support hardware-backed SSH commit signing (FIDO2, PIV, ssh-agent) --- asyncgit/src/sync/sign.rs | 242 +++++++++++++++++++++++++++++--------- src/app.rs | 8 ++ src/main.rs | 2 + src/popups/commit.rs | 85 ++++++++----- 4 files changed, 249 insertions(+), 88 deletions(-) diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index bde0fc53d2..9e3dc54484 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -1,7 +1,8 @@ //! Sign commit data. +use crate::sync::{repository::repo, RepoPath}; use ssh_key::{HashAlg, LineEnding, PrivateKey}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Error type for [`SignBuilder`], used to create [`Sign`]'s #[derive(thiserror::Error, Debug)] @@ -156,30 +157,20 @@ impl SignBuilder { String::from("x509"), )), "ssh" => { - let ssh_signer = config - .get_string("user.signingKey") - .ok() - .and_then(|key_path| { - key_path.strip_prefix('~').map_or_else( - || Some(PathBuf::from(&key_path)), - |ssh_key_path| { - dirs::home_dir().map(|home| { - home.join( - ssh_key_path - .strip_prefix('/') - .unwrap_or(ssh_key_path), - ) - }) - }, - ) - }) + // https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgsshprogram + let program = config + .get_string("gpg.ssh.program") + .unwrap_or_else(|_| "ssh-keygen".to_string()); + + let key_path = resolve_ssh_signing_key(config) .ok_or_else(|| { SignBuilderError::SSHSigningKey(String::from( "ssh key setting absent", )) - }) - .and_then(SSHSign::new)?; - let signer: Box = Box::new(ssh_signer); + })?; + + let signer: Box = + Box::new(SSHSign::new(&key_path, program)?); Ok(signer) } _ => Err(SignBuilderError::InvalidFormat(format)), @@ -187,6 +178,64 @@ impl SignBuilder { } } +/// Resolve `user.signingKey` to a private key path, expanding a leading `~`. +fn resolve_ssh_signing_key(config: &git2::Config) -> Option { + config + .get_string("user.signingKey") + .ok() + .and_then(|key_path| { + key_path.strip_prefix('~').map_or_else( + || Some(PathBuf::from(&key_path)), + |ssh_key_path| { + dirs::home_dir().map(|home| { + home.join( + ssh_key_path + .strip_prefix('/') + .unwrap_or(ssh_key_path), + ) + }) + }, + ) + }) +} + +const fn is_security_key(alg: &ssh_key::Algorithm) -> bool { + use ssh_key::Algorithm; + matches!( + alg, + Algorithm::SkEd25519 | Algorithm::SkEcdsaSha2NistP256 + ) +} + +pub fn signing_requires_user_presence(repo_path: &RepoPath) -> bool { + let Ok(repo) = repo(repo_path) else { + return false; + }; + let Ok(config) = repo.config() else { + return false; + }; + + if !config.get_bool("commit.gpgsign").unwrap_or(false) { + return false; + } + + if config.get_string("gpg.format").ok().as_deref() != Some("ssh") + { + return false; + } + + resolve_ssh_signing_key(&config) + .map(strip_ssh_key_extension) + .and_then(|key| std::fs::read(key).ok()) + .and_then(|bytes| PrivateKey::from_openssh(bytes).ok()) + .is_some_and(|key| is_security_key(&key.algorithm())) +} + +fn strip_ssh_key_extension(mut key: PathBuf) -> PathBuf { + key.set_extension(""); + key +} + /// Sign commit data using `OpenPGP` pub struct GPGSign { program: String, @@ -271,44 +320,115 @@ impl Sign for GPGSign { } } -/// Sign commit data using `SSHDiskKeySign` +/// Sign commit data using an SSH key. pub struct SSHSign { - #[cfg(test)] program: String, - #[cfg(test)] key_path: String, - secret_key: PrivateKey, + mode: SSHSignMode, +} + +enum SSHSignMode { + InMemory { secret_key: Box }, + /// Hardware (`FIDO2`/`U2F`, PIV/PKCS#11) or agent-backed key, signed by delegating to `ssh-keygen`. + Keygen, } impl SSHSign { - /// Create new `SSHDiskKeySign` for sign. - pub fn new(mut key: PathBuf) -> Result { - key.set_extension(""); - if key.is_file() { - #[cfg(test)] - let key_path = format!("{}", &key.display()); - std::fs::read(key) - .ok() - .and_then(|bytes| { + /// Create new [`SSHSign`] from a private key path and signing program. + pub fn new( + key: &Path, + program: String, + ) -> Result { + let private_key = strip_ssh_key_extension(key.to_path_buf()); + if private_key.is_file() { + if let Some(secret_key) = + std::fs::read(&private_key).ok().and_then(|bytes| { PrivateKey::from_openssh(bytes).ok() - }) - .map(|secret_key| Self { - #[cfg(test)] - program: "ssh".to_string(), - #[cfg(test)] - key_path, - secret_key, - }) - .ok_or_else(|| { - SignBuilderError::SSHSigningKey(String::from( - "Fail to read the private key for sign.", - )) - }) - } else { - Err(SignBuilderError::SSHSigningKey( - String::from("Currently, we only support a pair of ssh key in disk."), - )) + }) { + let mode = if is_security_key(&secret_key.algorithm()) + { + SSHSignMode::Keygen + } else { + SSHSignMode::InMemory { + secret_key: Box::new(secret_key), + } + }; + return Ok(Self { + program, + key_path: private_key.display().to_string(), + mode, + }); + } + } + + // No usable private key on disk (PIV/PKCS#11 or agent-only key): delegate to `ssh-keygen` with the configured (public) key, which signs through `ssh-agent`. + if key.is_file() { + return Ok(Self { + program, + key_path: key.display().to_string(), + mode: SSHSignMode::Keygen, + }); } + + Err(SignBuilderError::SSHSigningKey(String::from( + "could not find an ssh signing key on disk or in the agent", + ))) + } + + fn sign_with_keygen( + &self, + commit: &[u8], + ) -> Result<(String, Option), SignError> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut cmd = Command::new(&self.program); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .arg("-Y") + .arg("sign") + .arg("-n") + .arg("git") + .arg("-f") + .arg(&self.key_path); + + log::trace!("signing command: {cmd:?}"); + + let mut child = cmd + .spawn() + .map_err(|e| SignError::Spawn(e.to_string()))?; + + let mut stdin = child.stdin.take().ok_or(SignError::Stdin)?; + stdin + .write_all(commit) + .map_err(|e| SignError::WriteBuffer(e.to_string()))?; + drop(stdin); + + let output = child + .wait_with_output() + .map_err(|e| SignError::Output(e.to_string()))?; + + if !output.status.success() { + return Err(SignError::Shellout(format!( + "failed to sign data, program '{}' exited non-zero: {}", + self.program, + std::str::from_utf8(&output.stderr) + .unwrap_or("[error could not be read from stderr]") + ))); + } + + let signature = std::str::from_utf8(&output.stdout) + .map_err(|e| SignError::Shellout(e.to_string()))?; + + if !signature.contains("-----BEGIN SSH SIGNATURE-----") { + return Err(SignError::Shellout(format!( + "program '{}' did not produce an ssh signature", + self.program + ))); + } + + Ok((signature.to_string(), None)) } } @@ -317,13 +437,19 @@ impl Sign for SSHSign { &self, commit: &[u8], ) -> Result<(String, Option), SignError> { - let sig = self - .secret_key - .sign("git", HashAlg::Sha256, commit) - .map_err(|err| SignError::Spawn(err.to_string()))? - .to_pem(LineEnding::LF) - .map_err(|err| SignError::Spawn(err.to_string()))?; - Ok((sig, None)) + match &self.mode { + SSHSignMode::InMemory { secret_key } => { + let sig = secret_key + .sign("git", HashAlg::Sha256, commit) + .map_err(|err| SignError::Spawn(err.to_string()))? + .to_pem(LineEnding::LF) + .map_err(|err| { + SignError::Spawn(err.to_string()) + })?; + Ok((sig, None)) + } + SSHSignMode::Keygen => self.sign_with_keygen(commit), + } } #[cfg(test)] diff --git a/src/app.rs b/src/app.rs index ddb157af3f..81af71951a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -423,6 +423,14 @@ impl App { ) -> Result<()> { log::trace!("update_async: {ev:?}"); + if ev + == AsyncNotification::App( + AsyncAppNotification::PerformPendingCommit, + ) { + self.commit_popup.perform_pending_commit()?; + self.msg_popup.hide(); + } + if let AsyncNotification::Git(ev) = ev { self.status_tab.update_git(ev)?; self.stashing_tab.update_git(ev)?; diff --git a/src/main.rs b/src/main.rs index fd662950a2..2a3a22d125 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,6 +134,8 @@ pub enum SyntaxHighlightProgress { pub enum AsyncAppNotification { /// SyntaxHighlighting(SyntaxHighlightProgress), + /// run a commit whose signing was deferred to show a hint first + PerformPendingCommit, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/src/popups/commit.rs b/src/popups/commit.rs index b5dff7677c..54786bbedc 100644 --- a/src/popups/commit.rs +++ b/src/popups/commit.rs @@ -9,6 +9,7 @@ use crate::{ queue::{InternalEvent, NeedsUpdate, Queue}, strings, try_or_popup, ui::style::SharedTheme, + AsyncAppNotification, }; use anyhow::{bail, Ok, Result}; use asyncgit::sync::commit::commit_message_prettify; @@ -20,6 +21,7 @@ use asyncgit::{ }, StatusItem, StatusItemType, }; +use crossbeam_channel::Sender; use crossterm::event::Event; use easy_cast::Cast; use ratatui::{ @@ -38,11 +40,6 @@ use std::{ use super::ExternalEditorPopup; -enum CommitResult { - CommitDone, - Aborted, -} - enum Mode { Normal, Amend(CommitId), @@ -63,6 +60,8 @@ pub struct CommitPopup { commit_msg_history_idx: usize, options: SharedOptions, verify: bool, + sender_app: Sender, + pending_commit: Option, } const FIRST_LINE_LIMIT: usize = 50; @@ -89,6 +88,8 @@ impl CommitPopup { commit_msg_history_idx: 0, options: env.options.clone(), verify: true, + sender_app: env.sender_app.clone(), + pending_commit: None, } } @@ -208,29 +209,10 @@ impl CommitPopup { fn commit(&mut self) -> Result<()> { let msg = self.input.get_text().to_string(); - - if matches!( - self.commit_with_msg(msg)?, - CommitResult::CommitDone - ) { - self.options - .borrow_mut() - .add_commit_msg(self.input.get_text()); - self.commit_msg_history_idx = 0; - - self.hide(); - self.queue.push(InternalEvent::Update(NeedsUpdate::ALL)); - self.queue.push(InternalEvent::StatusLastFileMoved); - self.input.clear(); - } - - Ok(()) + self.commit_with_msg(msg) } - fn commit_with_msg( - &mut self, - msg: String, - ) -> Result { + fn commit_with_msg(&mut self, msg: String) -> Result<()> { // on exit verify should always be on let verify = self.verify; self.verify = true; @@ -244,7 +226,7 @@ impl CommitPopup { self.queue.push(InternalEvent::ShowErrorMsg( format!("pre-commit hook error:\n{e}"), )); - return Ok(CommitResult::Aborted); + return Ok(()); } } @@ -260,10 +242,43 @@ impl CommitPopup { self.queue.push(InternalEvent::ShowErrorMsg( format!("commit-msg hook error:\n{e}"), )); - return Ok(CommitResult::Aborted); + return Ok(()); } } - self.do_commit(&msg)?; + + // signing with a security key blocks for a physical touch; show a + // hint and defer the blocking commit to the next frame so it paints. + if sync::sign::signing_requires_user_presence( + &self.repo.borrow(), + ) { + self.pending_commit = Some(msg); + self.queue.push(InternalEvent::ShowInfoMsg( + "Touch your security key to sign the commit..." + .to_string(), + )); + self.sender_app + .send(AsyncAppNotification::PerformPendingCommit) + .expect("could not send commit notification"); + return Ok(()); + } + + self.finish_commit(&msg) + } + + /// Run the deferred commit once the touch hint has been shown. + pub fn perform_pending_commit(&mut self) -> Result<()> { + if let Some(msg) = self.pending_commit.take() { + try_or_popup!( + self, + "commit error:", + self.finish_commit(&msg) + ); + } + Ok(()) + } + + fn finish_commit(&mut self, msg: &str) -> Result<()> { + self.do_commit(msg)?; if let HookResult::NotOk(e) = sync::hooks_post_commit(&self.repo.borrow())? @@ -274,7 +289,17 @@ impl CommitPopup { ))); } - Ok(CommitResult::CommitDone) + self.options + .borrow_mut() + .add_commit_msg(self.input.get_text()); + self.commit_msg_history_idx = 0; + + self.hide(); + self.queue.push(InternalEvent::Update(NeedsUpdate::ALL)); + self.queue.push(InternalEvent::StatusLastFileMoved); + self.input.clear(); + + Ok(()) } fn do_commit(&self, msg: &str) -> Result<()> { From d75f795b160cc0762dbb2b6262c4fc1fd6318ae7 Mon Sep 17 00:00:00 2001 From: SWork <20725343+swork9@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:20:58 +0000 Subject: [PATCH 2/4] fix missing comment --- asyncgit/src/sync/sign.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index 9e3dc54484..eeee45a812 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -207,6 +207,7 @@ const fn is_security_key(alg: &ssh_key::Algorithm) -> bool { ) } +/// Whether signing a commit here will block on a security-key touch. pub fn signing_requires_user_presence(repo_path: &RepoPath) -> bool { let Ok(repo) = repo(repo_path) else { return false; From 6d1ec6cd9f743503bc895ee1ffd1974997a2571b Mon Sep 17 00:00:00 2001 From: SWork <20725343+swork9@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:34:08 +0000 Subject: [PATCH 3/4] fix changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db667675c..e0499dbb85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +* support hardware-backed SSH commit signing — FIDO2, PIV/PKCS#11, and ssh-agent keys — by delegating to `ssh-keygen` [[@swork9](https://github.com/swork9)] ([#2982](https://github.com/gitui-org/gitui/pull/2982)) + ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting * open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805)) From 3531f084c3a9e4b0f059d3fd35eb7936a3af3c78 Mon Sep 17 00:00:00 2001 From: SWork <20725343+swork9@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:12:32 +0000 Subject: [PATCH 4/4] fix enum formatting --- asyncgit/src/sync/sign.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index eeee45a812..3f9e8547dc 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -329,7 +329,9 @@ pub struct SSHSign { } enum SSHSignMode { - InMemory { secret_key: Box }, + InMemory { + secret_key: Box, + }, /// Hardware (`FIDO2`/`U2F`, PIV/PKCS#11) or agent-backed key, signed by delegating to `ssh-keygen`. Keygen, }