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