Skip to main content

jj_lib/
gpg_signing.rs

1// Copyright 2023 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![expect(missing_docs)]
16
17use std::ffi::OsString;
18use std::fmt::Debug;
19use std::io;
20use std::io::Write as _;
21use std::process;
22use std::process::Command;
23use std::process::ExitStatus;
24use std::process::Stdio;
25
26use thiserror::Error;
27
28use crate::config::ConfigGetError;
29use crate::config::ConfigGetResultExt as _;
30use crate::settings::UserSettings;
31use crate::signing::SigStatus;
32use crate::signing::SignError;
33use crate::signing::SigningBackend;
34use crate::signing::Verification;
35use crate::subprocess_util::suppress_console_window;
36
37/// Search for one of these in the output from `--status-fd=1`.
38///
39/// - `[GNUPG:] GOODSIG <long keyid> <primary uid..>`
40/// - `[GNUPG:] EXPKEYSIG <long keyid> <primary uid..>`
41/// - `[GNUPG:] NO_PUBKEY <long keyid>`
42/// - `[GNUPG:] BADSIG <long keyid> <primary uid..>`
43///
44/// Assume signature is invalid if none of the above was found, and if there are
45/// at least one line other than general program failures `[GNUPG:] FAILURE`.
46///
47/// https://github.com/gpg/gnupg/blob/gnupg-2.5.18/doc/DETAILS#format-of-the-status-fd-output
48fn parse_gpg_verify_output(
49    output: &[u8],
50    allow_expired_keys: bool,
51) -> Option<Result<Verification, SignError>> {
52    let status_lines = || {
53        output.split(|&b| b == b'\n').filter_map(|line| {
54            let line = line.strip_prefix(b"[GNUPG:] ")?;
55            let mut parts = line.splitn(3, |&b| b == b' ');
56            Some((parts.next()?, parts))
57        })
58    };
59    let maybe_verification = status_lines().find_map(|(name, mut args)| {
60        let status = match name {
61            b"GOODSIG" => SigStatus::Good,
62            b"EXPKEYSIG" => {
63                if allow_expired_keys {
64                    SigStatus::Good
65                } else {
66                    SigStatus::Bad
67                }
68            }
69            b"NO_PUBKEY" => SigStatus::Unknown,
70            b"BADSIG" => SigStatus::Bad,
71            b"ERROR" => match args.next()? {
72                b"verify.findkey" => return Some(Verification::unknown()),
73                _ => return None,
74            },
75            _ => return None,
76        };
77        let mut args = args.fuse();
78        let key = args
79            .next()
80            .and_then(|bs| str::from_utf8(bs).ok())
81            .map(|value| value.trim().to_owned());
82        let display = args
83            .next()
84            .and_then(|bs| str::from_utf8(bs).ok())
85            .map(|value| value.trim().to_owned());
86        Some(Verification::new(status, key, display))
87    });
88    if let Some(verification) = maybe_verification {
89        Some(Ok(verification))
90    } else if status_lines().any(|(name, _)| name != b"FAILURE") {
91        Some(Err(SignError::InvalidSignatureFormat))
92    } else {
93        None
94    }
95}
96
97fn make_command_error(output: &process::Output) -> GpgError {
98    GpgError::Command {
99        exit_status: output.status,
100        stderr: String::from_utf8_lossy(&output.stderr).trim_end().into(),
101    }
102}
103
104fn run_sign_command(command: &mut Command, input: &[u8]) -> Result<Vec<u8>, GpgError> {
105    tracing::info!(?command, "running GPG signing command");
106    let process = command.stderr(Stdio::piped()).spawn()?;
107    let write_result = process.stdin.as_ref().unwrap().write_all(input);
108    let output = process.wait_with_output()?;
109    tracing::info!(?command, ?output.status, "GPG signing command exited");
110    if output.status.success() {
111        write_result?;
112        Ok(output.stdout)
113    } else {
114        Err(make_command_error(&output))
115    }
116}
117
118fn run_verify_command(command: &mut Command, input: &[u8]) -> Result<process::Output, GpgError> {
119    tracing::info!(?command, "running GPG signing command");
120    let process = command.stderr(Stdio::piped()).spawn()?;
121    let write_result = process.stdin.as_ref().unwrap().write_all(input);
122    let output = process.wait_with_output()?;
123    tracing::info!(?command, ?output.status, "GPG signing command exited");
124    match write_result {
125        Ok(()) => Ok(output),
126        // If the signature format is invalid, gpg will terminate early. Writing
127        // more input data will fail in that case.
128        Err(err) if err.kind() == io::ErrorKind::BrokenPipe => Ok(output),
129        Err(err) => Err(err.into()),
130    }
131}
132
133fn write_temp_file(prefix: &str, content: &[u8]) -> io::Result<tempfile::TempPath> {
134    let mut file = tempfile::Builder::new().prefix(prefix).tempfile()?;
135    file.write_all(content)?;
136    file.flush()?;
137    Ok(file.into_temp_path())
138}
139
140#[derive(Debug)]
141pub struct GpgBackend {
142    program: OsString,
143    allow_expired_keys: bool,
144    extra_args: Vec<OsString>,
145    default_key: String,
146}
147
148#[derive(Debug, Error)]
149pub enum GpgError {
150    #[error("GPG failed with {exit_status}:\n{stderr}")]
151    Command {
152        exit_status: ExitStatus,
153        stderr: String,
154    },
155    #[error("Failed to run GPG")]
156    Io(#[from] std::io::Error),
157}
158
159impl From<GpgError> for SignError {
160    fn from(e: GpgError) -> Self {
161        Self::Backend(Box::new(e))
162    }
163}
164
165impl GpgBackend {
166    pub fn new(program: OsString, allow_expired_keys: bool, default_key: String) -> Self {
167        Self {
168            program,
169            allow_expired_keys,
170            extra_args: vec![],
171            default_key,
172        }
173    }
174
175    /// Primarily intended for testing
176    pub fn with_extra_args(mut self, args: &[OsString]) -> Self {
177        self.extra_args.extend_from_slice(args);
178        self
179    }
180
181    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
182        let program = settings.get_string("signing.backends.gpg.program")?;
183        let allow_expired_keys = settings.get_bool("signing.backends.gpg.allow-expired-keys")?;
184        let configured_key = settings.get_string("signing.key").optional()?;
185        let default_key = configured_key.unwrap_or_else(|| settings.user_email().to_owned());
186        Ok(Self::new(program.into(), allow_expired_keys, default_key))
187    }
188
189    fn create_command(&self) -> Command {
190        let mut command = Command::new(&self.program);
191        suppress_console_window(&mut command);
192
193        command
194            .stdin(Stdio::piped())
195            .stdout(Stdio::piped())
196            .args(&self.extra_args);
197        command
198    }
199}
200
201impl SigningBackend for GpgBackend {
202    fn name(&self) -> &'static str {
203        "gpg"
204    }
205
206    fn can_read(&self, signature: &[u8]) -> bool {
207        signature.starts_with(b"-----BEGIN PGP SIGNATURE-----")
208    }
209
210    fn sign(&self, data: &[u8], key: Option<&str>) -> Result<Vec<u8>, SignError> {
211        let key = key.unwrap_or(&self.default_key);
212        Ok(run_sign_command(
213            self.create_command().args(["-abu", key]),
214            data,
215        )?)
216    }
217
218    fn verify(&self, data: &[u8], signature: &[u8]) -> Result<Verification, SignError> {
219        let sig_path = write_temp_file(".jj-gpg-sig-tmp-", signature).map_err(GpgError::Io)?;
220
221        let output = run_verify_command(
222            self.create_command()
223                .args(["--keyid-format=long", "--status-fd=1", "--verify"])
224                .arg(&sig_path)
225                .arg("-"),
226            data,
227        )?;
228
229        parse_gpg_verify_output(&output.stdout, self.allow_expired_keys)
230            .unwrap_or_else(|| Err(make_command_error(&output).into()))
231    }
232}
233
234#[derive(Debug)]
235pub struct GpgsmBackend {
236    program: OsString,
237    allow_expired_keys: bool,
238    extra_args: Vec<OsString>,
239    default_key: String,
240}
241
242impl GpgsmBackend {
243    pub fn new(program: OsString, allow_expired_keys: bool, default_key: String) -> Self {
244        Self {
245            program,
246            allow_expired_keys,
247            extra_args: vec![],
248            default_key,
249        }
250    }
251
252    /// Primarily intended for testing
253    pub fn with_extra_args(mut self, args: &[OsString]) -> Self {
254        self.extra_args.extend_from_slice(args);
255        self
256    }
257
258    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
259        let program = settings.get_string("signing.backends.gpgsm.program")?;
260        let allow_expired_keys = settings.get_bool("signing.backends.gpgsm.allow-expired-keys")?;
261        let default_key = settings.user_email().to_owned();
262        Ok(Self::new(program.into(), allow_expired_keys, default_key))
263    }
264
265    fn create_command(&self) -> Command {
266        let mut command = Command::new(&self.program);
267        suppress_console_window(&mut command);
268
269        command
270            .stdin(Stdio::piped())
271            .stdout(Stdio::piped())
272            .args(&self.extra_args);
273        command
274    }
275}
276
277impl SigningBackend for GpgsmBackend {
278    fn name(&self) -> &'static str {
279        "gpgsm"
280    }
281
282    fn can_read(&self, signature: &[u8]) -> bool {
283        signature.starts_with(b"-----BEGIN SIGNED MESSAGE-----")
284    }
285
286    fn sign(&self, data: &[u8], key: Option<&str>) -> Result<Vec<u8>, SignError> {
287        let key = key.unwrap_or(&self.default_key);
288        Ok(run_sign_command(
289            self.create_command().args(["-abu", key]),
290            data,
291        )?)
292    }
293
294    fn verify(&self, data: &[u8], signature: &[u8]) -> Result<Verification, SignError> {
295        let data_path = write_temp_file(".jj-gpgsm-data-tmp-", data).map_err(GpgError::Io)?;
296        let sig_path = write_temp_file(".jj-gpgsm-sig-tmp-", signature).map_err(GpgError::Io)?;
297
298        // gpgsm 2.5.x doesn't parse "-" as stdin
299        let output = run_verify_command(
300            self.create_command()
301                .args(["--status-fd=1", "--verify"])
302                .arg(&sig_path)
303                .arg(&data_path),
304            b"",
305        )?;
306
307        parse_gpg_verify_output(&output.stdout, self.allow_expired_keys)
308            .unwrap_or_else(|| Err(make_command_error(&output).into()))
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use std::assert_matches;
315
316    use super::*;
317
318    #[test]
319    fn gpg_verify_invalid_signature_format() {
320        assert_matches!(
321            parse_gpg_verify_output(
322                b"[GNUPG:] NODATA 4\n[GNUPG:] FAILURE gpg-exit 33554433\n",
323                true
324            ),
325            Some(Err(SignError::InvalidSignatureFormat))
326        );
327    }
328
329    #[test]
330    fn gpg_verify_bad_signature() {
331        assert_eq!(
332            parse_gpg_verify_output(b"[GNUPG:] BADSIG 123 456", true)
333                .unwrap()
334                .unwrap(),
335            Verification::new(SigStatus::Bad, Some("123".into()), Some("456".into()))
336        );
337    }
338
339    #[test]
340    fn gpg_verify_unknown_signature() {
341        assert_eq!(
342            parse_gpg_verify_output(b"[GNUPG:] NO_PUBKEY 123", true)
343                .unwrap()
344                .unwrap(),
345            Verification::new(SigStatus::Unknown, Some("123".into()), None)
346        );
347    }
348
349    #[test]
350    fn gpg_verify_good_signature() {
351        assert_eq!(
352            parse_gpg_verify_output(b"[GNUPG:] GOODSIG 123 456", true)
353                .unwrap()
354                .unwrap(),
355            Verification::new(SigStatus::Good, Some("123".into()), Some("456".into()))
356        );
357    }
358
359    #[test]
360    fn gpg_verify_expired_signature() {
361        assert_eq!(
362            parse_gpg_verify_output(b"[GNUPG:] EXPKEYSIG 123 456", true)
363                .unwrap()
364                .unwrap(),
365            Verification::new(SigStatus::Good, Some("123".into()), Some("456".into()))
366        );
367
368        assert_eq!(
369            parse_gpg_verify_output(b"[GNUPG:] EXPKEYSIG 123 456", false)
370                .unwrap()
371                .unwrap(),
372            Verification::new(SigStatus::Bad, Some("123".into()), Some("456".into()))
373        );
374    }
375
376    #[test]
377    fn gpg_verify_unknown_error() {
378        assert_matches!(parse_gpg_verify_output(b"", true), None);
379        assert_matches!(
380            parse_gpg_verify_output(b"[GNUPG:] FAILURE gpg-exit 33554433\n", true),
381            None
382        );
383        assert_matches!(
384            parse_gpg_verify_output(b"[GNUPG:] FAILURE gpgsm-exit 50331649\n", true),
385            None
386        );
387    }
388
389    #[test]
390    fn gpgsm_verify_unknown_signature() {
391        assert_eq!(
392            parse_gpg_verify_output(b"[GNUPG:] ERROR verify.findkey 50331657", true)
393                .unwrap()
394                .unwrap(),
395            Verification::unknown(),
396        );
397    }
398
399    #[test]
400    fn gpgsm_verify_unknown_signature_with_issuer() {
401        assert_eq!(
402            parse_gpg_verify_output(
403                b"[GNUPG:] NO_PUBKEY #7C6DDB3D1978999B/CN=JJ,O=X509+Signing+Test\n\
404                  [GNUPG:] ERROR verify.findkey 50331657\n",
405                true,
406            )
407            .unwrap()
408            .unwrap(),
409            Verification::new(
410                SigStatus::Unknown,
411                Some("#7C6DDB3D1978999B/CN=JJ,O=X509+Signing+Test".into()),
412                None,
413            ),
414        );
415    }
416
417    #[test]
418    fn gpgsm_verify_invalid_signature_format() {
419        assert_matches!(
420            parse_gpg_verify_output(b"[GNUPG:] ERROR verify.leave 150995087", true),
421            Some(Err(SignError::InvalidSignatureFormat))
422        );
423    }
424}