From a9dc31fa652ceb1da9f16c1d963e6d4cef2656b9 Mon Sep 17 00:00:00 2001 From: Sean Aye Date: Thu, 18 Apr 2024 20:33:54 -0400 Subject: [PATCH] add support for external ssh signing --- asyncgit/Cargo.toml | 1 + asyncgit/src/sync/sign.rs | 238 +++++++++++++++++++++++++++++++++----- 2 files changed, 212 insertions(+), 27 deletions(-) diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index 40a83fffb3..8cb4a662b8 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -29,6 +29,7 @@ rayon-core = "1.12" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } ssh-key = { version = "0.6.6", features = ["crypto", "encryption"] } +tempfile = "3" thiserror = "1.0" unicode-truncate = "0.2" url = "2.5" diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index 53effe2477..a01c729868 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -1,7 +1,9 @@ //! Sign commit data. +use git2::Config; use ssh_key::{HashAlg, LineEnding, PrivateKey}; -use std::path::PathBuf; +use std::{fmt::Display, path::PathBuf}; +use tempfile::NamedTempFile; /// Error type for [`SignBuilder`], used to create [`Sign`]'s #[derive(thiserror::Error, Debug)] @@ -154,37 +156,81 @@ 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), - ) - }) - }, - ) - }) - .ok_or_else(|| { - SignBuilderError::SSHSigningKey(String::from( - "ssh key setting absent", - )) - }) - .and_then(SSHSign::new)?; - let signer: Box = Box::new(ssh_signer); - Ok(signer) + let program = SSHProgram::new(config); + program.into_signer(config) } _ => Err(SignBuilderError::InvalidFormat(format)), } } } +enum SSHProgram { + Default, + SystemBin(PathBuf), +} + +impl SSHProgram { + pub fn new(config: &git2::Config) -> Self { + match config.get_string("gpg.ssh.program") { + Err(_) => Self::Default, + Ok(ssh_program) => { + if ssh_program.is_empty() { + return Self::Default; + } + Self::SystemBin(PathBuf::from(ssh_program)) + } + } + } + + fn into_signer( + self, + config: &git2::Config, + ) -> Result, SignBuilderError> { + match self { + SSHProgram::Default => { + let ssh_signer = ConfigAccess(config) + .signing_key() + .and_then(SSHSign::new)?; + Ok(Box::new(ssh_signer)) + } + SSHProgram::SystemBin(exec_path) => { + let key = ConfigAccess(config).signing_key()?; + Ok(Box::new(ExternalBinSSHSign::new(exec_path, key))) + } + } + } +} + +/// wrapper struct for convenience methods over [Config] +struct ConfigAccess<'a>(&'a Config); + +impl<'a> ConfigAccess<'a> { + pub fn signing_key(&self) -> Result { + self.0 + .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), + ) + }) + }, + ) + }) + .ok_or_else(|| { + SignBuilderError::SSHSigningKey(String::from( + "ssh key setting absent", + )) + }) + } +} + /// Sign commit data using `OpenPGP` pub struct GPGSign { program: String, @@ -278,6 +324,144 @@ pub struct SSHSign { secret_key: PrivateKey, } +enum KeyPathOrLiteral { + Literal(PathBuf), + KeyPath(PathBuf), +} + +impl KeyPathOrLiteral { + fn new(buf: PathBuf) -> Self { + match buf.is_file() { + true => KeyPathOrLiteral::KeyPath(buf), + false => KeyPathOrLiteral::Literal(buf), + } + } +} + +impl Display for KeyPathOrLiteral { + fn fmt( + &self, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + let buf = match self { + Self::Literal(x) => x, + Self::KeyPath(x) => x, + }; + f.write_fmt(format_args!("{}", buf.display())) + } +} + +/// Struct which allows for signing via an external binary +pub struct ExternalBinSSHSign { + program_path: PathBuf, + key_path: KeyPathOrLiteral, + #[cfg(test)] + program: String, + #[cfg(test)] + signing_key: String, +} + +impl ExternalBinSSHSign { + /// constructs a new instance of the external ssh signer + pub fn new(program_path: PathBuf, key_path: PathBuf) -> Self { + #[cfg(test)] + let program: String = program_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + let key_path = KeyPathOrLiteral::new(key_path); + + #[cfg(test)] + let signing_key = key_path.to_string(); + + ExternalBinSSHSign { + program_path, + key_path, + #[cfg(test)] + program, + #[cfg(test)] + signing_key, + } + } +} + +impl Sign for ExternalBinSSHSign { + fn sign( + &self, + commit: &[u8], + ) -> Result<(String, Option), SignError> { + use std::io::Write; + use std::process::{Command, Stdio}; + + if cfg!(target_os = "windows") { + return Err(SignError::Spawn("External binary signing is only supported on Unix based systems".into())); + } + + let mut file = NamedTempFile::new() + .map_err(|e| SignError::Spawn(e.to_string()))?; + + let key = match &self.key_path { + KeyPathOrLiteral::Literal(x) => { + write!(file, "{}", x.display()).map_err(|e| { + SignError::WriteBuffer(e.to_string()) + })?; + file.path() + } + KeyPathOrLiteral::KeyPath(x) => x.as_path(), + }; + + let mut c = Command::new(&self.program_path); + c.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .arg("-Y") + .arg("sign") + .arg("-n") + .arg("git") + .arg("-f") + .arg(key); + + let mut child = + c.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_path.display(), + std::str::from_utf8(&output.stderr).unwrap_or("[error could not be read from stderr]") + ))); + } + + let signed_commit = std::str::from_utf8(&output.stdout) + .map_err(|e| SignError::Shellout(e.to_string()))?; + + Ok((signed_commit.to_string(), None)) + } + + #[cfg(test)] + fn program(&self) -> &String { + &self.program + } + + #[cfg(test)] + fn signing_key(&self) -> &String { + &self.signing_key + } +} + impl SSHSign { /// Create new [`SSHDiskKeySign`] for sign. pub fn new(mut key: PathBuf) -> Result { @@ -304,7 +488,7 @@ impl SSHSign { }) } else { Err(SignBuilderError::SSHSigningKey( - String::from("Currently, we only support a pair of ssh key in disk."), + format!("Currently, we only support a pair of ssh key in disk. Found {:?}", key), )) } }