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 { .. })
));
}
}