1use crate::ux::format::is_json_mode;
2use anyhow::{Context, Result, anyhow};
3use auths_infra_http::HttpOobiResolver;
4use auths_keri::Event;
5use auths_keri::witness::{SignedReceipt, WitnessReceiptLookup};
6use auths_sdk::core_config::EnvironmentConfig;
7use auths_sdk::ports::RegistryBackend;
8use auths_sdk::storage::{GitRegistryBackend, GitWitnessReceiptLookup, RegistryConfig};
9use auths_verifier::freshness::{Freshness, FreshnessEvidence, FreshnessPolicy};
10use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig};
11use auths_verifier::{
12 Attestation, BundleTrust, CommitVerdict, IdentityBundle, VerificationReport,
13 VerifierWitnessPolicy, WitnessGateStatus, verify_chain_with_witnesses,
14 verify_commit_against_kel_witnessed_scoped,
15};
16use clap::Parser;
17use serde::Serialize;
18use std::fs;
19use std::path::PathBuf;
20
21use crate::subprocess::git_command;
22
23use super::verify_helpers::parse_witness_keys;
24
25#[derive(Parser, Debug, Clone)]
26#[command(about = "Verify Git commit signatures against Auths identity.")]
27pub struct VerifyCommitCommand {
28 #[arg(default_value = "HEAD")]
30 pub commit: String,
31
32 #[arg(long = "witness-signatures")]
34 pub witness_receipts: Option<PathBuf>,
35
36 #[arg(long = "witnesses-required", default_value = "1")]
38 pub witness_threshold: usize,
39
40 #[arg(long, num_args = 1..)]
42 pub witness_keys: Vec<String>,
43
44 #[arg(long)]
50 pub remote: Option<String>,
51
52 #[arg(long)]
57 pub oobi: Option<String>,
58
59 #[arg(long = "require-witnesses")]
62 pub require_witnesses: bool,
63
64 #[arg(long, value_parser)]
68 pub identity_bundle: Option<PathBuf>,
69}
70
71#[derive(Serialize)]
72struct VerifyCommitResult {
73 commit: String,
74 valid: bool,
75 #[serde(skip_serializing_if = "Option::is_none")]
79 status: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
83 freshness: Option<Freshness>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 ssh_valid: Option<bool>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 chain_valid: Option<bool>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 chain_report: Option<VerificationReport>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 witness_quorum: Option<WitnessQuorum>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 witness_gate: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 signer: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 oidc_binding: Option<OidcBindingDisplay>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 error: Option<String>,
102 #[serde(skip_serializing_if = "Vec::is_empty")]
103 warnings: Vec<String>,
104}
105
106#[derive(Serialize)]
111struct OidcBindingDisplay {
112 issuer: String,
114 subject: String,
116 audience: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 platform: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 normalized_claims: Option<serde_json::Map<String, serde_json::Value>>,
124}
125
126impl VerifyCommitResult {
127 fn failure(commit: String, error: String) -> Self {
128 Self {
129 commit,
130 valid: false,
131 status: None,
132 freshness: None,
133 ssh_valid: None,
134 chain_valid: None,
135 chain_report: None,
136 witness_quorum: None,
137 witness_gate: None,
138 signer: None,
139 oidc_binding: None,
140 error: Some(error),
141 warnings: Vec::new(),
142 }
143 }
144}
145
146#[allow(clippy::disallowed_methods)]
149pub async fn handle_verify_commit(
150 cmd: VerifyCommitCommand,
151 env_config: &EnvironmentConfig,
152) -> Result<()> {
153 let auths_home = match auths_sdk::paths::auths_home() {
156 Ok(h) => h,
157 Err(e) => return handle_error(&cmd, 2, &format!("Could not locate ~/.auths: {e}")),
158 };
159 let sdk_ctx =
162 match crate::factories::storage::build_auths_context(&auths_home, env_config, None) {
163 Ok(c) => c,
164 Err(e) => {
165 return handle_error(
166 &cmd,
167 2,
168 &format!("Could not build context for org-policy evaluation: {e}"),
169 );
170 }
171 };
172 let registry =
175 GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home));
176 let mut pinned_roots = super::verify_helpers::load_project_pinned_roots();
182 if let Some(own_root) = auths_sdk::workflows::commit_trust::local_self_root(&sdk_ctx)
183 && !pinned_roots.contains(&own_root)
184 {
185 pinned_roots.push(own_root);
186 }
187 let mut bundle_kel: Option<BundleKel> = None;
188 if let Some(bundle_path) = &cmd.identity_bundle {
189 match load_bundle_trust(bundle_path, chrono::Utc::now()) {
190 Ok((root, kel)) => {
191 if !pinned_roots.contains(&root) {
199 return handle_error(
200 &cmd,
201 2,
202 &format!(
203 "identity bundle root {root} is not independently trusted: \
204 add it to .auths/roots (or verify from the identity that \
205 controls it). A bundle is evidence for a pinned root, never \
206 the source of the pin."
207 ),
208 );
209 }
210 if !kel.is_empty() {
211 bundle_kel = Some(BundleKel {
212 did: root,
213 events: kel,
214 });
215 }
216 }
217 Err(e) => return handle_error(&cmd, 2, &e),
218 }
219 }
220 let provider = auths_crypto::RingCryptoProvider;
221 let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home);
224
225 let commits = match resolve_commits(&cmd.commit) {
226 Ok(c) => c,
227 Err(e) => return handle_error(&cmd, 2, &e.to_string()),
228 };
229 let mut results = Vec::with_capacity(commits.len());
230 for commit_ref in &commits {
231 results.push(
232 verify_one_commit(
233 ®istry,
234 &pinned_roots,
235 &provider,
236 &receipt_lookup,
237 &sdk_ctx,
238 &cmd,
239 bundle_kel.as_ref(),
240 commit_ref,
241 )
242 .await,
243 );
244 }
245 output_results(&results)
246}
247
248struct BundleKel {
253 did: String,
255 events: Vec<Event>,
258}
259
260fn load_bundle_trust(
267 path: &std::path::Path,
268 now: chrono::DateTime<chrono::Utc>,
269) -> std::result::Result<(String, Vec<Event>), String> {
270 let content = fs::read_to_string(path)
271 .map_err(|e| format!("could not read identity bundle {path:?}: {e}"))?;
272 let bundle: IdentityBundle = serde_json::from_str(&content)
273 .map_err(|e| format!("identity bundle {path:?} is not valid JSON: {e}"))?;
274 let trust = BundleTrust::parse(&bundle, now)
275 .map_err(|e| format!("identity bundle {path:?} is not a usable trust anchor: {e}"))?;
276 let (root, kel) = trust.into_parts();
277 Ok((root, kel))
278}
279
280fn resolve_commits(commit_spec: &str) -> Result<Vec<String>> {
282 if commit_spec.contains("..") {
283 let output = git_command(&["rev-list", commit_spec])
285 .output()
286 .context("Failed to run git rev-list")?;
287
288 if !output.status.success() {
289 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
290 let lower = stderr.to_lowercase();
291
292 if lower.contains("unknown revision") || lower.contains("bad revision") {
293 return Err(anyhow!(
294 "{}",
295 format_commit_range_hint(commit_spec, stderr.trim())
296 ));
297 }
298
299 return Err(anyhow!("Invalid commit range: {}", stderr.trim()));
300 }
301
302 let commits: Vec<String> = std::str::from_utf8(&output.stdout)
303 .context("Invalid UTF-8 in git output")?
304 .lines()
305 .map(|s| s.to_string())
306 .collect();
307
308 if commits.is_empty() {
309 return Err(anyhow!("No commits in specified range"));
310 }
311 Ok(commits)
312 } else {
313 let sha = resolve_commit_sha(commit_spec)?;
315 Ok(vec![sha])
316 }
317}
318
319fn format_commit_range_hint(commit_spec: &str, raw_stderr: &str) -> String {
321 let hint = if commit_spec.contains('~') || commit_spec.contains('^') {
322 "This repository may not have enough commits for that range. \
323 Try a smaller offset (e.g. HEAD~1..HEAD) or verify with `git log --oneline`."
324 } else if commit_spec.contains("..") {
325 "One or both refs in the range do not exist. \
326 Check branch/tag names with `git branch -a` or `git tag -l`."
327 } else {
328 "The commit reference could not be resolved. \
329 Verify it exists with `git log --oneline`."
330 };
331
332 format!("Failed to resolve commit range '{commit_spec}': {raw_stderr}\n\nHint: {hint}")
333}
334
335fn try_load_attestation_from_ref(commit_sha: &str) -> Option<Attestation> {
343 let ref_name = format!("refs/auths/commits/{}", commit_sha);
344
345 let stdout = crate::subprocess::git_silent(&["show", &ref_name])?;
346 serde_json::from_str(&stdout).ok()
347}
348
349fn extract_oidc_binding_display(attestation: &Attestation) -> Option<OidcBindingDisplay> {
359 attestation
360 .oidc_binding
361 .as_ref()
362 .map(|binding| OidcBindingDisplay {
363 issuer: binding.issuer.clone(),
364 subject: binding.subject.clone(),
365 audience: binding.audience.clone(),
366 platform: binding.platform.clone(),
367 normalized_claims: binding.normalized_claims.clone(),
368 })
369}
370
371async fn resolve_signer_kel(
383 registry: &dyn RegistryBackend,
384 cmd: &VerifyCommitCommand,
385 bundle_kel: Option<&BundleKel>,
386 did: &str,
387) -> Result<Vec<Event>, String> {
388 if let Some(bundle) = bundle_kel
392 && bundle.did == did
393 {
394 let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
395 auths_sdk::keri::verify_prefix_binding(&prefix, &bundle.events)
396 .map_err(|e| e.to_string())?;
397 return Ok(bundle.events.clone());
398 }
399 if let Some(oobi_base) = &cmd.oobi {
400 let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
401 let resolver = HttpOobiResolver::new(oobi_base.clone()).map_err(|e| e.to_string())?;
402 let events = resolver
403 .fetch_kel(&prefix)
404 .await
405 .map_err(|e| e.to_string())?;
406 auths_sdk::keri::verify_prefix_binding(&prefix, &events).map_err(|e| e.to_string())?;
407 Ok(events)
408 } else {
409 let remote = cmd
417 .remote
418 .clone()
419 .or_else(crate::commands::verify_helpers::repo_origin_url);
420 let chain = match remote {
421 Some(url) => auths_sdk::keri::KelResolverChain::with_remote(registry, url),
422 None => auths_sdk::keri::KelResolverChain::local(registry),
423 };
424 chain.resolve_kel(did).map_err(|e| e.to_string())
425 }
426}
427
428#[allow(clippy::too_many_arguments)]
435async fn verify_one_commit(
436 registry: &dyn RegistryBackend,
437 pinned_roots: &[String],
438 provider: &dyn auths_crypto::CryptoProvider,
439 receipt_lookup: &dyn WitnessReceiptLookup,
440 sdk_ctx: &auths_sdk::context::AuthsContext,
441 cmd: &VerifyCommitCommand,
442 bundle_kel: Option<&BundleKel>,
443 commit_ref: &str,
444) -> VerifyCommitResult {
445 let sha = match resolve_commit_sha(commit_ref) {
446 Ok(sha) => sha,
447 Err(e) => {
448 return VerifyCommitResult::failure(
449 commit_ref.to_string(),
450 format!("Failed to resolve commit: {e}"),
451 );
452 }
453 };
454
455 let raw_commit = match raw_commit_object(&sha) {
456 Ok(c) => c,
457 Err(e) => return VerifyCommitResult::failure(sha, e.to_string()),
458 };
459
460 let (root_did, device_did) =
461 match auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit) {
462 Some(pair) => pair,
463 None => {
464 return VerifyCommitResult::failure(
465 sha,
466 "Commit carries no Auths-Id/Auths-Device trailer. The prepare-commit-msg \
467 hook installed by `auths init` adds these on every commit — if this repo \
468 sets its own core.hooksPath (e.g. husky), the hook is bypassed; run \
469 `auths doctor` to check. Backfill existing commits with `auths sign <ref>` \
470 (rewrites the commit)."
471 .to_string(),
472 );
473 }
474 };
475
476 let device_kel = match resolve_signer_kel(registry, cmd, bundle_kel, &device_did).await {
481 Ok(events) => events,
482 Err(e) => {
483 return VerifyCommitResult::failure(
484 sha,
485 format!("Device KEL for {device_did} could not be resolved: {e}"),
486 );
487 }
488 };
489 let root_did = match device_kel.first().and_then(|e| e.delegator()) {
500 Some(delegator) => format!("did:keri:{delegator}"),
501 None => root_did,
502 };
503 let root_kel = match resolve_signer_kel(registry, cmd, bundle_kel, &root_did).await {
504 Ok(events) => events,
505 Err(e) => {
506 return VerifyCommitResult::failure(
507 sha,
508 format!("Root KEL for {root_did} could not be resolved: {e}"),
509 );
510 }
511 };
512
513 let policy = if cmd.require_witnesses {
514 VerifierWitnessPolicy::RequireWitnesses
515 } else {
516 VerifierWitnessPolicy::Warn
517 };
518 #[allow(clippy::disallowed_methods)]
525 let now = chrono::Utc::now().timestamp();
526 let witnessed = verify_commit_against_kel_witnessed_scoped(
527 raw_commit.as_bytes(),
528 &device_kel,
529 &root_kel,
530 pinned_roots,
531 provider,
532 receipt_lookup,
533 policy,
534 now,
535 )
536 .await;
537 let verdict = match bundle_kel {
542 Some(_) => witnessed
543 .verdict
544 .with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline),
545 None => witnessed.verdict,
546 };
547 let mut result = verdict_to_result(sha.clone(), verdict);
548 match witnessed.witness {
549 WitnessGateStatus::NotRequired => {}
550 WitnessGateStatus::Met => result.witness_gate = Some("met".to_string()),
551 WitnessGateStatus::UnderQuorum {
552 collected,
553 required,
554 } => {
555 result.witness_gate = Some(format!("{collected} of {required} (under quorum)"));
556 result.warnings.push(format!(
557 "Witness quorum not met for the signer's root KEL: {collected} of {required} \
558 receipts (verifying anyway; pass --require-witnesses to fail closed)."
559 ));
560 }
561 }
562
563 if let Ok(Some(quorum)) = verify_witnesses(cmd, None).await {
564 if quorum.verified < quorum.required {
565 result.valid = false;
566 if result.error.is_none() {
567 result.error = Some(format!(
568 "Witness quorum not met: {}/{}",
569 quorum.verified, quorum.required
570 ));
571 }
572 }
573 result.witness_quorum = Some(quorum);
574 }
575
576 result.oidc_binding =
577 try_load_attestation_from_ref(&sha).and_then(|att| extract_oidc_binding_display(&att));
578
579 if result.valid {
583 let now = chrono::Utc::now();
584 match auths_sdk::workflows::commit_trust::evaluate_commit_policy(
585 sdk_ctx,
586 &root_did,
587 &device_did,
588 now,
589 ) {
590 Ok(auths_sdk::workflows::commit_trust::PolicyOutcome::Evaluated(decision))
591 if !decision.is_allowed() =>
592 {
593 result.valid = false;
594 result.chain_valid = Some(false);
595 result.error = Some(format!(
596 "Org policy denied this commit: {} [{}]",
597 decision.message, decision.reason
598 ));
599 }
600 Ok(_) => {}
601 Err(e) => {
602 result.valid = false;
604 result.error = Some(format!("Org policy could not be evaluated: {e}"));
605 }
606 }
607 }
608
609 result
610}
611
612fn raw_commit_object(sha: &str) -> Result<String> {
615 let output = git_command(&["cat-file", "commit", sha])
616 .output()
617 .context("Failed to run git cat-file")?;
618 if !output.status.success() {
619 return Err(anyhow!(
620 "git cat-file commit {sha} failed: {}",
621 String::from_utf8_lossy(&output.stderr)
622 ));
623 }
624 String::from_utf8(output.stdout).context("Commit object is not valid UTF-8")
625}
626
627fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResult {
630 let mut result = VerifyCommitResult::failure(commit, String::new());
631 result.status = Some(verdict.code().to_string());
635 let trusted = verdict.is_trusted(&FreshnessPolicy::default());
638 let freshness = verdict.freshness();
639 match verdict {
640 CommitVerdict::Valid {
641 signer_did,
642 root_did,
643 duplicitous_root,
644 ..
645 } => {
646 result.valid = trusted;
647 result.freshness = Some(freshness);
648 result.ssh_valid = Some(true);
649 result.signer = Some(signer_did);
650 result.error = if trusted {
651 None
652 } else if duplicitous_root {
653 Some(format!(
654 "Root {root_did} shows KEL duplicity (a fork) — not trusted. \
655 Resolve with `auths device remove`."
656 ))
657 } else {
658 Some(format!(
659 "commit verified but its freshness is {freshness:?}; the supplied slice is \
660 older than the verifier's trust window"
661 ))
662 };
663 }
664 CommitVerdict::Unsigned => {
665 result.error = Some("No signature found".to_string());
666 }
667 CommitVerdict::GpgUnsupported => {
668 result.error = Some("GPG signatures not supported, use SSH signing".to_string());
669 }
670 CommitVerdict::SshSignatureInvalid => {
671 result.ssh_valid = Some(false);
672 result.error = Some(
673 "SSH signature is invalid (commit tampered, wrong namespace, or bad signature)"
674 .to_string(),
675 );
676 }
677 CommitVerdict::DeviceKelInvalid(why) => {
678 result.error = Some(format!("Device KEL failed to replay: {why}"));
679 }
680 CommitVerdict::RootKelInvalid(why) => {
681 result.error = Some(format!("Root KEL failed to replay: {why}"));
682 }
683 CommitVerdict::RootNotPinned(root) => {
684 result.error = Some(format!(
685 "Root {root} is not a pinned trusted root. Pin it in .auths/roots to trust \
686 commits delegated under it."
687 ));
688 }
689 CommitVerdict::RootAbandoned => {
690 result.error =
691 Some("Root identity is abandoned (its KEL was rotated to a null key)".to_string());
692 }
693 CommitVerdict::NotDelegatedByClaimedRoot {
694 device_did,
695 root_did,
696 } => {
697 result.error = Some(format!(
698 "Device {device_did} is not delegated by the claimed root {root_did}"
699 ));
700 }
701 CommitVerdict::DelegationSealNotFound => {
702 result.error = Some(
703 "Root never anchored this device's delegated inception (no delegation seal)"
704 .to_string(),
705 );
706 }
707 CommitVerdict::DeviceRevoked => {
708 result.error = Some("Device delegation has been revoked by the root".to_string());
709 }
710 CommitVerdict::SignedAfterRevocation {
711 signed_at,
712 revoked_at,
713 ..
714 } => {
715 result.error = Some(format!(
716 "Commit was signed at/after the delegator revoked it (signed at KEL position {signed_at}, revoked at {revoked_at})"
717 ));
718 }
719 CommitVerdict::OutsideAgentScope { capability, .. } => {
720 result.error = Some(format!(
721 "Agent signed exercising capability '{capability}', outside its delegator-anchored scope"
722 ));
723 }
724 CommitVerdict::AgentExpired {
725 expired_at,
726 signed_at,
727 ..
728 } => {
729 result.error = Some(format!(
730 "Agent delegation expired (expired at {expired_at}, signed at {signed_at})"
731 ));
732 }
733 CommitVerdict::SignerKeyMismatch => {
734 result.ssh_valid = Some(false);
735 result.error = Some("Signing key is not the device's current key".to_string());
736 }
737 CommitVerdict::SignedBySupersededKey => {
738 result.ssh_valid = Some(false);
739 result.error = Some(
740 "Commit was signed by a superseded device key (the device has since rotated)"
741 .to_string(),
742 );
743 }
744 CommitVerdict::WitnessQuorumNotMet {
745 root_did,
746 collected,
747 required,
748 } => {
749 result.error = Some(format!(
750 "Witness quorum not met for root {root_did}: {collected} of {required} required \
751 receipts. Drop --require-witnesses to verify with a warning instead."
752 ));
753 }
754 }
755 result
756}
757
758async fn verify_witnesses(
760 cmd: &VerifyCommitCommand,
761 bundle: Option<&IdentityBundle>,
762) -> Result<Option<WitnessQuorum>> {
763 let receipts_path = match cmd.witness_receipts {
764 Some(ref p) => p,
765 None => return Ok(None),
766 };
767
768 let receipts_bytes = fs::read(receipts_path)
769 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
770
771 let receipts: Vec<SignedReceipt> =
772 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
773
774 let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
775
776 let config = WitnessVerifyConfig {
777 receipts: &receipts,
778 witness_keys: &witness_keys,
779 threshold: cmd.witness_threshold,
780 };
781
782 if let Some(bundle) = bundle
784 && !bundle.attestation_chain.is_empty()
785 {
786 let root_pk_bytes = hex::decode(bundle.public_key_hex.as_str())
787 .context("Invalid public key hex in bundle")?;
788 let root_pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &root_pk_bytes)
789 .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
790
791 let report = verify_chain_with_witnesses(&bundle.attestation_chain, &root_pk, &config)
792 .await
793 .context("Witness chain verification failed")?;
794
795 return Ok(report.witness_quorum);
796 }
797
798 let provider = auths_crypto::RingCryptoProvider;
800 let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await;
801 Ok(Some(quorum))
802}
803
804fn output_results(results: &[VerifyCommitResult]) -> Result<()> {
806 let all_valid = results.iter().all(|r| r.valid);
807
808 if is_json_mode() {
809 if results.len() == 1 {
810 println!("{}", serde_json::to_string(&results[0])?);
811 } else {
812 println!("{}", serde_json::to_string(&results)?);
813 }
814 } else if results.len() == 1 {
815 let r = &results[0];
816 if r.valid {
817 if let Some(ref signer) = r.signer {
818 print!("Commit {} verified: signed by {}", r.commit, signer);
819 } else {
820 print!("Commit {} verified", r.commit);
821 }
822 print_chain_witness_summary(r);
823 println!();
824 } else {
825 eprint!("Verification failed for {}", r.commit);
826 if let Some(ref error) = r.error {
827 eprint!(": {}", error);
828 }
829 print_chain_witness_summary_stderr(r);
830 eprintln!();
831 }
832 for w in &r.warnings {
833 eprintln!("Warning: {}", w);
834 }
835 } else {
836 for r in results {
837 print!(
838 "{}: {}",
839 &r.commit[..8.min(r.commit.len())],
840 format_result_text(r)
841 );
842 println!();
843 }
844 }
845
846 if all_valid {
847 Ok(())
848 } else {
849 std::process::exit(1);
850 }
851}
852
853fn format_result_text(result: &VerifyCommitResult) -> String {
855 let status = if result.valid { "valid" } else { "INVALID" };
856
857 let mut parts = vec![status.to_string()];
858
859 if let Some(ref signer) = result.signer {
860 parts.push(format!("signer: {}", signer));
861 }
862
863 if let Some(cv) = result.chain_valid {
864 let chain_desc = if cv {
865 "chain: valid".to_string()
866 } else if let Some(ref report) = result.chain_report {
867 format!("chain: {}", format_chain_status(&report.status))
868 } else {
869 "chain: invalid".to_string()
870 };
871 parts.push(chain_desc);
872 }
873
874 if let Some(ref q) = result.witness_quorum {
875 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
876 }
877
878 if let Some(ref gate) = result.witness_gate {
879 parts.push(format!("witness-gate: {gate}"));
880 }
881
882 if let Some(ref binding) = result.oidc_binding {
883 parts.push(format!("oidc: {}", binding.issuer));
884 }
885
886 if let Some(ref error) = result.error
887 && result.signer.is_none()
888 && result.chain_valid.is_none()
889 && result.witness_quorum.is_none()
890 {
891 parts.push(error.clone());
892 }
893
894 if parts.len() == 1 {
895 parts[0].clone()
896 } else {
897 format!("{} ({})", parts[0], parts[1..].join(", "))
898 }
899}
900
901fn format_chain_status(status: &auths_verifier::VerificationStatus) -> String {
903 match status {
904 auths_verifier::VerificationStatus::Valid => "valid".to_string(),
905 auths_verifier::VerificationStatus::Expired { at } => {
906 format!("expired at {}", at.to_rfc3339())
907 }
908 auths_verifier::VerificationStatus::Revoked { at } => match at {
909 Some(t) => format!("revoked at {}", t.to_rfc3339()),
910 None => "revoked".to_string(),
911 },
912 auths_verifier::VerificationStatus::InvalidSignature { step } => {
913 format!("invalid signature at step {}", step)
914 }
915 auths_verifier::VerificationStatus::BrokenChain { missing_link } => {
916 format!("broken chain: {}", missing_link)
917 }
918 auths_verifier::VerificationStatus::InsufficientWitnesses { required, verified } => {
919 format!("witnesses: {}/{} quorum not met", verified, required)
920 }
921 }
922}
923
924fn print_chain_witness_summary(r: &VerifyCommitResult) {
926 let mut parts = Vec::new();
927
928 if let Some(cv) = r.chain_valid {
929 if cv {
930 parts.push("chain: valid".to_string());
931 } else {
932 parts.push("chain: invalid".to_string());
933 }
934 }
935
936 if let Some(ref q) = r.witness_quorum {
937 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
938 }
939
940 if let Some(ref gate) = r.witness_gate {
941 parts.push(format!("witness-gate: {gate}"));
942 }
943
944 if let Some(ref binding) = r.oidc_binding {
945 parts.push(format!("oidc: {} ({})", binding.issuer, binding.subject));
946 }
947
948 if !parts.is_empty() {
949 print!(" ({})", parts.join(", "));
950 }
951}
952
953fn print_chain_witness_summary_stderr(r: &VerifyCommitResult) {
955 if let Some(cv) = r.chain_valid
956 && !cv
957 && let Some(ref report) = r.chain_report
958 {
959 eprint!(" (chain: {})", format_chain_status(&report.status));
960 }
961 if let Some(ref q) = r.witness_quorum
962 && q.verified < q.required
963 {
964 eprint!(" (witnesses: {}/{} quorum not met)", q.verified, q.required);
965 }
966}
967
968fn resolve_commit_sha(commit_ref: &str) -> Result<String> {
969 super::git_helpers::resolve_commit_sha(commit_ref)
970}
971
972fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> {
973 if is_json_mode() {
974 let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string());
975 println!("{}", serde_json::to_string(&result)?);
976 } else {
977 eprintln!("Error: {}", message);
978 }
979 std::process::exit(exit_code);
980}
981
982impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand {
983 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
984 let rt = tokio::runtime::Runtime::new()?;
985 rt.block_on(handle_verify_commit(self.clone(), &ctx.env_config))
986 }
987}
988
989#[cfg(test)]
990#[allow(clippy::disallowed_methods)]
991mod tests {
992 use super::*;
993
994 #[test]
995 fn verify_commit_result_failure_helper() {
996 let r = VerifyCommitResult::failure("abc123".into(), "bad sig".into());
997 assert!(!r.valid);
998 assert_eq!(r.commit, "abc123");
999 assert_eq!(r.error.as_deref(), Some("bad sig"));
1000 assert!(r.ssh_valid.is_none());
1001 assert!(r.chain_valid.is_none());
1002 assert!(r.witness_quorum.is_none());
1003 }
1004
1005 #[test]
1006 fn verify_commit_result_json_includes_new_fields() {
1007 let r = VerifyCommitResult {
1008 commit: "abc123".into(),
1009 valid: true,
1010 status: Some("valid".into()),
1011 freshness: None,
1012 ssh_valid: Some(true),
1013 chain_valid: Some(true),
1014 chain_report: None,
1015 witness_quorum: Some(WitnessQuorum {
1016 required: 2,
1017 verified: 2,
1018 receipts: vec![],
1019 }),
1020 witness_gate: Some("met".into()),
1021 signer: Some("did:keri:test".into()),
1022 oidc_binding: None,
1023 error: None,
1024 warnings: vec!["expiring soon".into()],
1025 };
1026 let json = serde_json::to_string(&r).unwrap();
1027 assert!(json.contains("\"ssh_valid\":true"));
1028 assert!(json.contains("\"chain_valid\":true"));
1029 assert!(json.contains("\"witness_quorum\""));
1030 assert!(json.contains("\"warnings\":[\"expiring soon\"]"));
1031 }
1032
1033 #[test]
1034 fn verify_commit_result_json_omits_none_fields() {
1035 let r = VerifyCommitResult::failure("abc".into(), "err".into());
1036 let json = serde_json::to_string(&r).unwrap();
1037 assert!(!json.contains("ssh_valid"));
1038 assert!(!json.contains("chain_valid"));
1039 assert!(!json.contains("chain_report"));
1040 assert!(!json.contains("witness_quorum"));
1041 assert!(!json.contains("warnings"));
1042 }
1043
1044 #[test]
1045 fn format_result_text_valid_ssh_only() {
1046 let r = VerifyCommitResult {
1047 commit: "abc12345".into(),
1048 valid: true,
1049 status: Some("valid".into()),
1050 freshness: None,
1051 ssh_valid: Some(true),
1052 chain_valid: None,
1053 chain_report: None,
1054 witness_quorum: None,
1055 witness_gate: None,
1056 signer: Some("did:keri:test".into()),
1057 oidc_binding: None,
1058 error: None,
1059 warnings: vec![],
1060 };
1061 let text = format_result_text(&r);
1062 assert!(text.contains("valid"));
1063 assert!(text.contains("signer: did:keri:test"));
1064 }
1065
1066 #[test]
1067 fn format_result_text_valid_with_chain_and_witnesses() {
1068 let r = VerifyCommitResult {
1069 commit: "abc12345".into(),
1070 valid: true,
1071 status: Some("valid".into()),
1072 freshness: None,
1073 ssh_valid: Some(true),
1074 chain_valid: Some(true),
1075 chain_report: Some(VerificationReport::valid(vec![])),
1076 witness_quorum: Some(WitnessQuorum {
1077 required: 2,
1078 verified: 2,
1079 receipts: vec![],
1080 }),
1081 witness_gate: Some("met".into()),
1082 signer: Some("did:keri:test".into()),
1083 oidc_binding: None,
1084 error: None,
1085 warnings: vec![],
1086 };
1087 let text = format_result_text(&r);
1088 assert!(text.contains("chain: valid"));
1089 assert!(text.contains("witnesses: 2/2"));
1090 assert!(text.contains("witness-gate: met"));
1091 }
1092
1093 #[test]
1094 fn verify_output_shows_quorum() {
1095 let mut r = VerifyCommitResult::failure("abc".into(), String::new());
1096 r.valid = true;
1097 r.error = None;
1098 r.witness_gate = Some("2 of 3 (under quorum)".into());
1099
1100 let text = format_result_text(&r);
1101 assert!(text.contains("witness-gate: 2 of 3 (under quorum)"));
1102 let json = serde_json::to_string(&r).unwrap();
1103 assert!(json.contains("\"witness_gate\":\"2 of 3 (under quorum)\""));
1104 }
1105
1106 #[test]
1107 fn verify_output_fails_closed_on_fork() {
1108 let result = verdict_to_result(
1111 "sha".into(),
1112 CommitVerdict::Valid {
1113 signer_did: "did:keri:dev".into(),
1114 root_did: "did:keri:root".into(),
1115 duplicitous_root: true,
1116 as_of: 0,
1117 freshness: auths_verifier::freshness::Freshness::Unknown,
1118 },
1119 );
1120 assert!(!result.valid, "a duplicitous root must fail closed");
1121 assert!(
1122 result
1123 .error
1124 .as_deref()
1125 .unwrap_or_default()
1126 .to_lowercase()
1127 .contains("duplicity"),
1128 "expected a duplicity error, got {:?}",
1129 result.error
1130 );
1131 }
1132
1133 #[test]
1134 fn format_result_text_invalid_with_error() {
1135 let r = VerifyCommitResult::failure("abc12345".into(), "No signature found".into());
1136 let text = format_result_text(&r);
1137 assert!(text.contains("INVALID"));
1138 assert!(text.contains("No signature found"));
1139 }
1140}