Skip to content

Commit

Permalink
Merge branch 'extract-signatures'
Browse files Browse the repository at this point in the history
  • Loading branch information
Byron committed Aug 8, 2023
2 parents c7d9129 + cd6cfe4 commit b37affe
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 16 deletions.
41 changes: 40 additions & 1 deletion gitoxide-core/src/repository/commit.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,43 @@
use anyhow::{Context, Result};
use anyhow::{anyhow, bail, Context, Result};
use std::io::Write;
use std::process::Stdio;

/// Note that this is a quick implementation of commit signature verification that ignores a lot of what
/// git does and can do, while focussing on the gist of it.
/// For this to go into `gix`, one will have to implement many more options and various validation programs.
pub fn verify(repo: gix::Repository, rev_spec: Option<&str>) -> Result<()> {
let rev_spec = rev_spec.unwrap_or("HEAD");
let commit = repo
.rev_parse_single(format!("{rev_spec}^{{commit}}").as_str())?
.object()?
.into_commit();
let (signature, signed_data) = commit
.signature()
.context("Could not parse commit to obtain signature")?
.ok_or_else(|| anyhow!("Commit at {rev_spec} is not signed"))?;

let mut signature_storage = tempfile::NamedTempFile::new()?;
signature_storage.write_all(signature.as_ref())?;
let signed_storage = signature_storage.into_temp_path();

let mut cmd = std::process::Command::new("gpg");
cmd.args(["--keyid-format=long", "--status-fd=1", "--verify"])
.arg(&signed_storage)
.arg("-")
.stdin(Stdio::piped());
gix::trace::debug!("About to execute {cmd:?}");
let mut child = cmd.spawn()?;
child
.stdin
.take()
.expect("configured")
.write_all(signed_data.to_bstring().as_ref())?;

if !child.wait()?.success() {
bail!("Command {cmd:?} failed");
}
Ok(())
}

pub fn describe(
mut repo: gix::Repository,
Expand Down
38 changes: 36 additions & 2 deletions gix-object/src/commit/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use bstr::{BStr, ByteSlice};
use bstr::{BStr, BString, ByteSlice};
use std::ops::Range;

use crate::{Commit, CommitRef, TagRef};

Expand All @@ -21,16 +22,49 @@ pub struct MessageRef<'a> {
pub body: Option<&'a BStr>,
}

/// The raw commit data, parseable by [`CommitRef`] or [`Commit`], which was fed into a program to produce a signature.
///
/// See [`extract_signature()`](crate::CommitRefIter::signature()) for how to obtain it.
// TODO: implement `std::io::Read` to avoid allocations
#[derive(PartialEq, Eq, Debug, Hash, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SignedData<'a> {
/// The raw commit data that includes the signature.
data: &'a [u8],
/// The byte range at which we find the signature. All but the signature is the data that was signed.
signature_range: Range<usize>,
}

impl SignedData<'_> {
/// Convenience method to obtain a copy of the signed data.
pub fn to_bstring(&self) -> BString {
let mut buf = BString::from(&self.data[..self.signature_range.start]);
buf.extend_from_slice(&self.data[self.signature_range.end..]);
buf
}
}

impl From<SignedData<'_>> for BString {
fn from(value: SignedData<'_>) -> Self {
value.to_bstring()
}
}

///
pub mod ref_iter;

mod write;

/// Lifecycle
impl<'a> CommitRef<'a> {
/// Deserialize a commit from the given `data` bytes while avoiding most allocations.
pub fn from_bytes(data: &'a [u8]) -> Result<CommitRef<'a>, crate::decode::Error> {
decode::commit(data).map(|(_, t)| t).map_err(crate::decode::Error::from)
}
}

/// Access
impl<'a> CommitRef<'a> {
/// Return the `tree` fields hash digest.
pub fn tree(&self) -> gix_hash::ObjectId {
gix_hash::ObjectId::from_hex(self.tree).expect("prior validation of tree hash during parsing")
Expand All @@ -45,7 +79,7 @@ impl<'a> CommitRef<'a> {

/// Returns a convenient iterator over all extra headers.
pub fn extra_headers(&self) -> crate::commit::ExtraHeaders<impl Iterator<Item = (&BStr, &BStr)>> {
crate::commit::ExtraHeaders::new(self.extra_headers.iter().map(|(k, v)| (*k, v.as_ref())))
ExtraHeaders::new(self.extra_headers.iter().map(|(k, v)| (*k, v.as_ref())))
}

/// Return the author, with whitespace trimmed.
Expand Down
76 changes: 76 additions & 0 deletions gix-object/src/commit/ref_iter.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::ops::Range;

use bstr::BStr;
use gix_hash::{oid, ObjectId};
Expand All @@ -9,6 +10,7 @@ use nom::{
error::context,
};

use crate::commit::SignedData;
use crate::{bstr::ByteSlice, commit::decode, parse, parse::NL, CommitRefIter};

#[derive(Copy, Clone)]
Expand All @@ -30,6 +32,7 @@ pub(crate) enum State {
Message,
}

/// Lifecycle
impl<'a> CommitRefIter<'a> {
/// Create a commit iterator from data.
pub fn from_bytes(data: &'a [u8]) -> CommitRefIter<'a> {
Expand All @@ -38,6 +41,37 @@ impl<'a> CommitRefIter<'a> {
state: State::default(),
}
}
}

/// Access
impl<'a> CommitRefIter<'a> {
/// Parse `data` as commit and return its PGP signature, along with *all non-signature* data as [`SignedData`], or `None`
/// if the commit isn't signed.
///
/// This allows the caller to validate the signature by passing the signed data along with the signature back to the program
/// that created it.
pub fn signature(data: &'a [u8]) -> Result<Option<(Cow<'a, BStr>, SignedData<'a>)>, crate::decode::Error> {
let mut signature_and_range = None;

let raw_tokens = CommitRefIterRaw {
data,
state: State::default(),
offset: 0,
};
for token in raw_tokens {
let token = token?;
if let Token::ExtraHeader((name, value)) = &token.token {
if *name == "gpgsig" {
// keep track of the signature range alongside the signature data,
// because all but the signature is the signed data.
signature_and_range = Some((value.clone(), token.token_range));
break;
}
}
}

Ok(signature_and_range.map(|(sig, signature_range)| (sig, SignedData { data, signature_range })))
}

/// Returns the object id of this commits tree if it is the first function called and if there is no error in decoding
/// the data.
Expand Down Expand Up @@ -233,6 +267,48 @@ impl<'a> Iterator for CommitRefIter<'a> {
}
}

/// A variation of [`CommitRefIter`] that return's [`RawToken`]s instead.
struct CommitRefIterRaw<'a> {
data: &'a [u8],
state: State,
offset: usize,
}

impl<'a> Iterator for CommitRefIterRaw<'a> {
type Item = Result<RawToken<'a>, crate::decode::Error>;

fn next(&mut self) -> Option<Self::Item> {
if self.data.is_empty() {
return None;
}
match CommitRefIter::next_inner(self.data, &mut self.state) {
Ok((remaining, token)) => {
let consumed = self.data.len() - remaining.len();
let start = self.offset;
let end = start + consumed;
self.offset = end;

self.data = remaining;
Some(Ok(RawToken {
token,
token_range: start..end,
}))
}
Err(err) => {
self.data = &[];
Some(Err(err))
}
}
}
}

/// A combination of a parsed [`Token`] as well as the range of bytes that were consumed to parse it.
struct RawToken<'a> {
/// The parsed token.
token: Token<'a>,
token_range: Range<usize>,
}

/// A token returned by the [commit iterator][CommitRefIter].
#[allow(missing_docs)]
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
Expand Down
14 changes: 1 addition & 13 deletions gix-object/tests/commit/from_bytes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,23 +294,11 @@ fn merge() -> crate::Result {
Ok(())
}

const OTHER_SIGNATURE: &[u8; 455] = b"-----BEGIN PGP SIGNATURE-----
wsBcBAABCAAQBQJeqxW4CRBK7hj4Ov3rIwAAdHIIAFD98qgN/k8ybukCLf6kpzvi
5V8gf6BflONXc/oIDySurW7kfS9/r6jOgu08UN8KlQx4Q4g8yY7PROABhwGI70B3
+mHPFcParQf5FBDDZ3GNNpJdlaI9eqzEnFk8AmHmyKHfuGLoclXUObXQ3oe3fmT7
QdTC7JTyk/bPnZ9HQKw7depa3+7Kw4wv4DG8QcW3BG6B9bcE15qaWmOiq0ryRXsv
k7D0LqGSXjU5wrQrKnemC7nWhmQsqaXDe89XXmliClCAx4/bepPiXK0eT/DNIKUr
iyBBl69jASy41Ug/BlFJbw4+ItkShpXwkJKuBBV/JExChmvbxYWaS7QnyYC9UO0=
=HLmy
-----END PGP SIGNATURE-----
";

#[test]
fn newline_right_after_signature_multiline_header() -> crate::Result {
let fixture = fixture_name("commit", "signed-whitespace.txt");
let commit = CommitRef::from_bytes(&fixture)?;
let pgp_sig = OTHER_SIGNATURE.as_bstr();
let pgp_sig = crate::commit::OTHER_SIGNATURE.as_bstr();
assert_eq!(commit.extra_headers[0].1.as_ref(), pgp_sig);
assert_eq!(commit.extra_headers().pgp_signature(), Some(pgp_sig));
assert_eq!(commit.extra_headers().find("gpgsig"), Some(pgp_sig));
Expand Down
56 changes: 56 additions & 0 deletions gix-object/tests/commit/iter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,60 @@ mod method {
assert_eq!(iter.author().ok(), Some(signature(1592437401)), "it's not consuming");
Ok(())
}

mod signature {
use bstr::{BStr, BString, ByteSlice};
use gix_object::CommitRefIter;

use crate::{
commit::{OTHER_SIGNATURE, SIGNATURE},
fixture_name,
};

fn validate<'a>(
fixture: &str,
expected_signature: impl Into<&'a BStr>,
signature_lines: std::ops::RangeInclusive<usize>,
) -> crate::Result {
let expected_signature = expected_signature.into();
let fixture_data = fixture_name("commit", fixture);

let (actual_signature, actual_signed_data) = CommitRefIter::signature(&fixture_data)?.expect("sig present");
assert_eq!(actual_signature, expected_signature);

let expected_signed_data: BString = fixture_data
.lines_with_terminator()
.enumerate()
.filter_map(|(i, line)| (!signature_lines.contains(&i)).then_some(line))
.collect();

assert_eq!(actual_signed_data.to_bstring(), expected_signed_data);
Ok(())
}

#[test]
fn single_line() -> crate::Result {
validate("signed-singleline.txt", b"magic:signature", 4..=4)
}

#[test]
fn signed() -> crate::Result {
validate("signed.txt", b"-----BEGIN PGP SIGNATURE-----\n\niQEzBAABCAAdFiEEdjYp/sh4j8NRKLX27gKdHl60AwAFAl7p9tgACgkQ7gKdHl60\nAwBpegf+KQciv9AOIN7+yPmowecGxBnSfpKWTDzFxnyGR8dq63SpWT8WEKG5mf3a\nG6iUqpsDWaMHlzihaMKRvgRpZxFRbjnNPFBj6F4RRqfE+5R7k6DRSLUV5PqnsdSH\nuccfIDWi1imhsm7AaP5trwl1t+83U2JhHqPcPVFLMODYwWeO6NLR/JCzGSTQRa8t\nRgaVMKI19O/fge5OT5Ua8D47VKEhsJX0LfmkP5RfZQ8JJvNd40TupqKRdlv0sAzP\nya7NXkSHXCavHNR6kA+KpWxn900UoGK8/IDlwU6MeOkpPVawb3NFMqnc7KJDaC2p\nSMzpuEG8LTrCx2YSpHNLqHyzvQ1CZA==\n=5ITV\n-----END PGP SIGNATURE-----", 4..=14)
}

#[test]
fn with_encoding() -> crate::Result {
validate("signed-with-encoding.txt", SIGNATURE, 5..=15)
}

#[test]
fn msg_footer() -> crate::Result {
validate("message-with-footer.txt", b"-----BEGIN PGP SIGNATURE-----\n\niHUEABYIAB0WIQSuZwcGWSQItmusNgR5URpSUCnwXQUCYT7xpAAKCRB5URpSUCnw\nXWB3AP9q323HlxnI8MyqszNOeYDwa7Y3yEZaUM2y/IRjz+z4YQEAq0yr1Syt3mrK\nOSFCqL2vDm3uStP+vF31f6FnzayhNg0=\n=Mhpp\n-----END PGP SIGNATURE-----", 4..=10)
}

#[test]
fn whitespace() -> crate::Result {
validate("signed-whitespace.txt", OTHER_SIGNATURE, 5..=15)
}
}
}
13 changes: 13 additions & 0 deletions gix-object/tests/commit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ zRo/4HJ04mSQYp0kluP/EBhz9g2wM/htIPyWRveB/ByKEYt3UNKjB++PJmPbu5UG
dS3aXZhRfaPqpdsWrMB9fY7ll+oyfw==
=T+RI
-----END PGP SIGNATURE-----";

const OTHER_SIGNATURE: &[u8; 455] = b"-----BEGIN PGP SIGNATURE-----
wsBcBAABCAAQBQJeqxW4CRBK7hj4Ov3rIwAAdHIIAFD98qgN/k8ybukCLf6kpzvi
5V8gf6BflONXc/oIDySurW7kfS9/r6jOgu08UN8KlQx4Q4g8yY7PROABhwGI70B3
+mHPFcParQf5FBDDZ3GNNpJdlaI9eqzEnFk8AmHmyKHfuGLoclXUObXQ3oe3fmT7
QdTC7JTyk/bPnZ9HQKw7depa3+7Kw4wv4DG8QcW3BG6B9bcE15qaWmOiq0ryRXsv
k7D0LqGSXjU5wrQrKnemC7nWhmQsqaXDe89XXmliClCAx4/bepPiXK0eT/DNIKUr
iyBBl69jASy41Ug/BlFJbw4+ItkShpXwkJKuBBV/JExChmvbxYWaS7QnyYC9UO0=
=HLmy
-----END PGP SIGNATURE-----
";

mod method {
use gix_object::CommitRef;
use pretty_assertions::assert_eq;
Expand Down
9 changes: 9 additions & 0 deletions gix/src/object/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ impl<'repo> Commit<'repo> {
max_candidates: 10,
}
}

/// Extracts the PGP signature and the data that was used to create the signature, or `None` if it wasn't signed.
// TODO: make it possible to verify the signature, probably by wrapping `SignedData`. It's quite some work to do it properly.
pub fn signature(
&self,
) -> Result<Option<(std::borrow::Cow<'_, BStr>, gix_object::commit::SignedData<'_>)>, gix_object::decode::Error>
{
gix_object::CommitRefIter::signature(&self.data)
}
}

impl<'r> std::fmt::Debug for Commit<'r> {
Expand Down
11 changes: 11 additions & 0 deletions src/plumbing/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,17 @@ pub fn main() -> Result<()> {
),
},
Subcommands::Commit(cmd) => match cmd {
commit::Subcommands::Verify { rev_spec } => prepare_and_run(
"commit-verify",
trace,
auto_verbose,
progress,
progress_keep_open,
None,
move |_progress, _out, _err| {
core::repository::commit::verify(repository(Mode::Lenient)?, rev_spec.as_deref())
},
),
commit::Subcommands::Describe {
annotated_tags,
all_refs,
Expand Down
5 changes: 5 additions & 0 deletions src/plumbing/options/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,11 @@ pub mod tree {
pub mod commit {
#[derive(Debug, clap::Subcommand)]
pub enum Subcommands {
/// Verify the signature of a commit.
Verify {
/// A specification of the revision to verify, or the current `HEAD` if unset.
rev_spec: Option<String>,
},
/// Describe the current commit or the given one using the name of the closest annotated tag in its ancestry.
Describe {
/// Use annotated tag references only, not all tags.
Expand Down

0 comments on commit b37affe

Please sign in to comment.