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