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 #[arg(default_value = "HEAD")]
29 pub commit: String,
30
31 #[arg(long = "witness-signatures")]
33 pub witness_receipts: Option<PathBuf>,
34
35 #[arg(long = "witnesses-required", default_value = "1")]
37 pub witness_threshold: usize,
38
39 #[arg(long, num_args = 1..)]
41 pub witness_keys: Vec<String>,
42
43 #[arg(long = "require-witnesses")]
46 pub require_witnesses: bool,
47
48 #[arg(long, value_parser)]
54 pub identity_bundle: Option<PathBuf>,
55}
56
57#[derive(Serialize)]
58struct VerifyCommitResult {
59 commit: String,
60 valid: bool,
61 #[serde(skip_serializing_if = "Option::is_none")]
65 status: Option<String>,
66 #[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 #[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#[derive(Serialize)]
97struct OidcBindingDisplay {
98 issuer: String,
100 subject: String,
102 audience: String,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 platform: Option<String>,
107 #[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#[allow(clippy::disallowed_methods)]
135pub async fn handle_verify_commit(
136 cmd: VerifyCommitCommand,
137 env_config: &EnvironmentConfig,
138) -> Result<()> {
139 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 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 let registry =
161 GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home));
162 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 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 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 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 ®istry,
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
245struct BundleKel {
250 did: String,
252 events: Vec<Event>,
255}
256
257fn 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
284fn resolve_commits(commit_spec: &str) -> Result<Vec<String>> {
286 if commit_spec.contains("..") {
287 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 let sha = resolve_commit_sha(commit_spec)?;
319 Ok(vec![sha])
320 }
321}
322
323fn 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
339fn 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
353fn 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
375async fn resolve_signer_kel(
386 registry: &dyn RegistryBackend,
387 bundle_kels: &[BundleKel],
388 did: &str,
389) -> Result<Vec<Event>, String> {
390 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#[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 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 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 #[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 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 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 result.valid = false;
581 result.error = Some(format!("Org policy could not be evaluated: {e}"));
582 }
583 }
584 }
585
586 result
587}
588
589fn 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
604fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResult {
607 let mut result = VerifyCommitResult::failure(commit, String::new());
608 result.status = Some(verdict.code().to_string());
612 let trusted = verdict.is_trusted(&FreshnessPolicy::default());
615 let freshness = verdict.freshness();
616 match verdict {
617 CommitVerdict::Valid {
618 signer_did,
619 root_did,
620 duplicitous_root,
621 ..
622 } => {
623 result.valid = trusted;
624 result.freshness = Some(freshness);
625 result.ssh_valid = Some(true);
626 result.signer = Some(signer_did);
627 result.error = if trusted {
628 None
629 } else if duplicitous_root {
630 Some(format!(
631 "Root {root_did} shows KEL duplicity (a fork) — not trusted. \
632 Resolve with `auths device remove`."
633 ))
634 } else {
635 Some(format!(
636 "commit verified but its freshness is {freshness:?}; the supplied slice is \
637 older than the verifier's trust window"
638 ))
639 };
640 }
641 CommitVerdict::Unsigned => {
642 result.error = Some("No signature found".to_string());
643 }
644 CommitVerdict::GpgUnsupported => {
645 result.error = Some("GPG signatures not supported, use SSH signing".to_string());
646 }
647 CommitVerdict::SshSignatureInvalid => {
648 result.ssh_valid = Some(false);
649 result.error = Some(
650 "SSH signature is invalid (commit tampered, wrong namespace, or bad signature)"
651 .to_string(),
652 );
653 }
654 CommitVerdict::DeviceKelInvalid(why) => {
655 result.error = Some(format!("Device KEL failed to replay: {why}"));
656 }
657 CommitVerdict::RootKelInvalid(why) => {
658 result.error = Some(format!("Root KEL failed to replay: {why}"));
659 }
660 CommitVerdict::RootNotPinned(root) => {
661 result.error = Some(format!(
662 "Root {root} is not a pinned trusted root. Pin it in .auths/roots to trust \
663 commits delegated under it."
664 ));
665 }
666 CommitVerdict::RootAbandoned => {
667 result.error =
668 Some("Root identity is abandoned (its KEL was rotated to a null key)".to_string());
669 }
670 CommitVerdict::NotDelegatedByClaimedRoot {
671 device_did,
672 root_did,
673 } => {
674 result.error = Some(format!(
675 "Device {device_did} is not delegated by the claimed root {root_did}"
676 ));
677 }
678 CommitVerdict::DelegationSealNotFound => {
679 result.error = Some(
680 "Root never anchored this device's delegated inception (no delegation seal)"
681 .to_string(),
682 );
683 }
684 CommitVerdict::DeviceRevoked => {
685 result.error = Some("Device delegation has been revoked by the root".to_string());
686 }
687 CommitVerdict::SignedAfterRevocation {
688 signed_at,
689 revoked_at,
690 ..
691 } => {
692 result.error = Some(format!(
693 "Commit was signed at/after the delegator revoked it (signed at KEL position {signed_at}, revoked at {revoked_at})"
694 ));
695 }
696 CommitVerdict::OutsideAgentScope { capability, .. } => {
697 result.error = Some(format!(
698 "Agent signed exercising capability '{capability}', outside its delegator-anchored scope"
699 ));
700 }
701 CommitVerdict::AgentExpired {
702 expired_at,
703 signed_at,
704 ..
705 } => {
706 result.error = Some(format!(
707 "Agent delegation expired (expired at {expired_at}, signed at {signed_at})"
708 ));
709 }
710 CommitVerdict::SignerKeyMismatch => {
711 result.ssh_valid = Some(false);
712 result.error = Some("Signing key is not the device's current key".to_string());
713 }
714 CommitVerdict::SignedBySupersededKey => {
715 result.ssh_valid = Some(false);
716 result.error = Some(
717 "Commit was signed by a superseded device key (the device has since rotated)"
718 .to_string(),
719 );
720 }
721 CommitVerdict::WitnessQuorumNotMet {
722 root_did,
723 collected,
724 required,
725 } => {
726 result.error = Some(format!(
727 "Witness quorum not met for root {root_did}: {collected} of {required} required \
728 receipts. Drop --require-witnesses to verify with a warning instead."
729 ));
730 }
731 }
732 result
733}
734
735async fn verify_witnesses(
737 cmd: &VerifyCommitCommand,
738 bundle: Option<&IdentityBundle>,
739) -> Result<Option<WitnessQuorum>> {
740 let receipts_path = match cmd.witness_receipts {
741 Some(ref p) => p,
742 None => return Ok(None),
743 };
744
745 let receipts_bytes = fs::read(receipts_path)
746 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
747
748 let receipts: Vec<SignedReceipt> =
749 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
750
751 let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
752
753 let config = WitnessVerifyConfig {
754 receipts: &receipts,
755 witness_keys: &witness_keys,
756 threshold: cmd.witness_threshold,
757 };
758
759 if let Some(bundle) = bundle
761 && !bundle.attestation_chain.is_empty()
762 {
763 let root_pk_bytes = hex::decode(bundle.public_key_hex.as_str())
764 .context("Invalid public key hex in bundle")?;
765 let root_pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &root_pk_bytes)
766 .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
767
768 let report = verify_chain_with_witnesses(&bundle.attestation_chain, &root_pk, &config)
769 .await
770 .context("Witness chain verification failed")?;
771
772 return Ok(report.witness_quorum);
773 }
774
775 let provider = auths_crypto::RingCryptoProvider;
777 let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await;
778 Ok(Some(quorum))
779}
780
781fn output_results(results: &[VerifyCommitResult]) -> Result<()> {
783 let all_valid = results.iter().all(|r| r.valid);
784
785 if is_json_mode() {
786 if results.len() == 1 {
787 println!("{}", serde_json::to_string(&results[0])?);
788 } else {
789 println!("{}", serde_json::to_string(&results)?);
790 }
791 } else if results.len() == 1 {
792 let r = &results[0];
793 if r.valid {
794 if let Some(ref signer) = r.signer {
795 print!("Commit {} verified: signed by {}", r.commit, signer);
796 } else {
797 print!("Commit {} verified", r.commit);
798 }
799 print_chain_witness_summary(r);
800 println!();
801 } else {
802 eprint!("Verification failed for {}", r.commit);
803 if let Some(ref error) = r.error {
804 eprint!(": {}", error);
805 }
806 print_chain_witness_summary_stderr(r);
807 eprintln!();
808 }
809 for w in &r.warnings {
810 eprintln!("Warning: {}", w);
811 }
812 } else {
813 for r in results {
814 print!(
815 "{}: {}",
816 &r.commit[..8.min(r.commit.len())],
817 format_result_text(r)
818 );
819 println!();
820 }
821 }
822
823 if all_valid {
824 Ok(())
825 } else {
826 std::process::exit(1);
827 }
828}
829
830fn format_result_text(result: &VerifyCommitResult) -> String {
832 let status = if result.valid { "valid" } else { "INVALID" };
833
834 let mut parts = vec![status.to_string()];
835
836 if let Some(ref signer) = result.signer {
837 parts.push(format!("signer: {}", signer));
838 }
839
840 if let Some(cv) = result.chain_valid {
841 let chain_desc = if cv {
842 "chain: valid".to_string()
843 } else if let Some(ref report) = result.chain_report {
844 format!("chain: {}", format_chain_status(&report.status))
845 } else {
846 "chain: invalid".to_string()
847 };
848 parts.push(chain_desc);
849 }
850
851 if let Some(ref q) = result.witness_quorum {
852 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
853 }
854
855 if let Some(ref gate) = result.witness_gate {
856 parts.push(format!("witness-gate: {gate}"));
857 }
858
859 if let Some(ref binding) = result.oidc_binding {
860 parts.push(format!("oidc: {}", binding.issuer));
861 }
862
863 if let Some(ref error) = result.error
864 && result.signer.is_none()
865 && result.chain_valid.is_none()
866 && result.witness_quorum.is_none()
867 {
868 parts.push(error.clone());
869 }
870
871 if parts.len() == 1 {
872 parts[0].clone()
873 } else {
874 format!("{} ({})", parts[0], parts[1..].join(", "))
875 }
876}
877
878fn format_chain_status(status: &auths_verifier::VerificationStatus) -> String {
880 match status {
881 auths_verifier::VerificationStatus::Valid => "valid".to_string(),
882 auths_verifier::VerificationStatus::Expired { at } => {
883 format!("expired at {}", at.to_rfc3339())
884 }
885 auths_verifier::VerificationStatus::Revoked { at } => match at {
886 Some(t) => format!("revoked at {}", t.to_rfc3339()),
887 None => "revoked".to_string(),
888 },
889 auths_verifier::VerificationStatus::InvalidSignature { step } => {
890 format!("invalid signature at step {}", step)
891 }
892 auths_verifier::VerificationStatus::BrokenChain { missing_link } => {
893 format!("broken chain: {}", missing_link)
894 }
895 auths_verifier::VerificationStatus::InsufficientWitnesses { required, verified } => {
896 format!("witnesses: {}/{} quorum not met", verified, required)
897 }
898 }
899}
900
901fn print_chain_witness_summary(r: &VerifyCommitResult) {
903 let mut parts = Vec::new();
904
905 if let Some(cv) = r.chain_valid {
906 if cv {
907 parts.push("chain: valid".to_string());
908 } else {
909 parts.push("chain: invalid".to_string());
910 }
911 }
912
913 if let Some(ref q) = r.witness_quorum {
914 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
915 }
916
917 if let Some(ref gate) = r.witness_gate {
918 parts.push(format!("witness-gate: {gate}"));
919 }
920
921 if let Some(ref binding) = r.oidc_binding {
922 parts.push(format!("oidc: {} ({})", binding.issuer, binding.subject));
923 }
924
925 if !parts.is_empty() {
926 print!(" ({})", parts.join(", "));
927 }
928}
929
930fn print_chain_witness_summary_stderr(r: &VerifyCommitResult) {
932 if let Some(cv) = r.chain_valid
933 && !cv
934 && let Some(ref report) = r.chain_report
935 {
936 eprint!(" (chain: {})", format_chain_status(&report.status));
937 }
938 if let Some(ref q) = r.witness_quorum
939 && q.verified < q.required
940 {
941 eprint!(" (witnesses: {}/{} quorum not met)", q.verified, q.required);
942 }
943}
944
945fn resolve_commit_sha(commit_ref: &str) -> Result<String> {
946 super::git_helpers::resolve_commit_sha(commit_ref)
947}
948
949fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> {
950 if is_json_mode() {
951 let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string());
952 println!("{}", serde_json::to_string(&result)?);
953 } else {
954 eprintln!("Error: {}", message);
955 }
956 std::process::exit(exit_code);
957}
958
959impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand {
960 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
961 let rt = tokio::runtime::Runtime::new()?;
962 rt.block_on(handle_verify_commit(self.clone(), &ctx.env_config))
963 }
964}
965
966#[cfg(test)]
967#[allow(clippy::disallowed_methods)]
968mod tests {
969 use super::*;
970
971 #[test]
972 fn verify_commit_result_failure_helper() {
973 let r = VerifyCommitResult::failure("abc123".into(), "bad sig".into());
974 assert!(!r.valid);
975 assert_eq!(r.commit, "abc123");
976 assert_eq!(r.error.as_deref(), Some("bad sig"));
977 assert!(r.ssh_valid.is_none());
978 assert!(r.chain_valid.is_none());
979 assert!(r.witness_quorum.is_none());
980 }
981
982 #[test]
983 fn verify_commit_result_json_includes_new_fields() {
984 let r = VerifyCommitResult {
985 commit: "abc123".into(),
986 valid: true,
987 status: Some("valid".into()),
988 freshness: None,
989 ssh_valid: Some(true),
990 chain_valid: Some(true),
991 chain_report: None,
992 witness_quorum: Some(WitnessQuorum {
993 required: 2,
994 verified: 2,
995 receipts: vec![],
996 }),
997 witness_gate: Some("met".into()),
998 signer: Some("did:keri:test".into()),
999 oidc_binding: None,
1000 error: None,
1001 warnings: vec!["expiring soon".into()],
1002 };
1003 let json = serde_json::to_string(&r).unwrap();
1004 assert!(json.contains("\"ssh_valid\":true"));
1005 assert!(json.contains("\"chain_valid\":true"));
1006 assert!(json.contains("\"witness_quorum\""));
1007 assert!(json.contains("\"warnings\":[\"expiring soon\"]"));
1008 }
1009
1010 #[test]
1011 fn verify_commit_result_json_omits_none_fields() {
1012 let r = VerifyCommitResult::failure("abc".into(), "err".into());
1013 let json = serde_json::to_string(&r).unwrap();
1014 assert!(!json.contains("ssh_valid"));
1015 assert!(!json.contains("chain_valid"));
1016 assert!(!json.contains("chain_report"));
1017 assert!(!json.contains("witness_quorum"));
1018 assert!(!json.contains("warnings"));
1019 }
1020
1021 #[test]
1022 fn format_result_text_valid_ssh_only() {
1023 let r = VerifyCommitResult {
1024 commit: "abc12345".into(),
1025 valid: true,
1026 status: Some("valid".into()),
1027 freshness: None,
1028 ssh_valid: Some(true),
1029 chain_valid: None,
1030 chain_report: None,
1031 witness_quorum: None,
1032 witness_gate: None,
1033 signer: Some("did:keri:test".into()),
1034 oidc_binding: None,
1035 error: None,
1036 warnings: vec![],
1037 };
1038 let text = format_result_text(&r);
1039 assert!(text.contains("valid"));
1040 assert!(text.contains("signer: did:keri:test"));
1041 }
1042
1043 #[test]
1044 fn format_result_text_valid_with_chain_and_witnesses() {
1045 let r = VerifyCommitResult {
1046 commit: "abc12345".into(),
1047 valid: true,
1048 status: Some("valid".into()),
1049 freshness: None,
1050 ssh_valid: Some(true),
1051 chain_valid: Some(true),
1052 chain_report: Some(VerificationReport::valid(vec![])),
1053 witness_quorum: Some(WitnessQuorum {
1054 required: 2,
1055 verified: 2,
1056 receipts: vec![],
1057 }),
1058 witness_gate: Some("met".into()),
1059 signer: Some("did:keri:test".into()),
1060 oidc_binding: None,
1061 error: None,
1062 warnings: vec![],
1063 };
1064 let text = format_result_text(&r);
1065 assert!(text.contains("chain: valid"));
1066 assert!(text.contains("witnesses: 2/2"));
1067 assert!(text.contains("witness-gate: met"));
1068 }
1069
1070 #[test]
1071 fn verify_output_shows_quorum() {
1072 let mut r = VerifyCommitResult::failure("abc".into(), String::new());
1073 r.valid = true;
1074 r.error = None;
1075 r.witness_gate = Some("2 of 3 (under quorum)".into());
1076
1077 let text = format_result_text(&r);
1078 assert!(text.contains("witness-gate: 2 of 3 (under quorum)"));
1079 let json = serde_json::to_string(&r).unwrap();
1080 assert!(json.contains("\"witness_gate\":\"2 of 3 (under quorum)\""));
1081 }
1082
1083 #[test]
1084 fn verify_output_fails_closed_on_fork() {
1085 let result = verdict_to_result(
1088 "sha".into(),
1089 CommitVerdict::Valid {
1090 signer_did: "did:keri:dev".into(),
1091 root_did: "did:keri:root".into(),
1092 duplicitous_root: true,
1093 as_of: 0,
1094 freshness: auths_verifier::freshness::Freshness::Unknown,
1095 },
1096 );
1097 assert!(!result.valid, "a duplicitous root must fail closed");
1098 assert!(
1099 result
1100 .error
1101 .as_deref()
1102 .unwrap_or_default()
1103 .to_lowercase()
1104 .contains("duplicity"),
1105 "expected a duplicity error, got {:?}",
1106 result.error
1107 );
1108 }
1109
1110 #[test]
1111 fn format_result_text_invalid_with_error() {
1112 let r = VerifyCommitResult::failure("abc12345".into(), "No signature found".into());
1113 let text = format_result_text(&r);
1114 assert!(text.contains("INVALID"));
1115 assert!(text.contains("No signature found"));
1116 }
1117}