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's `gpgsig` header is
6//!    parsed as a PROTOCOL.sshsig blob; the Ed25519 public key embedded in it
7//!    is matched against the keys published in the DID documents of the
8//!    repository's declared signers, and the signature is verified over the
9//!    exact bytes git signed.
10//! 2. **Is that DID trusted, right now?** The signer DID is checked against
11//!    the Trust Registry with a TRQP authorization query
12//!    (`{entity: signer, authority, action, resource}`) via `trql-client`.
13//!
14//! The signer set comes from a committed index file (default `.did-signers`,
15//! one DID per line) that lists *identities, not keys* — keys are resolved
16//! from each DID document at verification time, so key rotation never
17//! requires touching the repository, and revoking a signer is a registry
18//! operation that takes effect on the next run.
19//!
20//! Failure is closed at every layer: an unsigned commit, a signature by an
21//! unpublished key, a cryptographically invalid signature, an unauthorized
22//! DID, and an unreachable registry all fail the check — each with its own
23//! status so an operator can tell which remediation applies.
24//!
25//! Signers are reported by **agent name** where one is available
26//! (`example.com/@alice`) rather than by raw DID. Names come out of the DID
27//! documents this crate already resolves, and render through
28//! [`vta_sdk::display_name`] — the same seam the PNM, CNM and VTC operator
29//! surfaces use, so a DID is abbreviated identically wherever it appears.
30
31pub mod pgp_exempt;
32
33use std::collections::{BTreeMap, BTreeSet, HashMap};
34use std::path::{Path, PathBuf};
35use std::process::Command;
36use std::sync::Arc;
37
38use anyhow::{Context, Result, bail};
39use serde::Serialize;
40use ssh_key::{SshSig, public::KeyData};
41use trql_client::{HttpsTransport, HttpsTransportConfig, TrqlClient, TrqlError, TrqpQuery};
42use vgi_core::{
43    GIT_SSHSIG_NAMESPACE, ed25519_keys_from_doc, normalize_sshsig_armor, split_signed_commit,
44};
45use vta_sdk::display_name::{DisplayName, NameBook, NameSource};
46
47use crate::pgp_exempt::ExemptKeyring;
48
49/// Everything `verify-trust` needs for one run.
50#[derive(Debug, Clone)]
51pub struct VerifyTrustArgs {
52    /// Repository to verify (a working tree with `git` available).
53    pub repo_dir: PathBuf,
54    /// Commit range in `git rev-list` syntax, e.g. `origin/main..HEAD`.
55    pub range: String,
56    /// Signer index file; relative paths resolve against `repo_dir`.
57    pub signers_file: PathBuf,
58    /// Base URL of the Trust Registry (`POST <url>/trust-tasks`).
59    pub registry_url: String,
60    /// DID of the registry (the `recipient` on every query document).
61    pub registry_did: String,
62    /// DID of the authority the tuple is evaluated under.
63    pub authority_did: String,
64    /// TRQP action, e.g. `git.commit.sign`.
65    pub action: String,
66    /// TRQP resource, e.g. the `org/repo` slug.
67    pub resource: String,
68    /// Broader resource to try when the primary one does not authorize
69    /// (e.g. the org for an org-wide grant). Grant semantics are
70    /// `resource OR fallback`: the registry's wire contract cannot
71    /// distinguish "no record" from an explicit `authorized: false`, so a
72    /// repo-level record cannot veto an org-level grant.
73    pub fallback_resource: Option<String>,
74    /// Optional armored PGP keyring of exempt platform keys (e.g. GitHub's
75    /// web-flow key); relative paths resolve against `repo_dir`. Absent means
76    /// no exemptions: every PGP-signed commit fails.
77    pub exempt_keyring: Option<PathBuf>,
78    /// Round-trip the agent names the signers' DID documents claim, so a
79    /// verified name renders unqualified instead of tagged `[unverified]`.
80    ///
81    /// Costs one outbound HTTPS fetch per claimed name, to a host the
82    /// *document's author* chose, so it is opt-in — the same rule the PNM and
83    /// CNM CLIs apply to their `--resolve-agent-names` flag. With it off the
84    /// claims still show (they come free with the documents this crate must
85    /// resolve anyway), but as the self-assertions they are.
86    pub resolve_agent_names: bool,
87    /// Emit machine-readable JSON on stdout instead of human lines.
88    pub json: bool,
89}
90
91/// Outcome for one commit. Ordered worst-first so a report can sort on it.
92#[derive(Debug, Clone, PartialEq, Serialize)]
93#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
94pub enum CommitStatus {
95    /// No `gpgsig` header on the commit.
96    Unsigned,
97    /// The signature did not parse as an Ed25519 sshsig.
98    Malformed(String),
99    /// The embedded key is published by none of the declared signers.
100    UnknownKey { fingerprint: String },
101    /// The key maps to a signer, but the signature does not verify.
102    BadSignature { signer_did: String },
103    /// Valid signature, but the registry did not authorize the signer.
104    Unauthorized { signer_did: String },
105    /// Valid signature, but the registry could not be consulted. Fails the
106    /// run (closed), distinctly from a denial.
107    RegistryUnavailable { signer_did: String, error: String },
108    /// PGP-signed (a platform commit), but the signature verifies against no
109    /// key in the exempt keyring — or no keyring is configured.
110    PgpRejected { detail: String },
111    /// PGP-signed by a key in the committed exempt keyring (e.g. a GitHub
112    /// web-UI merge commit). Passes, reported distinctly from `Trusted`.
113    Exempt { fingerprint: String },
114    /// Valid signature by a registry-authorized signer. `resource` is the
115    /// tuple resource the grant was found under (the primary one or the
116    /// fallback).
117    Trusted {
118        signer_did: String,
119        resource: String,
120    },
121}
122
123impl CommitStatus {
124    /// Signed by a registry-authorized DID.
125    pub fn is_trusted(&self) -> bool {
126        matches!(self, Self::Trusted { .. })
127    }
128
129    /// Whether the commit passes the check: DID-trusted or keyring-exempt.
130    pub fn passes(&self) -> bool {
131        matches!(self, Self::Trusted { .. } | Self::Exempt { .. })
132    }
133}
134
135/// One commit's verdict, as reported.
136#[derive(Debug, Clone, Serialize)]
137#[serde(rename_all = "camelCase")]
138pub struct CommitVerdict {
139    pub sha: String,
140    #[serde(flatten)]
141    pub status: CommitStatus,
142}
143
144/// The full report for a range.
145#[derive(Debug, Serialize)]
146#[serde(rename_all = "camelCase")]
147pub struct TrustReport {
148    pub ok: bool,
149    pub commits: Vec<CommitVerdict>,
150    /// Signer DIDs whose resolution failed (their commits show as
151    /// `unknownKey`); surfaced so the cause is visible.
152    pub unresolved_signers: BTreeMap<String, String>,
153    /// Display name per named signer DID, with its provenance. Only DIDs that
154    /// have a name appear. The commit entries keep full DIDs, so a consumer
155    /// that does not care about names is unaffected.
156    pub signer_names: BTreeMap<String, DisplayName>,
157}
158
159/// The declared signers, resolved: their published keys, why any of them
160/// could not be resolved, and what to call them.
161///
162/// Produced by [`resolve_signer_keys`] and consumed by [`verify_with_keys`],
163/// which tests construct directly via [`ResolvedSigners::from_keys`].
164#[derive(Debug, Default)]
165pub struct ResolvedSigners {
166    /// Published Ed25519 key → the DID that publishes it.
167    pub keys: HashMap<[u8; 32], String>,
168    /// Declared DID → why it did not resolve.
169    pub unresolved: BTreeMap<String, String>,
170    /// DID → display name, for every signer whose document names it.
171    pub names: NameBook,
172}
173
174impl ResolvedSigners {
175    /// A signer set with keys but no names — the shape a test wants when it
176    /// supplies keys directly instead of resolving DID documents.
177    #[must_use]
178    pub fn from_keys(keys: HashMap<[u8; 32], String>) -> Self {
179        Self {
180            keys,
181            ..Self::default()
182        }
183    }
184}
185
186/// Run the check end to end: resolve the declared signers' keys, then verify
187/// the range. Returns the process exit code (0 = every commit trusted).
188pub async fn handle_verify_trust(args: VerifyTrustArgs) -> Result<i32> {
189    let signer_dids = load_signers(&args.repo_dir, &args.signers_file)?;
190    let exempt = load_exempt_keyring(&args)?;
191    let signers = resolve_signer_keys(&signer_dids, args.resolve_agent_names).await?;
192    let report = verify_with_keys(&args, &signers, exempt.as_ref()).await?;
193    print_report(&args, &report)?;
194    Ok(if report.ok { 0 } else { 1 })
195}
196
197/// Verify the range against an already-resolved signer set. Split from
198/// [`handle_verify_trust`] so tests can supply keys without a live resolver.
199pub async fn verify_with_keys(
200    args: &VerifyTrustArgs,
201    signers: &ResolvedSigners,
202    exempt: Option<&ExemptKeyring>,
203) -> Result<TrustReport> {
204    let shas = list_commits(&args.repo_dir, &args.range)?;
205
206    // Pass 1: cryptographic verification, collecting the DIDs that signed.
207    let mut checked = Vec::with_capacity(shas.len());
208    let mut signer_dids = BTreeSet::new();
209    for sha in shas {
210        let raw = read_commit_raw(&args.repo_dir, &sha)?;
211        let signature = check_commit_signature(&raw, &signers.keys, exempt);
212        if let SignatureCheck::Valid { signer_did } = &signature {
213            signer_dids.insert(signer_did.clone());
214        }
215        checked.push((sha, signature));
216    }
217
218    // Pass 2: one registry query per distinct signer DID.
219    let decisions = query_registry(args, &signer_dids).await?;
220
221    let commits: Vec<CommitVerdict> = checked
222        .into_iter()
223        .map(|(sha, signature)| CommitVerdict {
224            sha,
225            status: status_of(signature, &decisions),
226        })
227        .collect();
228
229    // Names are reported for the declared signers that actually signed
230    // something here — a name for a DID absent from the range is noise.
231    let signer_names = signer_dids
232        .iter()
233        .filter_map(|did| {
234            signers
235                .names
236                .get(did)
237                .map(|name| (did.clone(), name.clone()))
238        })
239        .collect();
240
241    // An empty range passes vacuously (nothing new to verify).
242    let ok = commits.iter().all(|c| c.status.passes());
243    Ok(TrustReport {
244        ok,
245        commits,
246        unresolved_signers: signers.unresolved.clone(),
247        signer_names,
248    })
249}
250
251// --- signature layer ---------------------------------------------------------
252
253/// Result of the cryptographic check for one commit.
254#[derive(Debug, Clone, PartialEq)]
255pub enum SignatureCheck {
256    Unsigned,
257    Malformed(String),
258    UnknownKey { fingerprint: String },
259    BadSignature { signer_did: String },
260    PgpRejected { detail: String },
261    Exempt { fingerprint: String },
262    Valid { signer_did: String },
263}
264
265/// Verify one raw commit object against the signer key map.
266pub fn check_commit_signature(
267    raw: &[u8],
268    signer_keys: &HashMap<[u8; 32], String>,
269    exempt: Option<&ExemptKeyring>,
270) -> SignatureCheck {
271    let (payload, pem) = match split_signed_commit(raw) {
272        Ok(Some(parts)) => parts,
273        Ok(None) => return SignatureCheck::Unsigned,
274        Err(e) => return SignatureCheck::Malformed(e.to_string()),
275    };
276    // Platform commits (GitHub web-UI merges, Dependabot) are PGP-signed;
277    // they pass only via the explicitly committed exempt keyring.
278    if pem.starts_with("-----BEGIN PGP SIGNATURE-----") {
279        let Some(keyring) = exempt else {
280            return SignatureCheck::PgpRejected {
281                detail: "PGP-signed commit, but no exempt keyring is configured".to_string(),
282            };
283        };
284        return match keyring.verify(&pem, &payload) {
285            Ok(fingerprint) => SignatureCheck::Exempt { fingerprint },
286            Err(detail) => SignatureCheck::PgpRejected { detail },
287        };
288    }
289    let sig = match SshSig::from_pem(normalize_sshsig_armor(&pem).as_bytes()) {
290        Ok(sig) => sig,
291        Err(e) => return SignatureCheck::Malformed(format!("sshsig did not parse: {e}")),
292    };
293    let KeyData::Ed25519(embedded) = sig.public_key() else {
294        return SignatureCheck::Malformed(format!(
295            "unsupported signature algorithm: {}",
296            sig.algorithm()
297        ));
298    };
299    let key_bytes: [u8; 32] = embedded.0;
300    let Some(signer_did) = signer_keys.get(&key_bytes) else {
301        return SignatureCheck::UnknownKey {
302            fingerprint: hex::encode(key_bytes),
303        };
304    };
305    let public_key = ssh_key::PublicKey::from(sig.public_key().clone());
306    match public_key.verify(GIT_SSHSIG_NAMESPACE, &payload, &sig) {
307        Ok(()) => SignatureCheck::Valid {
308            signer_did: signer_did.clone(),
309        },
310        Err(_) => SignatureCheck::BadSignature {
311            signer_did: signer_did.clone(),
312        },
313    }
314}
315
316/// Load the exempt keyring named by the args, resolving relative to the repo.
317fn load_exempt_keyring(args: &VerifyTrustArgs) -> Result<Option<ExemptKeyring>> {
318    let Some(path) = &args.exempt_keyring else {
319        return Ok(None);
320    };
321    let path = if path.is_absolute() {
322        path.clone()
323    } else {
324        args.repo_dir.join(path)
325    };
326    Ok(Some(ExemptKeyring::load(&path)?))
327}
328
329// --- signer index & DID resolution -------------------------------------------
330
331/// Read and parse the signer index: one DID per line, `#` comments allowed.
332/// A missing or malformed file is a hard error — with no declared signers
333/// there is nothing to verify against, and the check must not silently pass.
334pub fn load_signers(repo_dir: &Path, signers_file: &Path) -> Result<Vec<String>> {
335    let path = if signers_file.is_absolute() {
336        signers_file.to_path_buf()
337    } else {
338        repo_dir.join(signers_file)
339    };
340    let text = std::fs::read_to_string(&path)
341        .with_context(|| format!("cannot read signer index {}", path.display()))?;
342    let dids = parse_signers(&text)?;
343    if dids.is_empty() {
344        bail!("signer index {} declares no DIDs", path.display());
345    }
346    Ok(dids)
347}
348
349/// Parse signer-index text. Rejects non-DID entries outright rather than
350/// skipping them: a typo must fail loudly, not silently drop a signer.
351pub fn parse_signers(text: &str) -> Result<Vec<String>> {
352    let mut dids = Vec::new();
353    for (number, line) in text.lines().enumerate() {
354        let entry = line.trim();
355        if entry.is_empty() || entry.starts_with('#') {
356            continue;
357        }
358        if !entry.starts_with("did:") {
359            bail!("signer index line {}: not a DID: {entry}", number + 1);
360        }
361        dids.push(entry.to_string());
362    }
363    Ok(dids)
364}
365
366/// Resolve every declared signer DID: collect the Ed25519 keys their DID
367/// documents publish, and name each signer from the same document. A DID that
368/// fails to resolve is recorded (its commits will fail as `unknownKey`)
369/// without blocking the other signers.
370///
371/// `resolve_agent_names` turns on the resolver's shortcut derivation, which
372/// round-trips each claimed name before it is treated as this DID's — see
373/// [`VerifyTrustArgs::resolve_agent_names`]. Unverified claims are picked up
374/// either way, since they arrive with documents that must be resolved anyway.
375pub async fn resolve_signer_keys(
376    dids: &[String],
377    resolve_agent_names: bool,
378) -> Result<ResolvedSigners> {
379    use affinidi_tdk::TDK;
380    use affinidi_tdk::common::config::TDKConfig;
381    use affinidi_tdk::did_resolver::config::DIDCacheConfigBuilder;
382
383    // `with_resolve_shortcuts` exists because `vta-sdk/agent-names` turns on
384    // `affinidi-did-resolver-cache-sdk/agent-names`, which cargo unifies onto
385    // the resolver the TDK builds here.
386    let tdk = TDK::new(
387        TDKConfig::builder()
388            .with_load_environment(false)
389            .with_did_resolver_config(
390                DIDCacheConfigBuilder::default()
391                    .with_resolve_shortcuts(resolve_agent_names)
392                    .build(),
393            )
394            .build()
395            .context("TDK config")?,
396        None,
397    )
398    .await
399    .context("TDK init")?;
400
401    let mut signers = ResolvedSigners::default();
402    for did in dids {
403        match tdk.did_resolver().resolve(did).await {
404            Ok(response) => {
405                // A shortcut is only ever set after the resolver checked the
406                // claimed name resolves back to this DID; anything else the
407                // document claims is a bare self-assertion.
408                let name = signer_display_name(
409                    response.shortcut.as_ref().map(|s| s.label()),
410                    &vta_sdk::display_name::agent_name::claimed_names(&response.doc),
411                );
412                if let Some(name) = name {
413                    signers.names.insert(did.clone(), name);
414                }
415
416                let doc = serde_json::to_value(&response.doc)
417                    .with_context(|| format!("DID document for {did} did not serialize"))?;
418                let published = ed25519_keys_from_doc(&doc);
419                if published.is_empty() {
420                    signers.unresolved.insert(
421                        did.clone(),
422                        "DID document publishes no Ed25519 verification keys".to_string(),
423                    );
424                }
425                for key in published {
426                    signers.keys.insert(key, did.clone());
427                }
428            }
429            Err(e) => {
430                signers
431                    .unresolved
432                    .insert(did.clone(), format!("resolution failed: {e}"));
433            }
434        }
435    }
436    Ok(signers)
437}
438
439/// Pick what to call a signer, given the name its resolution verified (if any)
440/// and the names its document claims.
441///
442/// A verified shortcut wins outright. Otherwise the first claim is reported
443/// **unverified**: `alsoKnownAs` is self-asserted, so a hostile DID can claim
444/// `mybank.com/@treasury` and a verifier that printed that bare would have
445/// told the reviewer, in an authoritative voice, that the bank signed this
446/// commit. The claim still surfaces — a DID *attempting* to present as
447/// someone else is exactly what a reviewer should see — but tagged, and
448/// ranked below every trusted source. See [`vta_sdk::display_name`].
449fn signer_display_name(verified: Option<&str>, claimed: &[String]) -> Option<DisplayName> {
450    if let Some(name) = verified {
451        return Some(DisplayName::new(
452            name,
453            NameSource::AgentName { verified: true },
454        ));
455    }
456    claimed
457        .first()
458        .map(|name| DisplayName::new(name, NameSource::AgentName { verified: false }))
459}
460
461// --- registry layer -----------------------------------------------------------
462
463/// Per-DID registry decision: `Ok(Some(resource))` = authorized under that
464/// tuple resource, `Ok(None)` = denied everywhere queried, `Err` =
465/// registry unavailable.
466type RegistryDecisions = BTreeMap<String, Result<Option<String>, String>>;
467
468/// One TRQP authorization query per distinct signer DID.
469async fn query_registry(
470    args: &VerifyTrustArgs,
471    signer_dids: &BTreeSet<String>,
472) -> Result<RegistryDecisions> {
473    let mut decisions = RegistryDecisions::new();
474    if signer_dids.is_empty() {
475        return Ok(decisions);
476    }
477    let transport = HttpsTransport::new(HttpsTransportConfig::new(&args.registry_url))?;
478    let client = TrqlClient::new(Arc::new(transport), &args.registry_did);
479    // The primary resource, then the broader fallback if it did not grant.
480    let mut resources = vec![args.resource.clone()];
481    if let Some(fallback) = &args.fallback_resource
482        && fallback != &args.resource
483    {
484        resources.push(fallback.clone());
485    }
486    for did in signer_dids {
487        let mut decision: Result<Option<String>, String> = Ok(None);
488        for resource in &resources {
489            let query = TrqpQuery::new(did, &args.authority_did, &args.action, resource);
490            match client.authorization(query).await {
491                Ok(response) if response.authorized => {
492                    decision = Ok(Some(resource.clone()));
493                    break;
494                }
495                Ok(_) => {}
496                Err(e @ TrqlError::Rejected { .. }) => {
497                    // The registry answered and said no (e.g. unknown tuple
498                    // rejected rather than answered false) — a denial, not
499                    // an availability problem; the fallback may still grant.
500                    tracing::debug!("registry rejected the query for {did}: {e}");
501                }
502                Err(e) => {
503                    // Fail closed: with any scope undeterminable, "denied"
504                    // cannot be distinguished from "unreachable".
505                    decision = Err(e.to_string());
506                    break;
507                }
508            }
509        }
510        decisions.insert(did.clone(), decision);
511    }
512    Ok(decisions)
513}
514
515/// Combine the signature check with the registry decision.
516fn status_of(signature: SignatureCheck, decisions: &RegistryDecisions) -> CommitStatus {
517    match signature {
518        SignatureCheck::Unsigned => CommitStatus::Unsigned,
519        SignatureCheck::Malformed(detail) => CommitStatus::Malformed(detail),
520        SignatureCheck::UnknownKey { fingerprint } => CommitStatus::UnknownKey { fingerprint },
521        SignatureCheck::BadSignature { signer_did } => CommitStatus::BadSignature { signer_did },
522        SignatureCheck::PgpRejected { detail } => CommitStatus::PgpRejected { detail },
523        SignatureCheck::Exempt { fingerprint } => CommitStatus::Exempt { fingerprint },
524        SignatureCheck::Valid { signer_did } => match decisions.get(&signer_did) {
525            Some(Ok(Some(resource))) => CommitStatus::Trusted {
526                signer_did,
527                resource: resource.clone(),
528            },
529            Some(Ok(None)) => CommitStatus::Unauthorized { signer_did },
530            Some(Err(error)) => CommitStatus::RegistryUnavailable {
531                signer_did,
532                error: error.clone(),
533            },
534            None => CommitStatus::RegistryUnavailable {
535                signer_did,
536                error: "no registry decision recorded".to_string(),
537            },
538        },
539    }
540}
541
542// --- git plumbing --------------------------------------------------------------
543
544/// List the commits in `range`, oldest first.
545pub fn list_commits(repo_dir: &Path, range: &str) -> Result<Vec<String>> {
546    let output = git(repo_dir, &["rev-list", "--reverse", range])?;
547    Ok(output.lines().map(str::to_string).collect())
548}
549
550/// Read one raw commit object.
551pub fn read_commit_raw(repo_dir: &Path, sha: &str) -> Result<Vec<u8>> {
552    let output = Command::new("git")
553        .arg("-C")
554        .arg(repo_dir)
555        .args(["cat-file", "commit", sha])
556        .output()
557        .context("running git cat-file")?;
558    if !output.status.success() {
559        bail!(
560            "git cat-file commit {sha} failed: {}",
561            String::from_utf8_lossy(&output.stderr)
562        );
563    }
564    Ok(output.stdout)
565}
566
567fn git(repo_dir: &Path, args: &[&str]) -> Result<String> {
568    let output = Command::new("git")
569        .arg("-C")
570        .arg(repo_dir)
571        .args(args)
572        .output()
573        .with_context(|| format!("running git {}", args.join(" ")))?;
574    if !output.status.success() {
575        bail!(
576            "git {} failed: {}",
577            args.join(" "),
578            String::from_utf8_lossy(&output.stderr)
579        );
580    }
581    Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
582}
583
584// --- reporting ------------------------------------------------------------------
585
586fn print_report(args: &VerifyTrustArgs, report: &TrustReport) -> Result<()> {
587    if args.json {
588        println!("{}", serde_json::to_string_pretty(report)?);
589        return Ok(());
590    }
591
592    // Per-commit lines name the signer and abbreviate its DID; the signer
593    // block below carries every DID in full, so nothing that has to be
594    // cross-checked against `.did-signers` is lost to the abbreviation.
595    let signer = |did: &str| render_signer(report, did);
596
597    for commit in &report.commits {
598        let short = &commit.sha[..commit.sha.len().min(12)];
599        match &commit.status {
600            CommitStatus::Trusted {
601                signer_did,
602                resource,
603            } => {
604                println!(
605                    "TRUSTED      {short}  {} (via {resource})",
606                    signer(signer_did)
607                );
608            }
609            CommitStatus::Exempt { fingerprint } => {
610                println!("EXEMPT       {short}  PGP-signed by exempt platform key {fingerprint}");
611            }
612            CommitStatus::PgpRejected { detail } => {
613                println!("PGP-REJECTED {short}  {detail}");
614            }
615            CommitStatus::Unauthorized { signer_did } => {
616                println!(
617                    "UNAUTHORIZED {short}  {} is not authorized by the registry",
618                    signer(signer_did)
619                );
620            }
621            CommitStatus::RegistryUnavailable { signer_did, error } => {
622                println!(
623                    "UNAVAILABLE  {short}  signed by {}; registry check failed: {error}",
624                    signer(signer_did)
625                );
626            }
627            CommitStatus::BadSignature { signer_did } => {
628                println!(
629                    "BAD-SIG      {short}  signature by {} does not verify",
630                    signer(signer_did)
631                );
632            }
633            CommitStatus::UnknownKey { fingerprint } => {
634                println!(
635                    "UNKNOWN-KEY  {short}  key {fingerprint} is published by no declared signer"
636                );
637            }
638            CommitStatus::Malformed(detail) => {
639                println!("MALFORMED    {short}  {detail}");
640            }
641            CommitStatus::Unsigned => {
642                println!("UNSIGNED     {short}  commit carries no signature");
643            }
644        }
645    }
646    for (did, reason) in &report.unresolved_signers {
647        println!("WARNING      declared signer {did}: {reason}");
648    }
649
650    print_signer_block(args, report);
651
652    let passing = report.commits.iter().filter(|c| c.status.passes()).count();
653    println!(
654        "{}: {passing}/{} commits pass",
655        if report.ok { "PASS" } else { "FAIL" },
656        report.commits.len()
657    );
658    Ok(())
659}
660
661/// A signer for one commit line: `name (did:webvh:QmXk…:example.com)`, or the
662/// abbreviated DID alone when nothing names it. Unverified names carry the
663/// `[unverified]` tag `NameBook` appends — surfaces must not strip it.
664fn render_signer(report: &TrustReport, did: &str) -> String {
665    match report.signer_names.get(did) {
666        Some(name) if name.is_trusted() => {
667            format!(
668                "{} ({})",
669                name.name,
670                vta_sdk::display_name::shorten_did(did)
671            )
672        }
673        Some(name) => format!(
674            "{}{} ({})",
675            name.name,
676            vta_sdk::display_name::UNVERIFIED_SUFFIX,
677            vta_sdk::display_name::shorten_did(did)
678        ),
679        None => vta_sdk::display_name::shorten_did(did),
680    }
681}
682
683/// The signers that signed this range, each with its full DID.
684///
685/// Emitted only when something was named — on a repo whose signers claim no
686/// agent names this would be a list of DIDs already on every line above.
687fn print_signer_block(args: &VerifyTrustArgs, report: &TrustReport) {
688    if report.signer_names.is_empty() {
689        return;
690    }
691    println!();
692    println!("Signers:");
693    for (did, name) in &report.signer_names {
694        let tag = if name.is_trusted() {
695            String::new()
696        } else {
697            format!(" {}", vta_sdk::display_name::UNVERIFIED_SUFFIX.trim())
698        };
699        println!("  {}{tag}", name.name);
700        println!("    {did}");
701    }
702    if !args.resolve_agent_names && report.signer_names.values().any(|n| !n.is_trusted()) {
703        println!();
704        println!(
705            "  Names above are claimed by the DID and were not checked. Pass \
706             --resolve-agent-names to resolve each claim back to its DID."
707        );
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    #![allow(clippy::unwrap_used, clippy::expect_used)]
714
715    use super::*;
716    use ed25519_dalek::SigningKey;
717    use vgi_core::create_ssh_signature;
718
719    fn test_key() -> (SigningKey, [u8; 32]) {
720        let signing = SigningKey::from_bytes(&[7u8; 32]);
721        let public = signing.verifying_key().to_bytes();
722        (signing, public)
723    }
724
725    fn unsigned_commit() -> String {
726        "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
727         author A U Thor <a@example.com> 1700000000 +0000\n\
728         committer A U Thor <a@example.com> 1700000000 +0000\n\
729         \n\
730         a message\n"
731            .to_string()
732    }
733
734    /// Insert a gpgsig header before the blank line, continuation-indented,
735    /// exactly as git stores it.
736    fn signed_commit(payload: &str, armored: &str) -> String {
737        let (headers, body) = payload.split_once("\n\n").unwrap();
738        let mut sig_header = String::from("gpgsig ");
739        let mut lines = armored.trim_end().split('\n');
740        sig_header.push_str(lines.next().unwrap());
741        for line in lines {
742            sig_header.push('\n');
743            sig_header.push(' ');
744            sig_header.push_str(line);
745        }
746        format!("{headers}\n{sig_header}\n\n{body}")
747    }
748
749    fn sign_commit(payload: &str, key: &SigningKey) -> String {
750        let armored = create_ssh_signature(
751            key,
752            &key.verifying_key(),
753            GIT_SSHSIG_NAMESPACE,
754            payload.as_bytes(),
755        )
756        .unwrap();
757        signed_commit(payload, &armored)
758    }
759
760    #[test]
761    fn split_returns_none_for_unsigned_commit() {
762        assert!(
763            split_signed_commit(unsigned_commit().as_bytes())
764                .unwrap()
765                .is_none()
766        );
767    }
768
769    #[test]
770    fn split_recovers_exact_payload_and_signature() {
771        let payload = unsigned_commit();
772        let (key, _) = test_key();
773        let commit = sign_commit(&payload, &key);
774
775        let (recovered_payload, pem) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
776        assert_eq!(recovered_payload, payload.as_bytes());
777        assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
778        assert!(pem.trim_end().ends_with("-----END SSH SIGNATURE-----"));
779    }
780
781    #[test]
782    fn our_encoder_and_the_decoder_agree() {
783        // Cross-check: a signature produced by sign.rs verifies through the
784        // ssh-key crate's independent implementation.
785        let payload = unsigned_commit();
786        let (key, public) = test_key();
787        let commit = sign_commit(&payload, &key);
788        let keys = HashMap::from([(public, "did:example:signer".to_string())]);
789
790        let check = check_commit_signature(commit.as_bytes(), &keys, None);
791        assert_eq!(
792            check,
793            SignatureCheck::Valid {
794                signer_did: "did:example:signer".to_string()
795            }
796        );
797    }
798
799    #[test]
800    fn legacy_76_column_armor_still_verifies() {
801        // Signatures created before sign.rs matched ssh-keygen's 70-column
802        // wrapping are permanent in git history and must keep verifying.
803        let payload = unsigned_commit();
804        let (key, public) = test_key();
805        let armored = create_ssh_signature(
806            &key,
807            &key.verifying_key(),
808            GIT_SSHSIG_NAMESPACE,
809            payload.as_bytes(),
810        )
811        .unwrap();
812        let body: String = armored
813            .lines()
814            .filter(|l| !l.starts_with("-----"))
815            .collect();
816        let mut legacy = String::from("-----BEGIN SSH SIGNATURE-----\n");
817        for chunk in body.as_bytes().chunks(76) {
818            legacy.push_str(std::str::from_utf8(chunk).unwrap());
819            legacy.push('\n');
820        }
821        legacy.push_str("-----END SSH SIGNATURE-----\n");
822
823        let commit = signed_commit(&payload, &legacy);
824        let keys = HashMap::from([(public, "did:example:signer".to_string())]);
825        assert_eq!(
826            check_commit_signature(commit.as_bytes(), &keys, None),
827            SignatureCheck::Valid {
828                signer_did: "did:example:signer".to_string()
829            }
830        );
831    }
832
833    #[test]
834    fn unknown_key_is_reported_with_fingerprint() {
835        let payload = unsigned_commit();
836        let (key, _) = test_key();
837        let commit = sign_commit(&payload, &key);
838
839        let check = check_commit_signature(commit.as_bytes(), &HashMap::new(), None);
840        assert!(matches!(check, SignatureCheck::UnknownKey { .. }));
841    }
842
843    #[test]
844    fn tampered_payload_is_a_bad_signature() {
845        let payload = unsigned_commit();
846        let (key, public) = test_key();
847        let commit = sign_commit(&payload, &key).replace("a message", "b message");
848        let keys = HashMap::from([(public, "did:example:signer".to_string())]);
849
850        let check = check_commit_signature(commit.as_bytes(), &keys, None);
851        assert_eq!(
852            check,
853            SignatureCheck::BadSignature {
854                signer_did: "did:example:signer".to_string()
855            }
856        );
857    }
858
859    #[test]
860    fn unsigned_commit_is_unsigned() {
861        let check = check_commit_signature(unsigned_commit().as_bytes(), &HashMap::new(), None);
862        assert_eq!(check, SignatureCheck::Unsigned);
863    }
864
865    #[test]
866    fn signers_index_parses_and_rejects_non_dids() {
867        let parsed =
868            parse_signers("# team\n did:webvh:abc:example.com \n\ndid:webvh:def:example.com\n")
869                .unwrap();
870        assert_eq!(parsed.len(), 2);
871        assert!(parse_signers("not-a-did\n").is_err());
872    }
873
874    #[test]
875    fn statuses_compose_signature_and_registry_decisions() {
876        let did = "did:example:signer".to_string();
877        let mut decisions = RegistryDecisions::new();
878        decisions.insert(did.clone(), Ok(Some("example/repo".to_string())));
879        assert!(
880            status_of(
881                SignatureCheck::Valid {
882                    signer_did: did.clone()
883                },
884                &decisions
885            )
886            .is_trusted()
887        );
888
889        decisions.insert(did.clone(), Ok(None));
890        assert_eq!(
891            status_of(
892                SignatureCheck::Valid {
893                    signer_did: did.clone()
894                },
895                &decisions
896            ),
897            CommitStatus::Unauthorized {
898                signer_did: did.clone()
899            }
900        );
901
902        decisions.insert(did.clone(), Err("connect refused".to_string()));
903        assert!(matches!(
904            status_of(SignatureCheck::Valid { signer_did: did }, &decisions),
905            CommitStatus::RegistryUnavailable { .. }
906        ));
907    }
908
909    // --- signer naming ---
910
911    #[test]
912    fn a_verified_shortcut_is_the_name() {
913        let name = signer_display_name(
914            Some("example.com/@alice"),
915            &["https://example.com/@alice".to_string()],
916        )
917        .unwrap();
918        assert_eq!(name.name, "example.com/@alice");
919        assert!(name.is_trusted());
920    }
921
922    #[test]
923    fn an_unchecked_claim_is_never_trusted() {
924        // The spoof this exists for: a signer's document claims the bank's
925        // name. Nothing resolved it back, so it must not render as the bank.
926        let name =
927            signer_display_name(None, &["https://mybank.com/@treasury".to_string()]).unwrap();
928        assert_eq!(name.source, NameSource::AgentName { verified: false });
929        assert!(!name.is_trusted());
930    }
931
932    #[test]
933    fn a_signer_claiming_nothing_has_no_name() {
934        assert!(signer_display_name(None, &[]).is_none());
935    }
936
937    #[test]
938    fn an_unverified_name_renders_tagged_beside_its_did() {
939        let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
940        let report = TrustReport {
941            ok: true,
942            commits: Vec::new(),
943            unresolved_signers: BTreeMap::new(),
944            signer_names: BTreeMap::from([(
945                did.to_string(),
946                DisplayName::new(
947                    "mybank.com/@treasury",
948                    NameSource::AgentName { verified: false },
949                ),
950            )]),
951        };
952        let rendered = render_signer(&report, did);
953        assert!(
954            rendered.contains("unverified"),
955            "an unchecked claim must never render as a plain name: {rendered}"
956        );
957        assert!(
958            rendered.contains("example.com"),
959            "the DID must stay visible beside the name: {rendered}"
960        );
961    }
962
963    #[test]
964    fn an_unnamed_signer_falls_back_to_its_did() {
965        let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
966        let report = TrustReport {
967            ok: true,
968            commits: Vec::new(),
969            unresolved_signers: BTreeMap::new(),
970            signer_names: BTreeMap::new(),
971        };
972        assert_eq!(
973            render_signer(&report, did),
974            vta_sdk::display_name::shorten_did(did)
975        );
976    }
977}