1use std::{
2 ffi::{OsStr, OsString},
4 io::Write,
5 path::PathBuf,
6 process::Stdio,
7};
8
9use bstr::{BStr, BString, ByteSlice};
10
11use super::SignedData;
12
13use super::Format;
14
15#[derive(Clone, Debug)]
17pub enum Options {
18 OpenPgp {
20 program: OsString,
22 program_arguments: Vec<OsString>,
26 environment: Vec<(OsString, OsString)>,
28 minimum_trust: TrustLevel,
30 },
31 X509 {
33 program: OsString,
35 program_arguments: Vec<OsString>,
38 environment: Vec<(OsString, OsString)>,
40 minimum_trust: TrustLevel,
42 },
43 Ssh {
45 program: OsString,
47 program_arguments: Vec<OsString>,
50 environment: Vec<(OsString, OsString)>,
52 allowed_signers: PathBuf,
54 revocation_file: Option<PathBuf>,
56 verify_time: gix_date::Time,
62 minimum_trust: TrustLevel,
64 },
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum Status {
70 Good,
72 Bad,
74 Error,
76 Expired,
78 ExpiredKey,
80 RevokedKey,
82 Unknown,
84}
85
86#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
88pub enum TrustLevel {
89 #[default]
91 Undefined,
92 Never,
94 Marginal,
99 Fully,
105 Ultimate,
110}
111
112#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct Outcome {
115 pub format: Format,
117 pub status: Status,
119 pub trust_level: TrustLevel,
122 pub signer: Option<BString>,
124 pub key: Option<BString>,
126 pub fingerprint: Option<BString>,
128 pub primary_key_fingerprint: Option<BString>,
130 pub output: BString,
132 pub raw_output: BString,
134 valid: bool,
135}
136
137impl TrustLevel {
138 pub fn from_bytes(value: &[u8]) -> Option<Self> {
140 if value.eq_ignore_ascii_case(b"undefined") {
141 Some(TrustLevel::Undefined)
142 } else if value.eq_ignore_ascii_case(b"never") {
143 Some(TrustLevel::Never)
144 } else if value.eq_ignore_ascii_case(b"marginal") {
145 Some(TrustLevel::Marginal)
146 } else if value.eq_ignore_ascii_case(b"fully") {
147 Some(TrustLevel::Fully)
148 } else if value.eq_ignore_ascii_case(b"ultimate") {
149 Some(TrustLevel::Ultimate)
150 } else {
151 None
152 }
153 }
154}
155
156impl Outcome {
157 pub fn is_valid(&self) -> bool {
159 self.valid
160 }
161}
162
163#[derive(Debug, thiserror::Error)]
165#[expect(missing_docs)]
166pub enum Error {
167 #[error("The signature format is unsupported")]
168 UnsupportedFormat,
169 #[error("The configured program format {program_format:?} does not match signature format {signature_format:?}")]
170 FormatMismatch {
171 program_format: Format,
172 signature_format: Format,
173 },
174 #[error("Could not create or write the temporary signature file")]
175 TemporaryFile(#[source] std::io::Error),
176 #[error("Could not execute signature verifier {program:?}")]
177 Spawn { program: OsString, source: std::io::Error },
178 #[error("Could not communicate with signature verifier {program:?}")]
179 Communicate { program: OsString, source: std::io::Error },
180 #[error("Signature time could not be formatted for SSH verification")]
181 CommitTime(#[source] Box<dyn std::error::Error + Send + Sync>),
182}
183
184impl SignedData<'_> {
185 pub fn verify(&self, signature: &BStr, options: Options) -> Result<Outcome, Error> {
187 let format = Format::from_signature(signature).ok_or(Error::UnsupportedFormat)?;
188 match options {
189 Options::OpenPgp {
190 program,
191 program_arguments,
192 environment,
193 minimum_trust,
194 } if format == Format::OpenPgp => self.verify_gpg(
195 signature,
196 Format::OpenPgp,
197 program,
198 program_arguments,
199 environment,
200 minimum_trust,
201 ),
202 Options::X509 {
203 program,
204 program_arguments,
205 environment,
206 minimum_trust,
207 } if format == Format::X509 => self.verify_gpg(
208 signature,
209 Format::X509,
210 program,
211 program_arguments,
212 environment,
213 minimum_trust,
214 ),
215 Options::Ssh {
216 program,
217 program_arguments,
218 environment,
219 allowed_signers,
220 revocation_file,
221 verify_time,
222 minimum_trust,
223 } if format == Format::Ssh => self.verify_ssh(
224 signature,
225 program,
226 program_arguments,
227 environment,
228 allowed_signers,
229 revocation_file,
230 verify_time,
231 minimum_trust,
232 ),
233 Options::OpenPgp { .. } => Err(Error::FormatMismatch {
234 program_format: Format::OpenPgp,
235 signature_format: format,
236 }),
237 Options::X509 { .. } => Err(Error::FormatMismatch {
238 program_format: Format::X509,
239 signature_format: format,
240 }),
241 Options::Ssh { .. } => Err(Error::FormatMismatch {
242 program_format: Format::Ssh,
243 signature_format: format,
244 }),
245 }
246 }
247
248 fn verify_gpg(
249 &self,
250 signature: &BStr,
251 format: Format,
252 program: OsString,
253 program_arguments: Vec<OsString>,
254 environment: Vec<(OsString, OsString)>,
255 minimum_trust: TrustLevel,
256 ) -> Result<Outcome, Error> {
257 let mut signature_file = signature_file(signature)?;
258 let path = signature_path(&mut signature_file)?;
259 let mut command = prepare(&program, program_arguments, &environment);
260 if format == Format::OpenPgp {
261 command = command.arg("--keyid-format=long");
262 }
263 let command = command.args(["--status-fd=1", "--verify"]).arg(path);
264 let output = if format == Format::X509 {
265 let mut signed_file = temporary_file(self.segments())?;
266 let signed_path = signature_path(&mut signed_file)?;
267 run_without_input(
268 command
269 .arg(signed_path)
270 .stdin(Stdio::null())
271 .stdout(Stdio::piped())
272 .stderr(Stdio::piped()),
273 &program,
274 )?
275 } else {
276 self.run(
277 command
278 .arg("-")
279 .stdin(Stdio::piped())
280 .stdout(Stdio::piped())
281 .stderr(Stdio::piped()),
282 &program,
283 )?
284 };
285 let mut outcome = parse_gpg_output(format, output.stderr.into(), output.stdout.into());
286 outcome.valid =
287 output.status.success() && outcome.status == Status::Good && outcome.trust_level >= minimum_trust;
288 Ok(outcome)
289 }
290
291 #[expect(clippy::too_many_arguments)]
292 fn verify_ssh(
293 &self,
294 signature: &BStr,
295 program: OsString,
296 program_arguments: Vec<OsString>,
297 mut environment: Vec<(OsString, OsString)>,
298 allowed_signers: PathBuf,
299 revocation_file: Option<PathBuf>,
300 verify_time: gix_date::Time,
301 minimum_trust: TrustLevel,
302 ) -> Result<Outcome, Error> {
303 let verify_time = verify_time
304 .format(gix_date::time::CustomFormat::new("%Y%m%d%H%M%S"))
305 .map_err(|err| Error::CommitTime(Box::new(err)))?;
306 let verify_time = format!("-Overify-time={verify_time}");
307 let mut signature_file = signature_file(signature)?;
308 let signature_path = signature_path(&mut signature_file)?;
309 environment.extend([("LANG".into(), "C".into()), ("LC_ALL".into(), "C".into())]);
311 let common = (
312 program.as_os_str(),
313 program_arguments.as_slice(),
314 environment.as_slice(),
315 );
316 let principals = run_prepared(
317 common,
318 [
319 "-Y".into(),
320 "find-principals".into(),
321 "-f".into(),
322 allowed_signers.as_os_str().into(),
323 "-s".into(),
324 signature_path.as_os_str().into(),
325 verify_time.as_str().into(),
326 ],
327 &[],
328 )?;
329 let mut final_output = None;
330 let mut signer = None;
331 if principals.status.success() {
332 for principal in principals.stdout.lines().filter(|line| !line.trim().is_empty()) {
333 let principal = OsString::from(String::from_utf8_lossy(principal.trim()).as_ref());
334 let mut args = vec![
335 "-Y".into(),
336 "verify".into(),
337 "-n".into(),
338 "git".into(),
339 "-f".into(),
340 allowed_signers.as_os_str().into(),
341 "-I".into(),
342 principal.clone(),
343 "-s".into(),
344 signature_path.as_os_str().into(),
345 verify_time.as_str().into(),
346 ];
347 if let Some(revocation_file) = &revocation_file {
348 args.extend(["-r".into(), revocation_file.as_os_str().into()]);
349 }
350 let output = self.run_prepared(common, args)?;
351 if output.status.success() && output.stdout.starts_with(b"Good") {
352 signer = Some(principal.to_string_lossy().as_bytes().into());
353 final_output = Some(output);
354 break;
355 }
356 final_output = Some(output);
357 }
358 }
359 let (output, trust_level, command_success) = match final_output {
360 Some(output) => {
361 let command_success = output.status.success();
362 (output, TrustLevel::Fully, command_success)
363 }
364 None => (
365 self.run_prepared(
366 common,
367 [
368 "-Y".into(),
369 "check-novalidate".into(),
370 "-n".into(),
371 "git".into(),
372 "-s".into(),
373 signature_path.as_os_str().into(),
374 verify_time.as_str().into(),
375 ],
376 )?,
377 TrustLevel::Undefined,
378 false,
379 ),
380 };
381 let human = if output.stdout.is_empty() {
382 output.stderr
383 } else if output.stderr.is_empty() {
384 output.stdout
385 } else {
386 [output.stdout, output.stderr].concat()
387 };
388 let mut outcome = parse_ssh_output(human.into(), signer, trust_level);
389 outcome.valid = command_success && outcome.status == Status::Good && trust_level >= minimum_trust;
390 Ok(outcome)
391 }
392
393 fn run(&self, command: gix_command::Prepare, program: &OsStr) -> Result<std::process::Output, Error> {
394 let mut child = command.spawn().map_err(|source| Error::Spawn {
395 program: program.to_owned(),
396 source,
397 })?;
398 let mut stdin = child.stdin.take().expect("configured as piped");
399 let [before, after] = self.segments();
400 if let Err(source) = stdin.write_all(before).and_then(|_| stdin.write_all(after)) {
401 if source.kind() != std::io::ErrorKind::BrokenPipe {
404 return Err(Error::Communicate {
405 program: program.to_owned(),
406 source,
407 });
408 }
409 }
410 drop(stdin);
411 child.wait_with_output().map_err(|source| Error::Communicate {
412 program: program.to_owned(),
413 source,
414 })
415 }
416
417 fn run_prepared(
418 &self,
419 common: (&OsStr, &[OsString], &[(OsString, OsString)]),
420 args: impl IntoIterator<Item = OsString>,
421 ) -> Result<std::process::Output, Error> {
422 let (program, program_arguments, environment) = common;
423 self.run(
424 prepare(program, program_arguments.iter().cloned(), environment)
425 .args(args)
426 .stdin(Stdio::piped())
427 .stdout(Stdio::piped())
428 .stderr(Stdio::piped()),
429 program,
430 )
431 }
432}
433
434fn prepare(
435 program: &OsStr,
436 arguments: impl IntoIterator<Item = OsString>,
437 environment: &[(OsString, OsString)],
438) -> gix_command::Prepare {
439 environment.iter().fold(
440 gix_command::prepare(program)
441 .command_may_be_shell_script()
442 .args(arguments),
443 |command, (key, value)| command.env(key, value),
444 )
445}
446
447fn run_prepared(
448 common: (&OsStr, &[OsString], &[(OsString, OsString)]),
449 args: impl IntoIterator<Item = OsString>,
450 input: &[u8],
451) -> Result<std::process::Output, Error> {
452 let (program, program_arguments, environment) = common;
453 let command = prepare(program, program_arguments.iter().cloned(), environment)
454 .args(args)
455 .stdin(Stdio::piped())
456 .stdout(Stdio::piped())
457 .stderr(Stdio::piped());
458 let mut child = command.spawn().map_err(|source| Error::Spawn {
459 program: program.to_owned(),
460 source,
461 })?;
462 child
463 .stdin
464 .take()
465 .expect("configured as piped")
466 .write_all(input)
467 .map_err(|source| Error::Communicate {
468 program: program.to_owned(),
469 source,
470 })?;
471 child.wait_with_output().map_err(|source| Error::Communicate {
472 program: program.to_owned(),
473 source,
474 })
475}
476
477fn signature_file(signature: &BStr) -> Result<gix_tempfile::Handle<gix_tempfile::handle::Writable>, Error> {
478 temporary_file([signature.as_ref()])
479}
480
481fn temporary_file<'a>(
482 data: impl IntoIterator<Item = &'a [u8]>,
483) -> Result<gix_tempfile::Handle<gix_tempfile::handle::Writable>, Error> {
484 let mut file = gix_tempfile::new(
485 std::env::temp_dir(),
486 gix_tempfile::ContainingDirectory::Exists,
487 gix_tempfile::AutoRemove::Tempfile,
488 )
489 .map_err(Error::TemporaryFile)?;
490 file.with_mut(|file| {
491 for data in data {
492 file.write_all(data)?;
493 }
494 Ok(())
495 })
496 .map_err(Error::TemporaryFile)?
497 .map_err(Error::TemporaryFile)?;
498 Ok(file)
499}
500
501fn signature_path(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>) -> Result<PathBuf, Error> {
502 file.with_mut(|file| file.path().to_owned())
503 .map_err(Error::TemporaryFile)
504}
505
506fn run_without_input(command: gix_command::Prepare, program: &OsStr) -> Result<std::process::Output, Error> {
507 command
508 .spawn()
509 .map_err(|source| Error::Spawn {
510 program: program.to_owned(),
511 source,
512 })?
513 .wait_with_output()
514 .map_err(|source| Error::Communicate {
515 program: program.to_owned(),
516 source,
517 })
518}
519
520fn parse_gpg_output(format: Format, output: BString, raw_output: BString) -> Outcome {
521 let mut outcome = Outcome {
522 format,
523 status: Status::Unknown,
524 trust_level: TrustLevel::Undefined,
525 signer: None,
526 key: None,
527 fingerprint: None,
528 primary_key_fingerprint: None,
529 output,
530 raw_output,
531 valid: false,
532 };
533 let mut exclusive = false;
534 for line in outcome.raw_output.lines() {
535 let Some(line) = line.strip_prefix(b"[GNUPG:] ") else {
536 continue;
537 };
538 for (prefix, status) in [
539 (b"GOODSIG ".as_slice(), Status::Good),
540 (b"BADSIG ".as_slice(), Status::Bad),
541 (b"ERRSIG ".as_slice(), Status::Error),
542 (b"EXPSIG ".as_slice(), Status::Expired),
543 (b"EXPKEYSIG ".as_slice(), Status::ExpiredKey),
544 (b"REVKEYSIG ".as_slice(), Status::RevokedKey),
545 ] {
546 if let Some(value) = line.strip_prefix(prefix) {
547 if exclusive {
548 outcome.status = Status::Error;
549 outcome.signer = None;
550 outcome.key = None;
551 break;
552 }
553 exclusive = true;
554 outcome.status = status;
555 let mut fields = value.splitn(2, |byte| *byte == b' ');
556 outcome.key = fields.next().filter(|value| !value.is_empty()).map(BString::from);
557 outcome.signer = fields.next().filter(|value| !value.is_empty()).map(BString::from);
558 break;
559 }
560 }
561 if let Some(value) = line.strip_prefix(b"TRUST_") {
562 outcome.trust_level = TrustLevel::from_bytes(value.split(|byte| *byte == b' ').next().unwrap_or_default())
563 .unwrap_or(TrustLevel::Undefined);
564 } else if let Some(value) = line.strip_prefix(b"VALIDSIG ") {
565 let fields: Vec<_> = value.split(|byte| *byte == b' ').collect();
566 outcome.fingerprint = fields
567 .first()
568 .filter(|value| !value.is_empty())
569 .map(|value| BString::from(*value));
570 outcome.primary_key_fingerprint = fields
571 .get(9)
572 .filter(|value| !value.is_empty())
573 .map(|value| BString::from(*value));
574 }
575 }
576 outcome
577}
578
579fn parse_ssh_output(output: BString, signer: Option<BString>, trust_level: TrustLevel) -> Outcome {
580 let status = if output.starts_with(b"Good \"git\" signature") {
581 Status::Good
582 } else {
583 Status::Bad
584 };
585 let fingerprint = output
586 .lines()
587 .next()
588 .and_then(|line| line.rsplit_once_str(" key "))
589 .map(|(_, value)| value.into());
590 Outcome {
591 format: Format::Ssh,
592 status,
593 trust_level,
594 signer,
595 key: fingerprint.clone(),
596 fingerprint,
597 primary_key_fingerprint: None,
598 raw_output: output.clone(),
599 output,
600 valid: false,
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn parses_gpg_status() {
610 let Outcome {
611 format: _,
612 status,
613 trust_level,
614 signer,
615 key: _,
616 fingerprint,
617 primary_key_fingerprint,
618 output: _,
619 raw_output: _,
620 valid: _,
621 } = parse_gpg_output(
622 Format::OpenPgp,
623 "Good signature".into(),
624 "[GNUPG:] GOODSIG 0123456789ABCDEF Fixture Signer\n\
625 [GNUPG:] VALIDSIG FINGERPRINT 0 0 0 0 0 0 0 0 PRIMARY\n\
626 [GNUPG:] TRUST_FULLY 0 pgp\n"
627 .into(),
628 );
629 assert_eq!(status, Status::Good, "the signature is good");
630 assert_eq!(trust_level, TrustLevel::Fully, "the key is fully trusted");
631 assert_eq!(
632 signer.as_ref().map(|value| value.as_slice()),
633 Some(b"Fixture Signer".as_slice()),
634 "the signer identity is parsed"
635 );
636 assert_eq!(
637 fingerprint.as_ref().map(|value| value.as_slice()),
638 Some(b"FINGERPRINT".as_slice()),
639 "the signing-key fingerprint is parsed"
640 );
641 assert_eq!(
642 primary_key_fingerprint.as_ref().map(|value| value.as_slice()),
643 Some(b"PRIMARY".as_slice()),
644 "the primary-key fingerprint is parsed"
645 );
646 }
647
648 #[test]
649 fn parses_ssh_output() {
650 let Outcome {
651 format,
652 status,
653 trust_level,
654 signer,
655 key,
656 fingerprint,
657 primary_key_fingerprint,
658 output,
659 raw_output,
660 valid,
661 } = parse_ssh_output(
662 "Good \"git\" signature for Fixture Signer with ED25519 key SHA256:fixture\n".into(),
663 Some("Fixture Signer".into()),
664 TrustLevel::Fully,
665 );
666 assert_eq!(format, Format::Ssh, "the signature format is SSH");
667 assert_eq!(status, Status::Good, "Git's success output is recognized");
668 assert_eq!(trust_level, TrustLevel::Fully, "the supplied trust level is retained");
669 assert_eq!(signer, Some("Fixture Signer".into()), "the supplied signer is retained");
670 assert_eq!(key, Some("SHA256:fixture".into()), "the key fingerprint is parsed");
671 assert_eq!(fingerprint, key, "the key and fingerprint are identical for SSH");
672 assert_eq!(primary_key_fingerprint, None, "SSH has no primary-key fingerprint");
673 assert_eq!(output, raw_output, "SSH has no separate machine-readable output");
674 assert!(!valid, "parsing alone does not establish validity");
675
676 assert_eq!(
677 parse_ssh_output("invalid signature".into(), None, TrustLevel::Undefined).status,
678 Status::Bad,
679 "all output not starting with Git's success marker is bad"
680 );
681 }
682
683 #[test]
684 fn rejects_good_ssh_output_from_a_failed_verifier() -> Result<(), Error> {
685 let signed = SignedData::new(b"payloadsignature", 7..16);
686 let outcome = signed.verify(
687 BStr::new(b"-----BEGIN SSH SIGNATURE-----\n"),
688 Options::Ssh {
689 program: r#"if [ "$2" = find-principals ]; then printf 'fixture\n'; else printf 'Good "git" signature for fixture with ED25519 key SHA256:fixture\n'; exit 1; fi # "$@""#.into(),
690 program_arguments: Vec::new(),
691 environment: Vec::new(),
692 allowed_signers: "unused".into(),
693 revocation_file: None,
694 verify_time: gix_date::Time::default(),
695 minimum_trust: TrustLevel::Undefined,
696 },
697 )?;
698 assert_eq!(outcome.status, Status::Good, "the verifier's text is retained");
699 assert!(!outcome.is_valid(), "a failed verifier cannot produce a valid outcome");
700 Ok(())
701 }
702
703 #[test]
704 fn parses_signature_formats_and_trust_levels() {
705 for (signature, expected) in [
706 (b"-----BEGIN PGP SIGNATURE-----".as_slice(), Format::OpenPgp),
707 (b"-----BEGIN PGP MESSAGE-----".as_slice(), Format::OpenPgp),
708 (b"-----BEGIN SIGNED MESSAGE-----".as_slice(), Format::X509),
709 (b"-----BEGIN SSH SIGNATURE-----".as_slice(), Format::Ssh),
710 ] {
711 assert_eq!(Format::from_signature(signature), Some(expected));
712 }
713 assert_eq!(Format::from_signature(b"not a signature"), None);
714
715 for (name, expected) in [
716 (b"undefined".as_slice(), TrustLevel::Undefined),
717 (b"NEVER".as_slice(), TrustLevel::Never),
718 (b"Marginal".as_slice(), TrustLevel::Marginal),
719 (b"fully".as_slice(), TrustLevel::Fully),
720 (b"ultimate".as_slice(), TrustLevel::Ultimate),
721 ] {
722 assert_eq!(TrustLevel::from_bytes(name), Some(expected));
723 }
724 assert_eq!(TrustLevel::from_bytes(b"unknown"), None);
725 }
726
727 #[test]
728 fn rejects_unsupported_and_mismatched_formats_before_running_a_program() {
729 let signed = SignedData::new(b"payloadsignature", 7..16);
730 let options = Options::X509 {
731 program: "must-not-run".into(),
732 program_arguments: Vec::new(),
733 environment: Vec::new(),
734 minimum_trust: TrustLevel::Undefined,
735 };
736 assert!(matches!(
737 signed.verify(BStr::new(b"not a signature"), options.clone()),
738 Err(Error::UnsupportedFormat)
739 ));
740 assert!(
741 matches!(
742 signed.verify(BStr::new(b"-----BEGIN SSH SIGNATURE-----\n"), options),
743 Err(Error::FormatMismatch {
744 program_format: Format::X509,
745 signature_format: Format::Ssh,
746 })
747 ),
748 "the mismatch identifies both the configured program and detected signature formats"
749 );
750 }
751}