Skip to main content

auths_cli/commands/
verify_commit.rs

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