Skip to main content

gix_object/signature/
sign.rs

1use 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/// Fully resolved options for signing a commit or annotated tag.
16#[derive(Clone, Debug)]
17pub struct Options {
18    /// The signature format.
19    pub format: Format,
20    /// The external signing program.
21    pub program: OsString,
22    /// Additional arguments passed to the signing program before Git's fixed arguments.
23    pub program_arguments: Vec<OsString>,
24    /// The key, identity, or key path passed to the signing program.
25    ///
26    /// SSH key paths must already be resolved; this plumbing layer does not perform Git-style path interpolation.
27    pub signing_key: OsString,
28    /// Environment variables set only for the signing program.
29    pub environment: Vec<(OsString, OsString)>,
30}
31
32impl CommitRef<'_> {
33    /// Return an owned copy of this commit with its active signature replaced by a newly created one.
34    pub fn sign(self, options: Options) -> ExnResult<Commit> {
35        self.into_owned().or_erased()?.sign(options)
36    }
37}
38
39impl Commit {
40    /// Return this commit with its active signature replaced by a newly created one according to `options`.
41    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    /// Return an owned copy of this annotated tag with its in-body signature replaced by a newly created one
54    /// according to `options`.
55    pub fn sign(self, options: Options) -> ExnResult<Tag> {
56        self.into_owned().or_erased()?.sign(options)
57    }
58}
59
60impl Tag {
61    /// Return this annotated tag with its in-body signature replaced by a newly created one according to `options`.
62    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        // Tag signatures follow the message in the object body, separated by a newline which is itself signed. This
67        // differs from commit signatures, which are inserted as a header after signing the commit without that header.
68        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        // Unlike literal keys, resolved key paths can be passed directly to `ssh-keygen -f`.
138        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
175/// Return the SSH public key encoded by Git's literal-key syntax.
176///
177/// A literal key either starts with `key::`, in which case the prefix is removed, or directly with `ssh-`, in which
178/// case it is returned unchanged. All other values are considered paths.
179pub 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
184/// On Unix, creates a file with 0o600 just like Git.
185fn 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
220/// Normalize signer-produced CRLF line endings to LF before embedding the signature in an object.
221///
222/// This matches Git and keeps signed object bytes independent of the platform on which the signer ran.
223fn 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}