crates/gunnar-policy/tests/signature.rs
//! `gpg.format=ssh` verification, in both directions.
//!
//! Keys are minted in-process with `ssh-key`; there is no `ssh-keygen` and no
//! `gpg` anywhere in this crate. `tests/git_interop.rs` is the other half: it
//! checks this code against signatures produced by stock `git`, which is the only
//! thing that can prove the payload reconstruction is right rather than merely
//! self-consistent.
mod common;
use common::{
expect_allow, expect_reject, mint_key, refname, sign_commit_bytes, splice_signature, Repo,
Scenario,
};
use gunnar_policy::signature::{self, SignatureCheck, SignatureProblem, GIT_NAMESPACE};
use gunnar_policy::{RefUpdate, SignaturePolicy, SignedCommitsGate, SignerScope};
#[test]
fn a_genuine_signature_verifies() {
let repo = Repo::sha1();
let key = mint_key();
let commit = repo.signed_commit("signed", &[], &key);
let check = signature::check_commit(
&repo.objects,
&commit,
&[key.public_key().clone()],
GIT_NAMESPACE,
)
.unwrap();
match check {
SignatureCheck::Verified(verified) => {
assert_eq!(verified.commit, commit);
assert_eq!(verified.header, "gpgsig");
assert_eq!(
verified.fingerprint,
key.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string(),
"the reported fingerprint must be the signing key's, not some other key's"
);
}
SignatureCheck::Problem(problem) => panic!("a valid signature failed: {problem}"),
}
}
#[test]
fn one_flipped_byte_in_the_message_breaks_the_signature() {
// The tamper case, done at the byte level rather than by rebuilding the
// commit: the signature blob is left exactly as minted and only the payload
// moves. If verification passed here it would mean the payload is not
// actually part of what is checked.
let repo = Repo::sha1();
let key = mint_key();
let commit = repo.signed_commit("the original message", &[], &key);
let raw = repo.raw(&commit);
let tampered: Vec<u8> = {
let text = String::from_utf8(raw.clone()).unwrap();
text.replace("the original message", "the tampered message")
.into_bytes()
};
assert_ne!(raw, tampered, "the fixture must actually differ");
let check = signature::check_commit_bytes(
commit,
&tampered,
&[key.public_key().clone()],
GIT_NAMESPACE,
);
assert!(
matches!(check.problem(), Some(SignatureProblem::BadSignature { .. })),
"a tampered payload must fail verification, got {check:?}"
);
}
#[test]
fn a_signature_by_a_different_key_does_not_verify_as_the_registered_one() {
let repo = Repo::sha1();
let registered = mint_key();
let attacker = mint_key();
let commit = repo.signed_commit("signed by the attacker", &[], &attacker);
let check = signature::check_commit(
&repo.objects,
&commit,
&[registered.public_key().clone()],
GIT_NAMESPACE,
)
.unwrap();
match check.problem() {
Some(SignatureProblem::KeyNotRegistered { fingerprint }) => assert_eq!(
fingerprint,
&attacker
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string(),
"the report must name the key that actually signed"
),
other => panic!("expected KeyNotRegistered, got {other:?}"),
}
}
#[test]
fn an_empty_key_set_rejects_rather_than_vacuously_passing() {
// The identity-value trap for this gate: "no key matched" and "no keys to
// match against" must not both mean yes.
let repo = Repo::sha1();
let key = mint_key();
let commit = repo.signed_commit("signed", &[], &key);
let check = signature::check_commit(&repo.objects, &commit, &[], GIT_NAMESPACE).unwrap();
assert!(matches!(
check.problem(),
Some(SignatureProblem::KeyNotRegistered { .. })
));
}
#[test]
fn a_signature_from_another_namespace_does_not_transfer() {
// SSHSIG namespaces exist so a signature minted for signing files cannot be
// replayed as a commit signature. Same key, same bytes, different namespace.
let repo = Repo::sha1();
let key = mint_key();
let commit = repo.commit_signed_in_namespace("c", &[], &key, "file");
let check = signature::check_commit(
&repo.objects,
&commit,
&[key.public_key().clone()],
GIT_NAMESPACE,
)
.unwrap();
match check.problem() {
Some(SignatureProblem::WrongNamespace { got, want }) => {
assert_eq!(got, "file");
assert_eq!(want, "git");
}
other => panic!("expected WrongNamespace, got {other:?}"),
}
}
#[test]
fn an_unsigned_commit_reports_unsigned_rather_than_malformed() {
let repo = Repo::sha1();
let commit = repo.commit("plain", &[]);
let check = signature::check_commit(&repo.objects, &commit, &[], GIT_NAMESPACE).unwrap();
assert_eq!(check.problem(), Some(&SignatureProblem::Unsigned));
}
#[test]
fn a_garbled_signature_blob_is_malformed_not_accepted() {
let repo = Repo::sha1();
let key = mint_key();
let payload = repo.commit_bytes("c", &[], repo.empty_tree());
let signed = sign_commit_bytes(&payload, &key, GIT_NAMESPACE);
// Corrupt the base64 body while leaving the armor lines intact.
let text = String::from_utf8(signed).unwrap();
let corrupted = text.replacen("U1NIU0lH", "!!!!!!!!", 1);
assert_ne!(text, corrupted, "the fixture must actually be corrupted");
let check = signature::check_commit_bytes(
gix_hash::Kind::Sha1.null(),
corrupted.as_bytes(),
&[key.public_key().clone()],
GIT_NAMESPACE,
);
assert!(
!check.is_verified(),
"a corrupted signature blob must never verify"
);
}
#[test]
fn a_signature_lifted_onto_a_different_commit_does_not_verify() {
let repo = Repo::sha1();
let key = mint_key();
let genuine = repo.signed_commit("commit A", &[], &key);
let armor = common::extract_armor(&repo.raw(&genuine));
let other_payload = repo.commit_bytes("commit B", &[], repo.empty_tree());
let forged = splice_signature(&other_payload, &armor);
let forged_id = repo.write_commit(&forged);
let check = signature::check_commit(
&repo.objects,
&forged_id,
&[key.public_key().clone()],
GIT_NAMESPACE,
)
.unwrap();
assert!(
matches!(check.problem(), Some(SignatureProblem::BadSignature { .. })),
"a replayed signature must fail, got {check:?}"
);
}
#[test]
fn signatures_work_the_same_on_a_sha256_repository() {
let repo = Repo::sha256();
let key = mint_key();
let commit = repo.signed_commit("signed under sha256", &[], &key);
assert_eq!(commit.kind(), gix_hash::Kind::Sha256);
let check = signature::check_commit(
&repo.objects,
&commit,
&[key.public_key().clone()],
GIT_NAMESPACE,
)
.unwrap();
assert!(check.is_verified(), "sha256 signature failed: {check:?}");
}
#[test]
fn the_scope_decides_whose_keys_count() {
let repo = Repo::sha1();
let mine = mint_key();
let colleague = mint_key();
let commit = repo.signed_commit("written by a colleague", &[], &colleague);
let update = RefUpdate::create(refname("refs/heads/main"), commit);
let scenario = Scenario::new(repo.objects.clone())
.pushed_by("alice")
.pushing(update)
.introducing([commit])
.with_key("alice", &mine)
.with_key("colleague", &colleague);
// AnyRegisteredKey: a colleague's commit pushed by me is ordinary.
let permissive = SignedCommitsGate::new(SignaturePolicy::required());
expect_allow(&permissive, &scenario.context()).unwrap();
// PusherKeysOnly: it is not.
let strict = SignedCommitsGate::new(SignaturePolicy {
scope: SignerScope::PusherKeysOnly,
..SignaturePolicy::required()
});
expect_reject(
&strict,
&scenario.context(),
"is not registered with gunnar",
)
.unwrap();
}
#[test]
fn every_new_commit_is_checked_not_only_the_tip() {
// The plausible shortcut (verify the pushed tip and stop) passes any
// single-commit fixture. Here the tip is signed and an intermediate commit is
// not, so a tip-only check would allow the push.
let repo = Repo::sha1();
let key = mint_key();
let base = repo.signed_commit("base, signed", &[], &key);
let middle = repo.commit("middle, UNSIGNED", &[base]);
let tip = repo.signed_commit("tip, signed", &[middle], &key);
let gate = SignedCommitsGate::new(SignaturePolicy::required());
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/main"), tip))
.introducing([base, middle, tip])
.with_key("alice", &key);
let verdict = expect_reject(&gate, &scenario.context(), "is not signed").unwrap();
assert!(
verdict.reason().unwrap().contains(&middle.to_string()),
"the report must name the unsigned commit in the middle, said: {}",
verdict.reason().unwrap()
);
}
#[test]
fn a_delete_needs_no_signature_because_it_adds_no_commits() {
let repo = Repo::sha1();
let key = mint_key();
let tip = repo.commit("unsigned history", &[]);
let gate = SignedCommitsGate::new(SignaturePolicy::required());
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::delete(refname("refs/heads/old"), tip))
.with_key("alice", &key);
expect_allow(&gate, &scenario.context()).unwrap();
}
#[test]
fn the_gate_is_inert_when_signatures_are_not_required() {
let repo = Repo::sha1();
let tip = repo.commit("unsigned", &[]);
let gate = SignedCommitsGate::new(SignaturePolicy::default());
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/main"), tip))
.introducing([tip]);
expect_allow(&gate, &scenario.context()).unwrap();
}