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::{Result, ResultExt, bail, 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) -> Result<Commit> {
35        self.into_owned()?.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    ///
42    /// Unsuccessful signer exits provide `program`, `exit_status`, `stdout`, `stderr`, and, when available, `exit_code`
43    /// according to the [external program runtime failure schema](gix_error::Metadata#external-program-runtime-failure).
44    pub fn sign(mut self, options: Options) -> Result<Commit> {
45        let signature_field = crate::commit::signature_field_name(self.tree.kind());
46        self.extra_headers.retain(|(name, _)| name != signature_field);
47        let mut payload = Vec::new();
48        self.write_to(&mut payload).or_error()?;
49        let signature = sign(&payload, &options)?;
50        self.extra_headers.push((signature_field.into(), signature));
51        Ok(self)
52    }
53}
54
55impl TagRef<'_> {
56    /// Return an owned copy of this annotated tag with its in-body signature replaced by a newly created one
57    /// according to `options`.
58    pub fn sign(self, options: Options) -> Result<Tag> {
59        self.into_owned()?.sign(options)
60    }
61}
62
63impl Tag {
64    /// Return this annotated tag with its in-body signature replaced by a newly created one according to `options`.
65    ///
66    /// Unsuccessful signer exits provide `program`, `exit_status`, `stdout`, `stderr`, and, when available, `exit_code`
67    /// according to the [external program runtime failure schema](gix_error::Metadata#external-program-runtime-failure).
68    pub fn sign(mut self, options: Options) -> Result<Tag> {
69        self.signature = None;
70        let mut payload = Vec::new();
71        self.write_to(&mut payload).or_error()?;
72        // Tag signatures follow the message in the object body, separated by a newline which is itself signed. This
73        // differs from commit signatures, which are inserted as a header after signing the commit without that header.
74        payload.push(b'\n');
75        self.signature = Some(sign(&payload, &options)?);
76        Ok(self)
77    }
78}
79
80fn sign(payload: &[u8], options: &Options) -> Result<BString> {
81    match options.format {
82        Format::OpenPgp | Format::X509 => sign_gpg(payload, options),
83        Format::Ssh => sign_ssh(payload, options),
84    }
85}
86
87fn command(options: &Options) -> gix_command::Prepare {
88    options.environment.iter().fold(
89        gix_command::prepare(&options.program).args(&options.program_arguments),
90        |command, (key, value)| command.env(key, value),
91    )
92}
93
94fn sign_gpg(payload: &[u8], options: &Options) -> Result<BString> {
95    if options.signing_key.is_empty() {
96        bail!(validation("A signing key is required"));
97    }
98    let output = run(
99        command(options)
100            .args([OsStr::new("--status-fd=2"), OsStr::new("-bsau")])
101            .arg(&options.signing_key)
102            .stdin(Stdio::piped())
103            .stdout(Stdio::piped())
104            .stderr(Stdio::piped()),
105        &options.program,
106        payload,
107    )?;
108    if !output.status.success() {
109        bail!(program_failure(&options.program, output));
110    }
111    if !output
112        .stderr
113        .lines()
114        .any(|line| line.starts_with(b"[GNUPG:] SIG_CREATED "))
115    {
116        bail!(corruption("The OpenPGP/X.509 signer did not report SIG_CREATED"));
117    }
118    Ok(strip_cr_before_lf(output.stdout).into())
119}
120
121fn sign_ssh(payload: &[u8], options: &Options) -> Result<BString> {
122    if options.signing_key.is_empty() {
123        bail!(validation("A signing key is required"));
124    }
125    let mut literal_key_file = None;
126    let literal_key = options
127        .signing_key
128        .to_str()
129        .and_then(|key| is_literal_ssh_key(key.as_bytes()));
130    let (key, literal) = match literal_key {
131        Some(key) => {
132            let mut file = secure_temporary_file()?;
133            write_temporary(&mut file, key)?;
134            let path = temporary_path(&mut file)?;
135            literal_key_file = Some(file);
136            (path.into_os_string(), true)
137        }
138        // Unlike literal keys, resolved key paths can be passed directly to `ssh-keygen -f`.
139        None => (options.signing_key.clone(), false),
140    };
141    let key = super::ssh_path_argument(std::path::Path::new(&key))?;
142    let mut payload_file = secure_temporary_file()?;
143    write_temporary(&mut payload_file, payload)?;
144    let payload_path = temporary_path(&mut payload_file)?;
145    let mut signature_path = payload_path.as_os_str().to_owned();
146    signature_path.push(".sig");
147    let signature_path = PathBuf::from(signature_path);
148    let mut command = command(options).args(["-Y", "sign", "-n", "git", "-f"]).arg(key);
149    if literal {
150        command = command.arg("-U");
151    }
152    let output = command
153        .arg(super::ssh_path_argument(&payload_path)?)
154        .stdin(Stdio::null())
155        .stdout(Stdio::piped())
156        .stderr(Stdio::piped())
157        .spawn()
158        .or_raise(|| message("Could not execute signing program").with_program(&options.program))?
159        .wait_with_output()
160        .or_raise(|| message("Could not communicate with signing program").with_program(&options.program))?;
161    drop(literal_key_file);
162    if !output.status.success() {
163        bail!(program_failure(&options.program, output));
164    }
165    let signature = std::fs::read(&signature_path).or_raise(|| corruption("The SSH signer produced no signature"));
166    let _ = std::fs::remove_file(signature_path);
167    Ok(strip_cr_before_lf(signature?).into())
168}
169
170/// Return the SSH public key encoded by Git's literal-key syntax.
171///
172/// A literal key either starts with `key::`, in which case the prefix is removed, or directly with `ssh-`, in which
173/// case it is returned unchanged. All other values are considered paths.
174pub fn is_literal_ssh_key(key: &[u8]) -> Option<&[u8]> {
175    key.strip_prefix(b"key::")
176        .or_else(|| key.starts_with(b"ssh-").then_some(key))
177}
178
179/// On Unix, creates a file with 0o600 just like Git.
180fn secure_temporary_file() -> Result<gix_tempfile::Handle<gix_tempfile::handle::Writable>> {
181    gix_tempfile::new(
182        std::env::temp_dir(),
183        gix_tempfile::ContainingDirectory::Exists,
184        gix_tempfile::AutoRemove::Tempfile,
185    )
186    .or_raise(|| message("Could not create or write a temporary signing file"))
187}
188
189fn write_temporary(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>, data: &[u8]) -> Result {
190    file.with_mut(|file| file.write_all(data))
191        .or_raise(|| message("Could not create or write a temporary signing file"))?
192        .or_raise(|| message("Could not create or write a temporary signing file"))
193}
194
195fn temporary_path(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>) -> Result<PathBuf> {
196    file.with_mut(|file| file.path().to_owned())
197        .or_raise(|| message("Could not create or write a temporary signing file"))
198}
199
200fn program_failure(program: &OsStr, output: std::process::Output) -> gix_error::Message {
201    message("Signing program failed")
202        .with_program(program)
203        .with_exit_status(output.status)
204        .with("stdout", output.stdout)
205        .with("stderr", output.stderr)
206}
207
208fn run(command: gix_command::Prepare, program: &OsStr, input: &[u8]) -> Result<std::process::Output> {
209    let mut child = command
210        .spawn()
211        .or_raise(|| message("Could not execute signing program").with_program(program))?;
212    child
213        .stdin
214        .take()
215        .expect("configured as piped")
216        .write_all(input)
217        .or_raise(|| message("Could not communicate with signing program").with_program(program))?;
218    child
219        .wait_with_output()
220        .or_raise(|| message("Could not communicate with signing program").with_program(program))
221}
222
223/// Normalize signer-produced CRLF line endings to LF before embedding the signature in an object.
224///
225/// This matches Git and keeps signed object bytes independent of the platform on which the signer ran.
226fn strip_cr_before_lf(input: Vec<u8>) -> Vec<u8> {
227    let mut output = Vec::with_capacity(input.len());
228    let mut bytes = input.into_iter().peekable();
229    while let Some(byte) = bytes.next() {
230        if byte != b'\r' || bytes.peek() != Some(&b'\n') {
231            output.push(byte);
232        }
233    }
234    output
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn missing_signing_program_is_reported_only_in_metadata() -> gix_testtools::TestResult {
243        let directory = gix_testtools::tempfile::TempDir::new()?;
244        let program = directory.path().join("missing signer");
245        for format in [Format::OpenPgp, Format::X509, Format::Ssh] {
246            let err = sign(
247                b"payload",
248                &Options {
249                    format,
250                    program: program.as_os_str().into(),
251                    program_arguments: Vec::new(),
252                    signing_key: "unused".into(),
253                    environment: Vec::new(),
254                },
255            )
256            .expect_err("the signing program does not exist in the disposable directory");
257            let diagnostic = err
258                .iter_errors()
259                .find_map(|err| err.downcast_ref::<gix_error::Message>())
260                .expect("the spawn failure has a diagnostic message");
261            assert_eq!(
262                diagnostic.message, "Could not execute signing program",
263                "the prose identifies signing without repeating the program pathname"
264            );
265            assert_eq!(
266                diagnostic.values.get("program"),
267                Some(&gix_error::MetadataValue::from(program.as_path())),
268                "program metadata retains the exact signer pathname"
269            );
270        }
271        Ok(())
272    }
273
274    #[test]
275    fn programs_with_spaces_are_invoked_directly() -> gix_testtools::TestResult {
276        let program = OsStr::new("a directory/signer");
277        let command: std::process::Command = command(&Options {
278            format: Format::OpenPgp,
279            program: program.into(),
280            program_arguments: vec!["argument with spaces".into()],
281            signing_key: "key".into(),
282            environment: Vec::new(),
283        })
284        .try_into()?;
285
286        assert_eq!(
287            command.get_program(),
288            program,
289            "a signer pathname is passed directly instead of being interpreted by a shell"
290        );
291        assert_eq!(
292            command.get_args().collect::<Vec<_>>(),
293            [OsStr::new("argument with spaces")],
294            "signer arguments remain separate from the program pathname"
295        );
296        Ok(())
297    }
298}