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/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();
}