Skip to main content

auths_cli/commands/
verify_commit.rs

1use crate::config::CliConfig;
2use crate::ux::format::is_json_mode;
3use anyhow::{Context, Result, anyhow};
4use auths_keri::Event;
5use auths_keri::witness::{SignedReceipt, WitnessReceiptLookup};
6use auths_sdk::error::AuthsErrorInfo;
7use auths_sdk::ports::RegistryBackend;
8use auths_sdk::storage::{GitRegistryBackend, GitWitnessReceiptLookup, RegistryConfig};
9use auths_verifier::freshness::{Freshness, FreshnessEvidence, FreshnessPolicy};
10use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig};
11use auths_verifier::{
12    Attestation, BundleTrust, CommitVerdict, IdentityBundle, VerificationReport,
13    VerifierWitnessPolicy, WitnessGateStatus, verify_chain_with_witnesses,
14    verify_commit_against_kel_witnessed_scoped,
15};
16use clap::Parser;
17use serde::Serialize;
18use std::fs;
19use std::path::PathBuf;
20
21use crate::subprocess::git_command;
22
23use super::verify_helpers::parse_witness_keys;
24
25#[derive(Parser, Debug, Clone)]
26#[command(about = "Verify Git commit signatures against Auths identity.")]
27pub struct VerifyCommitCommand {
28    /// Commit SHA, range (e.g., HEAD~5..HEAD), or "HEAD" (default).
29    #[arg(default_value = "HEAD")]
30    pub commit: String,
31
32    /// Path to witness signatures JSON file.
33    #[arg(long = "witness-signatures")]
34    pub witness_receipts: Option<PathBuf>,
35
36    /// Number of witnesses required (default: 1).
37    #[arg(long = "witnesses-required", default_value = "1")]
38    pub witness_threshold: usize,
39
40    /// Witness public keys as DID:hex pairs (e.g., "did:key:z6Mk...:abcd1234...").
41    #[arg(long, num_args = 1..)]
42    pub witness_keys: Vec<String>,
43
44    /// Fail verification when the signer's root KEL has not reached witness
45    /// quorum (fail-closed). Default: warn and continue (trust-on-first-sight).
46    #[arg(long = "require-witnesses")]
47    pub require_witnesses: bool,
48
49    /// Path to an identity bundle JSON whose root `did:keri:` is pinned as a trusted
50    /// root for this verification (CI/stateless commit verification). The bundle is
51    /// freshness-checked; an unreadable or stale bundle fails closed. When absent,
52    /// a committed `.auths/ci-bundle.json` at the repo root is discovered and used
53    /// the same way — that is how a plain clone verifies with no flags.
54    #[arg(long, value_parser)]
55    pub identity_bundle: Option<PathBuf>,
56}
57
58#[derive(Serialize)]
59struct VerifyCommitResult {
60    commit: String,
61    valid: bool,
62    /// Stable machine-readable verdict code (e.g. `valid`, `outside-agent-scope`,
63    /// `device-revoked`). Lets a consumer attribute the outcome to its specific
64    /// cause without parsing the human `error` string.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    status: Option<String>,
67    /// The freshness grade of a positive verdict (ADR 009); a stale bundle is reported here
68    /// and is not trusted under the default policy.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    freshness: Option<Freshness>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    ssh_valid: Option<bool>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    chain_valid: Option<bool>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    chain_report: Option<VerificationReport>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    witness_quorum: Option<WitnessQuorum>,
79    /// Receipt-gated witness quorum status for the signer's root KEL (D.7/D.9):
80    /// `"met"`, or `"N of M (under quorum)"`. Absent when no witnesses are designated.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    witness_gate: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    signer: Option<String>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    oidc_binding: Option<OidcBindingDisplay>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    error: Option<String>,
89    #[serde(skip_serializing_if = "Vec::is_empty")]
90    warnings: Vec<String>,
91}
92
93/// Display representation of OIDC binding information.
94///
95/// Extracted from the attestation when available, shows CI/CD workload context
96/// that signed the commit (issuer, subject, platform, and normalized claims).
97#[derive(Serialize)]
98struct OidcBindingDisplay {
99    /// OIDC token issuer (e.g., "https://token.actions.githubusercontent.com").
100    issuer: String,
101    /// Token subject (unique workload identifier).
102    subject: String,
103    /// Expected audience.
104    audience: String,
105    /// CI/CD platform (e.g., "github", "gitlab", "circleci").
106    #[serde(skip_serializing_if = "Option::is_none")]
107    platform: Option<String>,
108    /// Platform-normalized claims (e.g., repo, actor, run_id for GitHub).
109    #[serde(skip_serializing_if = "Option::is_none")]
110    normalized_claims: Option<serde_json::Map<String, serde_json::Value>>,
111}
112
113impl VerifyCommitResult {
114    fn failure(commit: String, error: String) -> Self {
115        Self {
116            commit,
117            valid: false,
118            status: None,
119            freshness: None,
120            ssh_valid: None,
121            chain_valid: None,
122            chain_report: None,
123            witness_quorum: None,
124            witness_gate: None,
125            signer: None,
126            oidc_binding: None,
127            error: Some(error),
128            warnings: Vec::new(),
129        }
130    }
131}
132
133/// Handle verify-commit command.
134/// Exit codes: 0=valid, 1=invalid/unsigned, 2=error
135#[allow(clippy::disallowed_methods)]
136pub async fn handle_verify_commit(cmd: VerifyCommitCommand, ctx: &CliConfig) -> Result<()> {
137    // KEL-native verification: the trust root is the replayed KEL + the `.auths/roots`
138    // pin, not an allowlist. No `ssh-keygen` subprocess, no `allowed_signers`.
139    // Resolve the SAME storage root `init`/`sign` wrote to (--repo/AUTHS_REPO), so a
140    // freshly-signed commit verifies instead of dead-ending on `~/.auths`.
141    let auths_home = match auths_sdk::paths::resolve_registry_path(ctx.repo_path.clone()) {
142        Ok(h) => h,
143        Err(e) => {
144            return handle_error(
145                &cmd,
146                2,
147                &format!("Could not locate the auths registry: {e}"),
148            );
149        }
150    };
151    let env_config = &ctx.env_config;
152    // Read-only SDK context over the same global registry, for org-policy evaluation
153    // (E1.1). No passphrase — loading a policy never decrypts keys.
154    let sdk_ctx =
155        match crate::factories::storage::build_auths_context(&auths_home, env_config, None) {
156            Ok(c) => c,
157            Err(e) => {
158                return handle_error(
159                    &cmd,
160                    2,
161                    &format!("Could not build context for org-policy evaluation: {e}"),
162                );
163            }
164        };
165    // The registry backend holds every identity's KEL events (in the `refs/auths/registry`
166    // tree) — the source we replay to decide trust.
167    let registry =
168        GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home));
169    // Trust roots = the committed `.auths/roots` pin plus the verifier's own
170    // identity (self-trust — you can always verify what you signed). A
171    // `--identity-bundle` does NOT contribute a root: it supplies KEL *evidence*
172    // for a root that must already be pinned here (RT-005). An unusable or
173    // un-pinned bundle fails closed — trust is never left unconstrained.
174    let mut pinned_roots = super::verify_helpers::load_project_pinned_roots();
175    if let Some(own_root) = auths_sdk::workflows::commit_trust::local_self_root(&sdk_ctx)
176        && !pinned_roots.contains(&own_root)
177    {
178        pinned_roots.push(own_root);
179    }
180    let mut bundle_kels: Vec<BundleKel> = Vec::new();
181    let bundle_path = cmd
182        .identity_bundle
183        .clone()
184        .or_else(super::verify_helpers::discover_project_bundle);
185    if let Some(bundle_path) = &bundle_path {
186        match load_bundle_trust(bundle_path, chrono::Utc::now()) {
187            Ok((root, kel, device_kels)) => {
188                // Evidence-only (RT-005): the bundle is *evidence for* a root that
189                // must be pinned independently (`.auths/roots` or self-trust). It
190                // never becomes its own trust anchor — otherwise the anchor and the
191                // evidence both come from the same attacker-supplied file. (The
192                // self-certified root is additionally re-derived by replay and
193                // checked against the pins, so a coherent-but-unpinned bundle is
194                // still rejected.)
195                if !pinned_roots.contains(&root) {
196                    return handle_error(
197                        &cmd,
198                        2,
199                        &format!(
200                            "identity bundle root {root} is not independently trusted: \
201                             add it to .auths/roots (or verify from the identity that \
202                             controls it). A bundle is evidence for a pinned root, never \
203                             the source of the pin."
204                        ),
205                    );
206                }
207                if !kel.is_empty() {
208                    bundle_kels.push(BundleKel {
209                        did: root,
210                        events: kel,
211                    });
212                }
213                // Device KELs were seal-checked against the root by the parse;
214                // each resolves its own delegated signer DID statelessly.
215                bundle_kels.extend(
216                    device_kels
217                        .into_iter()
218                        .map(|(did, events)| BundleKel { did, events }),
219                );
220            }
221            Err(e) => return handle_error(&cmd, 2, &e),
222        }
223    }
224    let provider = auths_crypto::RingCryptoProvider;
225    // Stored witness receipts live in the identity repo; the gate reads them
226    // through this lookup (D.7). Empty store → under-quorum for witnessed roots.
227    let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home);
228
229    let commits = match resolve_commits(&cmd.commit) {
230        Ok(c) => c,
231        Err(e) => return handle_error(&cmd, 2, &e.to_string()),
232    };
233    let mut results = Vec::with_capacity(commits.len());
234    for commit_ref in &commits {
235        results.push(
236            verify_one_commit(
237                &registry,
238                &pinned_roots,
239                &provider,
240                &receipt_lookup,
241                &sdk_ctx,
242                &cmd,
243                &bundle_kels,
244                commit_ref,
245            )
246            .await,
247        );
248    }
249    output_results(&results)
250}
251
252/// A signer's KEL could not be resolved from the local registry or a bundle
253/// during commit verification. Coded so `auths error show` resolves it and the
254/// message carries the fetch remedy — the common case is verifying a teammate's
255/// commit before their `refs/auths/*` has been fetched.
256#[derive(Debug, thiserror::Error)]
257pub(crate) enum SignerKelError {
258    /// The signer's KEL is not in the local registry and no bundle supplied it.
259    #[error("signer's KEL for {did} is not available locally: {reason}")]
260    Unavailable {
261        /// The `did:keri:` whose KEL could not be resolved.
262        did: String,
263        /// The underlying resolution error, rendered for display.
264        reason: String,
265    },
266}
267
268impl AuthsErrorInfo for SignerKelError {
269    fn error_code(&self) -> &'static str {
270        match self {
271            Self::Unavailable { .. } => "AUTHS-E6301",
272        }
273    }
274
275    fn suggestion(&self) -> Option<&'static str> {
276        match self {
277            Self::Unavailable { .. } => Some(
278                "Fetch the signer's KEL with `git fetch <remote> 'refs/auths/*:refs/auths/*'`, \
279                 or verify against an evidence bundle with `--identity-bundle`.",
280            ),
281        }
282    }
283}
284
285impl SignerKelError {
286    /// Render this error as a single verify-result line carrying its code and remedy.
287    fn into_message(self) -> String {
288        let code = self.error_code();
289        let suggestion = self.suggestion().unwrap_or_default();
290        format!("[{code}] {self}. {suggestion}")
291    }
292}
293
294/// A bundle's authenticated KEL: the identity DID it self-certifies to plus the
295/// KEL events it carries. Built once by the `--identity-bundle` path and threaded
296/// into [`resolve_signer_kel`] so a stateless run (no identity store) can satisfy a
297/// signer lookup from the bundle. Replaces an anonymous `(String, Vec<Event>)`.
298struct BundleKel {
299    /// The bundle's identity DID (`did:keri:…`).
300    did: String,
301    /// The bundle's KEL events, oldest first — already signature-authenticated by
302    /// [`load_bundle_trust`] (RT-002).
303    events: Vec<Event>,
304}
305
306/// Load an identity bundle from `path` and return the trusted root `did:keri:` it pins
307/// plus the KEL events it carries for stateless resolution. The trust checks —
308/// freshness, RT-005 self-certification, RT-002 KEL signature authentication —
309/// live once, in [`BundleTrust::parse`]; this only adds the file I/O and path
310/// context. Fails closed: any read, parse, or trust error is returned so the
311/// caller can abort rather than verify unconstrained.
312fn load_bundle_trust(
313    path: &std::path::Path,
314    now: chrono::DateTime<chrono::Utc>,
315) -> std::result::Result<
316    (
317        String,
318        Vec<Event>,
319        Vec<auths_verifier::AuthenticatedDeviceKel>,
320    ),
321    String,
322> {
323    let content = fs::read_to_string(path)
324        .map_err(|e| format!("could not read identity bundle {path:?}: {e}"))?;
325    let bundle: IdentityBundle = serde_json::from_str(&content)
326        .map_err(|e| format!("identity bundle {path:?} is not valid JSON: {e}"))?;
327    let trust = BundleTrust::parse(&bundle, now)
328        .map_err(|e| format!("identity bundle {path:?} is not a usable trust anchor: {e}"))?;
329    let (root, kel, device_kels) = trust.into_parts();
330    Ok((root, kel, device_kels))
331}
332
333/// Resolve the commit spec to a list of commit SHAs.
334fn resolve_commits(commit_spec: &str) -> Result<Vec<String>> {
335    if commit_spec.contains("..") {
336        // Commit range — use git rev-list
337        let output = git_command(&["rev-list", commit_spec])
338            .output()
339            .context("Failed to run git rev-list")?;
340
341        if !output.status.success() {
342            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
343            let lower = stderr.to_lowercase();
344
345            if lower.contains("unknown revision") || lower.contains("bad revision") {
346                return Err(anyhow!(
347                    "{}",
348                    format_commit_range_hint(commit_spec, stderr.trim())
349                ));
350            }
351
352            return Err(anyhow!("Invalid commit range: {}", stderr.trim()));
353        }
354
355        let commits: Vec<String> = std::str::from_utf8(&output.stdout)
356            .context("Invalid UTF-8 in git output")?
357            .lines()
358            .map(|s| s.to_string())
359            .collect();
360
361        if commits.is_empty() {
362            return Err(anyhow!("No commits in specified range"));
363        }
364        Ok(commits)
365    } else {
366        // Single commit — resolve via rev-parse
367        let sha = resolve_commit_sha(commit_spec)?;
368        Ok(vec![sha])
369    }
370}
371
372/// Build a contextual hint when a commit range fails to resolve.
373fn format_commit_range_hint(commit_spec: &str, raw_stderr: &str) -> String {
374    let hint = if commit_spec.contains('~') || commit_spec.contains('^') {
375        "This repository may not have enough commits for that range. \
376         Try a smaller offset (e.g. HEAD~1..HEAD) or verify with `git log --oneline`."
377    } else if commit_spec.contains("..") {
378        "One or both refs in the range do not exist. \
379         Check branch/tag names with `git branch -a` or `git tag -l`."
380    } else {
381        "The commit reference could not be resolved. \
382         Verify it exists with `git log --oneline`."
383    };
384
385    format!("Failed to resolve commit range '{commit_spec}': {raw_stderr}\n\nHint: {hint}")
386}
387
388/// Load an attestation from git ref `refs/auths/commits/<sha>`.
389///
390/// Attestations are stored as JSON in git refs using the naming convention
391/// `refs/auths/commits/<commit-sha>`. This function reads the ref, parses the JSON,
392/// and returns the attestation if successful.
393///
394/// Returns None if the ref doesn't exist, can't be read, or the JSON is invalid.
395fn try_load_attestation_from_ref(commit_sha: &str) -> Option<Attestation> {
396    let ref_name = format!("refs/auths/commits/{}", commit_sha);
397
398    let stdout = crate::subprocess::git_silent(&["show", &ref_name])?;
399    serde_json::from_str(&stdout).ok()
400}
401
402/// Extract OIDC binding display from an attestation.
403///
404/// Converts the internal `OidcBinding` structure from an attestation into
405/// a display-friendly `OidcBindingDisplay` that includes issuer, subject,
406/// platform, and normalized claims from the CI/CD workload.
407///
408/// Returns None if the attestation has no OIDC binding, which is expected
409/// for non-OIDC attestations or older attestations created before OIDC binding
410/// was added.
411fn extract_oidc_binding_display(attestation: &Attestation) -> Option<OidcBindingDisplay> {
412    attestation
413        .oidc_binding
414        .as_ref()
415        .map(|binding| OidcBindingDisplay {
416            issuer: binding.issuer.clone(),
417            subject: binding.subject.clone(),
418            audience: binding.audience.clone(),
419            platform: binding.platform.clone(),
420            normalized_claims: binding.normalized_claims.clone(),
421        })
422}
423
424/// Resolve a signer's KEL for verification: committed/explicit bundle first,
425/// then the local registry. There is no network transport — a KEL arrives
426/// either in the clone (the bundle) or in the local trusted store, and the
427/// prefix-binding guard is applied to the bundle path here (the SDK chain
428/// applies it for local resolution internally).
429///
430/// Args:
431/// * `registry`: The local registry backend (the trusted floor).
432/// * `bundle_kels`: The authenticated identity-bundle KELs (root + devices), when loaded.
433/// * `did`: The `did:keri:` to resolve.
434async fn resolve_signer_kel(
435    registry: &dyn RegistryBackend,
436    bundle_kels: &[BundleKel],
437    did: &str,
438) -> Result<Vec<Event>, String> {
439    // Stateless first: a bundle that carries the signer's KEL (the root's or a
440    // delegated device's) satisfies resolution without any identity store (CI
441    // runners). Prefix binding is still enforced, so a tampered bundle cannot
442    // smuggle a foreign KEL.
443    if let Some(bundle) = bundle_kels.iter().find(|b| b.did == did) {
444        let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
445        auths_sdk::keri::verify_prefix_binding(&prefix, &bundle.events)
446            .map_err(|e| e.to_string())?;
447        return Ok(bundle.events.clone());
448    }
449    auths_sdk::keri::KelResolverChain::local(registry)
450        .resolve_kel(did)
451        .map_err(|e| e.to_string())
452}
453
454/// Verify a single commit against the replayed KEL.
455///
456/// Reads the in-band `Auths-Id` / `Auths-Device` trailers, replays the device + root
457/// KELs from the local identity repository, and checks the SSH signature in-process
458/// (no `ssh-keygen`, no `allowed_signers`). The KEL verdict is authoritative; witness
459/// receipts (Epic D) remain an orthogonal opt-in check layered on top.
460#[allow(clippy::too_many_arguments)]
461async fn verify_one_commit(
462    registry: &dyn RegistryBackend,
463    pinned_roots: &[String],
464    provider: &dyn auths_crypto::CryptoProvider,
465    receipt_lookup: &dyn WitnessReceiptLookup,
466    sdk_ctx: &auths_sdk::context::AuthsContext,
467    cmd: &VerifyCommitCommand,
468    bundle_kels: &[BundleKel],
469    commit_ref: &str,
470) -> VerifyCommitResult {
471    let sha = match resolve_commit_sha(commit_ref) {
472        Ok(sha) => sha,
473        Err(e) => {
474            return VerifyCommitResult::failure(
475                commit_ref.to_string(),
476                format!("Failed to resolve commit: {e}"),
477            );
478        }
479    };
480
481    let raw_commit = match raw_commit_object(&sha) {
482        Ok(c) => c,
483        Err(e) => return VerifyCommitResult::failure(sha, e.to_string()),
484    };
485
486    let (root_did, device_did) =
487        match auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit) {
488            Some(pair) => pair,
489            None => {
490                return VerifyCommitResult::failure(
491                    sha,
492                    "Commit carries no Auths-Id/Auths-Device trailer. The prepare-commit-msg \
493                     hook installed by `auths init` adds these on every commit — if this repo \
494                     sets its own core.hooksPath (e.g. husky), the hook is bypassed; run \
495                     `auths doctor` to check. Backfill existing commits with `auths sign <ref>` \
496                     (rewrites the commit)."
497                        .to_string(),
498                );
499            }
500        };
501
502    // KEL sourcing is an SDK/adapter concern: the committed bundle or the local
503    // registry — never a network fetch. The prefix-binding guard is applied
504    // regardless of source. The command stays presentation-thin.
505    let device_kel = match resolve_signer_kel(registry, bundle_kels, &device_did).await {
506        Ok(events) => events,
507        Err(e) => {
508            return VerifyCommitResult::failure(
509                sha,
510                SignerKelError::Unavailable {
511                    did: device_did.clone(),
512                    reason: e,
513                }
514                .into_message(),
515            );
516        }
517    };
518    // The ROOT KEL the verifier replays is the *delegator's* KEL — the carrier of
519    // the agent's delegator-anchored scope seal. When the signer is itself a
520    // delegated identity (its KEL opens with a `dip`, e.g. an agent signing its own
521    // commit), the trustworthy root is the delegator named in that `dip`, NOT the
522    // in-band `Auths-Id` trailer (which an agent acting on its own behalf sets to
523    // itself). Deriving the root from the authenticated device KEL — rather than a
524    // self-asserted trailer — is what lets the scoped commit-verify path replay a
525    // real root KEL (so it does not hit the "delegator lookup required" wall) and
526    // read the agent's scope from the delegator's anchored seal. A non-delegated
527    // signer keeps the trailer-named root unchanged.
528    let root_did = match device_kel.first().and_then(|e| e.delegator()) {
529        Some(delegator) => format!("did:keri:{delegator}"),
530        None => root_did,
531    };
532    let root_kel = match resolve_signer_kel(registry, bundle_kels, &root_did).await {
533        Ok(events) => events,
534        Err(e) => {
535            return VerifyCommitResult::failure(
536                sha,
537                SignerKelError::Unavailable {
538                    did: root_did.clone(),
539                    reason: e,
540                }
541                .into_message(),
542            );
543        }
544    };
545
546    let policy = if cmd.require_witnesses {
547        VerifierWitnessPolicy::RequireWitnesses
548    } else {
549        VerifierWitnessPolicy::Warn
550    };
551    // The signing time gates the delegator-anchored scope/expiry checks. KERI
552    // carries no wall-clock; the verifier injects `now` at this boundary (the SDK
553    // and core never call `Utc::now()`). Scope is read from the delegator's KEL —
554    // a delegate exercising a capability outside its anchored scope is rejected
555    // (`OutsideAgentScope`), and it cannot self-widen because the seal lives in the
556    // root's KEL, which only the root's key can advance.
557    #[allow(clippy::disallowed_methods)]
558    let now = chrono::Utc::now().timestamp();
559    let witnessed = verify_commit_against_kel_witnessed_scoped(
560        raw_commit.as_bytes(),
561        &device_kel,
562        &root_kel,
563        pinned_roots,
564        provider,
565        receipt_lookup,
566        policy,
567        now,
568    )
569    .await;
570    // A bundle's timestamp and TTL are producer-set, unsigned fields; an offline verifier holds
571    // no source it can trust to confirm freshness from them. Absent a verifier-supplied fresher
572    // tip, the strongest honest grade is Unknown — the policy decides whether to tolerate it
573    // (ADR 009 D5). A direct verify carries no such signal and likewise stays Unknown.
574    let verdict = if bundle_kels.is_empty() {
575        witnessed.verdict
576    } else {
577        witnessed
578            .verdict
579            .with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline)
580    };
581    let mut result = verdict_to_result(sha.clone(), verdict);
582    match witnessed.witness {
583        WitnessGateStatus::NotRequired => {}
584        WitnessGateStatus::Met => result.witness_gate = Some("met".to_string()),
585        WitnessGateStatus::UnderQuorum {
586            collected,
587            required,
588        } => {
589            result.witness_gate = Some(format!("{collected} of {required} (under quorum)"));
590            result.warnings.push(format!(
591                "Witness quorum not met for the signer's root KEL: {collected} of {required} \
592                 receipts (verifying anyway; pass --require-witnesses to fail closed)."
593            ));
594        }
595    }
596
597    if let Ok(Some(quorum)) = verify_witnesses(cmd, None).await {
598        if quorum.verified < quorum.required {
599            result.valid = false;
600            if result.error.is_none() {
601                result.error = Some(format!(
602                    "Witness quorum not met: {}/{}",
603                    quorum.verified, quorum.required
604                ));
605            }
606        }
607        result.witness_quorum = Some(quorum);
608    }
609
610    result.oidc_binding =
611        try_load_attestation_from_ref(&sha).and_then(|att| extract_oidc_binding_display(&att));
612
613    // E1.1 — org policy is enforced AFTER the cryptographic verdict (fail-closed
614    // ordering). It can only turn a valid result into a denial, never the reverse. A
615    // root that anchored no policy leaves the result unchanged (legacy allow).
616    if result.valid {
617        let now = chrono::Utc::now();
618        match auths_sdk::workflows::commit_trust::evaluate_commit_policy(
619            sdk_ctx,
620            &root_did,
621            &device_did,
622            now,
623        ) {
624            Ok(auths_sdk::workflows::commit_trust::PolicyOutcome::Evaluated(decision))
625                if !decision.is_allowed() =>
626            {
627                result.valid = false;
628                result.chain_valid = Some(false);
629                result.error = Some(format!(
630                    "Org policy denied this commit: {} [{}]",
631                    decision.message, decision.reason
632                ));
633            }
634            Ok(_) => {}
635            Err(e) => {
636                // Fail closed: if policy cannot be evaluated, do not certify the commit.
637                result.valid = false;
638                result.error = Some(format!("Org policy could not be evaluated: {e}"));
639            }
640        }
641    }
642
643    result
644}
645
646/// The raw git commit object (headers + message + `gpgsig`), exactly as produced by
647/// `git cat-file commit <sha>` — the bytes the SSH signature is computed over.
648fn raw_commit_object(sha: &str) -> Result<String> {
649    let output = git_command(&["cat-file", "commit", sha])
650        .output()
651        .context("Failed to run git cat-file")?;
652    if !output.status.success() {
653        return Err(anyhow!(
654            "git cat-file commit {sha} failed: {}",
655            String::from_utf8_lossy(&output.stderr)
656        ));
657    }
658    String::from_utf8(output.stdout).context("Commit object is not valid UTF-8")
659}
660
661/// Map a [`CommitVerdict`] onto a CLI result row: the valid flag, the verified signer,
662/// and a human-readable reason for every failure mode.
663/// Bundle-evidence commit trust for sibling commands — the artifact verifier's
664/// ephemeral commit-anchor leg. Same doctrine as `auths verify <sha>
665/// --identity-bundle`: the repo's pinned roots are the anchor, the bundle is KEL
666/// EVIDENCE only (RT-005), and the verdict tolerates the bundle's offline
667/// freshness grade exactly as the commit command does.
668///
669/// Args:
670/// * `sha`: the full commit SHA the attestation anchors to.
671/// * `bundle_path`: the identity bundle supplied to the artifact verify.
672///
673/// Usage:
674/// ```ignore
675/// commit_trusted_via_bundle(&sha, Path::new(".auths/ci-bundle.json")).await?;
676/// ```
677pub(crate) async fn commit_trusted_via_bundle(
678    sha: &str,
679    bundle_path: &std::path::Path,
680) -> std::result::Result<(), String> {
681    let raw_commit = raw_commit_object(sha).map_err(|e| e.to_string())?;
682    let (trailer_root_did, device_did) =
683        auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit)
684            .ok_or_else(|| "commit carries no Auths-Id/Auths-Device trailers".to_string())?;
685
686    let pinned_roots = super::verify_helpers::load_project_pinned_roots();
687    if pinned_roots.is_empty() {
688        return Err(
689            "no pinned roots (.auths/roots) — a bundle is evidence for a pinned root, \
690             never the source of the pin"
691                .to_string(),
692        );
693    }
694    #[allow(clippy::disallowed_methods)] // presentation boundary: freshness needs the wall clock
695    let now = chrono::Utc::now();
696    let (root, kel, device_kels) = load_bundle_trust(bundle_path, now)?;
697    if !pinned_roots.contains(&root) {
698        return Err(format!(
699            "identity bundle root {root} is not independently trusted: add it to .auths/roots"
700        ));
701    }
702    let mut bundle_kels: Vec<BundleKel> = Vec::new();
703    if !kel.is_empty() {
704        bundle_kels.push(BundleKel {
705            did: root,
706            events: kel,
707        });
708    }
709    bundle_kels.extend(
710        device_kels
711            .into_iter()
712            .map(|(did, events)| BundleKel { did, events }),
713    );
714
715    let auths_home = auths_sdk::paths::auths_home().map_err(|e| e.to_string())?;
716    let registry =
717        GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home));
718    let device_kel = resolve_signer_kel(&registry, &bundle_kels, &device_did)
719        .await
720        .map_err(|e| format!("device KEL for {device_did} could not be resolved: {e}"))?;
721    let root_did = match device_kel.first().and_then(|e| e.delegator()) {
722        Some(delegator) => format!("did:keri:{delegator}"),
723        None => trailer_root_did,
724    };
725    let root_kel = resolve_signer_kel(&registry, &bundle_kels, &root_did)
726        .await
727        .map_err(|e| format!("root KEL for {root_did} could not be resolved: {e}"))?;
728
729    let provider = auths_crypto::RingCryptoProvider;
730    let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home);
731    let witnessed = verify_commit_against_kel_witnessed_scoped(
732        raw_commit.as_bytes(),
733        &device_kel,
734        &root_kel,
735        &pinned_roots,
736        &provider,
737        &receipt_lookup,
738        VerifierWitnessPolicy::Warn,
739        now.timestamp(),
740    )
741    .await;
742    let verdict = witnessed
743        .verdict
744        .with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline);
745    let result = verdict_to_result(sha.to_string(), verdict);
746    if result.valid {
747        Ok(())
748    } else {
749        Err(result.error.unwrap_or_else(|| {
750            "commit did not verify against the bundle-evidenced KELs".to_string()
751        }))
752    }
753}
754
755fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResult {
756    let mut result = VerifyCommitResult::failure(commit, String::new());
757    // The stable machine code travels in the `status` field regardless of the
758    // valid/invalid branch — a consumer can attribute the outcome (e.g.
759    // `outside-agent-scope`) without parsing the human `error` string.
760    result.status = Some(verdict.code().to_string());
761    // Trust requires both authorization and freshness: a verified-but-stale commit (a slice
762    // older than the verifier's policy admits) is reported but not trusted (ADR 009).
763    let trusted = verdict.is_trusted(&FreshnessPolicy::default());
764    let freshness = verdict.freshness();
765    match verdict {
766        CommitVerdict::Valid {
767            signer_did,
768            root_did,
769            duplicitous_root,
770            ..
771        } => {
772            result.valid = trusted;
773            result.freshness = Some(freshness);
774            result.ssh_valid = Some(true);
775            result.signer = Some(signer_did);
776            result.error = if trusted {
777                None
778            } else if duplicitous_root {
779                Some(format!(
780                    "Root {root_did} shows KEL duplicity (a fork) — not trusted. \
781                     Resolve with `auths device remove`."
782                ))
783            } else {
784                Some(format!(
785                    "commit verified but its freshness is {freshness:?}; the supplied slice is \
786                     older than the verifier's trust window"
787                ))
788            };
789        }
790        CommitVerdict::Unsigned => {
791            result.error = Some("No signature found".to_string());
792        }
793        CommitVerdict::GpgUnsupported => {
794            result.error = Some(
795                "GPG signatures are not verified by Auths — run `auths init` to sign with \
796                 did:keri commit trailers instead."
797                    .to_string(),
798            );
799        }
800        CommitVerdict::SshSignatureInvalid => {
801            result.ssh_valid = Some(false);
802            result.error = Some(
803                "SSH signature is invalid (commit tampered, wrong namespace, or bad signature)"
804                    .to_string(),
805            );
806        }
807        CommitVerdict::DeviceKelInvalid(why) => {
808            result.error = Some(format!("Device KEL failed to replay: {why}"));
809        }
810        CommitVerdict::RootKelInvalid(why) => {
811            result.error = Some(format!("Root KEL failed to replay: {why}"));
812        }
813        CommitVerdict::RootNotPinned(root) => {
814            result.error = Some(format!(
815                "Root {root} is not a pinned trusted root. Pin it in .auths/roots to trust \
816                 commits delegated under it."
817            ));
818        }
819        CommitVerdict::RootAbandoned => {
820            result.error =
821                Some("Root identity is abandoned (its KEL was rotated to a null key)".to_string());
822        }
823        CommitVerdict::NotDelegatedByClaimedRoot {
824            device_did,
825            root_did,
826        } => {
827            result.error = Some(format!(
828                "Device {device_did} is not delegated by the claimed root {root_did}"
829            ));
830        }
831        CommitVerdict::DelegationSealNotFound => {
832            result.error = Some(
833                "Root never anchored this device's delegated inception (no delegation seal)"
834                    .to_string(),
835            );
836        }
837        CommitVerdict::DeviceRevoked => {
838            result.error = Some("Device delegation has been revoked by the root".to_string());
839        }
840        CommitVerdict::SignedAfterRevocation {
841            signed_at,
842            revoked_at,
843            ..
844        } => {
845            result.error = Some(format!(
846                "Commit was signed at/after the delegator revoked it (signed at KEL position {signed_at}, revoked at {revoked_at})"
847            ));
848        }
849        CommitVerdict::OutsideAgentScope { capability, .. } => {
850            result.error = Some(format!(
851                "Agent signed exercising capability '{capability}', outside its delegator-anchored scope"
852            ));
853        }
854        CommitVerdict::AgentExpired {
855            expired_at,
856            signed_at,
857            ..
858        } => {
859            result.error = Some(format!(
860                "Agent delegation expired (expired at {expired_at}, signed at {signed_at})"
861            ));
862        }
863        CommitVerdict::SignerKeyMismatch => {
864            result.ssh_valid = Some(false);
865            result.error = Some("Signing key is not the device's current key".to_string());
866        }
867        CommitVerdict::SignedBySupersededKey => {
868            result.ssh_valid = Some(false);
869            result.error = Some(
870                "Commit was signed by a superseded device key (the device has since rotated)"
871                    .to_string(),
872            );
873        }
874        CommitVerdict::WitnessQuorumNotMet {
875            root_did,
876            collected,
877            required,
878        } => {
879            result.error = Some(format!(
880                "Witness quorum not met for root {root_did}: {collected} of {required} required \
881                 receipts. Drop --require-witnesses to verify with a warning instead."
882            ));
883        }
884    }
885    result
886}
887
888/// Verify witness receipts if --witness-receipts was provided.
889async fn verify_witnesses(
890    cmd: &VerifyCommitCommand,
891    bundle: Option<&IdentityBundle>,
892) -> Result<Option<WitnessQuorum>> {
893    let receipts_path = match cmd.witness_receipts {
894        Some(ref p) => p,
895        None => return Ok(None),
896    };
897
898    let receipts_bytes = fs::read(receipts_path)
899        .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
900
901    let receipts: Vec<SignedReceipt> =
902        serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
903
904    let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
905
906    let config = WitnessVerifyConfig {
907        receipts: &receipts,
908        witness_keys: &witness_keys,
909        threshold: cmd.witness_threshold,
910    };
911
912    // If bundle has attestation chain, do combined chain + witness verification
913    if let Some(bundle) = bundle
914        && !bundle.attestation_chain.is_empty()
915    {
916        let root_pk_bytes = hex::decode(bundle.public_key_hex.as_str())
917            .context("Invalid public key hex in bundle")?;
918        let root_pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &root_pk_bytes)
919            .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
920
921        let report = verify_chain_with_witnesses(&bundle.attestation_chain, &root_pk, &config)
922            .await
923            .context("Witness chain verification failed")?;
924
925        return Ok(report.witness_quorum);
926    }
927
928    // Standalone witness receipt verification (no chain)
929    let provider = auths_crypto::RingCryptoProvider;
930    let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await;
931    Ok(Some(quorum))
932}
933
934/// Unified output for all results, with JSON/text formatting and exit codes.
935fn output_results(results: &[VerifyCommitResult]) -> Result<()> {
936    let all_valid = results.iter().all(|r| r.valid);
937
938    if is_json_mode() {
939        if results.len() == 1 {
940            println!("{}", serde_json::to_string(&results[0])?);
941        } else {
942            println!("{}", serde_json::to_string(&results)?);
943        }
944    } else if results.len() == 1 {
945        let r = &results[0];
946        if r.valid {
947            if let Some(ref signer) = r.signer {
948                print!("Commit {} verified: signed by {}", r.commit, signer);
949            } else {
950                print!("Commit {} verified", r.commit);
951            }
952            print_chain_witness_summary(r);
953            println!();
954        } else {
955            eprint!("Verification failed for {}", r.commit);
956            if let Some(ref error) = r.error {
957                eprint!(": {}", error);
958            }
959            print_chain_witness_summary_stderr(r);
960            eprintln!();
961        }
962        for w in &r.warnings {
963            eprintln!("Warning: {}", w);
964        }
965    } else {
966        for r in results {
967            print!(
968                "{}: {}",
969                &r.commit[..8.min(r.commit.len())],
970                format_result_text(r)
971            );
972            println!();
973        }
974    }
975
976    if all_valid {
977        Ok(())
978    } else {
979        std::process::exit(1);
980    }
981}
982
983/// Format a single result as a human-readable line (for range output).
984fn format_result_text(result: &VerifyCommitResult) -> String {
985    let status = if result.valid { "valid" } else { "INVALID" };
986
987    let mut parts = vec![status.to_string()];
988
989    if let Some(ref signer) = result.signer {
990        parts.push(format!("signer: {}", signer));
991    }
992
993    if let Some(cv) = result.chain_valid {
994        let chain_desc = if cv {
995            "chain: valid".to_string()
996        } else if let Some(ref report) = result.chain_report {
997            format!("chain: {}", format_chain_status(&report.status))
998        } else {
999            "chain: invalid".to_string()
1000        };
1001        parts.push(chain_desc);
1002    }
1003
1004    if let Some(ref q) = result.witness_quorum {
1005        parts.push(format!("witnesses: {}/{}", q.verified, q.required));
1006    }
1007
1008    if let Some(ref gate) = result.witness_gate {
1009        parts.push(format!("witness-gate: {gate}"));
1010    }
1011
1012    if let Some(ref binding) = result.oidc_binding {
1013        parts.push(format!("oidc: {}", binding.issuer));
1014    }
1015
1016    if let Some(ref error) = result.error
1017        && result.signer.is_none()
1018        && result.chain_valid.is_none()
1019        && result.witness_quorum.is_none()
1020    {
1021        parts.push(error.clone());
1022    }
1023
1024    if parts.len() == 1 {
1025        parts[0].clone()
1026    } else {
1027        format!("{} ({})", parts[0], parts[1..].join(", "))
1028    }
1029}
1030
1031/// Format a VerificationStatus for display.
1032fn format_chain_status(status: &auths_verifier::VerificationStatus) -> String {
1033    match status {
1034        auths_verifier::VerificationStatus::Valid => "valid".to_string(),
1035        auths_verifier::VerificationStatus::Expired { at } => {
1036            format!("expired at {}", at.to_rfc3339())
1037        }
1038        auths_verifier::VerificationStatus::Revoked { at } => match at {
1039            Some(t) => format!("revoked at {}", t.to_rfc3339()),
1040            None => "revoked".to_string(),
1041        },
1042        auths_verifier::VerificationStatus::InvalidSignature { step } => {
1043            format!("invalid signature at step {}", step)
1044        }
1045        auths_verifier::VerificationStatus::BrokenChain { missing_link } => {
1046            format!("broken chain: {}", missing_link)
1047        }
1048        auths_verifier::VerificationStatus::InsufficientWitnesses { required, verified } => {
1049            format!("witnesses: {}/{} quorum not met", verified, required)
1050        }
1051    }
1052}
1053
1054/// Print chain/witness summary to stdout (for valid single-commit output).
1055fn print_chain_witness_summary(r: &VerifyCommitResult) {
1056    let mut parts = Vec::new();
1057
1058    if let Some(cv) = r.chain_valid {
1059        if cv {
1060            parts.push("chain: valid".to_string());
1061        } else {
1062            parts.push("chain: invalid".to_string());
1063        }
1064    }
1065
1066    if let Some(ref q) = r.witness_quorum {
1067        parts.push(format!("witnesses: {}/{}", q.verified, q.required));
1068    }
1069
1070    if let Some(ref gate) = r.witness_gate {
1071        parts.push(format!("witness-gate: {gate}"));
1072    }
1073
1074    if let Some(ref binding) = r.oidc_binding {
1075        parts.push(format!("oidc: {} ({})", binding.issuer, binding.subject));
1076    }
1077
1078    if !parts.is_empty() {
1079        print!(" ({})", parts.join(", "));
1080    }
1081}
1082
1083/// Print chain/witness summary to stderr (for invalid single-commit output).
1084fn print_chain_witness_summary_stderr(r: &VerifyCommitResult) {
1085    if let Some(cv) = r.chain_valid
1086        && !cv
1087        && let Some(ref report) = r.chain_report
1088    {
1089        eprint!(" (chain: {})", format_chain_status(&report.status));
1090    }
1091    if let Some(ref q) = r.witness_quorum
1092        && q.verified < q.required
1093    {
1094        eprint!(" (witnesses: {}/{} quorum not met)", q.verified, q.required);
1095    }
1096}
1097
1098fn resolve_commit_sha(commit_ref: &str) -> Result<String> {
1099    super::git_helpers::resolve_commit_sha(commit_ref)
1100}
1101
1102fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> {
1103    if is_json_mode() {
1104        let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string());
1105        println!("{}", serde_json::to_string(&result)?);
1106    } else {
1107        eprintln!("Error: {}", message);
1108    }
1109    std::process::exit(exit_code);
1110}
1111
1112impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand {
1113    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
1114        let rt = tokio::runtime::Runtime::new()?;
1115        rt.block_on(handle_verify_commit(self.clone(), ctx))
1116    }
1117}
1118
1119#[cfg(test)]
1120#[allow(clippy::disallowed_methods)]
1121mod tests {
1122    use super::*;
1123
1124    #[test]
1125    fn verify_commit_result_failure_helper() {
1126        let r = VerifyCommitResult::failure("abc123".into(), "bad sig".into());
1127        assert!(!r.valid);
1128        assert_eq!(r.commit, "abc123");
1129        assert_eq!(r.error.as_deref(), Some("bad sig"));
1130        assert!(r.ssh_valid.is_none());
1131        assert!(r.chain_valid.is_none());
1132        assert!(r.witness_quorum.is_none());
1133    }
1134
1135    #[test]
1136    fn verify_commit_result_json_includes_new_fields() {
1137        let r = VerifyCommitResult {
1138            commit: "abc123".into(),
1139            valid: true,
1140            status: Some("valid".into()),
1141            freshness: None,
1142            ssh_valid: Some(true),
1143            chain_valid: Some(true),
1144            chain_report: None,
1145            witness_quorum: Some(WitnessQuorum {
1146                required: 2,
1147                verified: 2,
1148                receipts: vec![],
1149            }),
1150            witness_gate: Some("met".into()),
1151            signer: Some("did:keri:test".into()),
1152            oidc_binding: None,
1153            error: None,
1154            warnings: vec!["expiring soon".into()],
1155        };
1156        let json = serde_json::to_string(&r).unwrap();
1157        assert!(json.contains("\"ssh_valid\":true"));
1158        assert!(json.contains("\"chain_valid\":true"));
1159        assert!(json.contains("\"witness_quorum\""));
1160        assert!(json.contains("\"warnings\":[\"expiring soon\"]"));
1161    }
1162
1163    #[test]
1164    fn verify_commit_result_json_omits_none_fields() {
1165        let r = VerifyCommitResult::failure("abc".into(), "err".into());
1166        let json = serde_json::to_string(&r).unwrap();
1167        assert!(!json.contains("ssh_valid"));
1168        assert!(!json.contains("chain_valid"));
1169        assert!(!json.contains("chain_report"));
1170        assert!(!json.contains("witness_quorum"));
1171        assert!(!json.contains("warnings"));
1172    }
1173
1174    #[test]
1175    fn format_result_text_valid_ssh_only() {
1176        let r = VerifyCommitResult {
1177            commit: "abc12345".into(),
1178            valid: true,
1179            status: Some("valid".into()),
1180            freshness: None,
1181            ssh_valid: Some(true),
1182            chain_valid: None,
1183            chain_report: None,
1184            witness_quorum: None,
1185            witness_gate: None,
1186            signer: Some("did:keri:test".into()),
1187            oidc_binding: None,
1188            error: None,
1189            warnings: vec![],
1190        };
1191        let text = format_result_text(&r);
1192        assert!(text.contains("valid"));
1193        assert!(text.contains("signer: did:keri:test"));
1194    }
1195
1196    #[test]
1197    fn format_result_text_valid_with_chain_and_witnesses() {
1198        let r = VerifyCommitResult {
1199            commit: "abc12345".into(),
1200            valid: true,
1201            status: Some("valid".into()),
1202            freshness: None,
1203            ssh_valid: Some(true),
1204            chain_valid: Some(true),
1205            chain_report: Some(VerificationReport::valid(vec![])),
1206            witness_quorum: Some(WitnessQuorum {
1207                required: 2,
1208                verified: 2,
1209                receipts: vec![],
1210            }),
1211            witness_gate: Some("met".into()),
1212            signer: Some("did:keri:test".into()),
1213            oidc_binding: None,
1214            error: None,
1215            warnings: vec![],
1216        };
1217        let text = format_result_text(&r);
1218        assert!(text.contains("chain: valid"));
1219        assert!(text.contains("witnesses: 2/2"));
1220        assert!(text.contains("witness-gate: met"));
1221    }
1222
1223    #[test]
1224    fn verify_output_shows_quorum() {
1225        let mut r = VerifyCommitResult::failure("abc".into(), String::new());
1226        r.valid = true;
1227        r.error = None;
1228        r.witness_gate = Some("2 of 3 (under quorum)".into());
1229
1230        let text = format_result_text(&r);
1231        assert!(text.contains("witness-gate: 2 of 3 (under quorum)"));
1232        let json = serde_json::to_string(&r).unwrap();
1233        assert!(json.contains("\"witness_gate\":\"2 of 3 (under quorum)\""));
1234    }
1235
1236    #[test]
1237    fn verify_output_fails_closed_on_fork() {
1238        // A Valid verdict on a duplicitous root must FAIL CLOSED (not trusted) and explain why —
1239        // the relying party cannot tell which branch is real.
1240        let result = verdict_to_result(
1241            "sha".into(),
1242            CommitVerdict::Valid {
1243                signer_did: "did:keri:dev".into(),
1244                root_did: "did:keri:root".into(),
1245                duplicitous_root: true,
1246                as_of: 0,
1247                freshness: auths_verifier::freshness::Freshness::Unknown,
1248            },
1249        );
1250        assert!(!result.valid, "a duplicitous root must fail closed");
1251        assert!(
1252            result
1253                .error
1254                .as_deref()
1255                .unwrap_or_default()
1256                .to_lowercase()
1257                .contains("duplicity"),
1258            "expected a duplicity error, got {:?}",
1259            result.error
1260        );
1261    }
1262
1263    #[test]
1264    fn format_result_text_invalid_with_error() {
1265        let r = VerifyCommitResult::failure("abc12345".into(), "No signature found".into());
1266        let text = format_result_text(&r);
1267        assert!(text.contains("INVALID"));
1268        assert!(text.contains("No signature found"));
1269    }
1270
1271    #[test]
1272    fn absent_signer_kel_message_carries_code_and_fetch_remedy() {
1273        // A teammate's commit whose KEL is not local must name both the code (so it
1274        // is lookupable) and the `git fetch refs/auths/*` remedy that resolves it.
1275        let msg = SignerKelError::Unavailable {
1276            did: "did:keri:Eteammate".into(),
1277            reason: "KEL not found".into(),
1278        }
1279        .into_message();
1280        assert!(
1281            msg.contains("AUTHS-E6301"),
1282            "message must carry the code: {msg}"
1283        );
1284        assert!(
1285            msg.contains("git fetch"),
1286            "message must name the fetch remedy: {msg}"
1287        );
1288        assert!(msg.contains("refs/auths/*"));
1289    }
1290}