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

nordisk/gunnar

crates/gunnar-cli/tests/verify_chain.rs

#![cfg(all(feature = "server", feature = "control"))]

//! **The chain, end to end** (roadmap phase 2).
//!
//! A line of source is pushed by stock git over real ssh; then `verify` walks
//! backwards from that line and says which commit wrote it, which push landed
//! it, and where that push sits in the transparency log.
//!
//! # Why the arms are shaped like this
//!
//! Every one of them has a **control**. A verifier that reported `Proven` for
//! everything would pass an "it verifies" test and be worth nothing, so each
//! arm also drives the case the link is supposed to catch:
//!
//! * the blame is checked against a line whose author is known *because the
//!   test wrote it in a specific commit*, not against whatever the verifier
//!   says;
//! * the log link is checked before **and** after a tree head exists, because
//!   an inclusion proof with nothing signed over it is arithmetic the server
//!   did about itself;
//! * a rewritten log turns the link `BAD`, and `BAD` is asserted to be a
//!   different answer from the `??` an unsigned commit gets.

mod common;

use common::Harness;
use gunnar_cli::server::verify::{verify, Chain, LinkState};
use gunnar_core::HashKind;
use gunnar_proto::RepoPath;

fn tools_present() -> bool {
    ["git", "ssh"].iter().all(|tool| {
        std::process::Command::new(tool)
            .arg("--version")
            .output()
            .is_ok()
    })
}

/// Verify through the harness's **own** stores.
///
/// Not `verify_at`, which opens a second `ServerStores` — redb takes an
/// exclusive lock on `control.redb`, so a second handle while the server is up
/// is `Database already open`. That refusal is correct and is why the CLI verb
/// is a separate process from the server; a test that is *in* the server's
/// process has to reuse its handle.
fn chain_of(harness: &Harness, repo: &RepoPath, rev: &str, line: Option<&str>) -> Chain {
    let plane = gunnar_cli::server::CoreControlPlane::new(std::sync::Arc::clone(&harness.stores));
    verify(&harness.stores, &plane, repo, rev, line).expect("verify")
}

/// Three commits over one file, each adding one line, each its own push.
///
/// Returns the commit oids in order, so an arm can assert the blame against the
/// commit it *knows* wrote a line rather than against the verifier's own
/// answer.
fn history(harness: &Harness, work: &std::path::Path, url: &str) -> Vec<String> {
    harness
        .git(
            harness.dir.path(),
            &["init", "--quiet", "-b", "main", work.to_str().unwrap()],
        )
        .expect_ok("git init");
    let mut oids = Vec::new();
    let mut body = String::new();
    for (i, line) in ["first line", "second line", "third line"]
        .iter()
        .enumerate()
    {
        body.push_str(line);
        body.push('\n');
        std::fs::write(work.join("src.txt"), &body).expect("write");
        harness.git(work, &["add", "src.txt"]).expect_ok("add");
        harness
            .git(work, &["commit", "--quiet", "-m", &format!("commit {i}")])
            .expect_ok("commit");
        let head = harness.git(work, &["rev-parse", "HEAD"]);
        head.expect_ok("rev-parse");
        oids.push(head.stdout.trim().to_owned());
        harness
            .git(work, &["push", "--quiet", url, "main"])
            .expect_ok("push");
    }
    oids
}

/// **Line 2 was written by the second commit**, and the verifier says so.
///
/// The oid is the one `git rev-parse` reported at the time, so this is the
/// verifier being checked against git and not against itself.
#[test]
fn the_blame_names_the_commit_that_wrote_the_line() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "chain", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "chain");
    let oids = history(&harness, &work, &url);

    let repo = RepoPath::new("team", "chain");

    for (line, expected) in [(1, &oids[0]), (2, &oids[1]), (3, &oids[2])] {
        let chain = chain_of(
            &harness,
            &repo,
            "refs/heads/main",
            Some(&format!("src.txt:{line}")),
        );
        match &chain.line {
            LinkState::Proven(detail) => assert!(
                detail.contains(expected.as_str()),
                "line {line} was written by {expected} and the blame said: {detail}"
            ),
            other => panic!("line {line} did not resolve to a commit: {other:?}"),
        }
    }

    harness.stop();
}

/// **The log link is unproven until a head is signed, and proven after.**
///
/// This is the arm that says an inclusion proof alone is not evidence: before
/// `attest head` the record is in the log and nothing has sworn to the tree it
/// is in, so the honest answer is `??` and not `ok`.
#[test]
fn the_log_link_needs_a_signed_head_and_says_so_until_it_has_one() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "headed", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "headed");
    history(&harness, &work, &url);

    let repo = RepoPath::new("team", "headed");
    let target = Some("src.txt:3");

    let before = chain_of(&harness, &repo, "refs/heads/main", target);
    assert!(
        before.push.is_proven(),
        "the push must be attested before the log link means anything: {:?}",
        before.push
    );
    assert!(
        matches!(before.log, LinkState::Unproven(_)),
        "with no signed head the log link must be UNPROVEN, not proven: {:?}",
        before.log
    );
    assert!(
        !before.is_unbroken(),
        "a chain with an unproven link reported as unbroken"
    );

    // Now sign one.
    let key = harness.stores.transparency_key().expect("key");
    harness
        .stores
        .transparency()
        .issue_head(&key, 1_700_000_000_000)
        .expect("head");

    let after = chain_of(&harness, &repo, "refs/heads/main", target);
    assert!(
        after.log.is_proven(),
        "with a signed head covering it, the log link must be proven: {:?}",
        after.log
    );

    harness.stop();
}

/// **A rewritten log turns the link BAD**, which is a different answer from the
/// `??` an unsigned commit gets.
///
/// Both are "not proven"; only one is an accusation. A verifier that returned
/// the same thing for both would make an unsigned repository indistinguishable
/// from a tampered one.
#[test]
fn a_rewritten_log_breaks_the_chain_and_an_unsigned_commit_does_not() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "tampered", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "tampered");
    history(&harness, &work, &url);

    let repo = RepoPath::new("team", "tampered");
    let target = Some("src.txt:1");

    let key = harness.stores.transparency_key().expect("key");
    let log = harness.stores.transparency();
    log.issue_head(&key, 1_700_000_000_000).expect("head");

    let clean = chain_of(&harness, &repo, "refs/heads/main", target);
    assert!(clean.log.is_proven(), "control: {:?}", clean.log);
    // The commits stock git made here are unsigned, and that must read as
    // UNPROVEN — nothing is wrong with them.
    assert!(
        matches!(clean.signature, LinkState::Unproven(_)),
        "an unsigned commit must be unproven, never broken: {:?}",
        clean.signature
    );
    assert!(
        !clean.is_broken(),
        "an unsigned commit made the chain read as BROKEN, which accuses it of something"
    );

    // Rewrite a record under the signed head.
    let path = log.records().path().to_path_buf();
    let text = std::fs::read_to_string(&path).expect("read");
    let tampered = text.replacen("\"transport\":\"ssh\"", "\"transport\":\"smoke\"", 1);
    assert_ne!(tampered, text, "the rewrite changed nothing");
    std::fs::write(&path, tampered).expect("rewrite");

    let after = chain_of(&harness, &repo, "refs/heads/main", target);
    assert!(
        after.log.is_broken(),
        "the log was rewritten under a signed head and the chain did not say BROKEN: {:?}",
        after.log
    );
    assert!(after.is_broken(), "the chain must report as broken");

    harness.stop();
}

/// A line that is not there, and a repository that is not there, are refusals
/// with reasons — not an empty chain that reads as a pass.
#[test]
fn the_refusals_name_what_was_wrong() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "refuse", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "refuse");
    history(&harness, &work, &url);

    let repo = RepoPath::new("team", "refuse");

    let past_the_end = chain_of(&harness, &repo, "refs/heads/main", Some("src.txt:99"));
    assert!(
        past_the_end.line.is_broken(),
        "a line past the end of the file must break link 1, not pass it: {:?}",
        past_the_end.line
    );

    let no_such_file = chain_of(&harness, &repo, "refs/heads/main", Some("nope.txt:1"));
    assert!(
        no_such_file.line.is_broken(),
        "a path that is not in the tree must break link 1: {:?}",
        no_such_file.line
    );

    let plane = gunnar_cli::server::CoreControlPlane::new(std::sync::Arc::clone(&harness.stores));
    assert!(
        verify(
            &harness.stores,
            &plane,
            &RepoPath::new("team", "absent"),
            "HEAD",
            None
        )
        .is_err(),
        "a repository that does not exist must be an error, not an empty chain"
    );

    // Without `--line`, link 1 is UNPROVEN and the chain is incomplete — never
    // green just because nothing was asked of it.
    let no_line = chain_of(&harness, &repo, "refs/heads/main", None);
    assert!(matches!(no_line.line, LinkState::Unproven(_)));
    assert!(
        !no_line.is_unbroken(),
        "a chain that was never asked about a line reported as unbroken"
    );

    harness.stop();
}

// ─────────────────────────────────────────────────────────────────────────────
// Link 2 — the commit's OWN signature, at chain level.
//
// `gunnar-policy/tests/signature.rs` proves the cryptography red-first; these
// arms prove the CHAIN keeps the four verdicts apart, because collapsing any
// two of them is the failure the roadmap names:
//
//   signed-and-verified   → Proven, naming the key AND its principal
//   unsigned              → Unproven (covered by
//                           `a_rewritten_log_breaks_the_chain_…` above)
//   signed-by-unknown-key → Broken, saying "unknown", never "verified"
//   signature-invalid     → Broken, saying "invalid", never "unsigned"
//   PGP (present, uncheckable) → Unproven, "not verifiable by this server"
// ─────────────────────────────────────────────────────────────────────────────

/// `git commit -S` with `gpg.format=ssh` shells out to `ssh-keygen -Y sign`,
/// so these arms need it on PATH as well.
fn signing_tools_present() -> bool {
    tools_present()
        && std::process::Command::new("ssh-keygen")
            .arg("-Y")
            .arg("sign")
            .output()
            .is_ok()
}

/// Configure `work` so `git commit -S` signs with `key` the way a developer's
/// machine would: stock git, `gpg.format=ssh`, no gunnar code involved in the
/// signing half.
fn config_ssh_signing(harness: &Harness, work: &std::path::Path, key: &std::path::Path) {
    harness
        .git(work, &["config", "gpg.format", "ssh"])
        .expect_ok("config gpg.format");
    harness
        .git(work, &["config", "user.signingkey", key.to_str().unwrap()])
        .expect_ok("config user.signingkey");
}

/// One commit over `src.txt`, signed (`-S`) or not, pushed to `url`. Returns
/// the oid `git rev-parse` reported.
fn one_commit(
    harness: &Harness,
    work: &std::path::Path,
    url: &str,
    content: &str,
    message: &str,
    sign: bool,
) -> String {
    std::fs::write(work.join("src.txt"), content).expect("write");
    harness.git(work, &["add", "src.txt"]).expect_ok("add");
    let mut args = vec!["commit", "--quiet", "-m", message];
    if sign {
        args.push("-S");
    }
    harness.git(work, &args).expect_ok("commit");
    let head = harness.git(work, &["rev-parse", "HEAD"]);
    head.expect_ok("rev-parse");
    harness
        .git(work, &["push", "--quiet", "--force", url, "main"])
        .expect_ok("push");
    head.stdout.trim().to_owned()
}

/// Write `raw` into the odb as a commit, point `main` at it, and force-push.
/// This is how every doctored commit in these arms reaches the server: through
/// stock git, exactly as an attacker outside gunnar would send it.
fn push_raw_commit(harness: &Harness, work: &std::path::Path, url: &str, raw: &str) -> String {
    let path = work.join("doctored_commit");
    std::fs::write(&path, raw).expect("write the doctored commit");
    let hashed = harness.git(
        work,
        &[
            "hash-object",
            "-t",
            "commit",
            "-w",
            "--literally",
            "doctored_commit",
        ],
    );
    hashed.expect_ok("hash-object");
    let oid = hashed.stdout.trim().to_owned();
    harness
        .git(work, &["update-ref", "refs/heads/main", &oid])
        .expect_ok("update-ref");
    harness
        .git(work, &["push", "--quiet", "--force", url, "main"])
        .expect_ok("push the doctored commit");
    oid
}

/// **The happy path is proven with real `git commit -S`**, and the verdict
/// names both the key and the identity-table principal it belongs to — "signed" without
/// "by whom" is not an answer an auditor can act on.
#[test]
fn a_commit_signed_by_a_registered_key_is_proven_and_names_the_signer() {
    assert!(
        signing_tools_present(),
        "this test requires `git`, `ssh` and `ssh-keygen` on PATH"
    );
    let mut harness = Harness::start("team", "signed", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "signed");
    harness
        .git(
            harness.dir.path(),
            &["init", "--quiet", "-b", "main", work.to_str().unwrap()],
        )
        .expect_ok("git init");
    config_ssh_signing(&harness, &work, &harness.key_path);
    one_commit(
        &harness,
        &work,
        &url,
        "signed line\n",
        "a signed commit",
        true,
    );

    let repo = RepoPath::new("team", "signed");
    let chain = chain_of(&harness, &repo, "refs/heads/main", None);
    match &chain.signature {
        LinkState::Proven(detail) => {
            assert!(
                detail.contains(&harness.key_fingerprint),
                "the verdict must name the key that signed ({}): {detail}",
                harness.key_fingerprint
            );
            assert!(
                detail.contains("tester"),
                "the verdict must name the principal the key belongs to: {detail}"
            );
        }
        other => panic!("a commit signed by the registered key must prove link 2: {other:?}"),
    }
    harness.stop();
}

/// **Tampered is "invalid", an unknown key is "unknown", PGP is "present, not
/// verifiable" — and none of them is any of the others.**
#[test]
fn tampered_unknown_and_unverifiable_signatures_are_kept_apart() {
    assert!(
        signing_tools_present(),
        "this test requires `git`, `ssh` and `ssh-keygen` on PATH"
    );
    let mut harness = Harness::start("team", "verdicts", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "verdicts");
    harness
        .git(
            harness.dir.path(),
            &["init", "--quiet", "-b", "main", work.to_str().unwrap()],
        )
        .expect_ok("git init");
    config_ssh_signing(&harness, &work, &harness.key_path);
    let repo = RepoPath::new("team", "verdicts");

    // ── arm 1: a REAL signature over a commit whose content was then altered.
    // The signature is genuine and the key is registered; only the payload
    // lies. The answer is "invalid" — never "unsigned", never proven.
    one_commit(&harness, &work, &url, "line one\n", "good message", true);
    let raw = harness.git(&work.to_path_buf(), &["cat-file", "commit", "HEAD"]);
    raw.expect_ok("cat-file");
    let tampered = raw.stdout.replace("good message", "evil message");
    assert_ne!(tampered, raw.stdout, "the tamper changed nothing");
    let oid = push_raw_commit(&harness, &work, &url, &tampered);
    let chain = chain_of(&harness, &repo, &oid, None);
    match &chain.signature {
        LinkState::Broken(detail) => {
            assert!(
                detail.contains("invalid"),
                "a tampered signature must read as invalid: {detail}"
            );
            assert!(
                !detail.contains("not signed"),
                "a tampered signature must never read as unsigned: {detail}"
            );
        }
        other => panic!("a tampered signature must break link 2, got {other:?}"),
    }

    // ── arm 2: a genuine signature by a key NOBODY registered. The maths may
    // be perfect; the answer is "unknown", never "verified".
    let unknown_private = gunnar_core::mint_ed25519().expect("mint an unregistered key");
    let unknown_path = work.join("unknown_key");
    std::fs::write(&unknown_path, &unknown_private).expect("write the key");
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&unknown_path, std::fs::Permissions::from_mode(0o600))
            .expect("chmod 600");
    }
    config_ssh_signing(&harness, &work, &unknown_path);
    let oid = one_commit(
        &harness,
        &work,
        &url,
        "line one\nline two\n",
        "signed by a stranger",
        true,
    );
    let chain = chain_of(&harness, &repo, &oid, None);
    match &chain.signature {
        LinkState::Broken(detail) => {
            assert!(
                detail.contains("does not know"),
                "an unknown key must read as unknown: {detail}"
            );
        }
        other => panic!("an unknown key must break link 2, got {other:?}"),
    }
    assert!(
        !chain.signature.is_proven(),
        "a signature by an unregistered key read as verified"
    );

    // ── arm 3: a PGP signature. gunnar cannot check it, and the honest answer
    // is "present, not verifiable by this server" — a hole, not an accusation.
    config_ssh_signing(&harness, &work, &harness.key_path);
    one_commit(
        &harness,
        &work,
        &url,
        "line one\nline two\nline three\n",
        "unsigned base",
        false,
    );
    let raw = harness.git(&work.to_path_buf(), &["cat-file", "commit", "HEAD"]);
    raw.expect_ok("cat-file");
    let idx = raw
        .stdout
        .find("\n\n")
        .expect("a commit has a header/message boundary");
    let doctored = format!(
        "{}\ngpgsig -----BEGIN PGP SIGNATURE-----\n iQIzBAABCAAdFiEE\n =dead\n -----END PGP SIGNATURE-----\n\n{}",
        &raw.stdout[..idx],
        &raw.stdout[idx + 2..]
    );
    let oid = push_raw_commit(&harness, &work, &url, &doctored);
    let chain = chain_of(&harness, &repo, &oid, None);
    match &chain.signature {
        LinkState::Unproven(detail) => {
            assert!(
                detail.contains("not verifiable by this server"),
                "a PGP signature must read as present-but-not-verifiable: {detail}"
            );
        }
        other => {
            panic!("a PGP signature is a hole, not a broken link and not a pass, got {other:?}")
        }
    }

    harness.stop();
}

/// **The wire chain is the walk's chain** — `Admin.VerifyChain` through the
/// seam answers exactly what `gunnar verify` prints, and an unsigned commit
/// crosses the wire as a NAMED hole, never as a pass.
///
/// This is the arm that guards the console: a provenance view is exactly the
/// kind of page that renders green over a broken chain, so the wire shape is
/// pinned to carry all three verdicts. The commits stock git makes here are
/// unsigned, so the signature link must be UNPROVEN with the commit named in
/// the detail, and the summary must be INCOMPLETE — a chain with a hole must
/// not serialize as UNBROKEN.
#[test]
fn the_wire_chain_names_the_unsigned_commit_as_a_hole() {
    use gunnar_grpc::ControlPlane as _;
    use gunnar_proto::{ChainLinkVerdict, ChainSummary, VerifyChainRequest};

    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "wire", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "wire");
    let oids = history(&harness, &work, &url);

    // A head, so links 3 and 4 are proven and the ONE hole left is the
    // signature — otherwise "INCOMPLETE" would be true for the wrong reason
    // and the signature assertion below could pass against a chain that never
    // reached link 2.
    let key = harness.stores.transparency_key().expect("key");
    harness
        .stores
        .transparency()
        .issue_head(&key, 1_700_000_000_000)
        .expect("head");

    let plane = gunnar_cli::server::CoreControlPlane::new(std::sync::Arc::clone(&harness.stores));
    let response = plane
        .verify_chain(&VerifyChainRequest {
            repo: Some(RepoPath::new("team", "wire")),
            rev: "refs/heads/main".into(),
            target: "src.txt:2".into(),
        })
        .expect("the control plane serves Admin.VerifyChain");

    assert_eq!(response.repo, "team/wire");
    assert_eq!(response.target, "src.txt:2");

    let by_id = |id: &str| {
        response
            .links
            .iter()
            .find(|l| l.id == id)
            .unwrap_or_else(|| panic!("the wire chain carries no {id:?} link: {response:?}"))
    };

    let line = by_id("line");
    assert_eq!(line.verdict, ChainLinkVerdict::ChainLinkProven as i32);
    assert!(
        line.detail.contains(oids[1].as_str()),
        "line 2 was written by {} and the wire said: {}",
        oids[1],
        line.detail
    );

    let signature = by_id("signature");
    assert_eq!(
        signature.verdict,
        ChainLinkVerdict::ChainLinkUnproven as i32,
        "an unsigned commit must cross the wire as UNPROVEN, not as {:?}",
        signature.verdict
    );
    assert!(
        signature.detail.contains("not signed"),
        "the hole must be named in words: {}",
        signature.detail
    );

    assert_eq!(
        by_id("push").verdict,
        ChainLinkVerdict::ChainLinkProven as i32
    );
    assert_eq!(
        by_id("log").verdict,
        ChainLinkVerdict::ChainLinkProven as i32
    );

    assert_eq!(
        response.summary,
        ChainSummary::ChainIncomplete as i32,
        "a chain with an unproven link serialized as something other than INCOMPLETE"
    );

    harness.stop();
}

/// **A rewritten log crosses the wire as BROKEN**, and the wire refuses a walk
/// it cannot start: a repository that does not exist is an error, never an
/// empty chain that a console would render as six grey rows.
#[test]
fn the_wire_chain_breaks_and_refuses_like_the_walk_does() {
    use gunnar_grpc::ControlPlane as _;
    use gunnar_proto::{ChainLinkVerdict, ChainSummary, VerifyChainRequest};

    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "wiretamper", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "wiretamper");
    history(&harness, &work, &url);

    let key = harness.stores.transparency_key().expect("key");
    let log = harness.stores.transparency();
    log.issue_head(&key, 1_700_000_000_000).expect("head");

    // Rewrite a record under the signed head.
    let path = log.records().path().to_path_buf();
    let text = std::fs::read_to_string(&path).expect("read");
    let tampered = text.replacen("\"transport\":\"ssh\"", "\"transport\":\"smoke\"", 1);
    assert_ne!(tampered, text, "the rewrite changed nothing");
    std::fs::write(&path, tampered).expect("rewrite");

    let plane = gunnar_cli::server::CoreControlPlane::new(std::sync::Arc::clone(&harness.stores));
    let response = plane
        .verify_chain(&VerifyChainRequest {
            repo: Some(RepoPath::new("team", "wiretamper")),
            rev: "refs/heads/main".into(),
            target: "src.txt:1".into(),
        })
        .expect("verify_chain");
    let log_link = response
        .links
        .iter()
        .find(|l| l.id == "log")
        .expect("a log link");
    assert_eq!(
        log_link.verdict,
        ChainLinkVerdict::ChainLinkBroken as i32,
        "a tampered log must cross the wire as BROKEN: {}",
        log_link.detail
    );
    assert_eq!(response.summary, ChainSummary::ChainBroken as i32);

    // And the refusal: no such repository is an error, not an empty chain.
    assert!(
        plane
            .verify_chain(&VerifyChainRequest {
                repo: Some(RepoPath::new("team", "absent")),
                rev: "HEAD".into(),
                target: String::new(),
            })
            .is_err(),
        "a repository that does not exist must refuse, not answer an empty chain"
    );

    harness.stop();
}