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