Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 004b135

Browse files
committedJan 15, 2025
feat(ssh-agent-signing): implement commit signing with ssh-agent
1 parent 9b3bc7b commit 004b135

File tree

4 files changed

+227
-805
lines changed

4 files changed

+227
-805
lines changed
 

‎CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
* add support for ssh comming signing with ssh-agents [[@chirpcel](https://github.com/chirpcel)] ([#2188](https://github.com/extrawurst/gitui/issues/2188))
12+
1013
## [0.27.0] - 2024-01-14
1114

1215
**new: manage remotes**

‎Cargo.lock

Lines changed: 50 additions & 712 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎asyncgit/Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ rayon = "1.10"
3232
rayon-core = "1.12"
3333
scopetime = { path = "../scopetime", version = "0.1" }
3434
serde = { version = "1.0", features = ["derive"] }
35-
ssh-key = { version = "0.6.7", features = ["crypto", "encryption"] }
35+
tempfile = "3"
3636
thiserror = "2.0"
3737
unicode-truncate = "2.0"
3838
url = "2.5"
@@ -42,7 +42,6 @@ env_logger = "0.11"
4242
invalidstring = { path = "../invalidstring", version = "0.1" }
4343
pretty_assertions = "1.4"
4444
serial_test = "3.2"
45-
tempfile = "3"
4645

4746
[features]
4847
default = ["trace-libgit"]

‎asyncgit/src/sync/sign.rs

Lines changed: 173 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
//! Sign commit data.
22
3-
use ssh_key::{HashAlg, LineEnding, PrivateKey};
43
use std::path::PathBuf;
54

65
/// Error type for [`SignBuilder`], used to create [`Sign`]'s
@@ -72,11 +71,11 @@ pub trait Sign {
7271

7372
/// only available in `#[cfg(test)]` helping to diagnose issues
7473
#[cfg(test)]
75-
fn program(&self) -> &String;
74+
fn program(&self) -> String;
7675

7776
/// only available in `#[cfg(test)]` helping to diagnose issues
7877
#[cfg(test)]
79-
fn signing_key(&self) -> &String;
78+
fn signing_key(&self) -> String;
8079
}
8180

8281
/// A builder to facilitate the creation of a signing method ([`Sign`]) by examining the git configuration.
@@ -156,35 +155,52 @@ impl SignBuilder {
156155
String::from("x509"),
157156
)),
158157
"ssh" => {
159-
let ssh_signer = config
158+
let program = config
159+
.get_string("gpg.ssh.program")
160+
.unwrap_or_else(|_| "ssh-keygen".to_string());
161+
162+
let signing_key = config
160163
.get_string("user.signingKey")
161-
.ok()
162-
.and_then(|key_path| {
163-
key_path.strip_prefix('~').map_or_else(
164-
|| Some(PathBuf::from(&key_path)),
165-
|ssh_key_path| {
166-
dirs::home_dir().map(|home| {
167-
home.join(
168-
ssh_key_path
169-
.strip_prefix('/')
170-
.unwrap_or(ssh_key_path),
171-
)
172-
})
173-
},
164+
.map_err(|err| {
165+
SignBuilderError::SSHSigningKey(
166+
err.to_string(),
174167
)
175168
})
176-
.ok_or_else(|| {
177-
SignBuilderError::SSHSigningKey(String::from(
178-
"ssh key setting absent",
179-
))
180-
})
181-
.and_then(SSHSign::new)?;
182-
let signer: Box<dyn Sign> = Box::new(ssh_signer);
183-
Ok(signer)
169+
.and_then(|signing_key| {
170+
Self::signing_key_into_path(&signing_key)
171+
})?;
172+
173+
Ok(Box::new(SSHSign {
174+
program,
175+
signing_key,
176+
}))
184177
}
185178
_ => Err(SignBuilderError::InvalidFormat(format)),
186179
}
187180
}
181+
182+
fn signing_key_into_path(
183+
signing_key: &str,
184+
) -> Result<PathBuf, SignBuilderError> {
185+
let key_path = PathBuf::from(signing_key);
186+
if signing_key.starts_with("ssh-") {
187+
use std::io::Write;
188+
use tempfile::NamedTempFile;
189+
let mut temp_file =
190+
NamedTempFile::new().map_err(|err| {
191+
SignBuilderError::SSHSigningKey(err.to_string())
192+
})?;
193+
writeln!(temp_file, "{signing_key}").map_err(|err| {
194+
SignBuilderError::SSHSigningKey(err.to_string())
195+
})?;
196+
let temp_file = temp_file.keep().map_err(|err| {
197+
SignBuilderError::SSHSigningKey(err.to_string())
198+
})?;
199+
Ok(temp_file.1)
200+
} else {
201+
Ok(key_path)
202+
}
203+
}
188204
}
189205

190206
/// Sign commit data using `OpenPGP`
@@ -193,16 +209,6 @@ pub struct GPGSign {
193209
signing_key: String,
194210
}
195211

196-
impl GPGSign {
197-
/// Create new [`GPGSign`] using given program and signing key.
198-
pub fn new(program: &str, signing_key: &str) -> Self {
199-
Self {
200-
program: program.to_string(),
201-
signing_key: signing_key.to_string(),
202-
}
203-
}
204-
}
205-
206212
impl Sign for GPGSign {
207213
fn sign(
208214
&self,
@@ -261,79 +267,99 @@ impl Sign for GPGSign {
261267
}
262268

263269
#[cfg(test)]
264-
fn program(&self) -> &String {
265-
&self.program
270+
fn program(&self) -> String {
271+
self.program.clone()
266272
}
267273

268274
#[cfg(test)]
269-
fn signing_key(&self) -> &String {
270-
&self.signing_key
275+
fn signing_key(&self) -> String {
276+
self.signing_key.clone()
271277
}
272278
}
273279

274-
/// Sign commit data using `SSHDiskKeySign`
280+
/// Sign commit data using `SSHSign`
275281
pub struct SSHSign {
276-
#[cfg(test)]
277282
program: String,
278-
#[cfg(test)]
279-
key_path: String,
280-
secret_key: PrivateKey,
281-
}
282-
283-
impl SSHSign {
284-
/// Create new [`SSHDiskKeySign`] for sign.
285-
pub fn new(mut key: PathBuf) -> Result<Self, SignBuilderError> {
286-
key.set_extension("");
287-
if key.is_file() {
288-
#[cfg(test)]
289-
let key_path = format!("{}", &key.display());
290-
std::fs::read(key)
291-
.ok()
292-
.and_then(|bytes| {
293-
PrivateKey::from_openssh(bytes).ok()
294-
})
295-
.map(|secret_key| Self {
296-
#[cfg(test)]
297-
program: "ssh".to_string(),
298-
#[cfg(test)]
299-
key_path,
300-
secret_key,
301-
})
302-
.ok_or_else(|| {
303-
SignBuilderError::SSHSigningKey(String::from(
304-
"Fail to read the private key for sign.",
305-
))
306-
})
307-
} else {
308-
Err(SignBuilderError::SSHSigningKey(
309-
String::from("Currently, we only support a pair of ssh key in disk."),
310-
))
311-
}
312-
}
283+
signing_key: PathBuf,
313284
}
314285

315286
impl Sign for SSHSign {
316287
fn sign(
317288
&self,
318289
commit: &[u8],
319290
) -> Result<(String, Option<String>), SignError> {
320-
let sig = self
321-
.secret_key
322-
.sign("git", HashAlg::Sha256, commit)
323-
.map_err(|err| SignError::Spawn(err.to_string()))?
324-
.to_pem(LineEnding::LF)
325-
.map_err(|err| SignError::Spawn(err.to_string()))?;
326-
Ok((sig, None))
291+
use std::io::Write;
292+
use std::process::{Command, Stdio};
293+
294+
let mut cmd = Command::new(&self.program);
295+
cmd.stdin(Stdio::piped())
296+
.stdout(Stdio::piped())
297+
.stderr(Stdio::piped())
298+
.arg("-Y")
299+
.arg("sign")
300+
.arg("-n")
301+
.arg("git")
302+
.arg("-f")
303+
.arg(&self.signing_key);
304+
305+
if &self.program == "ssh-keygen" {
306+
cmd.arg("-P").arg("\"\"");
307+
}
308+
309+
log::trace!("signing command: {cmd:?}");
310+
311+
let mut child = cmd
312+
.spawn()
313+
.map_err(|e| SignError::Spawn(e.to_string()))?;
314+
315+
let mut stdin = child.stdin.take().ok_or(SignError::Stdin)?;
316+
317+
stdin
318+
.write_all(commit)
319+
.map_err(|e| SignError::WriteBuffer(e.to_string()))?;
320+
drop(stdin);
321+
322+
//hllo
323+
324+
let output = child
325+
.wait_with_output()
326+
.map_err(|e| SignError::Output(e.to_string()))?;
327+
328+
let tmp_path = std::env::temp_dir();
329+
if self.signing_key.starts_with(tmp_path) {
330+
// Not handling error, as its not that bad. OS maintenance tasks will take care of it at a later point.
331+
let _ = std::fs::remove_file(PathBuf::from(
332+
&self.signing_key,
333+
));
334+
}
335+
336+
if !output.status.success() {
337+
let error_msg = std::str::from_utf8(&output.stderr)
338+
.unwrap_or("[error could not be read from stderr]");
339+
if error_msg.contains("passphrase") {
340+
return Err(SignError::Shellout(String::from("Currently, we only support unencrypted pairs of ssh keys in disk or ssh-agents")));
341+
}
342+
return Err(SignError::Shellout(format!(
343+
"failed to sign data, program '{}' exited non-zero: {}",
344+
&self.program,
345+
error_msg
346+
)));
347+
}
348+
349+
let signed_commit = std::str::from_utf8(&output.stdout)
350+
.map_err(|e| SignError::Shellout(e.to_string()))?;
351+
352+
Ok((signed_commit.to_string(), None))
327353
}
328354

329355
#[cfg(test)]
330-
fn program(&self) -> &String {
331-
&self.program
356+
fn program(&self) -> String {
357+
self.program.clone()
332358
}
333359

334360
#[cfg(test)]
335-
fn signing_key(&self) -> &String {
336-
&self.key_path
361+
fn signing_key(&self) -> String {
362+
format!("{}", self.signing_key.display())
337363
}
338364
}
339365

@@ -424,18 +450,74 @@ mod tests {
424450
#[test]
425451
fn test_ssh_program_configs() -> Result<()> {
426452
let (_tmp_dir, repo) = repo_init_empty()?;
453+
let temp_file = tempfile::NamedTempFile::new()
454+
.expect("failed to create temp file");
455+
456+
{
457+
let mut config = repo.config()?;
458+
config.set_str("gpg.format", "ssh")?;
459+
config.set_str(
460+
"user.signingKey",
461+
temp_file.path().to_str().unwrap(),
462+
)?;
463+
}
464+
465+
let sign =
466+
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
467+
468+
assert_eq!("ssh-keygen", sign.program());
469+
assert_eq!(
470+
temp_file.path().to_str().unwrap(),
471+
sign.signing_key()
472+
);
473+
474+
drop(temp_file);
475+
Ok(())
476+
}
477+
478+
#[test]
479+
fn test_ssh_keyliteral_config() -> Result<()> {
480+
let (_tmp_dir, repo) = repo_init_empty()?;
427481

428482
{
429483
let mut config = repo.config()?;
430-
config.set_str("gpg.program", "ssh")?;
431-
config.set_str("user.signingKey", "/tmp/key.pub")?;
484+
config.set_str("gpg.format", "ssh")?;
485+
config.set_str("user.signingKey", "ssh-ed25519 test")?;
486+
}
487+
488+
let sign =
489+
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
490+
491+
assert_eq!("ssh-keygen", sign.program());
492+
assert!(PathBuf::from(sign.signing_key()).is_file());
493+
494+
Ok(())
495+
}
496+
497+
#[test]
498+
fn test_ssh_external_bin_config() -> Result<()> {
499+
let (_tmp_dir, repo) = repo_init_empty()?;
500+
let temp_file = tempfile::NamedTempFile::new()
501+
.expect("failed to create temp file");
502+
503+
{
504+
let mut config = repo.config()?;
505+
config.set_str("gpg.format", "ssh")?;
506+
config.set_str("gpg.ssh.program", "/opt/ssh/signer")?;
507+
config.set_str(
508+
"user.signingKey",
509+
temp_file.path().to_str().unwrap(),
510+
)?;
432511
}
433512

434513
let sign =
435514
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
436515

437-
assert_eq!("ssh", sign.program());
438-
assert_eq!("/tmp/key.pub", sign.signing_key());
516+
assert_eq!("/opt/ssh/signer", sign.program());
517+
assert_eq!(
518+
temp_file.path().to_str().unwrap(),
519+
sign.signing_key()
520+
);
439521

440522
Ok(())
441523
}

0 commit comments

Comments
 (0)
Please sign in to comment.