gix_object/signature/
sign.rs1use std::{
2 ffi::{OsStr, OsString},
3 io::Write,
4 path::PathBuf,
5 process::Stdio,
6};
7
8use bstr::{BString, ByteSlice};
9use gix_error::{ErrorExt, ExnResult, ResultExt, corruption, message, validation};
10
11use crate::{Commit, CommitRef, Tag, TagRef, WriteTo};
12
13use super::Format;
14
15#[derive(Clone, Debug)]
17pub struct Options {
18 pub format: Format,
20 pub program: OsString,
22 pub program_arguments: Vec<OsString>,
24 pub signing_key: OsString,
28 pub environment: Vec<(OsString, OsString)>,
30}
31
32impl CommitRef<'_> {
33 pub fn sign(self, options: Options) -> ExnResult<Commit> {
35 self.into_owned().or_erased()?.sign(options)
36 }
37}
38
39impl Commit {
40 pub fn sign(mut self, options: Options) -> ExnResult<Commit> {
42 let signature_field = crate::commit::signature_field_name(self.tree.kind());
43 self.extra_headers.retain(|(name, _)| name != signature_field);
44 let mut payload = Vec::new();
45 self.write_to(&mut payload).or_erased()?;
46 let signature = sign(&payload, &options)?;
47 self.extra_headers.push((signature_field.into(), signature));
48 Ok(self)
49 }
50}
51
52impl TagRef<'_> {
53 pub fn sign(self, options: Options) -> ExnResult<Tag> {
56 self.into_owned().or_erased()?.sign(options)
57 }
58}
59
60impl Tag {
61 pub fn sign(mut self, options: Options) -> ExnResult<Tag> {
63 self.signature = None;
64 let mut payload = Vec::new();
65 self.write_to(&mut payload).or_erased()?;
66 payload.push(b'\n');
69 self.signature = Some(sign(&payload, &options)?);
70 Ok(self)
71 }
72}
73
74fn sign(payload: &[u8], options: &Options) -> ExnResult<BString> {
75 match options.format {
76 Format::OpenPgp | Format::X509 => sign_gpg(payload, options),
77 Format::Ssh => sign_ssh(payload, options),
78 }
79}
80
81fn command(options: &Options) -> gix_command::Prepare {
82 options.environment.iter().fold(
83 gix_command::prepare(&options.program).args(&options.program_arguments),
84 |command, (key, value)| command.env(key, value),
85 )
86}
87
88fn sign_gpg(payload: &[u8], options: &Options) -> ExnResult<BString> {
89 if options.signing_key.is_empty() {
90 return Err(validation("A signing key is required").raise_erased());
91 }
92 let output = run(
93 command(options)
94 .args([OsStr::new("--status-fd=2"), OsStr::new("-bsau")])
95 .arg(&options.signing_key)
96 .stdin(Stdio::piped())
97 .stdout(Stdio::piped())
98 .stderr(Stdio::piped()),
99 &options.program,
100 payload,
101 )?;
102 if !output.status.success() {
103 return Err(message!(
104 "Signing program {:?} failed: {}",
105 options.program,
106 output.stderr.as_bstr()
107 )
108 .raise_erased());
109 }
110 if !output
111 .stderr
112 .lines()
113 .any(|line| line.starts_with(b"[GNUPG:] SIG_CREATED "))
114 {
115 return Err(corruption("The OpenPGP/X.509 signer did not report SIG_CREATED").raise_erased());
116 }
117 Ok(strip_cr_before_lf(output.stdout).into())
118}
119
120fn sign_ssh(payload: &[u8], options: &Options) -> ExnResult<BString> {
121 if options.signing_key.is_empty() {
122 return Err(validation("A signing key is required").raise_erased());
123 }
124 let mut literal_key_file = None;
125 let literal_key = options
126 .signing_key
127 .to_str()
128 .and_then(|key| is_literal_ssh_key(key.as_bytes()));
129 let (key, literal) = match literal_key {
130 Some(key) => {
131 let mut file = secure_temporary_file()?;
132 write_temporary(&mut file, key)?;
133 let path = temporary_path(&mut file)?;
134 literal_key_file = Some(file);
135 (path.into_os_string(), true)
136 }
137 None => (options.signing_key.clone(), false),
139 };
140 let key = super::ssh_path_argument(std::path::Path::new(&key));
141 let mut payload_file = secure_temporary_file()?;
142 write_temporary(&mut payload_file, payload)?;
143 let payload_path = temporary_path(&mut payload_file)?;
144 let mut signature_path = payload_path.as_os_str().to_owned();
145 signature_path.push(".sig");
146 let signature_path = PathBuf::from(signature_path);
147 let mut command = command(options).args(["-Y", "sign", "-n", "git", "-f"]).arg(key);
148 if literal {
149 command = command.arg("-U");
150 }
151 let output = command
152 .arg(super::ssh_path_argument(&payload_path))
153 .stdin(Stdio::null())
154 .stdout(Stdio::piped())
155 .stderr(Stdio::piped())
156 .spawn()
157 .or_raise_erased(|| message!("Could not execute signing program {:?}", options.program))?
158 .wait_with_output()
159 .or_raise_erased(|| message!("Could not communicate with signing program {:?}", options.program))?;
160 drop(literal_key_file);
161 if !output.status.success() {
162 return Err(message!(
163 "Signing program {:?} failed: {}",
164 options.program,
165 output.stderr.as_bstr()
166 )
167 .raise_erased());
168 }
169 let signature =
170 std::fs::read(&signature_path).or_raise_erased(|| corruption("The SSH signer produced no signature"));
171 let _ = std::fs::remove_file(signature_path);
172 Ok(strip_cr_before_lf(signature?).into())
173}
174
175pub fn is_literal_ssh_key(key: &[u8]) -> Option<&[u8]> {
180 key.strip_prefix(b"key::")
181 .or_else(|| key.starts_with(b"ssh-").then_some(key))
182}
183
184fn secure_temporary_file() -> ExnResult<gix_tempfile::Handle<gix_tempfile::handle::Writable>> {
186 gix_tempfile::new(
187 std::env::temp_dir(),
188 gix_tempfile::ContainingDirectory::Exists,
189 gix_tempfile::AutoRemove::Tempfile,
190 )
191 .or_raise_erased(|| message("Could not create or write a temporary signing file"))
192}
193
194fn write_temporary(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>, data: &[u8]) -> ExnResult {
195 file.with_mut(|file| file.write_all(data))
196 .or_raise_erased(|| message("Could not create or write a temporary signing file"))?
197 .or_raise_erased(|| message("Could not create or write a temporary signing file"))
198}
199
200fn temporary_path(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>) -> ExnResult<PathBuf> {
201 file.with_mut(|file| file.path().to_owned())
202 .or_raise_erased(|| message("Could not create or write a temporary signing file"))
203}
204
205fn run(command: gix_command::Prepare, program: &OsStr, input: &[u8]) -> ExnResult<std::process::Output> {
206 let mut child = command
207 .spawn()
208 .or_raise_erased(|| message!("Could not execute signing program {program:?}"))?;
209 child
210 .stdin
211 .take()
212 .expect("configured as piped")
213 .write_all(input)
214 .or_raise_erased(|| message!("Could not communicate with signing program {program:?}"))?;
215 child
216 .wait_with_output()
217 .or_raise_erased(|| message!("Could not communicate with signing program {program:?}"))
218}
219
220fn strip_cr_before_lf(input: Vec<u8>) -> Vec<u8> {
224 let mut output = Vec::with_capacity(input.len());
225 let mut bytes = input.into_iter().peekable();
226 while let Some(byte) = bytes.next() {
227 if byte != b'\r' || bytes.peek() != Some(&b'\n') {
228 output.push(byte);
229 }
230 }
231 output
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn programs_with_spaces_are_invoked_directly() {
240 let program = OsStr::new("a directory/signer");
241 let command: std::process::Command = command(&Options {
242 format: Format::OpenPgp,
243 program: program.into(),
244 program_arguments: vec!["argument with spaces".into()],
245 signing_key: "key".into(),
246 environment: Vec::new(),
247 })
248 .into();
249
250 assert_eq!(
251 command.get_program(),
252 program,
253 "a signer pathname is passed directly instead of being interpreted by a shell"
254 );
255 assert_eq!(
256 command.get_args().collect::<Vec<_>>(),
257 [OsStr::new("argument with spaces")],
258 "signer arguments remain separate from the program pathname"
259 );
260 }
261}