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/transparency.rs

#![cfg(feature = "server")]

//! **The transparency log, over real pushes** (push attestation, roadmap phase 2).
//!
//! The attestation log has been append-only *by discipline* from the start: nothing
//! stopped the file being rewritten, and nothing in it said whether it had been.
//! These arms are what turn the discipline into evidence, and the one that
//! carries the file is `a_rewritten_record_is_caught_by_a_head_signed_before_it`
//! — because every other assertion here would pass against a log with no
//! integrity at all.
//!
//! # What each arm is guarding
//!
//! * a head signs what is there, and a client can check it with the public key
//!   alone;
//! * a record can be proved to be in the log **without the log** — that is what
//!   an inclusion proof is for;
//! * rewriting a record is caught; **truncating** the log is caught; and the two
//!   are reported as different things, because they are.

mod common;

use common::Harness;
use gunnar_cli::server::attest::AuditVerdict;
use gunnar_core::HashKind;

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

/// Push `count` distinct commits, so the log gains `count` records.
fn push_some(harness: &Harness, work: &std::path::Path, url: &str, count: usize) {
    harness
        .git(
            harness.dir.path(),
            &["init", "--quiet", "-b", "main", work.to_str().unwrap()],
        )
        .expect_ok("git init");
    for i in 0..count {
        std::fs::write(work.join("a.txt"), format!("line {i}\n")).expect("write");
        harness.git(work, &["add", "a.txt"]).expect_ok("git add");
        harness
            .git(work, &["commit", "--quiet", "-m", &format!("commit {i}")])
            .expect_ok("git commit");
        harness
            .git(work, &["push", "--quiet", url, "main"])
            .expect_ok("push");
    }
}

/// **The one that matters.** A head is signed, a record is rewritten, and the
/// head catches it.
///
/// The rewrite is a plausible one — a principal's name changed, nothing else —
/// which is exactly the edit an append-only file cannot refuse and a Merkle root
/// cannot miss.
///
/// The control comes first: the same log must audit **clean** before the
/// rewrite, or "caught after" would be satisfied by an auditor that refuses
/// everything.
#[test]
fn a_rewritten_record_is_caught_by_a_head_signed_before_it() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "logged", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "logged");
    push_some(&harness, &work, &url, 3);

    let log = harness.stores.transparency();
    let key = harness
        .stores
        .transparency_key()
        .expect("the log's signing key");
    assert_eq!(log.len().expect("count"), 3, "three pushes, three records");

    let head = log
        .issue_head(&key, 1_700_000_000_000)
        .expect("issuing a head");
    assert_eq!(head.head.size, 3);

    // ── the control ────────────────────────────────────────────────────────
    assert_eq!(
        log.audit(key.public_key()).expect("audit"),
        AuditVerdict::Consistent {
            records: 3,
            heads: 1
        },
        "the log must audit clean before a rewrite means anything"
    );

    // ── the rewrite: one field, in one line, in place ──────────────────────
    let path = log.records().path().to_path_buf();
    let text = std::fs::read_to_string(&path).expect("read the log");
    let tampered = text.replacen(
        "\"principal_name\":\"tester\"",
        "\"principal_name\":\"mallory\"",
        1,
    );
    assert_ne!(
        tampered, text,
        "the rewrite changed nothing, so the assertion below would pass against an untouched log"
    );
    std::fs::write(&path, &tampered).expect("rewrite the log");

    match log.audit(key.public_key()).expect("audit") {
        AuditVerdict::RecordsRewritten {
            size,
            signed_root,
            actual_root,
        } => {
            assert_eq!(size, 3);
            assert_ne!(signed_root, actual_root);
        }
        other => panic!(
            "a record was rewritten under a signed head and the audit said {other}. An \
             append-only log that cannot say it was rewritten is a file with a promise on it"
        ),
    }

    harness.stop();
}

/// Removing lines is caught, and reported as **truncation** rather than as a
/// rewrite. The two need different people to look at them.
#[test]
fn a_truncated_log_is_caught_and_named_as_truncation() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "cut", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "cut");
    push_some(&harness, &work, &url, 3);

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

    let path = log.records().path().to_path_buf();
    let text = std::fs::read_to_string(&path).expect("read");
    let kept: Vec<&str> = text.lines().take(1).collect();
    std::fs::write(&path, format!("{}\n", kept.join("\n"))).expect("truncate");

    match log.audit(key.public_key()).expect("audit") {
        AuditVerdict::RecordsTruncated { size, records } => {
            assert_eq!((size, records), (3, 1));
        }
        other => panic!("two records were deleted and the audit said {other}"),
    }

    harness.stop();
}

/// **A record proves without the log.**
///
/// The proof is taken from the server and then checked with nothing but the
/// arithmetic — no log, no server, no trust in the thing that served it. That
/// is the whole difference between a log you are told about and one you can
/// check.
#[test]
fn a_push_proves_its_place_in_the_log_offline() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "proved", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "proved");
    push_some(&harness, &work, &url, 5);

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

    // A verifier holds: the signed head, the public key, and the record it
    // cares about. Nothing else.
    gunnar_core::transparency::verify_tree_head(&head, key.public_key())
        .expect("the head must verify against the key it names");

    let records = log.records().read().expect("read the records");
    let wanted = &records[2];
    let inclusion = log
        .prove_pack(&wanted.pack_hash)
        .expect("prove")
        .expect("the pack is in the log");
    assert_eq!(inclusion.index, 2);
    assert_eq!(inclusion.size, 5);
    assert_eq!(
        inclusion.root, head.head.root,
        "the proof is against a different tree from the one that was signed"
    );

    let leaf = gunnar_core::transparency::unhex(&inclusion.leaf).expect("a leaf hash");
    let proof: Vec<_> = inclusion
        .proof
        .iter()
        .map(|h| gunnar_core::transparency::unhex(h).expect("a proof step"))
        .collect();
    let root = gunnar_core::transparency::unhex(&head.head.root).expect("the signed root");
    assert!(
        gunnar_core::transparency::verify_inclusion(&leaf, 2, 5, &proof, &root),
        "the proof the server handed out does not reconstruct the root the server signed"
    );

    // And the same proof must NOT verify for a different position, or it proves
    // nothing about where the record is.
    assert!(
        !gunnar_core::transparency::verify_inclusion(&leaf, 3, 5, &proof, &root),
        "the proof verified at the wrong index"
    );

    // A pack nobody pushed has no proof, and that is not an empty proof.
    assert!(
        log.prove_pack(&"f".repeat(40)).expect("prove").is_none(),
        "a proof was produced for a pack the log does not name"
    );

    harness.stop();
}

/// Heads accumulate, and **every** one is checked — not just the newest.
///
/// A head is a claim about a prefix, so an old head must keep verifying as the
/// log grows. An auditor that only checked the newest would miss a rewrite of a
/// record that a later head no longer covers on its own.
#[test]
fn an_older_head_still_covers_its_prefix_after_the_log_grows() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "grown", HashKind::Sha1);
    let work = harness.dir.path().join("work");
    let url = harness.url("team", "grown");
    push_some(&harness, &work, &url, 2);

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

    // Two more pushes, then a second head.
    for i in 2..4 {
        std::fs::write(work.join("a.txt"), format!("more {i}\n")).expect("write");
        harness.git(&work, &["add", "a.txt"]).expect_ok("add");
        harness
            .git(&work, &["commit", "--quiet", "-m", &format!("more {i}")])
            .expect_ok("commit");
        harness
            .git(&work, &["push", "--quiet", &url, "main"])
            .expect_ok("push");
    }
    let late = log
        .issue_head(&key, 1_700_000_001_000)
        .expect("second head");
    assert_eq!(late.head.size, 4);
    assert_ne!(
        early.head.root, late.head.root,
        "the log grew and the root did not move"
    );

    assert_eq!(
        log.audit(key.public_key()).expect("audit"),
        AuditVerdict::Consistent {
            records: 4,
            heads: 2
        },
        "both heads must still verify against the log they cover"
    );

    // Now rewrite a record the EARLY head covers. The newest head covers it too,
    // but the point is that the early one is checked at all.
    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\":\"http\"", 1);
    std::fs::write(&path, tampered).expect("rewrite");

    match log.audit(key.public_key()).expect("audit") {
        AuditVerdict::RecordsRewritten { size, .. } => assert_eq!(
            size, 2,
            "the EARLIEST head that no longer matches must be the one reported, so an operator \
             is told how far back the rewrite reaches"
        ),
        other => panic!("a covered record was rewritten and the audit said {other}"),
    }

    harness.stop();
}