Skip to main content

verify_trust/
lib.rs

1//! `verify-trust`: verify a git commit range against the VTC Trust Registry.
2//!
3//! For every commit in a range this module answers two questions, in order:
4//!
5//! 1. **Who signed it, cryptographically?** The commit names a DID on its
6//!    `committer` header; that DID is resolved, its document must publish the
7//!    Ed25519 key embedded in the commit's sshsig, and the signature must
8//!    verify over the exact bytes git signed.
9//! 2. **Is that DID trusted, right now?** The signer DID is checked against
10//!    the Trust Registry with a TRQP authorization query
11//!    (`{entity: signer, authority, action, resource}`) via `trql-client`.
12//!
13//! The signer set is **derived from the commits themselves** — there is no
14//! per-repository allowlist. The committer header is author-controlled text,
15//! so it is treated strictly as a lookup hint: the claim is only ever as good
16//! as the two checks that follow it. A commit claiming a DID it cannot sign
17//! for fails step 1 (the DID does not publish the signing key, or the
18//! signature does not verify over a payload that includes the claim itself);
19//! a commit signed by a DID nobody enrolled fails step 2.
20//!
21//! That places every question of *who may sign here* in the registry, where
22//! enrolment, rotation and revocation already live. `--resource` is
23//! consequently the only thing scoping a signer to this repository, and is
24//! security-relevant input: widening it, or widening `--fallback-resource`,
25//! widens who may sign, with nothing in the repository to contradict it.
26//!
27//! Failure is closed at every layer: an unsigned commit, a committer naming no
28//! DID, a DID that will not resolve, a signature by a key that DID does not
29//! publish, a cryptographically invalid signature, an unauthorized DID, and an
30//! unreachable registry all fail the check — each with its own status so an
31//! operator can tell which remediation applies.
32//!
33//! Signers are reported by **agent name** where one is available
34//! (`example.com/@alice`) rather than by raw DID. Names come out of the DID
35//! documents this crate already resolves, and render through
36//! [`vta_sdk::display_name`] — the same seam the PNM, CNM and VTC operator
37//! surfaces use, so a DID is abbreviated identically wherever it appears.
38
39pub mod pgp_exempt;
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::path::{Path, PathBuf};
43use std::process::Command;
44use std::sync::Arc;
45
46use anyhow::{Context, Result, bail};
47use serde::Serialize;
48use ssh_key::{SshSig, public::KeyData};
49use trql_client::{HttpsTransport, HttpsTransportConfig, TrqlClient, TrqlError, TrqpQuery};
50use vgi_core::{
51    GIT_SSHSIG_NAMESPACE, committer_did, committer_identity, ed25519_keys_from_doc,
52    normalize_sshsig_armor, split_signed_commit,
53};
54use vta_sdk::display_name::{DisplayName, NameBook, NameSource};
55
56use crate::pgp_exempt::ExemptKeyring;
57
58/// Everything `verify-trust` needs for one run.
59#[derive(Debug, Clone)]
60pub struct VerifyTrustArgs {
61    /// Repository to verify (a working tree with `git` available).
62    pub repo_dir: PathBuf,
63    /// Commit range in `git rev-list` syntax, e.g. `origin/main..HEAD`.
64    pub range: String,
65    /// Ceiling on the number of *distinct* DIDs a range may claim, each of
66    /// which costs one resolution.
67    ///
68    /// The signer set is derived from the commits, so a pull request chooses
69    /// which identifiers CI resolves — and for the network-resolved methods
70    /// (`did:web`, `did:webvh`) that means an outbound fetch to a host the
71    /// author picked. Distinct DIDs are deduplicated first; this bounds what
72    /// remains. Exceeding it fails the run rather than resolving anyway.
73    pub max_signers: usize,
74    /// Base URL of the Trust Registry (`POST <url>/trust-tasks`).
75    pub registry_url: String,
76    /// DID of the registry (the `recipient` on every query document).
77    pub registry_did: String,
78    /// DID of the authority the tuple is evaluated under.
79    pub authority_did: String,
80    /// TRQP action, e.g. `git.commit.sign`.
81    pub action: String,
82    /// TRQP resource, e.g. the `org/repo` slug.
83    ///
84    /// With no committed signer index, this is the **only** thing scoping a
85    /// signer to this repository: a grant is accepted exactly when the
86    /// registry authorizes the tuple under this resource (or the fallback).
87    /// Treat it as security-relevant configuration.
88    pub resource: String,
89    /// Broader resource to try when the primary one does not authorize
90    /// (e.g. the org for an org-wide grant). Grant semantics are
91    /// `resource OR fallback`: the registry's wire contract cannot
92    /// distinguish "no record" from an explicit `authorized: false`, so a
93    /// repo-level record cannot veto an org-level grant.
94    pub fallback_resource: Option<String>,
95    /// Optional armored PGP keyring of exempt platform keys (e.g. GitHub's
96    /// web-flow key); relative paths resolve against `repo_dir`. Absent means
97    /// no exemptions: every PGP-signed commit fails.
98    pub exempt_keyring: Option<PathBuf>,
99    /// Round-trip the agent names the signers' DID documents claim, so a
100    /// verified name renders unqualified instead of tagged `[unverified]`.
101    ///
102    /// Costs one outbound HTTPS fetch per claimed name, to a host the
103    /// *document's author* chose, so it is opt-in — the same rule the PNM and
104    /// CNM CLIs apply to their `--resolve-agent-names` flag. With it off the
105    /// claims still show (they come free with the documents this crate must
106    /// resolve anyway), but as the self-assertions they are.
107    pub resolve_agent_names: bool,
108    /// Emit machine-readable JSON on stdout instead of human lines.
109    pub json: bool,
110}
111
112/// Outcome for one commit. Ordered worst-first so a report can sort on it.
113#[derive(Debug, Clone, PartialEq, Serialize)]
114#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
115pub enum CommitStatus {
116    /// No `gpgsig` header on the commit.
117    Unsigned,
118    /// The signature did not parse as an Ed25519 sshsig.
119    Malformed(String),
120    /// Signed, but the `committer` header names no DID, so the commit asserts
121    /// no identity to resolve or authorize.
122    NoSignerDid { committer: String },
123    /// The claimed DID could not be resolved, so its published keys are
124    /// unknown. Fails closed: an unresolvable signer is not a trusted one.
125    UnresolvedSigner { did: String, error: String },
126    /// The claimed DID resolved, but publishes no verification method holding
127    /// the key that signed this commit.
128    UnknownKey { did: String, fingerprint: String },
129    /// The DID publishes the key, but the signature does not verify.
130    BadSignature { signer_did: String },
131    /// Valid signature, but the registry did not authorize the signer.
132    Unauthorized { signer_did: String },
133    /// Valid signature, but the registry could not be consulted. Fails the
134    /// run (closed), distinctly from a denial.
135    RegistryUnavailable { signer_did: String, error: String },
136    /// PGP-signed (a platform commit), but the signature verifies against no
137    /// key in the exempt keyring — or no keyring is configured.
138    PgpRejected { detail: String },
139    /// PGP-signed by a key in the committed exempt keyring (e.g. a GitHub
140    /// web-UI merge commit). Passes, reported distinctly from `Trusted`.
141    Exempt { fingerprint: String },
142    /// Valid signature by a registry-authorized signer. `resource` is the
143    /// tuple resource the grant was found under (the primary one or the
144    /// fallback).
145    Trusted {
146        signer_did: String,
147        resource: String,
148    },
149}
150
151impl CommitStatus {
152    /// Signed by a registry-authorized DID.
153    pub fn is_trusted(&self) -> bool {
154        matches!(self, Self::Trusted { .. })
155    }
156
157    /// Whether the commit passes the check: DID-trusted or keyring-exempt.
158    pub fn passes(&self) -> bool {
159        matches!(self, Self::Trusted { .. } | Self::Exempt { .. })
160    }
161}
162
163/// One commit's verdict, as reported.
164#[derive(Debug, Clone, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct CommitVerdict {
167    pub sha: String,
168    #[serde(flatten)]
169    pub status: CommitStatus,
170}
171
172/// The full report for a range.
173#[derive(Debug, Serialize)]
174#[serde(rename_all = "camelCase")]
175pub struct TrustReport {
176    pub ok: bool,
177    pub commits: Vec<CommitVerdict>,
178    /// Claimed DIDs whose resolution failed, each with the reason. Their
179    /// commits already carry `unresolvedSigner`; this aggregates the set for
180    /// a consumer that wants it without walking every commit.
181    pub unresolved_signers: BTreeMap<String, String>,
182    /// Display name per named signer DID, with its provenance. Only DIDs that
183    /// have a name appear. The commit entries keep full DIDs, so a consumer
184    /// that does not care about names is unaffected.
185    pub signer_names: BTreeMap<String, DisplayName>,
186}
187
188/// The DIDs a range claimed, resolved: the keys each publishes, why any of
189/// them could not be resolved, and what to call them.
190///
191/// Produced by [`resolve_signer_keys`] and consumed by [`verify_with_keys`],
192/// which tests construct directly via [`ResolvedSigners::from_keys`].
193#[derive(Debug, Default)]
194pub struct ResolvedSigners {
195    /// DID → the Ed25519 keys its document publishes. Keyed by DID rather
196    /// than by key so a commit is checked against *the identity it claims*,
197    /// not against whatever identity happens to publish the signing key.
198    pub keys: BTreeMap<String, Vec<[u8; 32]>>,
199    /// Claimed DID → why it did not resolve.
200    pub unresolved: BTreeMap<String, String>,
201    /// DID → display name, for every signer whose document names it.
202    pub names: NameBook,
203}
204
205impl ResolvedSigners {
206    /// A signer set with keys but no names — the shape a test wants when it
207    /// supplies keys directly instead of resolving DID documents.
208    #[must_use]
209    pub fn from_keys<I, D>(keys: I) -> Self
210    where
211        I: IntoIterator<Item = (D, Vec<[u8; 32]>)>,
212        D: Into<String>,
213    {
214        Self {
215            keys: keys.into_iter().map(|(did, k)| (did.into(), k)).collect(),
216            ..Self::default()
217        }
218    }
219
220    /// The keys `did` publishes, or `None` if it never resolved.
221    fn published(&self, did: &str) -> Option<&[[u8; 32]]> {
222        self.keys.get(did).map(Vec::as_slice)
223    }
224}
225
226/// Run the check end to end: collect the DIDs the range claims, resolve them,
227/// then verify. Returns the process exit code (0 = every commit passes).
228pub async fn handle_verify_trust(args: VerifyTrustArgs) -> Result<i32> {
229    let exempt = load_exempt_keyring(&args)?;
230    let commits = read_range(&args.repo_dir, &args.range)?;
231    let claimed = claimed_signer_dids(&commits, args.max_signers)?;
232    let signers = resolve_signer_keys(&claimed, args.resolve_agent_names).await?;
233    let report = verify_prepared(&args, &commits, &signers, exempt.as_ref()).await?;
234    print_report(&args, &report)?;
235    Ok(if report.ok { 0 } else { 1 })
236}
237
238/// One commit of the range, read once so the object is not fetched again for
239/// the claim pass and the verification pass.
240#[derive(Debug, Clone)]
241pub struct RangeCommit {
242    pub sha: String,
243    pub raw: Vec<u8>,
244}
245
246/// Read every commit object in the range, oldest first.
247pub fn read_range(repo_dir: &Path, range: &str) -> Result<Vec<RangeCommit>> {
248    list_commits(repo_dir, range)?
249        .into_iter()
250        .map(|sha| {
251            let raw = read_commit_raw(repo_dir, &sha)?;
252            Ok(RangeCommit { sha, raw })
253        })
254        .collect()
255}
256
257/// The distinct DIDs the range's commits claim on their committer headers.
258///
259/// Deduplicated, then bounded by `max_signers`: the set is chosen by whoever
260/// wrote the commits, and each entry costs a resolution. Commits claiming no
261/// DID contribute nothing here — they fail later, individually, with a status
262/// that says so.
263pub fn claimed_signer_dids(commits: &[RangeCommit], max_signers: usize) -> Result<Vec<String>> {
264    let dids: BTreeSet<String> = commits
265        .iter()
266        .filter_map(|commit| committer_did(&commit.raw))
267        .collect();
268    if dids.len() > max_signers {
269        bail!(
270            "range claims {} distinct signer DIDs, over the limit of {max_signers}; \
271             each costs a resolution to a host the commit's author chose. Raise \
272             --max-signers only if this range is legitimately that wide.",
273            dids.len()
274        );
275    }
276    Ok(dids.into_iter().collect())
277}
278
279/// Verify commits already read and resolved. Split from
280/// [`handle_verify_trust`] so tests can supply keys without a live resolver.
281pub async fn verify_prepared(
282    args: &VerifyTrustArgs,
283    commits: &[RangeCommit],
284    signers: &ResolvedSigners,
285    exempt: Option<&ExemptKeyring>,
286) -> Result<TrustReport> {
287    // Pass 1: cryptographic verification, collecting the DIDs that signed.
288    let mut checked = Vec::with_capacity(commits.len());
289    let mut signer_dids = BTreeSet::new();
290    for commit in commits {
291        let signature = check_commit_signature(&commit.raw, signers, exempt);
292        if let SignatureCheck::Valid { signer_did } = &signature {
293            signer_dids.insert(signer_did.clone());
294        }
295        checked.push((commit.sha.clone(), signature));
296    }
297
298    // Pass 2: one registry query per distinct signer DID.
299    let decisions = query_registry(args, &signer_dids).await?;
300
301    let commits: Vec<CommitVerdict> = checked
302        .into_iter()
303        .map(|(sha, signature)| CommitVerdict {
304            sha,
305            status: status_of(signature, &decisions),
306        })
307        .collect();
308
309    // Names are reported for the signers that actually signed something here
310    // — a name for a DID absent from the range is noise.
311    let signer_names = signer_dids
312        .iter()
313        .filter_map(|did| {
314            signers
315                .names
316                .get(did)
317                .map(|name| (did.clone(), name.clone()))
318        })
319        .collect();
320
321    // An empty range passes vacuously (nothing new to verify).
322    let ok = commits.iter().all(|c| c.status.passes());
323    Ok(TrustReport {
324        ok,
325        commits,
326        unresolved_signers: signers.unresolved.clone(),
327        signer_names,
328    })
329}
330
331// --- signature layer ---------------------------------------------------------
332
333/// Result of the cryptographic check for one commit.
334#[derive(Debug, Clone, PartialEq)]
335pub enum SignatureCheck {
336    Unsigned,
337    Malformed(String),
338    NoSignerDid { committer: String },
339    UnresolvedSigner { did: String, error: String },
340    UnknownKey { did: String, fingerprint: String },
341    BadSignature { signer_did: String },
342    PgpRejected { detail: String },
343    Exempt { fingerprint: String },
344    Valid { signer_did: String },
345}
346
347/// Verify one raw commit object against the resolved signers.
348///
349/// The identity comes from the commit's own `committer` header, and is checked
350/// against itself: the DID it claims must publish the key that signed, and the
351/// signature must verify over a payload that includes that very header. The
352/// claim is therefore never trusted — it only selects which document to check
353/// the key against, and a commit naming a DID it cannot sign for fails here.
354pub fn check_commit_signature(
355    raw: &[u8],
356    signers: &ResolvedSigners,
357    exempt: Option<&ExemptKeyring>,
358) -> SignatureCheck {
359    let (payload, pem) = match split_signed_commit(raw) {
360        Ok(Some(parts)) => parts,
361        Ok(None) => return SignatureCheck::Unsigned,
362        Err(e) => return SignatureCheck::Malformed(e.to_string()),
363    };
364    // Platform commits (GitHub web-UI merges, Dependabot) are PGP-signed;
365    // they pass only via the explicitly committed exempt keyring.
366    if pem.starts_with("-----BEGIN PGP SIGNATURE-----") {
367        let Some(keyring) = exempt else {
368            return SignatureCheck::PgpRejected {
369                detail: "PGP-signed commit, but no exempt keyring is configured".to_string(),
370            };
371        };
372        return match keyring.verify(&pem, &payload) {
373            Ok(fingerprint) => SignatureCheck::Exempt { fingerprint },
374            Err(detail) => SignatureCheck::PgpRejected { detail },
375        };
376    }
377    let sig = match SshSig::from_pem(normalize_sshsig_armor(&pem).as_bytes()) {
378        Ok(sig) => sig,
379        Err(e) => return SignatureCheck::Malformed(format!("sshsig did not parse: {e}")),
380    };
381    let KeyData::Ed25519(embedded) = sig.public_key() else {
382        return SignatureCheck::Malformed(format!(
383            "unsupported signature algorithm: {}",
384            sig.algorithm()
385        ));
386    };
387    let key_bytes: [u8; 32] = embedded.0;
388
389    // The identity is read from the payload — the bytes the signature covers —
390    // so a claim that survives verification is one the signer committed to.
391    let Some(claimed) = committer_did(&payload) else {
392        return SignatureCheck::NoSignerDid {
393            committer: committer_identity(&payload).unwrap_or_else(|| "(absent)".to_string()),
394        };
395    };
396    let Some(published) = signers.published(&claimed) else {
397        let error = signers
398            .unresolved
399            .get(&claimed)
400            .cloned()
401            .unwrap_or_else(|| "not resolved".to_string());
402        return SignatureCheck::UnresolvedSigner {
403            did: claimed,
404            error,
405        };
406    };
407    if !published.contains(&key_bytes) {
408        return SignatureCheck::UnknownKey {
409            did: claimed,
410            fingerprint: hex::encode(key_bytes),
411        };
412    }
413    let public_key = ssh_key::PublicKey::from(sig.public_key().clone());
414    match public_key.verify(GIT_SSHSIG_NAMESPACE, &payload, &sig) {
415        Ok(()) => SignatureCheck::Valid {
416            signer_did: claimed,
417        },
418        Err(_) => SignatureCheck::BadSignature {
419            signer_did: claimed,
420        },
421    }
422}
423
424/// Load the exempt keyring named by the args, resolving relative to the repo.
425fn load_exempt_keyring(args: &VerifyTrustArgs) -> Result<Option<ExemptKeyring>> {
426    let Some(path) = &args.exempt_keyring else {
427        return Ok(None);
428    };
429    let path = if path.is_absolute() {
430        path.clone()
431    } else {
432        args.repo_dir.join(path)
433    };
434    Ok(Some(ExemptKeyring::load(&path)?))
435}
436
437// --- DID resolution ----------------------------------------------------------
438
439/// Resolve every DID the range claimed: collect the Ed25519 keys their
440/// documents publish, and name each signer from the same document. A DID that
441/// fails to resolve is recorded (its commits fail as `unresolvedSigner`)
442/// without blocking the others.
443///
444/// `resolve_agent_names` turns on the resolver's shortcut derivation, which
445/// round-trips each claimed name before it is treated as this DID's — see
446/// [`VerifyTrustArgs::resolve_agent_names`]. Unverified claims are picked up
447/// either way, since they arrive with documents that must be resolved anyway.
448pub async fn resolve_signer_keys(
449    dids: &[String],
450    resolve_agent_names: bool,
451) -> Result<ResolvedSigners> {
452    use affinidi_tdk::TDK;
453    use affinidi_tdk::common::config::TDKConfig;
454    use affinidi_tdk::did_resolver::config::DIDCacheConfigBuilder;
455
456    // `with_resolve_shortcuts` exists because `vta-sdk/agent-names` turns on
457    // `affinidi-did-resolver-cache-sdk/agent-names`, which cargo unifies onto
458    // the resolver the TDK builds here.
459    let tdk = TDK::new(
460        TDKConfig::builder()
461            .with_load_environment(false)
462            .with_did_resolver_config(
463                DIDCacheConfigBuilder::default()
464                    .with_resolve_shortcuts(resolve_agent_names)
465                    .build(),
466            )
467            .build()
468            .context("TDK config")?,
469        None,
470    )
471    .await
472    .context("TDK init")?;
473
474    let mut signers = ResolvedSigners::default();
475    for did in dids {
476        match tdk.did_resolver().resolve(did).await {
477            Ok(response) => {
478                // A shortcut is only ever set after the resolver checked the
479                // claimed name resolves back to this DID; anything else the
480                // document claims is a bare self-assertion.
481                let name = signer_display_name(
482                    response.shortcut.as_ref().map(|s| s.label()),
483                    &vta_sdk::display_name::agent_name::claimed_names(&response.doc),
484                );
485                if let Some(name) = name {
486                    signers.names.insert(did.clone(), name);
487                }
488
489                let doc = serde_json::to_value(&response.doc)
490                    .with_context(|| format!("DID document for {did} did not serialize"))?;
491                let published = ed25519_keys_from_doc(&doc);
492                if published.is_empty() {
493                    // Left out of `keys` deliberately: a document with no
494                    // Ed25519 method can verify nothing, and recording it as
495                    // resolved-but-empty would report its commits as an
496                    // unknown key rather than as this, the actual cause.
497                    signers.unresolved.insert(
498                        did.clone(),
499                        "DID document publishes no Ed25519 verification keys".to_string(),
500                    );
501                } else {
502                    signers.keys.insert(did.clone(), published);
503                }
504            }
505            Err(e) => {
506                signers
507                    .unresolved
508                    .insert(did.clone(), format!("resolution failed: {e}"));
509            }
510        }
511    }
512    Ok(signers)
513}
514
515/// Pick what to call a signer, given the name its resolution verified (if any)
516/// and the names its document claims.
517///
518/// A verified shortcut wins outright. Otherwise the first claim is reported
519/// **unverified**: `alsoKnownAs` is self-asserted, so a hostile DID can claim
520/// `mybank.com/@treasury` and a verifier that printed that bare would have
521/// told the reviewer, in an authoritative voice, that the bank signed this
522/// commit. The claim still surfaces — a DID *attempting* to present as
523/// someone else is exactly what a reviewer should see — but tagged, and
524/// ranked below every trusted source. See [`vta_sdk::display_name`].
525fn signer_display_name(verified: Option<&str>, claimed: &[String]) -> Option<DisplayName> {
526    if let Some(name) = verified {
527        return Some(DisplayName::new(
528            name,
529            NameSource::AgentName { verified: true },
530        ));
531    }
532    claimed
533        .first()
534        .map(|name| DisplayName::new(name, NameSource::AgentName { verified: false }))
535}
536
537// --- registry layer -----------------------------------------------------------
538
539/// Per-DID registry decision: `Ok(Some(resource))` = authorized under that
540/// tuple resource, `Ok(None)` = denied everywhere queried, `Err` =
541/// registry unavailable.
542type RegistryDecisions = BTreeMap<String, Result<Option<String>, String>>;
543
544/// One TRQP authorization query per distinct signer DID.
545async fn query_registry(
546    args: &VerifyTrustArgs,
547    signer_dids: &BTreeSet<String>,
548) -> Result<RegistryDecisions> {
549    let mut decisions = RegistryDecisions::new();
550    if signer_dids.is_empty() {
551        return Ok(decisions);
552    }
553    let transport = HttpsTransport::new(HttpsTransportConfig::new(&args.registry_url))?;
554    let client = TrqlClient::new(Arc::new(transport), &args.registry_did);
555    // The primary resource, then the broader fallback if it did not grant.
556    let mut resources = vec![args.resource.clone()];
557    if let Some(fallback) = &args.fallback_resource
558        && fallback != &args.resource
559    {
560        resources.push(fallback.clone());
561    }
562    for did in signer_dids {
563        let mut decision: Result<Option<String>, String> = Ok(None);
564        for resource in &resources {
565            let query = TrqpQuery::new(did, &args.authority_did, &args.action, resource);
566            match client.authorization(query).await {
567                Ok(response) if response.authorized => {
568                    decision = Ok(Some(resource.clone()));
569                    break;
570                }
571                Ok(_) => {}
572                Err(e @ TrqlError::Rejected { .. }) => {
573                    // The registry answered and said no (e.g. unknown tuple
574                    // rejected rather than answered false) — a denial, not
575                    // an availability problem; the fallback may still grant.
576                    tracing::debug!("registry rejected the query for {did}: {e}");
577                }
578                Err(e) => {
579                    // Fail closed: with any scope undeterminable, "denied"
580                    // cannot be distinguished from "unreachable".
581                    decision = Err(e.to_string());
582                    break;
583                }
584            }
585        }
586        decisions.insert(did.clone(), decision);
587    }
588    Ok(decisions)
589}
590
591/// Combine the signature check with the registry decision.
592fn status_of(signature: SignatureCheck, decisions: &RegistryDecisions) -> CommitStatus {
593    match signature {
594        SignatureCheck::Unsigned => CommitStatus::Unsigned,
595        SignatureCheck::Malformed(detail) => CommitStatus::Malformed(detail),
596        SignatureCheck::NoSignerDid { committer } => CommitStatus::NoSignerDid { committer },
597        SignatureCheck::UnresolvedSigner { did, error } => {
598            CommitStatus::UnresolvedSigner { did, error }
599        }
600        SignatureCheck::UnknownKey { did, fingerprint } => {
601            CommitStatus::UnknownKey { did, fingerprint }
602        }
603        SignatureCheck::BadSignature { signer_did } => CommitStatus::BadSignature { signer_did },
604        SignatureCheck::PgpRejected { detail } => CommitStatus::PgpRejected { detail },
605        SignatureCheck::Exempt { fingerprint } => CommitStatus::Exempt { fingerprint },
606        SignatureCheck::Valid { signer_did } => match decisions.get(&signer_did) {
607            Some(Ok(Some(resource))) => CommitStatus::Trusted {
608                signer_did,
609                resource: resource.clone(),
610            },
611            Some(Ok(None)) => CommitStatus::Unauthorized { signer_did },
612            Some(Err(error)) => CommitStatus::RegistryUnavailable {
613                signer_did,
614                error: error.clone(),
615            },
616            None => CommitStatus::RegistryUnavailable {
617                signer_did,
618                error: "no registry decision recorded".to_string(),
619            },
620        },
621    }
622}
623
624// --- git plumbing --------------------------------------------------------------
625
626/// List the commits in `range`, oldest first.
627pub fn list_commits(repo_dir: &Path, range: &str) -> Result<Vec<String>> {
628    let output = git(repo_dir, &["rev-list", "--reverse", range])?;
629    Ok(output.lines().map(str::to_string).collect())
630}
631
632/// Read one raw commit object.
633pub fn read_commit_raw(repo_dir: &Path, sha: &str) -> Result<Vec<u8>> {
634    let output = Command::new("git")
635        .arg("-C")
636        .arg(repo_dir)
637        .args(["cat-file", "commit", sha])
638        .output()
639        .context("running git cat-file")?;
640    if !output.status.success() {
641        bail!(
642            "git cat-file commit {sha} failed: {}",
643            String::from_utf8_lossy(&output.stderr)
644        );
645    }
646    Ok(output.stdout)
647}
648
649fn git(repo_dir: &Path, args: &[&str]) -> Result<String> {
650    let output = Command::new("git")
651        .arg("-C")
652        .arg(repo_dir)
653        .args(args)
654        .output()
655        .with_context(|| format!("running git {}", args.join(" ")))?;
656    if !output.status.success() {
657        bail!(
658            "git {} failed: {}",
659            args.join(" "),
660            String::from_utf8_lossy(&output.stderr)
661        );
662    }
663    Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
664}
665
666// --- reporting ------------------------------------------------------------------
667
668fn print_report(args: &VerifyTrustArgs, report: &TrustReport) -> Result<()> {
669    if args.json {
670        println!("{}", serde_json::to_string_pretty(report)?);
671        return Ok(());
672    }
673
674    // Per-commit lines name the signer and abbreviate its DID; the signer
675    // block below carries every DID in full, so nothing a reviewer has to
676    // check against the registry is lost to the abbreviation.
677    let signer = |did: &str| render_signer(report, did);
678
679    for commit in &report.commits {
680        let short = &commit.sha[..commit.sha.len().min(12)];
681        match &commit.status {
682            CommitStatus::Trusted {
683                signer_did,
684                resource,
685            } => {
686                println!(
687                    "TRUSTED      {short}  {} (via {resource})",
688                    signer(signer_did)
689                );
690            }
691            CommitStatus::Exempt { fingerprint } => {
692                println!("EXEMPT       {short}  PGP-signed by exempt platform key {fingerprint}");
693            }
694            CommitStatus::PgpRejected { detail } => {
695                println!("PGP-REJECTED {short}  {detail}");
696            }
697            CommitStatus::Unauthorized { signer_did } => {
698                println!(
699                    "UNAUTHORIZED {short}  {} is not authorized by the registry",
700                    signer(signer_did)
701                );
702            }
703            CommitStatus::RegistryUnavailable { signer_did, error } => {
704                println!(
705                    "UNAVAILABLE  {short}  signed by {}; registry check failed: {error}",
706                    signer(signer_did)
707                );
708            }
709            CommitStatus::BadSignature { signer_did } => {
710                println!(
711                    "BAD-SIG      {short}  signature by {} does not verify",
712                    signer(signer_did)
713                );
714            }
715            CommitStatus::UnknownKey { did, fingerprint } => {
716                println!(
717                    "UNKNOWN-KEY  {short}  {} publishes no key {fingerprint}",
718                    signer(did)
719                );
720            }
721            CommitStatus::UnresolvedSigner { did, error } => {
722                println!("UNRESOLVED   {short}  claimed signer {did} did not resolve: {error}");
723            }
724            CommitStatus::NoSignerDid { committer } => {
725                println!("NO-SIGNER    {short}  committer <{committer}> is not a DID");
726            }
727            CommitStatus::Malformed(detail) => {
728                println!("MALFORMED    {short}  {detail}");
729            }
730            CommitStatus::Unsigned => {
731                println!("UNSIGNED     {short}  commit carries no signature");
732            }
733        }
734    }
735
736    print_signer_block(args, report);
737
738    let passing = report.commits.iter().filter(|c| c.status.passes()).count();
739    println!(
740        "{}: {passing}/{} commits pass",
741        if report.ok { "PASS" } else { "FAIL" },
742        report.commits.len()
743    );
744    Ok(())
745}
746
747/// A signer for one commit line: `name (did:webvh:QmXk…:example.com)`, or the
748/// abbreviated DID alone when nothing names it. Unverified names carry the
749/// `[unverified]` tag `NameBook` appends — surfaces must not strip it.
750fn render_signer(report: &TrustReport, did: &str) -> String {
751    match report.signer_names.get(did) {
752        Some(name) if name.is_trusted() => {
753            format!(
754                "{} ({})",
755                name.name,
756                vta_sdk::display_name::shorten_did(did)
757            )
758        }
759        Some(name) => format!(
760            "{}{} ({})",
761            name.name,
762            vta_sdk::display_name::UNVERIFIED_SUFFIX,
763            vta_sdk::display_name::shorten_did(did)
764        ),
765        None => vta_sdk::display_name::shorten_did(did),
766    }
767}
768
769/// The signers that signed this range, each with its full DID.
770///
771/// Emitted only when something was named — on a repo whose signers claim no
772/// agent names this would be a list of DIDs already on every line above.
773fn print_signer_block(args: &VerifyTrustArgs, report: &TrustReport) {
774    if report.signer_names.is_empty() {
775        return;
776    }
777    println!();
778    println!("Signers:");
779    for (did, name) in &report.signer_names {
780        let tag = if name.is_trusted() {
781            String::new()
782        } else {
783            format!(" {}", vta_sdk::display_name::UNVERIFIED_SUFFIX.trim())
784        };
785        println!("  {}{tag}", name.name);
786        println!("    {did}");
787    }
788    if !args.resolve_agent_names && report.signer_names.values().any(|n| !n.is_trusted()) {
789        println!();
790        println!(
791            "  Names above are claimed by the DID and were not checked. Pass \
792             --resolve-agent-names to resolve each claim back to its DID."
793        );
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    #![allow(clippy::unwrap_used, clippy::expect_used)]
800
801    use super::*;
802    use ed25519_dalek::SigningKey;
803    use vgi_core::create_ssh_signature;
804
805    fn test_key() -> (SigningKey, [u8; 32]) {
806        let signing = SigningKey::from_bytes(&[7u8; 32]);
807        let public = signing.verifying_key().to_bytes();
808        (signing, public)
809    }
810
811    const SIGNER: &str = "did:webvh:QmSigner:example.com";
812
813    /// An unsigned commit whose committer claims `SIGNER`, as `did-git-sign`
814    /// writes it: `user.email` is the verification-method id.
815    fn unsigned_commit() -> String {
816        commit_committed_by(&format!("{SIGNER}#key-0"))
817    }
818
819    fn commit_committed_by(committer: &str) -> String {
820        format!(
821            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
822             author A U Thor <a@example.com> 1700000000 +0000\n\
823             committer A U Thor <{committer}> 1700000000 +0000\n\
824             \n\
825             a message\n"
826        )
827    }
828
829    /// A signer set in which `SIGNER` publishes `public`.
830    fn signers_publishing(public: [u8; 32]) -> ResolvedSigners {
831        ResolvedSigners::from_keys([(SIGNER, vec![public])])
832    }
833
834    /// Insert a gpgsig header before the blank line, continuation-indented,
835    /// exactly as git stores it.
836    fn signed_commit(payload: &str, armored: &str) -> String {
837        let (headers, body) = payload.split_once("\n\n").unwrap();
838        let mut sig_header = String::from("gpgsig ");
839        let mut lines = armored.trim_end().split('\n');
840        sig_header.push_str(lines.next().unwrap());
841        for line in lines {
842            sig_header.push('\n');
843            sig_header.push(' ');
844            sig_header.push_str(line);
845        }
846        format!("{headers}\n{sig_header}\n\n{body}")
847    }
848
849    fn sign_commit(payload: &str, key: &SigningKey) -> String {
850        let armored = create_ssh_signature(
851            key,
852            &key.verifying_key(),
853            GIT_SSHSIG_NAMESPACE,
854            payload.as_bytes(),
855        )
856        .unwrap();
857        signed_commit(payload, &armored)
858    }
859
860    #[test]
861    fn split_returns_none_for_unsigned_commit() {
862        assert!(
863            split_signed_commit(unsigned_commit().as_bytes())
864                .unwrap()
865                .is_none()
866        );
867    }
868
869    #[test]
870    fn split_recovers_exact_payload_and_signature() {
871        let payload = unsigned_commit();
872        let (key, _) = test_key();
873        let commit = sign_commit(&payload, &key);
874
875        let (recovered_payload, pem) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
876        assert_eq!(recovered_payload, payload.as_bytes());
877        assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
878        assert!(pem.trim_end().ends_with("-----END SSH SIGNATURE-----"));
879    }
880
881    #[test]
882    fn our_encoder_and_the_decoder_agree() {
883        // Cross-check: a signature produced by sign.rs verifies through the
884        // ssh-key crate's independent implementation.
885        let payload = unsigned_commit();
886        let (key, public) = test_key();
887        let commit = sign_commit(&payload, &key);
888
889        let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
890        assert_eq!(
891            check,
892            SignatureCheck::Valid {
893                signer_did: SIGNER.to_string()
894            }
895        );
896    }
897
898    #[test]
899    fn the_signer_is_the_did_the_commit_claims() {
900        // The identity is not configuration: it comes off the commit's own
901        // committer header, with the fragment stripped.
902        let payload = unsigned_commit();
903        let (key, public) = test_key();
904        let commit = sign_commit(&payload, &key);
905
906        let SignatureCheck::Valid { signer_did } =
907            check_commit_signature(commit.as_bytes(), &signers_publishing(public), None)
908        else {
909            panic!("expected a valid signature");
910        };
911        assert_eq!(signer_did, SIGNER, "the bare DID, not the key id");
912    }
913
914    #[test]
915    fn claiming_a_did_that_does_not_publish_the_signing_key_fails() {
916        // The spoof the committer header invites: sign with your own key while
917        // naming someone else's DID. The claim selects whose document to
918        // check, and that document does not publish this key.
919        let payload = commit_committed_by("did:webvh:QmVictim:example.com#key-0");
920        let (key, public) = test_key();
921        let commit = sign_commit(&payload, &key);
922
923        let signers = ResolvedSigners::from_keys([
924            (SIGNER, vec![public]),
925            ("did:webvh:QmVictim:example.com", vec![[0u8; 32]]),
926        ]);
927        assert_eq!(
928            check_commit_signature(commit.as_bytes(), &signers, None),
929            SignatureCheck::UnknownKey {
930                did: "did:webvh:QmVictim:example.com".to_string(),
931                fingerprint: hex::encode(public),
932            },
933            "a key published by another DID must not authenticate this claim"
934        );
935    }
936
937    #[test]
938    fn a_committer_that_is_not_a_did_has_no_identity_to_check() {
939        let payload = commit_committed_by("alice@example.com");
940        let (key, public) = test_key();
941        let commit = sign_commit(&payload, &key);
942
943        assert_eq!(
944            check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
945            SignatureCheck::NoSignerDid {
946                committer: "alice@example.com".to_string()
947            }
948        );
949    }
950
951    #[test]
952    fn a_claimed_did_that_did_not_resolve_fails_closed() {
953        let payload = unsigned_commit();
954        let (key, _) = test_key();
955        let commit = sign_commit(&payload, &key);
956
957        let mut signers = ResolvedSigners::default();
958        signers
959            .unresolved
960            .insert(SIGNER.to_string(), "resolution failed: no such host".into());
961
962        assert_eq!(
963            check_commit_signature(commit.as_bytes(), &signers, None),
964            SignatureCheck::UnresolvedSigner {
965                did: SIGNER.to_string(),
966                error: "resolution failed: no such host".to_string(),
967            },
968            "an unresolvable signer is not a trusted one"
969        );
970    }
971
972    #[test]
973    fn the_claim_is_read_from_the_bytes_the_signature_covers() {
974        // Rewriting the committer after signing invalidates the signature, so
975        // a surviving claim is one the signer committed to.
976        let payload = unsigned_commit();
977        let (key, public) = test_key();
978        let commit = sign_commit(&payload, &key).replace(
979            &format!("{SIGNER}#key-0"),
980            "did:webvh:QmOther:example.com#key-0",
981        );
982
983        let signers = ResolvedSigners::from_keys([
984            (SIGNER, vec![public]),
985            ("did:webvh:QmOther:example.com", vec![public]),
986        ]);
987        assert_eq!(
988            check_commit_signature(commit.as_bytes(), &signers, None),
989            SignatureCheck::BadSignature {
990                signer_did: "did:webvh:QmOther:example.com".to_string()
991            },
992            "tampering with the claimed identity breaks the signature over it"
993        );
994    }
995
996    #[test]
997    fn distinct_claimed_dids_are_deduplicated_and_bounded() {
998        let (key, _) = test_key();
999        let commits: Vec<RangeCommit> = ["QmA", "QmB", "QmA"]
1000            .iter()
1001            .enumerate()
1002            .map(|(i, scid)| RangeCommit {
1003                sha: format!("{i:040}"),
1004                raw: sign_commit(
1005                    &commit_committed_by(&format!("did:webvh:{scid}:example.com#key-0")),
1006                    &key,
1007                )
1008                .into_bytes(),
1009            })
1010            .collect();
1011
1012        let claimed = claimed_signer_dids(&commits, 32).unwrap();
1013        assert_eq!(
1014            claimed,
1015            vec![
1016                "did:webvh:QmA:example.com".to_string(),
1017                "did:webvh:QmB:example.com".to_string(),
1018            ],
1019            "three commits, two identities, two resolutions"
1020        );
1021        assert!(
1022            claimed_signer_dids(&commits, 1).is_err(),
1023            "a range may not make CI resolve more hosts than the cap allows"
1024        );
1025    }
1026
1027    #[test]
1028    fn legacy_76_column_armor_still_verifies() {
1029        // Signatures created before sign.rs matched ssh-keygen's 70-column
1030        // wrapping are permanent in git history and must keep verifying.
1031        let payload = unsigned_commit();
1032        let (key, public) = test_key();
1033        let armored = create_ssh_signature(
1034            &key,
1035            &key.verifying_key(),
1036            GIT_SSHSIG_NAMESPACE,
1037            payload.as_bytes(),
1038        )
1039        .unwrap();
1040        let body: String = armored
1041            .lines()
1042            .filter(|l| !l.starts_with("-----"))
1043            .collect();
1044        let mut legacy = String::from("-----BEGIN SSH SIGNATURE-----\n");
1045        for chunk in body.as_bytes().chunks(76) {
1046            legacy.push_str(std::str::from_utf8(chunk).unwrap());
1047            legacy.push('\n');
1048        }
1049        legacy.push_str("-----END SSH SIGNATURE-----\n");
1050
1051        let commit = signed_commit(&payload, &legacy);
1052        assert_eq!(
1053            check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
1054            SignatureCheck::Valid {
1055                signer_did: SIGNER.to_string()
1056            }
1057        );
1058    }
1059
1060    #[test]
1061    fn a_key_the_claimed_did_does_not_publish_is_reported_with_its_fingerprint() {
1062        let payload = unsigned_commit();
1063        let (key, public) = test_key();
1064        let commit = sign_commit(&payload, &key);
1065
1066        // The DID resolved, but publishes a different key.
1067        let signers = ResolvedSigners::from_keys([(SIGNER, vec![[3u8; 32]])]);
1068        assert_eq!(
1069            check_commit_signature(commit.as_bytes(), &signers, None),
1070            SignatureCheck::UnknownKey {
1071                did: SIGNER.to_string(),
1072                fingerprint: hex::encode(public),
1073            }
1074        );
1075    }
1076
1077    #[test]
1078    fn tampered_payload_is_a_bad_signature() {
1079        let payload = unsigned_commit();
1080        let (key, public) = test_key();
1081        let commit = sign_commit(&payload, &key).replace("a message", "b message");
1082
1083        let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
1084        assert_eq!(
1085            check,
1086            SignatureCheck::BadSignature {
1087                signer_did: SIGNER.to_string()
1088            }
1089        );
1090    }
1091
1092    #[test]
1093    fn unsigned_commit_is_unsigned() {
1094        let check = check_commit_signature(
1095            unsigned_commit().as_bytes(),
1096            &ResolvedSigners::default(),
1097            None,
1098        );
1099        assert_eq!(check, SignatureCheck::Unsigned);
1100    }
1101
1102    #[test]
1103    fn statuses_compose_signature_and_registry_decisions() {
1104        let did = "did:example:signer".to_string();
1105        let mut decisions = RegistryDecisions::new();
1106        decisions.insert(did.clone(), Ok(Some("example/repo".to_string())));
1107        assert!(
1108            status_of(
1109                SignatureCheck::Valid {
1110                    signer_did: did.clone()
1111                },
1112                &decisions
1113            )
1114            .is_trusted()
1115        );
1116
1117        decisions.insert(did.clone(), Ok(None));
1118        assert_eq!(
1119            status_of(
1120                SignatureCheck::Valid {
1121                    signer_did: did.clone()
1122                },
1123                &decisions
1124            ),
1125            CommitStatus::Unauthorized {
1126                signer_did: did.clone()
1127            }
1128        );
1129
1130        decisions.insert(did.clone(), Err("connect refused".to_string()));
1131        assert!(matches!(
1132            status_of(SignatureCheck::Valid { signer_did: did }, &decisions),
1133            CommitStatus::RegistryUnavailable { .. }
1134        ));
1135    }
1136
1137    // --- signer naming ---
1138
1139    #[test]
1140    fn a_verified_shortcut_is_the_name() {
1141        let name = signer_display_name(
1142            Some("example.com/@alice"),
1143            &["https://example.com/@alice".to_string()],
1144        )
1145        .unwrap();
1146        assert_eq!(name.name, "example.com/@alice");
1147        assert!(name.is_trusted());
1148    }
1149
1150    #[test]
1151    fn an_unchecked_claim_is_never_trusted() {
1152        // The spoof this exists for: a signer's document claims the bank's
1153        // name. Nothing resolved it back, so it must not render as the bank.
1154        let name =
1155            signer_display_name(None, &["https://mybank.com/@treasury".to_string()]).unwrap();
1156        assert_eq!(name.source, NameSource::AgentName { verified: false });
1157        assert!(!name.is_trusted());
1158    }
1159
1160    #[test]
1161    fn a_signer_claiming_nothing_has_no_name() {
1162        assert!(signer_display_name(None, &[]).is_none());
1163    }
1164
1165    #[test]
1166    fn an_unverified_name_renders_tagged_beside_its_did() {
1167        let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
1168        let report = TrustReport {
1169            ok: true,
1170            commits: Vec::new(),
1171            unresolved_signers: BTreeMap::new(),
1172            signer_names: BTreeMap::from([(
1173                did.to_string(),
1174                DisplayName::new(
1175                    "mybank.com/@treasury",
1176                    NameSource::AgentName { verified: false },
1177                ),
1178            )]),
1179        };
1180        let rendered = render_signer(&report, did);
1181        assert!(
1182            rendered.contains("unverified"),
1183            "an unchecked claim must never render as a plain name: {rendered}"
1184        );
1185        assert!(
1186            rendered.contains("example.com"),
1187            "the DID must stay visible beside the name: {rendered}"
1188        );
1189    }
1190
1191    #[test]
1192    fn an_unnamed_signer_falls_back_to_its_did() {
1193        let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
1194        let report = TrustReport {
1195            ok: true,
1196            commits: Vec::new(),
1197            unresolved_signers: BTreeMap::new(),
1198            signer_names: BTreeMap::new(),
1199        };
1200        assert_eq!(
1201            render_signer(&report, did),
1202            vta_sdk::display_name::shorten_did(did)
1203        );
1204    }
1205}