Skip to main content

gunnar_server/
verify.rs

1//! **`gunnar verify` — the unbroken chain, walked backwards.**
2//!
3//! The roadmap's whole thesis in one command: given a line of source, say which
4//! commit put it there, whether that commit is signed by a key this server
5//! knows, which push landed it, and whether that push is in the transparency
6//! log under a signed tree head.
7//!
8//! ```text
9//!   file:line  ──▶  commit  ──▶  signature  ──▶  push attestation  ──▶  log
10//!     link 1       link 1         link 2            link 3            link 4
11//! ```
12//!
13//! # What it proves, and where it stops
14//!
15//! Links 1–4, and nothing above them. Gunnar is only a git server: it builds
16//! nothing, holds no artifacts and never verifies a build record. Builds run in
17//! dwarves through Build Thing (the SLSA builder) and artifacts live in Holger,
18//! which is where a walk from an artifact back to its source starts.
19//!
20//! # Every link may be UNPROVEN, and that is a third answer
21//!
22//! Not `true`/`false`. A commit with no signature is not a commit whose
23//! signature failed; a push with no attestation is not a push that was refused.
24//! Collapsing the two would make an unsigned repository look identical to a
25//! forged one, which is the failure this whole design is written against, so
26//! [`LinkState`] has three values and the summary refuses to reduce them to a
27//! boolean.
28
29use std::path::Path;
30
31use anyhow::{Context, Result};
32use gunnar_store::git_storage_trait::{GitServe, ObjType};
33
34use crate::stores::ServerStores;
35
36/// How much history a blame will walk before giving up.
37///
38/// A bound rather than a promise: a line in a file with a hundred thousand
39/// revisions is a question this command should refuse rather than answer in an
40/// hour. Exceeding it is reported as [`LinkState::Unproven`] with the reason,
41/// never as "the line was introduced at the oldest commit we happened to look
42/// at".
43pub const BLAME_BUDGET: usize = 4096;
44
45/// What is known about one link of the chain.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum LinkState {
48    /// Checked, and it holds.
49    Proven(String),
50    /// Checked, and it does not. **This is the answer that must never be
51    /// confused with the one below.**
52    Broken(String),
53    /// Not checked, and why. Nothing was found wrong; nothing was found right.
54    Unproven(String),
55}
56
57impl LinkState {
58    pub fn is_proven(&self) -> bool {
59        matches!(self, LinkState::Proven(_))
60    }
61    pub fn is_broken(&self) -> bool {
62        matches!(self, LinkState::Broken(_))
63    }
64    fn mark(&self) -> &'static str {
65        match self {
66            LinkState::Proven(_) => "ok  ",
67            LinkState::Broken(_) => "BAD ",
68            LinkState::Unproven(_) => "??  ",
69        }
70    }
71    fn detail(&self) -> &str {
72        match self {
73            LinkState::Proven(d) | LinkState::Broken(d) | LinkState::Unproven(d) => d,
74        }
75    }
76}
77
78/// The chain, as far as it could be walked.
79#[derive(Debug, Clone)]
80pub struct Chain {
81    /// `<namespace>/<repo>`.
82    pub repo: String,
83    /// The revision the walk started from.
84    pub from: String,
85    /// `path:line`, one-based, as the operator wrote it.
86    pub target: Option<String>,
87    /// Link 1: which commit put the line there.
88    pub line: LinkState,
89    /// Link 2: the commit's own signature.
90    pub signature: LinkState,
91    /// Link 3: the push that landed the commit.
92    pub push: LinkState,
93    /// Link 4: that push's place in the transparency log.
94    pub log: LinkState,
95}
96
97impl Chain {
98    /// Every link, in chain order, with a stable id and a print label.
99    ///
100    /// The ONE enumeration (one implementation): [`std::fmt::Display`] prints the labels,
101    /// [`Chain::to_proto`] ships the ids, and both walk this list — so a link
102    /// added here reaches the CLI and `Admin.VerifyChain` together, and cannot
103    /// reach one without the other.
104    pub fn links(&self) -> Vec<(&'static str, &'static str, &LinkState)> {
105        vec![
106            ("line", "1 line → commit  ", &self.line),
107            ("signature", "2 commit signed  ", &self.signature),
108            ("push", "3 push attested  ", &self.push),
109            ("log", "4 in the log     ", &self.log),
110        ]
111    }
112
113    /// The chain rendered for `Admin.VerifyChain` — a projection of
114    /// [`Chain::links`], never a second verdict.
115    pub fn to_proto(&self) -> gunnar_proto::VerifyChainResponse {
116        use gunnar_proto::{ChainLink, ChainLinkVerdict, ChainSummary};
117        gunnar_proto::VerifyChainResponse {
118            repo: self.repo.clone(),
119            from: self.from.clone(),
120            target: self.target.clone().unwrap_or_default(),
121            links: self
122                .links()
123                .iter()
124                .map(|(id, _, state)| ChainLink {
125                    id: (*id).to_owned(),
126                    verdict: match state {
127                        LinkState::Proven(_) => ChainLinkVerdict::ChainLinkProven,
128                        LinkState::Broken(_) => ChainLinkVerdict::ChainLinkBroken,
129                        LinkState::Unproven(_) => ChainLinkVerdict::ChainLinkUnproven,
130                    } as i32,
131                    detail: state.detail().to_owned(),
132                })
133                .collect(),
134            summary: if self.is_unbroken() {
135                ChainSummary::ChainUnbroken
136            } else if self.is_broken() {
137                ChainSummary::ChainBroken
138            } else {
139                ChainSummary::ChainIncomplete
140            } as i32,
141        }
142    }
143
144    /// **Unbroken** means every link is proven. A single `Unproven` is not
145    /// unbroken: an unverified link is a hole in the chain, and calling it
146    /// green is exactly the false confidence the roadmap's `Risker` section
147    /// names.
148    pub fn is_unbroken(&self) -> bool {
149        self.links().iter().all(|(_, _, state)| state.is_proven())
150    }
151
152    /// `true` if anything was actively wrong, as opposed to merely unchecked.
153    pub fn is_broken(&self) -> bool {
154        self.links().iter().any(|(_, _, state)| state.is_broken())
155    }
156}
157
158impl std::fmt::Display for Chain {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        writeln!(f, "repo   {}", self.repo)?;
161        writeln!(f, "from   {}", self.from)?;
162        if let Some(target) = &self.target {
163            writeln!(f, "target {target}")?;
164        }
165        writeln!(f)?;
166        for (_, label, state) in self.links() {
167            writeln!(f, "  {} {}  {}", state.mark(), label, state.detail())?;
168        }
169        writeln!(f)?;
170        if self.is_unbroken() {
171            write!(f, "UNBROKEN: every link is proven.")
172        } else if self.is_broken() {
173            write!(
174                f,
175                "BROKEN: a link was checked and did not hold. See the BAD line(s) above."
176            )
177        } else {
178            write!(
179                f,
180                "INCOMPLETE: nothing is wrong and not everything is proven. An unchecked link \
181                 is a hole, not a pass."
182            )
183        }
184    }
185}
186
187/// Where a line came from.
188struct Origin {
189    commit: gix_hash::ObjectId,
190    /// How many commits the walk visited to find it.
191    visited: usize,
192}
193
194/// The blob at `path` in `commit`'s tree, or `None` if the path is not there.
195///
196/// `pub(crate)` because it is the one tree/blob reader link-1 rests on, and a
197/// whole-file blame ([`crate::blame`]) is that same read for every line: it
198/// must resolve a path to a blob at a commit exactly as this does, or the two
199/// answers can disagree (one implementation).
200pub(crate) fn blob_at(
201    git: &dyn GitServe,
202    hash: gix_hash::Kind,
203    commit: gix_hash::ObjectId,
204    path: &str,
205) -> Result<Option<(gix_hash::ObjectId, Vec<u8>)>> {
206    let (kind, bytes) = git
207        .read(commit.as_bytes())?
208        .with_context(|| format!("commit {commit} is not in this repository"))?;
209    anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
210    let mut tree = gunnar_store::object::commit(&bytes, hash)
211        .with_context(|| format!("decoding commit {commit}"))?
212        .tree;
213
214    let mut components = path.split('/').peekable();
215    while let Some(component) = components.next() {
216        let (kind, bytes) = match git.read(tree.as_bytes())? {
217            Some(pair) => pair,
218            None => return Ok(None),
219        };
220        if kind != ObjType::Tree {
221            return Ok(None);
222        }
223        let mut found = None;
224        for entry in gunnar_store::object::TreeIter::from_bytes(&bytes, hash).named() {
225            let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
226            if entry.name == component.as_bytes() {
227                found = Some(entry);
228                break;
229            }
230        }
231        let Some(entry) = found else {
232            return Ok(None);
233        };
234        if components.peek().is_some() {
235            tree = entry.oid;
236            continue;
237        }
238        // Last component: it must be a blob for a line to live in it.
239        let Some((ObjType::Blob, blob)) = git.read(entry.oid.as_bytes())? else {
240            return Ok(None);
241        };
242        return Ok(Some((entry.oid, blob)));
243    }
244    Ok(None)
245}
246
247/// A commit's parents, in order.
248///
249/// `pub(crate)` for the same reason as [`blob_at`]: the blame walk steps through
250/// parents the way link-1 does, and sharing the one reader keeps them from
251/// drifting.
252pub(crate) fn parents(
253    git: &dyn GitServe,
254    hash: gix_hash::Kind,
255    commit: gix_hash::ObjectId,
256) -> Result<Vec<gix_hash::ObjectId>> {
257    let (_, bytes) = git
258        .read(commit.as_bytes())?
259        .with_context(|| format!("commit {commit} is not in this repository"))?;
260    Ok(gunnar_store::object::commit(&bytes, hash)
261        .with_context(|| format!("decoding commit {commit}"))?
262        .parents)
263}
264
265/// **Which commit put this line here.**
266///
267/// Walks back from `tip`, following the parent the line came *through* — for a
268/// merge that means trying each parent in order and taking the first one the
269/// line traces back into, which is what git's blame does and what makes a line
270/// merged from a side branch attributable to the branch rather than to the
271/// merge.
272///
273/// Stops when the line has no ancestor in any parent: that commit is where it
274/// was written.
275fn blame(
276    git: &dyn GitServe,
277    hash: gix_hash::Kind,
278    tip: gix_hash::ObjectId,
279    path: &str,
280    line: usize,
281) -> Result<Option<Origin>> {
282    let mut commit = tip;
283    let mut line = line;
284    for visited in 1..=BLAME_BUDGET {
285        let Some((_, blob)) = blob_at(git, hash, commit, path)? else {
286            // The path is not in this commit at all. Only reachable for the
287            // very first one — the walk never steps into a parent that lacks
288            // it — so it means the caller asked about a file that is not there.
289            return Ok(None);
290        };
291        let current = crate::diff::split_lines(&blob);
292        if line >= current.len() {
293            return Ok(None);
294        }
295        let mut stepped = None;
296        for parent in parents(git, hash, commit)? {
297            let Some((_, parent_blob)) = blob_at(git, hash, parent, path)? else {
298                continue;
299            };
300            let older = crate::diff::split_lines(&parent_blob);
301            // Identical blobs: the line certainly survived, and tracing it is
302            // both unnecessary and the expensive path.
303            if parent_blob == blob {
304                stepped = Some((parent, line));
305                break;
306            }
307            if let Some(at) = crate::diff::trace_line(&older, &current, line) {
308                stepped = Some((parent, at));
309                break;
310            }
311        }
312        match stepped {
313            Some((parent, at)) => {
314                commit = parent;
315                line = at;
316            }
317            // No parent holds this line: it was written here.
318            None => return Ok(Some(Origin { commit, visited })),
319        }
320    }
321    anyhow::bail!(
322        "the blame walked {BLAME_BUDGET} commits without finding where the line was written; \
323         this is a bound, not an answer"
324    )
325}
326
327/// Walk the chain for one line of one file.
328///
329/// `target` is `path:line`, one-based, the way an editor and `git blame` count.
330/// `None` verifies the revision itself — links 2 to 4 for the commit at `rev`,
331/// with link 1 reported as unchecked rather than as passed.
332pub fn verify(
333    stores: &ServerStores,
334    plane: &crate::control_plane::CoreControlPlane,
335    repo: &gunnar_proto::RepoPath,
336    rev: &str,
337    target: Option<&str>,
338) -> Result<Chain> {
339    use gunnar_grpc::ControlPlane as _;
340
341    let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
342        .context("the repository path is not a legal one")?;
343    let entry = stores
344        .catalog()
345        .get(&repo_ref)?
346        .with_context(|| format!("no such repository: {repo_ref}"))?;
347    let store = stores.repo_store(&entry)?;
348    let git = store.serve();
349    let hash = store.hash_kind();
350
351    // The revision, through the same resolver `Refs.Log` uses, so `HEAD`, a
352    // branch and a hex oid all mean here what they mean there.
353    // `touching` empty: the walk wants the tip, not the history of one path.
354    let commits = plane
355        .log(repo, rev, 1, "")
356        .map_err(|err| anyhow::anyhow!("{err}"))?;
357    let head = commits
358        .first()
359        .with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
360    let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
361        .with_context(|| format!("{} is not an object id", head.oid))?;
362
363    let mut chain = Chain {
364        repo: repo_ref.to_string(),
365        from: rev.to_owned(),
366        target: target.map(str::to_owned),
367        line: LinkState::Unproven(
368            "no line was named; pass `--line <path>:<n>` to close link 1".to_owned(),
369        ),
370        signature: LinkState::Unproven("not reached".to_owned()),
371        push: LinkState::Unproven("not reached".to_owned()),
372        log: LinkState::Unproven("not reached".to_owned()),
373    };
374
375    // ── link 1: the line, and the commit that wrote it ─────────────────────
376    let subject = match target {
377        None => tip,
378        Some(spec) => {
379            let (path, number) = spec.rsplit_once(':').with_context(|| {
380                format!("{spec:?} is not `<path>:<line>`; the line number is what makes it link 1")
381            })?;
382            let number: usize = number
383                .parse()
384                .with_context(|| format!("{number:?} is not a line number"))?;
385            anyhow::ensure!(number >= 1, "line numbers start at 1");
386            match blame(git, hash, tip, path, number - 1) {
387                Ok(Some(origin)) => {
388                    chain.line = LinkState::Proven(format!(
389                        "{path}:{number} was written by {} ({} commit(s) walked)",
390                        origin.commit, origin.visited
391                    ));
392                    origin.commit
393                }
394                Ok(None) => {
395                    chain.line = LinkState::Broken(format!("{path} has no line {number} at {rev}"));
396                    return Ok(chain);
397                }
398                Err(err) => {
399                    chain.line = LinkState::Unproven(format!("{err:#}"));
400                    return Ok(chain);
401                }
402            }
403        }
404    };
405
406    // ── link 2: is that commit signed, by a key this server knows ──────────
407    //
408    // Five answers, kept apart on purpose, because collapsing any two is the
409    // roadmap's named failure:
410    //   verified            → Proven, naming the key AND the ACL principal —
411    //                         "signed" without "by whom" is not something an
412    //                         auditor can act on
413    //   unsigned            → Unproven; nothing is wrong with it
414    //   not an ssh sig (PGP)→ Unproven; PRESENT and uncheckable is a hole,
415    //                         not an accusation — gunnar checked nothing
416    //   unknown key         → Broken; perfect cryptography by a stranger
417    //                         proves nothing about identity
418    //   invalid / malformed → Broken; the commit is not what was signed
419    let registered = stores.auth().signing_keys()?;
420    let keys: Vec<gunnar_core::ssh_key::PublicKey> = registered
421        .iter()
422        .filter_map(|key| gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh).ok())
423        .collect();
424    chain.signature = if keys.is_empty() {
425        // Not "unsigned": there is nothing to check against, which is a fact
426        // about the server and not about the commit.
427        LinkState::Unproven(
428            "this server holds no signing keys, so no commit signature can be checked".to_owned(),
429        )
430    } else {
431        use gunnar_policy::{signature::SignatureCheck, SignatureProblem};
432        match gunnar_policy::signature::check_commit(git, &subject, &keys, "git") {
433            Ok(SignatureCheck::Verified(sig)) => {
434                // The key table row is where the fingerprint becomes a person:
435                // `check_commit` already refused any key that is not in
436                // `registered`, so the lookup can only miss if the table
437                // changed under us — and then the honest text names the key
438                // alone rather than inventing an owner.
439                let owner = registered
440                    .iter()
441                    .find(|key| key.fingerprint_sha256 == sig.fingerprint)
442                    .and_then(|key| {
443                        stores
444                            .auth()
445                            .principal(&key.principal_id)
446                            .ok()
447                            .flatten()
448                            .map(|p| p.name)
449                    });
450                LinkState::Proven(match owner {
451                    Some(name) => format!(
452                        "{subject} is signed by {}, registered to {name}; the signature verifies",
453                        sig.fingerprint
454                    ),
455                    None => format!(
456                        "{subject} is signed by {}; the signature verifies",
457                        sig.fingerprint
458                    ),
459                })
460            }
461            Ok(SignatureCheck::Problem(problem)) => match problem {
462                SignatureProblem::Unsigned => LinkState::Unproven(format!(
463                    "{subject} is not signed. That is not a failed signature — this repository \
464                     does not require one"
465                )),
466                SignatureProblem::NotAnSshSignature { armor } => LinkState::Unproven(format!(
467                    "{subject} carries a signature that is not verifiable by this server \
468                     ({armor}); gunnar verifies ssh signatures (gpg.format=ssh), and a \
469                     signature it cannot check is a hole, not a verdict"
470                )),
471                SignatureProblem::KeyNotRegistered { fingerprint } => LinkState::Broken(format!(
472                    "{subject} is signed by {fingerprint}, a key this server does not know; \
473                     cryptography by a stranger proves nothing about identity"
474                )),
475                SignatureProblem::BadSignature {
476                    fingerprint,
477                    reason,
478                } => LinkState::Broken(format!(
479                    "{subject}'s signature by {fingerprint} is invalid: {reason}"
480                )),
481                // Malformed, WrongNamespace, and whatever `#[non_exhaustive]`
482                // adds later: a signature that is present, ssh-shaped and
483                // wrong is a checked failure, and a NEW problem variant must
484                // land on the accusing side rather than silently reading as a
485                // hole — a hole is a pass a reader forgives.
486                problem => {
487                    LinkState::Broken(format!("{subject}'s signature is invalid: {problem}"))
488                }
489            },
490            Err(err) => LinkState::Unproven(format!("reading {subject}: {err}")),
491        }
492    };
493
494    // ── link 3: which push landed it ───────────────────────────────────────
495    let log = stores.transparency();
496    // Indexed: the number link 4 proves against is the LINE the record sits
497    // at, which is not its position among the pushes once the metered
498    // appliance's entitlement lines share the log.
499    let indexed = log.records().read_indexed()?;
500    let records: Vec<&gunnar_proto::PushAttestation> = indexed.iter().map(|(_, r)| r).collect();
501    let wanted = subject.to_string();
502    let attested = indexed.iter().find(|(_, record)| {
503        record
504            .repo
505            .as_ref()
506            .is_some_and(|r| r.namespace == repo.namespace && r.repo == repo.repo)
507            && record.new_object_oids.contains(&wanted)
508    });
509    let index = match attested {
510        Some((index, record)) => {
511            let index = *index;
512            chain.push = LinkState::Proven(format!(
513                "pushed by {} with key {} at {} (pack {})",
514                record.principal_name,
515                record.key_fingerprint,
516                record.received_at_unix_ms,
517                record.pack_hash
518            ));
519            Some(index)
520        }
521        None => {
522            // The distinction that matters: a log with no object lists cannot
523            // answer this, and saying "no attestation" would blame the push.
524            let blind = records
525                .iter()
526                .any(|record| !record.new_object_oids_unknown.is_empty());
527            chain.push = LinkState::Unproven(if blind {
528                "this log holds attestations whose object set was never enumerated, so it \
529                 cannot say which push introduced this commit. Start the server with object \
530                 attestation on."
531                    .to_owned()
532            } else {
533                format!("no attestation in this log names {subject} as an object it introduced")
534            });
535            None
536        }
537    };
538
539    // ── link 4: and is that push in the log ────────────────────────────────
540    chain.log = match index {
541        None => LinkState::Unproven("no attestation to prove".to_owned()),
542        Some(index) => {
543            let key = stores.transparency_key()?;
544            let heads = log.heads()?;
545            let inclusion = log
546                .prove(index)?
547                .context("the record is in the log and has no proof, which cannot happen")?;
548            // A proof is only evidence against a head somebody signed. Without
549            // one it is arithmetic the server did about itself.
550            let covering = heads.iter().rev().find(|head| {
551                head.head.size as usize >= inclusion.size
552                    && gunnar_core::transparency::verify_tree_head(head, key.public_key()).is_ok()
553            });
554            match covering {
555                Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
556                    "record {} of {} under the head signed at {} (root {})",
557                    inclusion.index, inclusion.size, head.head.at_unix_ms, head.head.root
558                )),
559                Some(head) => LinkState::Broken(format!(
560                    "the log's records hash to {} and the head signed at {} swore to {}",
561                    inclusion.root, head.head.at_unix_ms, head.head.root
562                )),
563                None => LinkState::Unproven(format!(
564                    "record {} of {} is in the log and no signed tree head covers it yet; run \
565                     `gunnar attest head`",
566                    inclusion.index, inclusion.size
567                )),
568            }
569        }
570    };
571
572    Ok(chain)
573}
574
575/// [`verify`] over a data root, opening the stores itself.
576pub fn verify_at(
577    data_root: &Path,
578    repo: &gunnar_proto::RepoPath,
579    rev: &str,
580    target: Option<&str>,
581) -> Result<Chain> {
582    let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
583    let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
584    verify(&stores, &plane, repo, rev, target)
585}