Skip to main content

auths_cli/commands/
verify_commit.rs

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