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