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.
606/// Bundle-evidence commit trust for sibling commands — the artifact verifier's
607/// ephemeral commit-anchor leg. Same doctrine as `auths verify <sha>
608/// --identity-bundle`: the repo's pinned roots are the anchor, the bundle is KEL
609/// EVIDENCE only (RT-005), and the verdict tolerates the bundle's offline
610/// freshness grade exactly as the commit command does.
611///
612/// Args:
613/// * `sha`: the full commit SHA the attestation anchors to.
614/// * `bundle_path`: the identity bundle supplied to the artifact verify.
615///
616/// Usage:
617/// ```ignore
618/// commit_trusted_via_bundle(&sha, Path::new(".auths/ci-bundle.json")).await?;
619/// ```
620pub(crate) async fn commit_trusted_via_bundle(
621    sha: &str,
622    bundle_path: &std::path::Path,
623) -> std::result::Result<(), String> {
624    let raw_commit = raw_commit_object(sha).map_err(|e| e.to_string())?;
625    let (trailer_root_did, device_did) =
626        auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit)
627            .ok_or_else(|| "commit carries no Auths-Id/Auths-Device trailers".to_string())?;
628
629    let pinned_roots = super::verify_helpers::load_project_pinned_roots();
630    if pinned_roots.is_empty() {
631        return Err(
632            "no pinned roots (.auths/roots) — a bundle is evidence for a pinned root, \
633             never the source of the pin"
634                .to_string(),
635        );
636    }
637    #[allow(clippy::disallowed_methods)] // presentation boundary: freshness needs the wall clock
638    let now = chrono::Utc::now();
639    let (root, kel, device_kels) = load_bundle_trust(bundle_path, now)?;
640    if !pinned_roots.contains(&root) {
641        return Err(format!(
642            "identity bundle root {root} is not independently trusted: add it to .auths/roots"
643        ));
644    }
645    let mut bundle_kels: Vec<BundleKel> = Vec::new();
646    if !kel.is_empty() {
647        bundle_kels.push(BundleKel {
648            did: root,
649            events: kel,
650        });
651    }
652    bundle_kels.extend(
653        device_kels
654            .into_iter()
655            .map(|(did, events)| BundleKel { did, events }),
656    );
657
658    let auths_home = auths_sdk::paths::auths_home().map_err(|e| e.to_string())?;
659    let registry =
660        GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home));
661    let device_kel = resolve_signer_kel(&registry, &bundle_kels, &device_did)
662        .await
663        .map_err(|e| format!("device KEL for {device_did} could not be resolved: {e}"))?;
664    let root_did = match device_kel.first().and_then(|e| e.delegator()) {
665        Some(delegator) => format!("did:keri:{delegator}"),
666        None => trailer_root_did,
667    };
668    let root_kel = resolve_signer_kel(&registry, &bundle_kels, &root_did)
669        .await
670        .map_err(|e| format!("root KEL for {root_did} could not be resolved: {e}"))?;
671
672    let provider = auths_crypto::RingCryptoProvider;
673    let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home);
674    let witnessed = verify_commit_against_kel_witnessed_scoped(
675        raw_commit.as_bytes(),
676        &device_kel,
677        &root_kel,
678        &pinned_roots,
679        &provider,
680        &receipt_lookup,
681        VerifierWitnessPolicy::Warn,
682        now.timestamp(),
683    )
684    .await;
685    let verdict = witnessed
686        .verdict
687        .with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline);
688    let result = verdict_to_result(sha.to_string(), verdict);
689    if result.valid {
690        Ok(())
691    } else {
692        Err(result.error.unwrap_or_else(|| {
693            "commit did not verify against the bundle-evidenced KELs".to_string()
694        }))
695    }
696}
697
698fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResult {
699    let mut result = VerifyCommitResult::failure(commit, String::new());
700    // The stable machine code travels in the `status` field regardless of the
701    // valid/invalid branch — a consumer can attribute the outcome (e.g.
702    // `outside-agent-scope`) without parsing the human `error` string.
703    result.status = Some(verdict.code().to_string());
704    // Trust requires both authorization and freshness: a verified-but-stale commit (a slice
705    // older than the verifier's policy admits) is reported but not trusted (ADR 009).
706    let trusted = verdict.is_trusted(&FreshnessPolicy::default());
707    let freshness = verdict.freshness();
708    match verdict {
709        CommitVerdict::Valid {
710            signer_did,
711            root_did,
712            duplicitous_root,
713            ..
714        } => {
715            result.valid = trusted;
716            result.freshness = Some(freshness);
717            result.ssh_valid = Some(true);
718            result.signer = Some(signer_did);
719            result.error = if trusted {
720                None
721            } else if duplicitous_root {
722                Some(format!(
723                    "Root {root_did} shows KEL duplicity (a fork) — not trusted. \
724                     Resolve with `auths device remove`."
725                ))
726            } else {
727                Some(format!(
728                    "commit verified but its freshness is {freshness:?}; the supplied slice is \
729                     older than the verifier's trust window"
730                ))
731            };
732        }
733        CommitVerdict::Unsigned => {
734            result.error = Some("No signature found".to_string());
735        }
736        CommitVerdict::GpgUnsupported => {
737            result.error = Some("GPG signatures not supported, use SSH signing".to_string());
738        }
739        CommitVerdict::SshSignatureInvalid => {
740            result.ssh_valid = Some(false);
741            result.error = Some(
742                "SSH signature is invalid (commit tampered, wrong namespace, or bad signature)"
743                    .to_string(),
744            );
745        }
746        CommitVerdict::DeviceKelInvalid(why) => {
747            result.error = Some(format!("Device KEL failed to replay: {why}"));
748        }
749        CommitVerdict::RootKelInvalid(why) => {
750            result.error = Some(format!("Root KEL failed to replay: {why}"));
751        }
752        CommitVerdict::RootNotPinned(root) => {
753            result.error = Some(format!(
754                "Root {root} is not a pinned trusted root. Pin it in .auths/roots to trust \
755                 commits delegated under it."
756            ));
757        }
758        CommitVerdict::RootAbandoned => {
759            result.error =
760                Some("Root identity is abandoned (its KEL was rotated to a null key)".to_string());
761        }
762        CommitVerdict::NotDelegatedByClaimedRoot {
763            device_did,
764            root_did,
765        } => {
766            result.error = Some(format!(
767                "Device {device_did} is not delegated by the claimed root {root_did}"
768            ));
769        }
770        CommitVerdict::DelegationSealNotFound => {
771            result.error = Some(
772                "Root never anchored this device's delegated inception (no delegation seal)"
773                    .to_string(),
774            );
775        }
776        CommitVerdict::DeviceRevoked => {
777            result.error = Some("Device delegation has been revoked by the root".to_string());
778        }
779        CommitVerdict::SignedAfterRevocation {
780            signed_at,
781            revoked_at,
782            ..
783        } => {
784            result.error = Some(format!(
785                "Commit was signed at/after the delegator revoked it (signed at KEL position {signed_at}, revoked at {revoked_at})"
786            ));
787        }
788        CommitVerdict::OutsideAgentScope { capability, .. } => {
789            result.error = Some(format!(
790                "Agent signed exercising capability '{capability}', outside its delegator-anchored scope"
791            ));
792        }
793        CommitVerdict::AgentExpired {
794            expired_at,
795            signed_at,
796            ..
797        } => {
798            result.error = Some(format!(
799                "Agent delegation expired (expired at {expired_at}, signed at {signed_at})"
800            ));
801        }
802        CommitVerdict::SignerKeyMismatch => {
803            result.ssh_valid = Some(false);
804            result.error = Some("Signing key is not the device's current key".to_string());
805        }
806        CommitVerdict::SignedBySupersededKey => {
807            result.ssh_valid = Some(false);
808            result.error = Some(
809                "Commit was signed by a superseded device key (the device has since rotated)"
810                    .to_string(),
811            );
812        }
813        CommitVerdict::WitnessQuorumNotMet {
814            root_did,
815            collected,
816            required,
817        } => {
818            result.error = Some(format!(
819                "Witness quorum not met for root {root_did}: {collected} of {required} required \
820                 receipts. Drop --require-witnesses to verify with a warning instead."
821            ));
822        }
823    }
824    result
825}
826
827/// Verify witness receipts if --witness-receipts was provided.
828async fn verify_witnesses(
829    cmd: &VerifyCommitCommand,
830    bundle: Option<&IdentityBundle>,
831) -> Result<Option<WitnessQuorum>> {
832    let receipts_path = match cmd.witness_receipts {
833        Some(ref p) => p,
834        None => return Ok(None),
835    };
836
837    let receipts_bytes = fs::read(receipts_path)
838        .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
839
840    let receipts: Vec<SignedReceipt> =
841        serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
842
843    let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
844
845    let config = WitnessVerifyConfig {
846        receipts: &receipts,
847        witness_keys: &witness_keys,
848        threshold: cmd.witness_threshold,
849    };
850
851    // If bundle has attestation chain, do combined chain + witness verification
852    if let Some(bundle) = bundle
853        && !bundle.attestation_chain.is_empty()
854    {
855        let root_pk_bytes = hex::decode(bundle.public_key_hex.as_str())
856            .context("Invalid public key hex in bundle")?;
857        let root_pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &root_pk_bytes)
858            .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
859
860        let report = verify_chain_with_witnesses(&bundle.attestation_chain, &root_pk, &config)
861            .await
862            .context("Witness chain verification failed")?;
863
864        return Ok(report.witness_quorum);
865    }
866
867    // Standalone witness receipt verification (no chain)
868    let provider = auths_crypto::RingCryptoProvider;
869    let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await;
870    Ok(Some(quorum))
871}
872
873/// Unified output for all results, with JSON/text formatting and exit codes.
874fn output_results(results: &[VerifyCommitResult]) -> Result<()> {
875    let all_valid = results.iter().all(|r| r.valid);
876
877    if is_json_mode() {
878        if results.len() == 1 {
879            println!("{}", serde_json::to_string(&results[0])?);
880        } else {
881            println!("{}", serde_json::to_string(&results)?);
882        }
883    } else if results.len() == 1 {
884        let r = &results[0];
885        if r.valid {
886            if let Some(ref signer) = r.signer {
887                print!("Commit {} verified: signed by {}", r.commit, signer);
888            } else {
889                print!("Commit {} verified", r.commit);
890            }
891            print_chain_witness_summary(r);
892            println!();
893        } else {
894            eprint!("Verification failed for {}", r.commit);
895            if let Some(ref error) = r.error {
896                eprint!(": {}", error);
897            }
898            print_chain_witness_summary_stderr(r);
899            eprintln!();
900        }
901        for w in &r.warnings {
902            eprintln!("Warning: {}", w);
903        }
904    } else {
905        for r in results {
906            print!(
907                "{}: {}",
908                &r.commit[..8.min(r.commit.len())],
909                format_result_text(r)
910            );
911            println!();
912        }
913    }
914
915    if all_valid {
916        Ok(())
917    } else {
918        std::process::exit(1);
919    }
920}
921
922/// Format a single result as a human-readable line (for range output).
923fn format_result_text(result: &VerifyCommitResult) -> String {
924    let status = if result.valid { "valid" } else { "INVALID" };
925
926    let mut parts = vec![status.to_string()];
927
928    if let Some(ref signer) = result.signer {
929        parts.push(format!("signer: {}", signer));
930    }
931
932    if let Some(cv) = result.chain_valid {
933        let chain_desc = if cv {
934            "chain: valid".to_string()
935        } else if let Some(ref report) = result.chain_report {
936            format!("chain: {}", format_chain_status(&report.status))
937        } else {
938            "chain: invalid".to_string()
939        };
940        parts.push(chain_desc);
941    }
942
943    if let Some(ref q) = result.witness_quorum {
944        parts.push(format!("witnesses: {}/{}", q.verified, q.required));
945    }
946
947    if let Some(ref gate) = result.witness_gate {
948        parts.push(format!("witness-gate: {gate}"));
949    }
950
951    if let Some(ref binding) = result.oidc_binding {
952        parts.push(format!("oidc: {}", binding.issuer));
953    }
954
955    if let Some(ref error) = result.error
956        && result.signer.is_none()
957        && result.chain_valid.is_none()
958        && result.witness_quorum.is_none()
959    {
960        parts.push(error.clone());
961    }
962
963    if parts.len() == 1 {
964        parts[0].clone()
965    } else {
966        format!("{} ({})", parts[0], parts[1..].join(", "))
967    }
968}
969
970/// Format a VerificationStatus for display.
971fn format_chain_status(status: &auths_verifier::VerificationStatus) -> String {
972    match status {
973        auths_verifier::VerificationStatus::Valid => "valid".to_string(),
974        auths_verifier::VerificationStatus::Expired { at } => {
975            format!("expired at {}", at.to_rfc3339())
976        }
977        auths_verifier::VerificationStatus::Revoked { at } => match at {
978            Some(t) => format!("revoked at {}", t.to_rfc3339()),
979            None => "revoked".to_string(),
980        },
981        auths_verifier::VerificationStatus::InvalidSignature { step } => {
982            format!("invalid signature at step {}", step)
983        }
984        auths_verifier::VerificationStatus::BrokenChain { missing_link } => {
985            format!("broken chain: {}", missing_link)
986        }
987        auths_verifier::VerificationStatus::InsufficientWitnesses { required, verified } => {
988            format!("witnesses: {}/{} quorum not met", verified, required)
989        }
990    }
991}
992
993/// Print chain/witness summary to stdout (for valid single-commit output).
994fn print_chain_witness_summary(r: &VerifyCommitResult) {
995    let mut parts = Vec::new();
996
997    if let Some(cv) = r.chain_valid {
998        if cv {
999            parts.push("chain: valid".to_string());
1000        } else {
1001            parts.push("chain: invalid".to_string());
1002        }
1003    }
1004
1005    if let Some(ref q) = r.witness_quorum {
1006        parts.push(format!("witnesses: {}/{}", q.verified, q.required));
1007    }
1008
1009    if let Some(ref gate) = r.witness_gate {
1010        parts.push(format!("witness-gate: {gate}"));
1011    }
1012
1013    if let Some(ref binding) = r.oidc_binding {
1014        parts.push(format!("oidc: {} ({})", binding.issuer, binding.subject));
1015    }
1016
1017    if !parts.is_empty() {
1018        print!(" ({})", parts.join(", "));
1019    }
1020}
1021
1022/// Print chain/witness summary to stderr (for invalid single-commit output).
1023fn print_chain_witness_summary_stderr(r: &VerifyCommitResult) {
1024    if let Some(cv) = r.chain_valid
1025        && !cv
1026        && let Some(ref report) = r.chain_report
1027    {
1028        eprint!(" (chain: {})", format_chain_status(&report.status));
1029    }
1030    if let Some(ref q) = r.witness_quorum
1031        && q.verified < q.required
1032    {
1033        eprint!(" (witnesses: {}/{} quorum not met)", q.verified, q.required);
1034    }
1035}
1036
1037fn resolve_commit_sha(commit_ref: &str) -> Result<String> {
1038    super::git_helpers::resolve_commit_sha(commit_ref)
1039}
1040
1041fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> {
1042    if is_json_mode() {
1043        let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string());
1044        println!("{}", serde_json::to_string(&result)?);
1045    } else {
1046        eprintln!("Error: {}", message);
1047    }
1048    std::process::exit(exit_code);
1049}
1050
1051impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand {
1052    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
1053        let rt = tokio::runtime::Runtime::new()?;
1054        rt.block_on(handle_verify_commit(self.clone(), &ctx.env_config))
1055    }
1056}
1057
1058#[cfg(test)]
1059#[allow(clippy::disallowed_methods)]
1060mod tests {
1061    use super::*;
1062
1063    #[test]
1064    fn verify_commit_result_failure_helper() {
1065        let r = VerifyCommitResult::failure("abc123".into(), "bad sig".into());
1066        assert!(!r.valid);
1067        assert_eq!(r.commit, "abc123");
1068        assert_eq!(r.error.as_deref(), Some("bad sig"));
1069        assert!(r.ssh_valid.is_none());
1070        assert!(r.chain_valid.is_none());
1071        assert!(r.witness_quorum.is_none());
1072    }
1073
1074    #[test]
1075    fn verify_commit_result_json_includes_new_fields() {
1076        let r = VerifyCommitResult {
1077            commit: "abc123".into(),
1078            valid: true,
1079            status: Some("valid".into()),
1080            freshness: None,
1081            ssh_valid: Some(true),
1082            chain_valid: Some(true),
1083            chain_report: None,
1084            witness_quorum: Some(WitnessQuorum {
1085                required: 2,
1086                verified: 2,
1087                receipts: vec![],
1088            }),
1089            witness_gate: Some("met".into()),
1090            signer: Some("did:keri:test".into()),
1091            oidc_binding: None,
1092            error: None,
1093            warnings: vec!["expiring soon".into()],
1094        };
1095        let json = serde_json::to_string(&r).unwrap();
1096        assert!(json.contains("\"ssh_valid\":true"));
1097        assert!(json.contains("\"chain_valid\":true"));
1098        assert!(json.contains("\"witness_quorum\""));
1099        assert!(json.contains("\"warnings\":[\"expiring soon\"]"));
1100    }
1101
1102    #[test]
1103    fn verify_commit_result_json_omits_none_fields() {
1104        let r = VerifyCommitResult::failure("abc".into(), "err".into());
1105        let json = serde_json::to_string(&r).unwrap();
1106        assert!(!json.contains("ssh_valid"));
1107        assert!(!json.contains("chain_valid"));
1108        assert!(!json.contains("chain_report"));
1109        assert!(!json.contains("witness_quorum"));
1110        assert!(!json.contains("warnings"));
1111    }
1112
1113    #[test]
1114    fn format_result_text_valid_ssh_only() {
1115        let r = VerifyCommitResult {
1116            commit: "abc12345".into(),
1117            valid: true,
1118            status: Some("valid".into()),
1119            freshness: None,
1120            ssh_valid: Some(true),
1121            chain_valid: None,
1122            chain_report: None,
1123            witness_quorum: None,
1124            witness_gate: None,
1125            signer: Some("did:keri:test".into()),
1126            oidc_binding: None,
1127            error: None,
1128            warnings: vec![],
1129        };
1130        let text = format_result_text(&r);
1131        assert!(text.contains("valid"));
1132        assert!(text.contains("signer: did:keri:test"));
1133    }
1134
1135    #[test]
1136    fn format_result_text_valid_with_chain_and_witnesses() {
1137        let r = VerifyCommitResult {
1138            commit: "abc12345".into(),
1139            valid: true,
1140            status: Some("valid".into()),
1141            freshness: None,
1142            ssh_valid: Some(true),
1143            chain_valid: Some(true),
1144            chain_report: Some(VerificationReport::valid(vec![])),
1145            witness_quorum: Some(WitnessQuorum {
1146                required: 2,
1147                verified: 2,
1148                receipts: vec![],
1149            }),
1150            witness_gate: Some("met".into()),
1151            signer: Some("did:keri:test".into()),
1152            oidc_binding: None,
1153            error: None,
1154            warnings: vec![],
1155        };
1156        let text = format_result_text(&r);
1157        assert!(text.contains("chain: valid"));
1158        assert!(text.contains("witnesses: 2/2"));
1159        assert!(text.contains("witness-gate: met"));
1160    }
1161
1162    #[test]
1163    fn verify_output_shows_quorum() {
1164        let mut r = VerifyCommitResult::failure("abc".into(), String::new());
1165        r.valid = true;
1166        r.error = None;
1167        r.witness_gate = Some("2 of 3 (under quorum)".into());
1168
1169        let text = format_result_text(&r);
1170        assert!(text.contains("witness-gate: 2 of 3 (under quorum)"));
1171        let json = serde_json::to_string(&r).unwrap();
1172        assert!(json.contains("\"witness_gate\":\"2 of 3 (under quorum)\""));
1173    }
1174
1175    #[test]
1176    fn verify_output_fails_closed_on_fork() {
1177        // A Valid verdict on a duplicitous root must FAIL CLOSED (not trusted) and explain why —
1178        // the relying party cannot tell which branch is real.
1179        let result = verdict_to_result(
1180            "sha".into(),
1181            CommitVerdict::Valid {
1182                signer_did: "did:keri:dev".into(),
1183                root_did: "did:keri:root".into(),
1184                duplicitous_root: true,
1185                as_of: 0,
1186                freshness: auths_verifier::freshness::Freshness::Unknown,
1187            },
1188        );
1189        assert!(!result.valid, "a duplicitous root must fail closed");
1190        assert!(
1191            result
1192                .error
1193                .as_deref()
1194                .unwrap_or_default()
1195                .to_lowercase()
1196                .contains("duplicity"),
1197            "expected a duplicity error, got {:?}",
1198            result.error
1199        );
1200    }
1201
1202    #[test]
1203    fn format_result_text_invalid_with_error() {
1204        let r = VerifyCommitResult::failure("abc12345".into(), "No signature found".into());
1205        let text = format_result_text(&r);
1206        assert!(text.contains("INVALID"));
1207        assert!(text.contains("No signature found"));
1208    }
1209}