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};
9
10use crate::{Commit, CommitRef, Tag, TagRef, WriteTo};
11
12use super::Format;
13
14/// Fully resolved options for signing a commit or annotated tag.
15#[derive(Clone, Debug)]
16pub struct Options {
17    /// The signature format.
18    pub format: Format,
19    /// The external signing program or command.
20    pub program: OsString,
21    /// Additional arguments passed to the signing program before Git's fixed arguments.
22    pub program_arguments: Vec<OsString>,
23    /// The key, identity, or key path passed to the signing program.
24    ///
25    /// SSH key paths must already be resolved; this plumbing layer does not perform Git-style path interpolation.
26    pub signing_key: OsString,
27    /// Environment variables set only for the signing program.
28    pub environment: Vec<(OsString, OsString)>,
29}
30
31/// The error returned when signing an object.
32#[derive(Debug, thiserror::Error)]
33#[expect(missing_docs)]
34pub enum Error {
35    #[error(transparent)]
36    Decode(#[from] crate::decode::Error),
37    #[error(transparent)]
38    Encode(#[from] std::io::Error),
39    #[error("A signing key is required")]
40    MissingSigningKey,
41    #[error("Could not create or write a temporary signing file")]
42    TemporaryFile(#[source] std::io::Error),
43    #[error("Could not execute signing program {program:?}")]
44    Spawn {
45        program: OsString,
46        #[source]
47        source: std::io::Error,
48    },
49    #[error("Could not communicate with signing program {program:?}")]
50    Communicate {
51        program: OsString,
52        #[source]
53        source: std::io::Error,
54    },
55    #[error("Signing program {program:?} failed: {output}")]
56    Failed { program: OsString, output: BString },
57    #[error("The OpenPGP/X.509 signer did not report SIG_CREATED")]
58    MissingSignatureConfirmation,
59    #[error("The SSH signer produced no signature")]
60    MissingSshSignature(#[source] std::io::Error),
61}
62
63impl CommitRef<'_> {
64    /// Return an owned copy of this commit with its active signature replaced by a newly created one.
65    pub fn sign(self, options: Options) -> Result<Commit, Error> {
66        self.into_owned()?.sign(options)
67    }
68}
69
70impl Commit {
71    /// Return this commit with its active signature replaced by a newly created one according to `options`.
72    pub fn sign(mut self, options: Options) -> Result<Commit, Error> {
73        let signature_field = crate::commit::signature_field_name(self.tree.kind());
74        self.extra_headers.retain(|(name, _)| name != signature_field);
75        let mut payload = Vec::new();
76        self.write_to(&mut payload)?;
77        let signature = sign(&payload, &options)?;
78        self.extra_headers.push((signature_field.into(), signature));
79        Ok(self)
80    }
81}
82
83impl TagRef<'_> {
84    /// Return an owned copy of this annotated tag with its in-body signature replaced by a newly created one
85    /// according to `options`.
86    pub fn sign(self, options: Options) -> Result<Tag, Error> {
87        self.into_owned()?.sign(options)
88    }
89}
90
91impl Tag {
92    /// Return this annotated tag with its in-body signature replaced by a newly created one according to `options`.
93    pub fn sign(mut self, options: Options) -> Result<Tag, Error> {
94        self.signature = None;
95        let mut payload = Vec::new();
96        self.write_to(&mut payload)?;
97        // Tag signatures follow the message in the object body, separated by a newline which is itself signed. This
98        // differs from commit signatures, which are inserted as a header after signing the commit without that header.
99        payload.push(b'\n');
100        self.signature = Some(sign(&payload, &options)?);
101        Ok(self)
102    }
103}
104
105fn sign(payload: &[u8], options: &Options) -> Result<BString, Error> {
106    match options.format {
107        Format::OpenPgp | Format::X509 => sign_gpg(payload, options),
108        Format::Ssh => sign_ssh(payload, options),
109    }
110}
111
112fn command(options: &Options) -> gix_command::Prepare {
113    options.environment.iter().fold(
114        gix_command::prepare(&options.program)
115            .command_may_be_shell_script()
116            .args(&options.program_arguments),
117        |command, (key, value)| command.env(key, value),
118    )
119}
120
121fn sign_gpg(payload: &[u8], options: &Options) -> Result<BString, Error> {
122    if options.signing_key.is_empty() {
123        return Err(Error::MissingSigningKey);
124    }
125    let output = run(
126        command(options)
127            .args([OsStr::new("--status-fd=2"), OsStr::new("-bsau")])
128            .arg(&options.signing_key)
129            .stdin(Stdio::piped())
130            .stdout(Stdio::piped())
131            .stderr(Stdio::piped()),
132        &options.program,
133        payload,
134    )?;
135    if !output.status.success() {
136        return Err(Error::Failed {
137            program: options.program.clone(),
138            output: output.stderr.into(),
139        });
140    }
141    if !output
142        .stderr
143        .lines()
144        .any(|line| line.starts_with(b"[GNUPG:] SIG_CREATED "))
145    {
146        return Err(Error::MissingSignatureConfirmation);
147    }
148    Ok(strip_cr_before_lf(output.stdout).into())
149}
150
151fn sign_ssh(payload: &[u8], options: &Options) -> Result<BString, Error> {
152    if options.signing_key.is_empty() {
153        return Err(Error::MissingSigningKey);
154    }
155    let mut literal_key_file = None;
156    let literal_key = options
157        .signing_key
158        .to_str()
159        .and_then(|key| is_literal_ssh_key(key.as_bytes()));
160    let (key, literal) = match literal_key {
161        Some(key) => {
162            let mut file = secure_temporary_file()?;
163            write_temporary(&mut file, key)?;
164            let path = temporary_path(&mut file)?;
165            literal_key_file = Some(file);
166            (path.into_os_string(), true)
167        }
168        // Unlike literal keys, resolved key paths can be passed directly to `ssh-keygen -f`.
169        None => (options.signing_key.clone(), false),
170    };
171    let mut payload_file = secure_temporary_file()?;
172    write_temporary(&mut payload_file, payload)?;
173    let payload_path = temporary_path(&mut payload_file)?;
174    let mut signature_path = payload_path.as_os_str().to_owned();
175    signature_path.push(".sig");
176    let signature_path = PathBuf::from(signature_path);
177    let mut command = command(options).args(["-Y", "sign", "-n", "git", "-f"]).arg(key);
178    if literal {
179        command = command.arg("-U");
180    }
181    let output = command
182        .arg(&payload_path)
183        .stdin(Stdio::null())
184        .stdout(Stdio::piped())
185        .stderr(Stdio::piped())
186        .spawn()
187        .map_err(|source| Error::Spawn {
188            program: options.program.clone(),
189            source,
190        })?
191        .wait_with_output()
192        .map_err(|source| Error::Communicate {
193            program: options.program.clone(),
194            source,
195        })?;
196    drop(literal_key_file);
197    if !output.status.success() {
198        return Err(Error::Failed {
199            program: options.program.clone(),
200            output: output.stderr.into(),
201        });
202    }
203    let signature = std::fs::read(&signature_path).map_err(Error::MissingSshSignature);
204    let _ = std::fs::remove_file(signature_path);
205    Ok(strip_cr_before_lf(signature?).into())
206}
207
208/// Return the SSH public key encoded by Git's literal-key syntax.
209///
210/// A literal key either starts with `key::`, in which case the prefix is removed, or directly with `ssh-`, in which
211/// case it is returned unchanged. All other values are considered paths.
212pub fn is_literal_ssh_key(key: &[u8]) -> Option<&[u8]> {
213    key.strip_prefix(b"key::")
214        .or_else(|| key.starts_with(b"ssh-").then_some(key))
215}
216
217/// On Unix, creates a file with 0o600 just like Git.
218fn secure_temporary_file() -> Result<gix_tempfile::Handle<gix_tempfile::handle::Writable>, Error> {
219    gix_tempfile::new(
220        std::env::temp_dir(),
221        gix_tempfile::ContainingDirectory::Exists,
222        gix_tempfile::AutoRemove::Tempfile,
223    )
224    .map_err(Error::TemporaryFile)
225}
226
227fn write_temporary(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>, data: &[u8]) -> Result<(), Error> {
228    file.with_mut(|file| file.write_all(data))
229        .map_err(Error::TemporaryFile)?
230        .map_err(Error::TemporaryFile)
231}
232
233fn temporary_path(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>) -> Result<PathBuf, Error> {
234    file.with_mut(|file| file.path().to_owned())
235        .map_err(Error::TemporaryFile)
236}
237
238fn run(command: gix_command::Prepare, program: &OsStr, input: &[u8]) -> Result<std::process::Output, Error> {
239    let mut child = command.spawn().map_err(|source| Error::Spawn {
240        program: program.to_owned(),
241        source,
242    })?;
243    child
244        .stdin
245        .take()
246        .expect("configured as piped")
247        .write_all(input)
248        .map_err(|source| Error::Communicate {
249            program: program.to_owned(),
250            source,
251        })?;
252    child.wait_with_output().map_err(|source| Error::Communicate {
253        program: program.to_owned(),
254        source,
255    })
256}
257
258/// Normalize signer-produced CRLF line endings to LF before embedding the signature in an object.
259///
260/// This matches Git and keeps signed object bytes independent of the platform on which the signer ran.
261fn strip_cr_before_lf(input: Vec<u8>) -> Vec<u8> {
262    let mut output = Vec::with_capacity(input.len());
263    let mut bytes = input.into_iter().peekable();
264    while let Some(byte) = bytes.next() {
265        if byte != b'\r' || bytes.peek() != Some(&b'\n') {
266            output.push(byte);
267        }
268    }
269    output
270}