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

nordisk/gunnar

crates/gunnar-server/src/verify.rs

//! **`gunnar verify` — the unbroken chain, walked backwards.**
//!
//! The roadmap's whole thesis in one command: given a line of source, say which
//! commit put it there, whether that commit is signed by a key this server
//! knows, which push landed it, and whether that push is in the transparency
//! log under a signed tree head.
//!
//! ```text
//!   file:line  ──▶  commit  ──▶  signature  ──▶  push attestation  ──▶  log
//!     link 1       link 1         link 2            link 3            link 4
//! ```
//!
//! # What it proves, and where it stops
//!
//! Links 1–4, and nothing above them. Gunnar is only a git server: it builds
//! nothing, holds no artifacts and never verifies a build record. Builds run in
//! dwarves through Build Thing (the SLSA builder) and artifacts live in Holger,
//! which is where a walk from an artifact back to its source starts.
//!
//! # Every link may be UNPROVEN, and that is a third answer
//!
//! Not `true`/`false`. A commit with no signature is not a commit whose
//! signature failed; a push with no attestation is not a push that was refused.
//! Collapsing the two would make an unsigned repository look identical to a
//! forged one, which is the failure this whole design is written against, so
//! [`LinkState`] has three values and the summary refuses to reduce them to a
//! boolean.

use std::path::Path;

use anyhow::{Context, Result};
use gunnar_store::git_storage_trait::{GitServe, ObjType};

use crate::stores::ServerStores;

/// How much history a blame will walk before giving up.
///
/// A bound rather than a promise: a line in a file with a hundred thousand
/// revisions is a question this command should refuse rather than answer in an
/// hour. Exceeding it is reported as [`LinkState::Unproven`] with the reason,
/// never as "the line was introduced at the oldest commit we happened to look
/// at".
pub const BLAME_BUDGET: usize = 4096;

/// What is known about one link of the chain.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkState {
    /// Checked, and it holds.
    Proven(String),
    /// Checked, and it does not. **This is the answer that must never be
    /// confused with the one below.**
    Broken(String),
    /// Not checked, and why. Nothing was found wrong; nothing was found right.
    Unproven(String),
}

impl LinkState {
    pub fn is_proven(&self) -> bool {
        matches!(self, LinkState::Proven(_))
    }
    pub fn is_broken(&self) -> bool {
        matches!(self, LinkState::Broken(_))
    }
    fn mark(&self) -> &'static str {
        match self {
            LinkState::Proven(_) => "ok  ",
            LinkState::Broken(_) => "BAD ",
            LinkState::Unproven(_) => "??  ",
        }
    }
    fn detail(&self) -> &str {
        match self {
            LinkState::Proven(d) | LinkState::Broken(d) | LinkState::Unproven(d) => d,
        }
    }
}

/// The chain, as far as it could be walked.
#[derive(Debug, Clone)]
pub struct Chain {
    /// `<namespace>/<repo>`.
    pub repo: String,
    /// The revision the walk started from.
    pub from: String,
    /// `path:line`, one-based, as the operator wrote it.
    pub target: Option<String>,
    /// Link 1: which commit put the line there.
    pub line: LinkState,
    /// Link 2: the commit's own signature.
    pub signature: LinkState,
    /// Link 3: the push that landed the commit.
    pub push: LinkState,
    /// Link 4: that push's place in the transparency log.
    pub log: LinkState,
}

impl Chain {
    /// Every link, in chain order, with a stable id and a print label.
    ///
    /// The ONE enumeration (one implementation): [`std::fmt::Display`] prints the labels,
    /// [`Chain::to_proto`] ships the ids, and both walk this list — so a link
    /// added here reaches the CLI and `Admin.VerifyChain` together, and cannot
    /// reach one without the other.
    pub fn links(&self) -> Vec<(&'static str, &'static str, &LinkState)> {
        vec![
            ("line", "1 line → commit  ", &self.line),
            ("signature", "2 commit signed  ", &self.signature),
            ("push", "3 push attested  ", &self.push),
            ("log", "4 in the log     ", &self.log),
        ]
    }

    /// The chain rendered for `Admin.VerifyChain` — a projection of
    /// [`Chain::links`], never a second verdict.
    pub fn to_proto(&self) -> gunnar_proto::VerifyChainResponse {
        use gunnar_proto::{ChainLink, ChainLinkVerdict, ChainSummary};
        gunnar_proto::VerifyChainResponse {
            repo: self.repo.clone(),
            from: self.from.clone(),
            target: self.target.clone().unwrap_or_default(),
            links: self
                .links()
                .iter()
                .map(|(id, _, state)| ChainLink {
                    id: (*id).to_owned(),
                    verdict: match state {
                        LinkState::Proven(_) => ChainLinkVerdict::ChainLinkProven,
                        LinkState::Broken(_) => ChainLinkVerdict::ChainLinkBroken,
                        LinkState::Unproven(_) => ChainLinkVerdict::ChainLinkUnproven,
                    } as i32,
                    detail: state.detail().to_owned(),
                })
                .collect(),
            summary: if self.is_unbroken() {
                ChainSummary::ChainUnbroken
            } else if self.is_broken() {
                ChainSummary::ChainBroken
            } else {
                ChainSummary::ChainIncomplete
            } as i32,
        }
    }

    /// **Unbroken** means every link is proven. A single `Unproven` is not
    /// unbroken: an unverified link is a hole in the chain, and calling it
    /// green is exactly the false confidence the roadmap's `Risker` section
    /// names.
    pub fn is_unbroken(&self) -> bool {
        self.links().iter().all(|(_, _, state)| state.is_proven())
    }

    /// `true` if anything was actively wrong, as opposed to merely unchecked.
    pub fn is_broken(&self) -> bool {
        self.links().iter().any(|(_, _, state)| state.is_broken())
    }
}

impl std::fmt::Display for Chain {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        writeln!(f, "repo   {}", self.repo)?;
        writeln!(f, "from   {}", self.from)?;
        if let Some(target) = &self.target {
            writeln!(f, "target {target}")?;
        }
        writeln!(f)?;
        for (_, label, state) in self.links() {
            writeln!(f, "  {} {}  {}", state.mark(), label, state.detail())?;
        }
        writeln!(f)?;
        if self.is_unbroken() {
            write!(f, "UNBROKEN: every link is proven.")
        } else if self.is_broken() {
            write!(
                f,
                "BROKEN: a link was checked and did not hold. See the BAD line(s) above."
            )
        } else {
            write!(
                f,
                "INCOMPLETE: nothing is wrong and not everything is proven. An unchecked link \
                 is a hole, not a pass."
            )
        }
    }
}

/// Where a line came from.
struct Origin {
    commit: gix_hash::ObjectId,
    /// How many commits the walk visited to find it.
    visited: usize,
}

/// The blob at `path` in `commit`'s tree, or `None` if the path is not there.
///
/// `pub(crate)` because it is the one tree/blob reader link-1 rests on, and a
/// whole-file blame ([`crate::blame`]) is that same read for every line: it
/// must resolve a path to a blob at a commit exactly as this does, or the two
/// answers can disagree (one implementation).
pub(crate) fn blob_at(
    git: &dyn GitServe,
    hash: gix_hash::Kind,
    commit: gix_hash::ObjectId,
    path: &str,
) -> Result<Option<(gix_hash::ObjectId, Vec<u8>)>> {
    let (kind, bytes) = git
        .read(commit.as_bytes())?
        .with_context(|| format!("commit {commit} is not in this repository"))?;
    anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
    let mut tree = gunnar_store::object::commit(&bytes, hash)
        .with_context(|| format!("decoding commit {commit}"))?
        .tree;

    let mut components = path.split('/').peekable();
    while let Some(component) = components.next() {
        let (kind, bytes) = match git.read(tree.as_bytes())? {
            Some(pair) => pair,
            None => return Ok(None),
        };
        if kind != ObjType::Tree {
            return Ok(None);
        }
        let mut found = None;
        for entry in gunnar_store::object::TreeIter::from_bytes(&bytes, hash).named() {
            let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
            if entry.name == component.as_bytes() {
                found = Some(entry);
                break;
            }
        }
        let Some(entry) = found else {
            return Ok(None);
        };
        if components.peek().is_some() {
            tree = entry.oid;
            continue;
        }
        // Last component: it must be a blob for a line to live in it.
        let Some((ObjType::Blob, blob)) = git.read(entry.oid.as_bytes())? else {
            return Ok(None);
        };
        return Ok(Some((entry.oid, blob)));
    }
    Ok(None)
}

/// A commit's parents, in order.
///
/// `pub(crate)` for the same reason as [`blob_at`]: the blame walk steps through
/// parents the way link-1 does, and sharing the one reader keeps them from
/// drifting.
pub(crate) fn parents(
    git: &dyn GitServe,
    hash: gix_hash::Kind,
    commit: gix_hash::ObjectId,
) -> Result<Vec<gix_hash::ObjectId>> {
    let (_, bytes) = git
        .read(commit.as_bytes())?
        .with_context(|| format!("commit {commit} is not in this repository"))?;
    Ok(gunnar_store::object::commit(&bytes, hash)
        .with_context(|| format!("decoding commit {commit}"))?
        .parents)
}

/// **Which commit put this line here.**
///
/// Walks back from `tip`, following the parent the line came *through* — for a
/// merge that means trying each parent in order and taking the first one the
/// line traces back into, which is what git's blame does and what makes a line
/// merged from a side branch attributable to the branch rather than to the
/// merge.
///
/// Stops when the line has no ancestor in any parent: that commit is where it
/// was written.
fn blame(
    git: &dyn GitServe,
    hash: gix_hash::Kind,
    tip: gix_hash::ObjectId,
    path: &str,
    line: usize,
) -> Result<Option<Origin>> {
    let mut commit = tip;
    let mut line = line;
    for visited in 1..=BLAME_BUDGET {
        let Some((_, blob)) = blob_at(git, hash, commit, path)? else {
            // The path is not in this commit at all. Only reachable for the
            // very first one — the walk never steps into a parent that lacks
            // it — so it means the caller asked about a file that is not there.
            return Ok(None);
        };
        let current = crate::diff::split_lines(&blob);
        if line >= current.len() {
            return Ok(None);
        }
        let mut stepped = None;
        for parent in parents(git, hash, commit)? {
            let Some((_, parent_blob)) = blob_at(git, hash, parent, path)? else {
                continue;
            };
            let older = crate::diff::split_lines(&parent_blob);
            // Identical blobs: the line certainly survived, and tracing it is
            // both unnecessary and the expensive path.
            if parent_blob == blob {
                stepped = Some((parent, line));
                break;
            }
            if let Some(at) = crate::diff::trace_line(&older, &current, line) {
                stepped = Some((parent, at));
                break;
            }
        }
        match stepped {
            Some((parent, at)) => {
                commit = parent;
                line = at;
            }
            // No parent holds this line: it was written here.
            None => return Ok(Some(Origin { commit, visited })),
        }
    }
    anyhow::bail!(
        "the blame walked {BLAME_BUDGET} commits without finding where the line was written; \
         this is a bound, not an answer"
    )
}

/// Walk the chain for one line of one file.
///
/// `target` is `path:line`, one-based, the way an editor and `git blame` count.
/// `None` verifies the revision itself — links 2 to 4 for the commit at `rev`,
/// with link 1 reported as unchecked rather than as passed.
pub fn verify(
    stores: &ServerStores,
    plane: &crate::control_plane::CoreControlPlane,
    repo: &gunnar_proto::RepoPath,
    rev: &str,
    target: Option<&str>,
) -> Result<Chain> {
    use gunnar_grpc::ControlPlane as _;

    let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
        .context("the repository path is not a legal one")?;
    let entry = stores
        .catalog()
        .get(&repo_ref)?
        .with_context(|| format!("no such repository: {repo_ref}"))?;
    let store = stores.repo_store(&entry)?;
    let git = store.serve();
    let hash = store.hash_kind();

    // The revision, through the same resolver `Refs.Log` uses, so `HEAD`, a
    // branch and a hex oid all mean here what they mean there.
    // `touching` empty: the walk wants the tip, not the history of one path.
    let commits = plane
        .log(repo, rev, 1, "")
        .map_err(|err| anyhow::anyhow!("{err}"))?;
    let head = commits
        .first()
        .with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
    let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
        .with_context(|| format!("{} is not an object id", head.oid))?;

    let mut chain = Chain {
        repo: repo_ref.to_string(),
        from: rev.to_owned(),
        target: target.map(str::to_owned),
        line: LinkState::Unproven(
            "no line was named; pass `--line <path>:<n>` to close link 1".to_owned(),
        ),
        signature: LinkState::Unproven("not reached".to_owned()),
        push: LinkState::Unproven("not reached".to_owned()),
        log: LinkState::Unproven("not reached".to_owned()),
    };

    // ── link 1: the line, and the commit that wrote it ─────────────────────
    let subject = match target {
        None => tip,
        Some(spec) => {
            let (path, number) = spec.rsplit_once(':').with_context(|| {
                format!("{spec:?} is not `<path>:<line>`; the line number is what makes it link 1")
            })?;
            let number: usize = number
                .parse()
                .with_context(|| format!("{number:?} is not a line number"))?;
            anyhow::ensure!(number >= 1, "line numbers start at 1");
            match blame(git, hash, tip, path, number - 1) {
                Ok(Some(origin)) => {
                    chain.line = LinkState::Proven(format!(
                        "{path}:{number} was written by {} ({} commit(s) walked)",
                        origin.commit, origin.visited
                    ));
                    origin.commit
                }
                Ok(None) => {
                    chain.line = LinkState::Broken(format!("{path} has no line {number} at {rev}"));
                    return Ok(chain);
                }
                Err(err) => {
                    chain.line = LinkState::Unproven(format!("{err:#}"));
                    return Ok(chain);
                }
            }
        }
    };

    // ── link 2: is that commit signed, by a key this server knows ──────────
    //
    // Five answers, kept apart on purpose, because collapsing any two is the
    // roadmap's named failure:
    //   verified            → Proven, naming the key AND the ACL principal —
    //                         "signed" without "by whom" is not something an
    //                         auditor can act on
    //   unsigned            → Unproven; nothing is wrong with it
    //   not an ssh sig (PGP)→ Unproven; PRESENT and uncheckable is a hole,
    //                         not an accusation — gunnar checked nothing
    //   unknown key         → Broken; perfect cryptography by a stranger
    //                         proves nothing about identity
    //   invalid / malformed → Broken; the commit is not what was signed
    let registered = stores.auth().signing_keys()?;
    let keys: Vec<gunnar_core::ssh_key::PublicKey> = registered
        .iter()
        .filter_map(|key| gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh).ok())
        .collect();
    chain.signature = if keys.is_empty() {
        // Not "unsigned": there is nothing to check against, which is a fact
        // about the server and not about the commit.
        LinkState::Unproven(
            "this server holds no signing keys, so no commit signature can be checked".to_owned(),
        )
    } else {
        use gunnar_policy::{signature::SignatureCheck, SignatureProblem};
        match gunnar_policy::signature::check_commit(git, &subject, &keys, "git") {
            Ok(SignatureCheck::Verified(sig)) => {
                // The key table row is where the fingerprint becomes a person:
                // `check_commit` already refused any key that is not in
                // `registered`, so the lookup can only miss if the table
                // changed under us — and then the honest text names the key
                // alone rather than inventing an owner.
                let owner = registered
                    .iter()
                    .find(|key| key.fingerprint_sha256 == sig.fingerprint)
                    .and_then(|key| {
                        stores
                            .auth()
                            .principal(&key.principal_id)
                            .ok()
                            .flatten()
                            .map(|p| p.name)
                    });
                LinkState::Proven(match owner {
                    Some(name) => format!(
                        "{subject} is signed by {}, registered to {name}; the signature verifies",
                        sig.fingerprint
                    ),
                    None => format!(
                        "{subject} is signed by {}; the signature verifies",
                        sig.fingerprint
                    ),
                })
            }
            Ok(SignatureCheck::Problem(problem)) => match problem {
                SignatureProblem::Unsigned => LinkState::Unproven(format!(
                    "{subject} is not signed. That is not a failed signature — this repository \
                     does not require one"
                )),
                SignatureProblem::NotAnSshSignature { armor } => LinkState::Unproven(format!(
                    "{subject} carries a signature that is not verifiable by this server \
                     ({armor}); gunnar verifies ssh signatures (gpg.format=ssh), and a \
                     signature it cannot check is a hole, not a verdict"
                )),
                SignatureProblem::KeyNotRegistered { fingerprint } => LinkState::Broken(format!(
                    "{subject} is signed by {fingerprint}, a key this server does not know; \
                     cryptography by a stranger proves nothing about identity"
                )),
                SignatureProblem::BadSignature {
                    fingerprint,
                    reason,
                } => LinkState::Broken(format!(
                    "{subject}'s signature by {fingerprint} is invalid: {reason}"
                )),
                // Malformed, WrongNamespace, and whatever `#[non_exhaustive]`
                // adds later: a signature that is present, ssh-shaped and
                // wrong is a checked failure, and a NEW problem variant must
                // land on the accusing side rather than silently reading as a
                // hole — a hole is a pass a reader forgives.
                problem => {
                    LinkState::Broken(format!("{subject}'s signature is invalid: {problem}"))
                }
            },
            Err(err) => LinkState::Unproven(format!("reading {subject}: {err}")),
        }
    };

    // ── link 3: which push landed it ───────────────────────────────────────
    let log = stores.transparency();
    // Indexed: the number link 4 proves against is the LINE the record sits
    // at, which is not its position among the pushes once the metered
    // appliance's entitlement lines share the log.
    let indexed = log.records().read_indexed()?;
    let records: Vec<&gunnar_proto::PushAttestation> = indexed.iter().map(|(_, r)| r).collect();
    let wanted = subject.to_string();
    let attested = indexed.iter().find(|(_, record)| {
        record
            .repo
            .as_ref()
            .is_some_and(|r| r.namespace == repo.namespace && r.repo == repo.repo)
            && record.new_object_oids.contains(&wanted)
    });
    let index = match attested {
        Some((index, record)) => {
            let index = *index;
            chain.push = LinkState::Proven(format!(
                "pushed by {} with key {} at {} (pack {})",
                record.principal_name,
                record.key_fingerprint,
                record.received_at_unix_ms,
                record.pack_hash
            ));
            Some(index)
        }
        None => {
            // The distinction that matters: a log with no object lists cannot
            // answer this, and saying "no attestation" would blame the push.
            let blind = records
                .iter()
                .any(|record| !record.new_object_oids_unknown.is_empty());
            chain.push = LinkState::Unproven(if blind {
                "this log holds attestations whose object set was never enumerated, so it \
                 cannot say which push introduced this commit. Start the server with object \
                 attestation on."
                    .to_owned()
            } else {
                format!("no attestation in this log names {subject} as an object it introduced")
            });
            None
        }
    };

    // ── link 4: and is that push in the log ────────────────────────────────
    chain.log = match index {
        None => LinkState::Unproven("no attestation to prove".to_owned()),
        Some(index) => {
            let key = stores.transparency_key()?;
            let heads = log.heads()?;
            let inclusion = log
                .prove(index)?
                .context("the record is in the log and has no proof, which cannot happen")?;
            // A proof is only evidence against a head somebody signed. Without
            // one it is arithmetic the server did about itself.
            let covering = heads.iter().rev().find(|head| {
                head.head.size as usize >= inclusion.size
                    && gunnar_core::transparency::verify_tree_head(head, key.public_key()).is_ok()
            });
            match covering {
                Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
                    "record {} of {} under the head signed at {} (root {})",
                    inclusion.index, inclusion.size, head.head.at_unix_ms, head.head.root
                )),
                Some(head) => LinkState::Broken(format!(
                    "the log's records hash to {} and the head signed at {} swore to {}",
                    inclusion.root, head.head.at_unix_ms, head.head.root
                )),
                None => LinkState::Unproven(format!(
                    "record {} of {} is in the log and no signed tree head covers it yet; run \
                     `gunnar attest head`",
                    inclusion.index, inclusion.size
                )),
            }
        }
    };

    Ok(chain)
}

/// [`verify`] over a data root, opening the stores itself.
pub fn verify_at(
    data_root: &Path,
    repo: &gunnar_proto::RepoPath,
    rev: &str,
    target: Option<&str>,
) -> Result<Chain> {
    let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
    let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
    verify(&stores, &plane, repo, rev, target)
}