Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-policy/src/signature.rs

//! `gpg.format=ssh` commit signatures, verified in pure Rust.
//!
//! No `gpg`, no `ssh-keygen`, no subprocess, no C: the server has no shell to
//! run one in — the server has no shell. Everything here is `ssh-key` and byte handling.
//!
//! # What git actually writes
//!
//! With `gpg.format=ssh`, `git commit -S` puts an **armored SSHSIG blob** in a
//! `gpgsig` header of the commit object, folded the way git folds every
//! multi-line header, where continuation lines get one leading space:
//!
//! ```text
//! tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
//! parent 0d4a5c1…
//! author  A U Thor <a@example.com> 1700000000 +0000
//! committer A U Thor <a@example.com> 1700000000 +0000
//! gpgsig -----BEGIN SSH SIGNATURE-----
//!  U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg…
//!  -----END SSH SIGNATURE-----
//!
//! the commit message
//! ```
//!
//! # The part that is easy to get wrong
//!
//! **The signed payload is the commit object with that header removed**, not
//! the commit id, not the message, not the commit as stored. Get it wrong and
//! *every* signature fails, which at least fails loudly. Get it wrong in the
//! forgiving direction (verify the stored bytes and fall back to "well, it had
//! a signature header") and every signature passes, which is the always-allow
//! failure mode wearing a beard.
//!
//! So the payload is reconstructed here by removing exactly the header span that
//! carried the signature, and the reconstruction is checked against **signatures
//! produced by stock `git` 2.53 with `ssh-keygen`**, not only against signatures
//! this crate minted itself. Two implementations agreeing is evidence; one
//! implementation agreeing with itself is not.
//!
//! # Namespace
//!
//! SSHSIG signatures are namespaced so a signature minted for one purpose cannot
//! be replayed for another. Git uses `git`. A blob whose namespace is anything
//! else is refused, and there is a test that a `file`-namespaced signature over
//! byte-identical content does not verify.

use gix_hash::ObjectId;
use gunnar_store::git_storage_trait::{GitServe, ObjType};
use ssh_key::{PublicKey, SshSig};

use crate::error::{Error, Result};

/// The SSHSIG namespace git signs commits under.
pub const GIT_NAMESPACE: &str = "git";

/// The commit headers git puts a signature in.
///
/// `gpgsig-sha256` carries the signature over the SHA-256 form of a commit in a
/// repository that also has a SHA-1 form. Accepting both costs one array entry
/// and means an interop repository does not silently read as unsigned.
pub const SIGNATURE_HEADERS: [&str; 2] = ["gpgsig", "gpgsig-sha256"];

const SSH_ARMOR_BEGIN: &[u8] = b"-----BEGIN SSH SIGNATURE-----";

/// A signature that verified.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedSignature {
    /// The commit it was on.
    pub commit: ObjectId,
    /// The header the blob was found in.
    pub header: &'static str,
    /// `SHA256:…` fingerprint of the key that signed.
    pub fingerprint: String,
}

/// Why a commit's signature was not accepted.
///
/// Every variant is a *policy* answer, not a gunnar failure. See
/// [`crate::error`]. They are separate variants rather than one string because
/// the difference between "unsigned" and "signed by a key we do not know" is the
/// difference between a developer who forgot a flag and one whose key was
/// revoked, and the push report should not blur them.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SignatureProblem {
    /// No signature header at all.
    Unsigned,
    /// There is a signature, but it is not an SSHSIG blob; a PGP signature, most
    /// likely. Never silently accepted: the point of this gate is that gunnar
    /// checked the cryptography, and it cannot check PGP.
    NotAnSshSignature {
        /// The first line of the armor that was found.
        armor: String,
    },
    /// The blob is an SSHSIG but will not decode.
    Malformed(String),
    /// The blob was minted for a different purpose.
    WrongNamespace {
        /// What the blob says.
        got: String,
        /// What git signs commits under.
        want: String,
    },
    /// Nobody gunnar knows owns this key.
    KeyNotRegistered {
        /// Fingerprint of the key in the blob.
        fingerprint: String,
    },
    /// The cryptography did not check out: the payload was altered, or the blob
    /// came from a different commit.
    BadSignature {
        /// Fingerprint of the key in the blob.
        fingerprint: String,
        /// What `ssh-key` said.
        reason: String,
    },
}

impl std::fmt::Display for SignatureProblem {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            // "the object", not "the commit": a tag is signable too, and the
            // same vocabulary now reports both (GUN-003). The substring
            // `is not signed` is what every existing needle matches on.
            SignatureProblem::Unsigned => f.write_str("the object is not signed"),
            SignatureProblem::NotAnSshSignature { armor } => write!(
                f,
                "the object carries a {armor} signature, and gunnar verifies ssh signatures only"
            ),
            SignatureProblem::Malformed(reason) => {
                write!(f, "ssh signature is malformed: {reason}")
            }
            SignatureProblem::WrongNamespace { got, want } => {
                write!(f, "ssh signature namespace is {got:?}, expected {want:?}")
            }
            SignatureProblem::KeyNotRegistered { fingerprint } => {
                write!(f, "signing key {fingerprint} is not registered with gunnar")
            }
            SignatureProblem::BadSignature {
                fingerprint,
                reason,
            } => write!(
                f,
                "ssh signature by {fingerprint} does not verify against the object: {reason}"
            ),
        }
    }
}

/// The outcome of checking one commit.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SignatureCheck {
    /// It verified.
    Verified(VerifiedSignature),
    /// It did not, for a reason that is policy's business.
    Problem(SignatureProblem),
}

impl SignatureCheck {
    /// `true` if the signature verified.
    pub fn is_verified(&self) -> bool {
        matches!(self, SignatureCheck::Verified(_))
    }

    /// The problem, if there was one.
    pub fn problem(&self) -> Option<&SignatureProblem> {
        match self {
            SignatureCheck::Problem(problem) => Some(problem),
            SignatureCheck::Verified(_) => None,
        }
    }
}

/// One header of a git object, with the byte span it occupies.
#[derive(Debug)]
struct Header<'a> {
    name: &'a [u8],
    /// The value with git's fold undone: continuation lines rejoined with `\n`
    /// and their single leading space dropped.
    value: Vec<u8>,
    /// Where the whole header sits in the raw object, trailing newline included,
    /// so removing it leaves a well-formed object behind.
    span: std::ops::Range<usize>,
}

/// Split a git object's header block into headers.
///
/// Stops at the blank line that separates headers from the message, exactly as
/// git's own parser does.
fn headers(raw: &[u8]) -> Vec<Header<'_>> {
    let mut out: Vec<Header<'_>> = Vec::new();
    let mut pos = 0usize;
    while pos < raw.len() {
        let line_end = raw[pos..]
            .iter()
            .position(|&b| b == b'\n')
            .map(|i| pos + i)
            .unwrap_or(raw.len());
        if line_end == pos {
            break; // blank line: the header block is over
        }
        let line = &raw[pos..line_end];
        let next = (line_end + 1).min(raw.len());
        if line[0] == b' ' {
            if let Some(last) = out.last_mut() {
                last.value.push(b'\n');
                last.value.extend_from_slice(&line[1..]);
                last.span.end = next;
            }
        } else {
            let (name, value) = match line.iter().position(|&b| b == b' ') {
                Some(i) => (&line[..i], &line[i + 1..]),
                None => (line, &line[line.len()..]),
            };
            out.push(Header {
                name,
                value: value.to_vec(),
                span: pos..next,
            });
        }
        pos = next;
    }
    out
}

/// The signature blob on a commit, and the bytes it was made over.
#[derive(Debug)]
pub struct SignedPayload {
    /// The header the blob came from.
    pub header: &'static str,
    /// The armored SSHSIG blob, unfolded.
    pub armor: Vec<u8>,
    /// The commit object with that header removed: what was actually signed.
    pub payload: Vec<u8>,
}

/// Find a signature header on a raw commit and reconstruct the signed payload.
///
/// Returns `Ok(None)` when the commit carries no signature header at all.
pub fn split_signature(raw: &[u8]) -> std::result::Result<Option<SignedPayload>, SignatureProblem> {
    let parsed = headers(raw);
    let Some((header_name, found)) = SIGNATURE_HEADERS.iter().find_map(|&name| {
        parsed
            .iter()
            .find(|h| h.name == name.as_bytes())
            .map(|h| (name, h))
    }) else {
        return Ok(None);
    };

    let mut payload = Vec::with_capacity(raw.len());
    payload.extend_from_slice(&raw[..found.span.start]);
    payload.extend_from_slice(&raw[found.span.end..]);

    Ok(Some(SignedPayload {
        header: header_name,
        armor: found.value.clone(),
        payload,
    }))
}

/// Verify the signature on one commit against `allowed`.
///
/// `allowed` is the set of keys this signature may have come from: the pushing
/// principal's keys, or every key gunnar knows, depending on the configured
/// scope. An **empty** `allowed` therefore rejects every signed commit rather
/// than accepting them, which is the safe direction and is pinned by a test.
pub fn check_commit(
    objects: &dyn GitServe,
    commit: &gix_hash::oid,
    allowed: &[PublicKey],
    namespace: &str,
) -> Result<SignatureCheck> {
    let Some((kind, bytes)) = objects.read(commit.as_bytes()).map_err(Error::backend)? else {
        return Err(Error::MissingObject {
            oid: commit.to_owned(),
            what: "the signature",
        });
    };
    if kind != ObjType::Commit {
        return Err(Error::decode(
            commit.to_owned(),
            format_args!("expected a commit, found a {}", kind.as_str()),
        ));
    }
    Ok(check_commit_bytes(
        commit.to_owned(),
        &bytes,
        allowed,
        namespace,
    ))
}

/// The pure half of [`check_commit`]: no store, just bytes.
///
/// Separated so the payload reconstruction can be tested against raw commits
/// captured from stock `git` without standing a store up.
pub fn check_commit_bytes(
    commit: ObjectId,
    raw: &[u8],
    allowed: &[PublicKey],
    namespace: &str,
) -> SignatureCheck {
    let split = match split_signature(raw) {
        Ok(Some(split)) => split,
        Ok(None) => return SignatureCheck::Problem(SignatureProblem::Unsigned),
        Err(problem) => return SignatureCheck::Problem(problem),
    };

    if !split.armor.starts_with(SSH_ARMOR_BEGIN) {
        let armor = split
            .armor
            .split(|&b| b == b'\n')
            .next()
            .map(|line| String::from_utf8_lossy(line).into_owned())
            .unwrap_or_default();
        return SignatureCheck::Problem(SignatureProblem::NotAnSshSignature { armor });
    }

    // The PEM decoder wants the armor to end with a line break; git's folded
    // header does not carry one.
    let mut armor = split.armor.clone();
    if !armor.ends_with(b"\n") {
        armor.push(b'\n');
    }

    verify_armored(
        commit,
        &split.payload,
        &armor,
        allowed,
        namespace,
        split.header,
    )
}

/// Verify the signature on one **annotated tag** object against `allowed`.
///
/// # Why this is not [`check_commit`] with a different type check
///
/// A signed tag does not carry its signature in a header. `git tag -s` writes
/// the armor at the **end of the object**, after the message, with no folding
/// and no header name:
///
/// ```text
/// object 0d4a5c1…
/// type commit
/// tag v1
/// tagger A U Thor <a@example.com> 1700000000 +0000
///
/// the tag message
/// -----BEGIN SSH SIGNATURE-----
/// U1NIU0lH…
/// -----END SSH SIGNATURE-----
/// ```
///
/// …and the signed payload is everything before that line. Two formats, so two
/// splitters; the *verification* — namespace, then registered key, then the
/// maths, in that order and with those three distinct answers — is
/// [`verify_armored`], which both go through, so a push report reads the same
/// either way and the two cannot come to disagree about what "verified" means.
///
/// GUN-003 needed this. Peeling the tag makes the commits under it checkable,
/// but a tag object is a signable git object in its own right, and a policy
/// that checked only what was under it would still let `refs/tags/v1` land on
/// a protected pattern as an object nobody signed.
pub fn check_tag(
    objects: &dyn GitServe,
    tag: &gix_hash::oid,
    allowed: &[PublicKey],
    namespace: &str,
) -> Result<SignatureCheck> {
    let Some((kind, bytes)) = objects.read(tag.as_bytes()).map_err(Error::backend)? else {
        return Err(Error::MissingObject {
            oid: tag.to_owned(),
            what: "the tag signature",
        });
    };
    if kind != ObjType::Tag {
        return Err(Error::decode(
            tag.to_owned(),
            format_args!("expected a tag, found a {}", kind.as_str()),
        ));
    }
    Ok(check_tag_bytes(tag.to_owned(), &bytes, allowed, namespace))
}

/// The pure half of [`check_tag`]: no store, just bytes.
pub fn check_tag_bytes(
    tag: ObjectId,
    raw: &[u8],
    allowed: &[PublicKey],
    namespace: &str,
) -> SignatureCheck {
    let Some(start) = trailing_armor_start(raw) else {
        return SignatureCheck::Problem(SignatureProblem::Unsigned);
    };
    let payload = &raw[..start];
    let mut armor = raw[start..].to_vec();
    if !armor.ends_with(b"\n") {
        armor.push(b'\n');
    }
    verify_armored(tag, payload, &armor, allowed, namespace, "tag")
}

/// Where the trailing SSHSIG block of a tag object begins.
///
/// The **last** line that opens one, not the first. A tag message is
/// attacker-supplied text and can contain a `-----BEGIN SSH SIGNATURE-----`
/// line of its own; taking the first would let that line define the payload,
/// and everything after it — including a real signature over a shorter
/// prefix — would be read as the armor. Taking the last can only ever make the
/// payload longer and the armor the tail git itself appended; if the tail is
/// junk the PEM decode refuses. Both directions fail closed.
///
/// `None` when the object carries no armor at all: an unsigned tag.
fn trailing_armor_start(raw: &[u8]) -> Option<usize> {
    let mut found = None;
    let mut at = 0usize;
    for line in raw.split_inclusive(|&b| b == b'\n') {
        if line.strip_suffix(b"\n").unwrap_or(line) == SSH_ARMOR_BEGIN {
            found = Some(at);
        }
        at += line.len();
    }
    found
}

/// Namespace, then registered key, then the maths.
///
/// The one place those three answers are told apart. `KeyNotRegistered` before
/// `BadSignature` because a revoked key must not read as a corrupt signature,
/// and the blob's own copy of the key is never trusted: the lookup is against
/// the registered set.
fn verify_armored(
    object: ObjectId,
    payload: &[u8],
    armor: &[u8],
    allowed: &[PublicKey],
    namespace: &str,
    header: &'static str,
) -> SignatureCheck {
    let sig = match SshSig::from_pem(armor) {
        Ok(sig) => sig,
        Err(err) => return SignatureCheck::Problem(SignatureProblem::Malformed(err.to_string())),
    };
    if sig.namespace() != namespace {
        return SignatureCheck::Problem(SignatureProblem::WrongNamespace {
            got: sig.namespace().to_owned(),
            want: namespace.to_owned(),
        });
    }
    let Some(key) = allowed.iter().find(|k| k.key_data() == sig.public_key()) else {
        let unknown = PublicKey::new(sig.public_key().clone(), "");
        return SignatureCheck::Problem(SignatureProblem::KeyNotRegistered {
            fingerprint: unknown.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
        });
    };
    let fingerprint = key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
    match key.verify(namespace, payload, &sig) {
        Ok(()) => SignatureCheck::Verified(VerifiedSignature {
            commit: object,
            header,
            fingerprint,
        }),
        Err(err) => SignatureCheck::Problem(SignatureProblem::BadSignature {
            fingerprint,
            reason: err.to_string(),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // NOTE: written as one literal on purpose. Rust's `\`-at-end-of-line
    // continuation eats the *leading whitespace* of the next line, and the
    // single leading space is exactly what makes a git header line a
    // continuation. Splitting this constant across lines silently unfolds the
    // fixture, and the two assertions below caught precisely that.
    const SIGNED: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A U Thor <a@example.com> 1700000000 +0000\ncommitter A U Thor <a@example.com> 1700000000 +0000\ngpgsig -----BEGIN SSH SIGNATURE-----\n AAAA\n -----END SSH SIGNATURE-----\n\nsubject\n";

    const UNSIGNED: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A U Thor <a@example.com> 1700000000 +0000\ncommitter A U Thor <a@example.com> 1700000000 +0000\n\nsubject\n";

    #[test]
    fn the_payload_is_the_commit_without_the_signature_header() {
        // The fixture must actually be folded, or this proves nothing.
        assert!(SIGNED.windows(6).any(|w| w == b"\n AAAA"));

        let split = split_signature(SIGNED).unwrap().expect("a signature");
        assert_eq!(
            split.payload,
            UNSIGNED.to_vec(),
            "the gpgsig header, its continuation lines and nothing else must be removed"
        );
    }

    #[test]
    fn folded_continuation_lines_are_rejoined_without_their_leading_space() {
        let split = split_signature(SIGNED).unwrap().expect("a signature");
        assert_eq!(
            split.armor,
            b"-----BEGIN SSH SIGNATURE-----\nAAAA\n-----END SSH SIGNATURE-----".to_vec()
        );
    }

    #[test]
    fn an_unsigned_commit_has_no_signature_span() {
        assert!(split_signature(UNSIGNED).unwrap().is_none());
    }

    #[test]
    fn a_message_line_that_looks_like_a_header_is_not_one() {
        // The header block ends at the blank line. If it did not, a commit whose
        // *message* began with `gpgsig ` could strip an arbitrary line out of its
        // own payload.
        let raw = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\ncommitter A U Thor <a@example.com> 1700000000 +0000\n\ngpgsig -----BEGIN SSH SIGNATURE-----\n";
        assert!(split_signature(raw).unwrap().is_none());
    }

    #[test]
    fn a_pgp_signature_is_refused_rather_than_ignored() {
        let raw = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\ncommitter A U Thor <a@example.com> 1700000000 +0000\ngpgsig -----BEGIN PGP SIGNATURE-----\n iQIzBAAB\n -----END PGP SIGNATURE-----\n\nsubject\n";
        let check = check_commit_bytes(gix_hash::Kind::Sha1.null(), raw, &[], GIT_NAMESPACE);
        assert!(matches!(
            check.problem(),
            Some(SignatureProblem::NotAnSshSignature { .. })
        ));
    }
}