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