1use crate::config::CliConfig;
2use crate::ux::format::is_json_mode;
3use anyhow::{Context, Result, anyhow};
4use auths_keri::Event;
5use auths_keri::witness::{SignedReceipt, WitnessReceiptLookup};
6use auths_sdk::error::AuthsErrorInfo;
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 = "require-witnesses")]
47 pub require_witnesses: bool,
48
49 #[arg(long, value_parser)]
55 pub identity_bundle: Option<PathBuf>,
56}
57
58#[derive(Serialize)]
59struct VerifyCommitResult {
60 commit: String,
61 valid: bool,
62 #[serde(skip_serializing_if = "Option::is_none")]
66 status: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
70 freshness: Option<Freshness>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 ssh_valid: Option<bool>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 chain_valid: Option<bool>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 chain_report: Option<VerificationReport>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 witness_quorum: Option<WitnessQuorum>,
79 #[serde(skip_serializing_if = "Option::is_none")]
82 witness_gate: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 signer: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 oidc_binding: Option<OidcBindingDisplay>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 error: Option<String>,
89 #[serde(skip_serializing_if = "Vec::is_empty")]
90 warnings: Vec<String>,
91}
92
93#[derive(Serialize)]
98struct OidcBindingDisplay {
99 issuer: String,
101 subject: String,
103 audience: String,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 platform: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 normalized_claims: Option<serde_json::Map<String, serde_json::Value>>,
111}
112
113impl VerifyCommitResult {
114 fn failure(commit: String, error: String) -> Self {
115 Self {
116 commit,
117 valid: false,
118 status: None,
119 freshness: None,
120 ssh_valid: None,
121 chain_valid: None,
122 chain_report: None,
123 witness_quorum: None,
124 witness_gate: None,
125 signer: None,
126 oidc_binding: None,
127 error: Some(error),
128 warnings: Vec::new(),
129 }
130 }
131}
132
133#[allow(clippy::disallowed_methods)]
136pub async fn handle_verify_commit(cmd: VerifyCommitCommand, ctx: &CliConfig) -> Result<()> {
137 let auths_home = match auths_sdk::paths::resolve_registry_path(ctx.repo_path.clone()) {
142 Ok(h) => h,
143 Err(e) => {
144 return handle_error(
145 &cmd,
146 2,
147 &format!("Could not locate the auths registry: {e}"),
148 );
149 }
150 };
151 let env_config = &ctx.env_config;
152 let sdk_ctx =
155 match crate::factories::storage::build_auths_context(&auths_home, env_config, None) {
156 Ok(c) => c,
157 Err(e) => {
158 return handle_error(
159 &cmd,
160 2,
161 &format!("Could not build context for org-policy evaluation: {e}"),
162 );
163 }
164 };
165 let registry =
168 GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home));
169 let mut pinned_roots = super::verify_helpers::load_project_pinned_roots();
175 if let Some(own_root) = auths_sdk::workflows::commit_trust::local_self_root(&sdk_ctx)
176 && !pinned_roots.contains(&own_root)
177 {
178 pinned_roots.push(own_root);
179 }
180 let mut bundle_kels: Vec<BundleKel> = Vec::new();
181 let bundle_path = cmd
182 .identity_bundle
183 .clone()
184 .or_else(super::verify_helpers::discover_project_bundle);
185 if let Some(bundle_path) = &bundle_path {
186 match load_bundle_trust(bundle_path, chrono::Utc::now()) {
187 Ok((root, kel, device_kels)) => {
188 if !pinned_roots.contains(&root) {
196 return handle_error(
197 &cmd,
198 2,
199 &format!(
200 "identity bundle root {root} is not independently trusted: \
201 add it to .auths/roots (or verify from the identity that \
202 controls it). A bundle is evidence for a pinned root, never \
203 the source of the pin."
204 ),
205 );
206 }
207 if !kel.is_empty() {
208 bundle_kels.push(BundleKel {
209 did: root,
210 events: kel,
211 });
212 }
213 bundle_kels.extend(
216 device_kels
217 .into_iter()
218 .map(|(did, events)| BundleKel { did, events }),
219 );
220 }
221 Err(e) => return handle_error(&cmd, 2, &e),
222 }
223 }
224 let provider = auths_crypto::RingCryptoProvider;
225 let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home);
228
229 let commits = match resolve_commits(&cmd.commit) {
230 Ok(c) => c,
231 Err(e) => return handle_error(&cmd, 2, &e.to_string()),
232 };
233 let mut results = Vec::with_capacity(commits.len());
234 for commit_ref in &commits {
235 results.push(
236 verify_one_commit(
237 ®istry,
238 &pinned_roots,
239 &provider,
240 &receipt_lookup,
241 &sdk_ctx,
242 &cmd,
243 &bundle_kels,
244 commit_ref,
245 )
246 .await,
247 );
248 }
249 output_results(&results)
250}
251
252#[derive(Debug, thiserror::Error)]
257pub(crate) enum SignerKelError {
258 #[error("signer's KEL for {did} is not available locally: {reason}")]
260 Unavailable {
261 did: String,
263 reason: String,
265 },
266}
267
268impl AuthsErrorInfo for SignerKelError {
269 fn error_code(&self) -> &'static str {
270 match self {
271 Self::Unavailable { .. } => "AUTHS-E6301",
272 }
273 }
274
275 fn suggestion(&self) -> Option<&'static str> {
276 match self {
277 Self::Unavailable { .. } => Some(
278 "Fetch the signer's KEL with `git fetch <remote> 'refs/auths/*:refs/auths/*'`, \
279 or verify against an evidence bundle with `--identity-bundle`.",
280 ),
281 }
282 }
283}
284
285impl SignerKelError {
286 fn into_message(self) -> String {
288 let code = self.error_code();
289 let suggestion = self.suggestion().unwrap_or_default();
290 format!("[{code}] {self}. {suggestion}")
291 }
292}
293
294struct BundleKel {
299 did: String,
301 events: Vec<Event>,
304}
305
306fn load_bundle_trust(
313 path: &std::path::Path,
314 now: chrono::DateTime<chrono::Utc>,
315) -> std::result::Result<
316 (
317 String,
318 Vec<Event>,
319 Vec<auths_verifier::AuthenticatedDeviceKel>,
320 ),
321 String,
322> {
323 let content = fs::read_to_string(path)
324 .map_err(|e| format!("could not read identity bundle {path:?}: {e}"))?;
325 let bundle: IdentityBundle = serde_json::from_str(&content)
326 .map_err(|e| format!("identity bundle {path:?} is not valid JSON: {e}"))?;
327 let trust = BundleTrust::parse(&bundle, now)
328 .map_err(|e| format!("identity bundle {path:?} is not a usable trust anchor: {e}"))?;
329 let (root, kel, device_kels) = trust.into_parts();
330 Ok((root, kel, device_kels))
331}
332
333fn resolve_commits(commit_spec: &str) -> Result<Vec<String>> {
335 if commit_spec.contains("..") {
336 let output = git_command(&["rev-list", commit_spec])
338 .output()
339 .context("Failed to run git rev-list")?;
340
341 if !output.status.success() {
342 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
343 let lower = stderr.to_lowercase();
344
345 if lower.contains("unknown revision") || lower.contains("bad revision") {
346 return Err(anyhow!(
347 "{}",
348 format_commit_range_hint(commit_spec, stderr.trim())
349 ));
350 }
351
352 return Err(anyhow!("Invalid commit range: {}", stderr.trim()));
353 }
354
355 let commits: Vec<String> = std::str::from_utf8(&output.stdout)
356 .context("Invalid UTF-8 in git output")?
357 .lines()
358 .map(|s| s.to_string())
359 .collect();
360
361 if commits.is_empty() {
362 return Err(anyhow!("No commits in specified range"));
363 }
364 Ok(commits)
365 } else {
366 let sha = resolve_commit_sha(commit_spec)?;
368 Ok(vec![sha])
369 }
370}
371
372fn format_commit_range_hint(commit_spec: &str, raw_stderr: &str) -> String {
374 let hint = if commit_spec.contains('~') || commit_spec.contains('^') {
375 "This repository may not have enough commits for that range. \
376 Try a smaller offset (e.g. HEAD~1..HEAD) or verify with `git log --oneline`."
377 } else if commit_spec.contains("..") {
378 "One or both refs in the range do not exist. \
379 Check branch/tag names with `git branch -a` or `git tag -l`."
380 } else {
381 "The commit reference could not be resolved. \
382 Verify it exists with `git log --oneline`."
383 };
384
385 format!("Failed to resolve commit range '{commit_spec}': {raw_stderr}\n\nHint: {hint}")
386}
387
388fn try_load_attestation_from_ref(commit_sha: &str) -> Option<Attestation> {
396 let ref_name = format!("refs/auths/commits/{}", commit_sha);
397
398 let stdout = crate::subprocess::git_silent(&["show", &ref_name])?;
399 serde_json::from_str(&stdout).ok()
400}
401
402fn extract_oidc_binding_display(attestation: &Attestation) -> Option<OidcBindingDisplay> {
412 attestation
413 .oidc_binding
414 .as_ref()
415 .map(|binding| OidcBindingDisplay {
416 issuer: binding.issuer.clone(),
417 subject: binding.subject.clone(),
418 audience: binding.audience.clone(),
419 platform: binding.platform.clone(),
420 normalized_claims: binding.normalized_claims.clone(),
421 })
422}
423
424async fn resolve_signer_kel(
435 registry: &dyn RegistryBackend,
436 bundle_kels: &[BundleKel],
437 did: &str,
438) -> Result<Vec<Event>, String> {
439 if let Some(bundle) = bundle_kels.iter().find(|b| b.did == did) {
444 let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
445 auths_sdk::keri::verify_prefix_binding(&prefix, &bundle.events)
446 .map_err(|e| e.to_string())?;
447 return Ok(bundle.events.clone());
448 }
449 auths_sdk::keri::KelResolverChain::local(registry)
450 .resolve_kel(did)
451 .map_err(|e| e.to_string())
452}
453
454#[allow(clippy::too_many_arguments)]
461async fn verify_one_commit(
462 registry: &dyn RegistryBackend,
463 pinned_roots: &[String],
464 provider: &dyn auths_crypto::CryptoProvider,
465 receipt_lookup: &dyn WitnessReceiptLookup,
466 sdk_ctx: &auths_sdk::context::AuthsContext,
467 cmd: &VerifyCommitCommand,
468 bundle_kels: &[BundleKel],
469 commit_ref: &str,
470) -> VerifyCommitResult {
471 let sha = match resolve_commit_sha(commit_ref) {
472 Ok(sha) => sha,
473 Err(e) => {
474 return VerifyCommitResult::failure(
475 commit_ref.to_string(),
476 format!("Failed to resolve commit: {e}"),
477 );
478 }
479 };
480
481 let raw_commit = match raw_commit_object(&sha) {
482 Ok(c) => c,
483 Err(e) => return VerifyCommitResult::failure(sha, e.to_string()),
484 };
485
486 let (root_did, device_did) =
487 match auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit) {
488 Some(pair) => pair,
489 None => {
490 return VerifyCommitResult::failure(
491 sha,
492 "Commit carries no Auths-Id/Auths-Device trailer. The prepare-commit-msg \
493 hook installed by `auths init` adds these on every commit — if this repo \
494 sets its own core.hooksPath (e.g. husky), the hook is bypassed; run \
495 `auths doctor` to check. Backfill existing commits with `auths sign <ref>` \
496 (rewrites the commit)."
497 .to_string(),
498 );
499 }
500 };
501
502 let device_kel = match resolve_signer_kel(registry, bundle_kels, &device_did).await {
506 Ok(events) => events,
507 Err(e) => {
508 return VerifyCommitResult::failure(
509 sha,
510 SignerKelError::Unavailable {
511 did: device_did.clone(),
512 reason: e,
513 }
514 .into_message(),
515 );
516 }
517 };
518 let root_did = match device_kel.first().and_then(|e| e.delegator()) {
529 Some(delegator) => format!("did:keri:{delegator}"),
530 None => root_did,
531 };
532 let root_kel = match resolve_signer_kel(registry, bundle_kels, &root_did).await {
533 Ok(events) => events,
534 Err(e) => {
535 return VerifyCommitResult::failure(
536 sha,
537 SignerKelError::Unavailable {
538 did: root_did.clone(),
539 reason: e,
540 }
541 .into_message(),
542 );
543 }
544 };
545
546 let policy = if cmd.require_witnesses {
547 VerifierWitnessPolicy::RequireWitnesses
548 } else {
549 VerifierWitnessPolicy::Warn
550 };
551 #[allow(clippy::disallowed_methods)]
558 let now = chrono::Utc::now().timestamp();
559 let witnessed = verify_commit_against_kel_witnessed_scoped(
560 raw_commit.as_bytes(),
561 &device_kel,
562 &root_kel,
563 pinned_roots,
564 provider,
565 receipt_lookup,
566 policy,
567 now,
568 )
569 .await;
570 let verdict = if bundle_kels.is_empty() {
575 witnessed.verdict
576 } else {
577 witnessed
578 .verdict
579 .with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline)
580 };
581 let mut result = verdict_to_result(sha.clone(), verdict);
582 match witnessed.witness {
583 WitnessGateStatus::NotRequired => {}
584 WitnessGateStatus::Met => result.witness_gate = Some("met".to_string()),
585 WitnessGateStatus::UnderQuorum {
586 collected,
587 required,
588 } => {
589 result.witness_gate = Some(format!("{collected} of {required} (under quorum)"));
590 result.warnings.push(format!(
591 "Witness quorum not met for the signer's root KEL: {collected} of {required} \
592 receipts (verifying anyway; pass --require-witnesses to fail closed)."
593 ));
594 }
595 }
596
597 if let Ok(Some(quorum)) = verify_witnesses(cmd, None).await {
598 if quorum.verified < quorum.required {
599 result.valid = false;
600 if result.error.is_none() {
601 result.error = Some(format!(
602 "Witness quorum not met: {}/{}",
603 quorum.verified, quorum.required
604 ));
605 }
606 }
607 result.witness_quorum = Some(quorum);
608 }
609
610 result.oidc_binding =
611 try_load_attestation_from_ref(&sha).and_then(|att| extract_oidc_binding_display(&att));
612
613 if result.valid {
617 let now = chrono::Utc::now();
618 match auths_sdk::workflows::commit_trust::evaluate_commit_policy(
619 sdk_ctx,
620 &root_did,
621 &device_did,
622 now,
623 ) {
624 Ok(auths_sdk::workflows::commit_trust::PolicyOutcome::Evaluated(decision))
625 if !decision.is_allowed() =>
626 {
627 result.valid = false;
628 result.chain_valid = Some(false);
629 result.error = Some(format!(
630 "Org policy denied this commit: {} [{}]",
631 decision.message, decision.reason
632 ));
633 }
634 Ok(_) => {}
635 Err(e) => {
636 result.valid = false;
638 result.error = Some(format!("Org policy could not be evaluated: {e}"));
639 }
640 }
641 }
642
643 result
644}
645
646fn raw_commit_object(sha: &str) -> Result<String> {
649 let output = git_command(&["cat-file", "commit", sha])
650 .output()
651 .context("Failed to run git cat-file")?;
652 if !output.status.success() {
653 return Err(anyhow!(
654 "git cat-file commit {sha} failed: {}",
655 String::from_utf8_lossy(&output.stderr)
656 ));
657 }
658 String::from_utf8(output.stdout).context("Commit object is not valid UTF-8")
659}
660
661pub(crate) async fn commit_trusted_via_bundle(
678 sha: &str,
679 bundle_path: &std::path::Path,
680) -> std::result::Result<(), String> {
681 let raw_commit = raw_commit_object(sha).map_err(|e| e.to_string())?;
682 let (trailer_root_did, device_did) =
683 auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit)
684 .ok_or_else(|| "commit carries no Auths-Id/Auths-Device trailers".to_string())?;
685
686 let pinned_roots = super::verify_helpers::load_project_pinned_roots();
687 if pinned_roots.is_empty() {
688 return Err(
689 "no pinned roots (.auths/roots) — a bundle is evidence for a pinned root, \
690 never the source of the pin"
691 .to_string(),
692 );
693 }
694 #[allow(clippy::disallowed_methods)] let now = chrono::Utc::now();
696 let (root, kel, device_kels) = load_bundle_trust(bundle_path, now)?;
697 if !pinned_roots.contains(&root) {
698 return Err(format!(
699 "identity bundle root {root} is not independently trusted: add it to .auths/roots"
700 ));
701 }
702 let mut bundle_kels: Vec<BundleKel> = Vec::new();
703 if !kel.is_empty() {
704 bundle_kels.push(BundleKel {
705 did: root,
706 events: kel,
707 });
708 }
709 bundle_kels.extend(
710 device_kels
711 .into_iter()
712 .map(|(did, events)| BundleKel { did, events }),
713 );
714
715 let auths_home = auths_sdk::paths::auths_home().map_err(|e| e.to_string())?;
716 let registry =
717 GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home));
718 let device_kel = resolve_signer_kel(®istry, &bundle_kels, &device_did)
719 .await
720 .map_err(|e| format!("device KEL for {device_did} could not be resolved: {e}"))?;
721 let root_did = match device_kel.first().and_then(|e| e.delegator()) {
722 Some(delegator) => format!("did:keri:{delegator}"),
723 None => trailer_root_did,
724 };
725 let root_kel = resolve_signer_kel(®istry, &bundle_kels, &root_did)
726 .await
727 .map_err(|e| format!("root KEL for {root_did} could not be resolved: {e}"))?;
728
729 let provider = auths_crypto::RingCryptoProvider;
730 let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home);
731 let witnessed = verify_commit_against_kel_witnessed_scoped(
732 raw_commit.as_bytes(),
733 &device_kel,
734 &root_kel,
735 &pinned_roots,
736 &provider,
737 &receipt_lookup,
738 VerifierWitnessPolicy::Warn,
739 now.timestamp(),
740 )
741 .await;
742 let verdict = witnessed
743 .verdict
744 .with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline);
745 let result = verdict_to_result(sha.to_string(), verdict);
746 if result.valid {
747 Ok(())
748 } else {
749 Err(result.error.unwrap_or_else(|| {
750 "commit did not verify against the bundle-evidenced KELs".to_string()
751 }))
752 }
753}
754
755fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResult {
756 let mut result = VerifyCommitResult::failure(commit, String::new());
757 result.status = Some(verdict.code().to_string());
761 let trusted = verdict.is_trusted(&FreshnessPolicy::default());
764 let freshness = verdict.freshness();
765 match verdict {
766 CommitVerdict::Valid {
767 signer_did,
768 root_did,
769 duplicitous_root,
770 ..
771 } => {
772 result.valid = trusted;
773 result.freshness = Some(freshness);
774 result.ssh_valid = Some(true);
775 result.signer = Some(signer_did);
776 result.error = if trusted {
777 None
778 } else if duplicitous_root {
779 Some(format!(
780 "Root {root_did} shows KEL duplicity (a fork) — not trusted. \
781 Resolve with `auths device remove`."
782 ))
783 } else {
784 Some(format!(
785 "commit verified but its freshness is {freshness:?}; the supplied slice is \
786 older than the verifier's trust window"
787 ))
788 };
789 }
790 CommitVerdict::Unsigned => {
791 result.error = Some("No signature found".to_string());
792 }
793 CommitVerdict::GpgUnsupported => {
794 result.error = Some(
795 "GPG signatures are not verified by Auths — run `auths init` to sign with \
796 did:keri commit trailers instead."
797 .to_string(),
798 );
799 }
800 CommitVerdict::SshSignatureInvalid => {
801 result.ssh_valid = Some(false);
802 result.error = Some(
803 "SSH signature is invalid (commit tampered, wrong namespace, or bad signature)"
804 .to_string(),
805 );
806 }
807 CommitVerdict::DeviceKelInvalid(why) => {
808 result.error = Some(format!("Device KEL failed to replay: {why}"));
809 }
810 CommitVerdict::RootKelInvalid(why) => {
811 result.error = Some(format!("Root KEL failed to replay: {why}"));
812 }
813 CommitVerdict::RootNotPinned(root) => {
814 result.error = Some(format!(
815 "Root {root} is not a pinned trusted root. Pin it in .auths/roots to trust \
816 commits delegated under it."
817 ));
818 }
819 CommitVerdict::RootAbandoned => {
820 result.error =
821 Some("Root identity is abandoned (its KEL was rotated to a null key)".to_string());
822 }
823 CommitVerdict::NotDelegatedByClaimedRoot {
824 device_did,
825 root_did,
826 } => {
827 result.error = Some(format!(
828 "Device {device_did} is not delegated by the claimed root {root_did}"
829 ));
830 }
831 CommitVerdict::DelegationSealNotFound => {
832 result.error = Some(
833 "Root never anchored this device's delegated inception (no delegation seal)"
834 .to_string(),
835 );
836 }
837 CommitVerdict::DeviceRevoked => {
838 result.error = Some("Device delegation has been revoked by the root".to_string());
839 }
840 CommitVerdict::SignedAfterRevocation {
841 signed_at,
842 revoked_at,
843 ..
844 } => {
845 result.error = Some(format!(
846 "Commit was signed at/after the delegator revoked it (signed at KEL position {signed_at}, revoked at {revoked_at})"
847 ));
848 }
849 CommitVerdict::OutsideAgentScope { capability, .. } => {
850 result.error = Some(format!(
851 "Agent signed exercising capability '{capability}', outside its delegator-anchored scope"
852 ));
853 }
854 CommitVerdict::AgentExpired {
855 expired_at,
856 signed_at,
857 ..
858 } => {
859 result.error = Some(format!(
860 "Agent delegation expired (expired at {expired_at}, signed at {signed_at})"
861 ));
862 }
863 CommitVerdict::SignerKeyMismatch => {
864 result.ssh_valid = Some(false);
865 result.error = Some("Signing key is not the device's current key".to_string());
866 }
867 CommitVerdict::SignedBySupersededKey => {
868 result.ssh_valid = Some(false);
869 result.error = Some(
870 "Commit was signed by a superseded device key (the device has since rotated)"
871 .to_string(),
872 );
873 }
874 CommitVerdict::WitnessQuorumNotMet {
875 root_did,
876 collected,
877 required,
878 } => {
879 result.error = Some(format!(
880 "Witness quorum not met for root {root_did}: {collected} of {required} required \
881 receipts. Drop --require-witnesses to verify with a warning instead."
882 ));
883 }
884 }
885 result
886}
887
888async fn verify_witnesses(
890 cmd: &VerifyCommitCommand,
891 bundle: Option<&IdentityBundle>,
892) -> Result<Option<WitnessQuorum>> {
893 let receipts_path = match cmd.witness_receipts {
894 Some(ref p) => p,
895 None => return Ok(None),
896 };
897
898 let receipts_bytes = fs::read(receipts_path)
899 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
900
901 let receipts: Vec<SignedReceipt> =
902 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
903
904 let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
905
906 let config = WitnessVerifyConfig {
907 receipts: &receipts,
908 witness_keys: &witness_keys,
909 threshold: cmd.witness_threshold,
910 };
911
912 if let Some(bundle) = bundle
914 && !bundle.attestation_chain.is_empty()
915 {
916 let root_pk_bytes = hex::decode(bundle.public_key_hex.as_str())
917 .context("Invalid public key hex in bundle")?;
918 let root_pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &root_pk_bytes)
919 .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
920
921 let report = verify_chain_with_witnesses(&bundle.attestation_chain, &root_pk, &config)
922 .await
923 .context("Witness chain verification failed")?;
924
925 return Ok(report.witness_quorum);
926 }
927
928 let provider = auths_crypto::RingCryptoProvider;
930 let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await;
931 Ok(Some(quorum))
932}
933
934fn output_results(results: &[VerifyCommitResult]) -> Result<()> {
936 let all_valid = results.iter().all(|r| r.valid);
937
938 if is_json_mode() {
939 if results.len() == 1 {
940 println!("{}", serde_json::to_string(&results[0])?);
941 } else {
942 println!("{}", serde_json::to_string(&results)?);
943 }
944 } else if results.len() == 1 {
945 let r = &results[0];
946 if r.valid {
947 if let Some(ref signer) = r.signer {
948 print!("Commit {} verified: signed by {}", r.commit, signer);
949 } else {
950 print!("Commit {} verified", r.commit);
951 }
952 print_chain_witness_summary(r);
953 println!();
954 } else {
955 eprint!("Verification failed for {}", r.commit);
956 if let Some(ref error) = r.error {
957 eprint!(": {}", error);
958 }
959 print_chain_witness_summary_stderr(r);
960 eprintln!();
961 }
962 for w in &r.warnings {
963 eprintln!("Warning: {}", w);
964 }
965 } else {
966 for r in results {
967 print!(
968 "{}: {}",
969 &r.commit[..8.min(r.commit.len())],
970 format_result_text(r)
971 );
972 println!();
973 }
974 }
975
976 if all_valid {
977 Ok(())
978 } else {
979 std::process::exit(1);
980 }
981}
982
983fn format_result_text(result: &VerifyCommitResult) -> String {
985 let status = if result.valid { "valid" } else { "INVALID" };
986
987 let mut parts = vec![status.to_string()];
988
989 if let Some(ref signer) = result.signer {
990 parts.push(format!("signer: {}", signer));
991 }
992
993 if let Some(cv) = result.chain_valid {
994 let chain_desc = if cv {
995 "chain: valid".to_string()
996 } else if let Some(ref report) = result.chain_report {
997 format!("chain: {}", format_chain_status(&report.status))
998 } else {
999 "chain: invalid".to_string()
1000 };
1001 parts.push(chain_desc);
1002 }
1003
1004 if let Some(ref q) = result.witness_quorum {
1005 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
1006 }
1007
1008 if let Some(ref gate) = result.witness_gate {
1009 parts.push(format!("witness-gate: {gate}"));
1010 }
1011
1012 if let Some(ref binding) = result.oidc_binding {
1013 parts.push(format!("oidc: {}", binding.issuer));
1014 }
1015
1016 if let Some(ref error) = result.error
1017 && result.signer.is_none()
1018 && result.chain_valid.is_none()
1019 && result.witness_quorum.is_none()
1020 {
1021 parts.push(error.clone());
1022 }
1023
1024 if parts.len() == 1 {
1025 parts[0].clone()
1026 } else {
1027 format!("{} ({})", parts[0], parts[1..].join(", "))
1028 }
1029}
1030
1031fn format_chain_status(status: &auths_verifier::VerificationStatus) -> String {
1033 match status {
1034 auths_verifier::VerificationStatus::Valid => "valid".to_string(),
1035 auths_verifier::VerificationStatus::Expired { at } => {
1036 format!("expired at {}", at.to_rfc3339())
1037 }
1038 auths_verifier::VerificationStatus::Revoked { at } => match at {
1039 Some(t) => format!("revoked at {}", t.to_rfc3339()),
1040 None => "revoked".to_string(),
1041 },
1042 auths_verifier::VerificationStatus::InvalidSignature { step } => {
1043 format!("invalid signature at step {}", step)
1044 }
1045 auths_verifier::VerificationStatus::BrokenChain { missing_link } => {
1046 format!("broken chain: {}", missing_link)
1047 }
1048 auths_verifier::VerificationStatus::InsufficientWitnesses { required, verified } => {
1049 format!("witnesses: {}/{} quorum not met", verified, required)
1050 }
1051 }
1052}
1053
1054fn print_chain_witness_summary(r: &VerifyCommitResult) {
1056 let mut parts = Vec::new();
1057
1058 if let Some(cv) = r.chain_valid {
1059 if cv {
1060 parts.push("chain: valid".to_string());
1061 } else {
1062 parts.push("chain: invalid".to_string());
1063 }
1064 }
1065
1066 if let Some(ref q) = r.witness_quorum {
1067 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
1068 }
1069
1070 if let Some(ref gate) = r.witness_gate {
1071 parts.push(format!("witness-gate: {gate}"));
1072 }
1073
1074 if let Some(ref binding) = r.oidc_binding {
1075 parts.push(format!("oidc: {} ({})", binding.issuer, binding.subject));
1076 }
1077
1078 if !parts.is_empty() {
1079 print!(" ({})", parts.join(", "));
1080 }
1081}
1082
1083fn print_chain_witness_summary_stderr(r: &VerifyCommitResult) {
1085 if let Some(cv) = r.chain_valid
1086 && !cv
1087 && let Some(ref report) = r.chain_report
1088 {
1089 eprint!(" (chain: {})", format_chain_status(&report.status));
1090 }
1091 if let Some(ref q) = r.witness_quorum
1092 && q.verified < q.required
1093 {
1094 eprint!(" (witnesses: {}/{} quorum not met)", q.verified, q.required);
1095 }
1096}
1097
1098fn resolve_commit_sha(commit_ref: &str) -> Result<String> {
1099 super::git_helpers::resolve_commit_sha(commit_ref)
1100}
1101
1102fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> {
1103 if is_json_mode() {
1104 let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string());
1105 println!("{}", serde_json::to_string(&result)?);
1106 } else {
1107 eprintln!("Error: {}", message);
1108 }
1109 std::process::exit(exit_code);
1110}
1111
1112impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand {
1113 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
1114 let rt = tokio::runtime::Runtime::new()?;
1115 rt.block_on(handle_verify_commit(self.clone(), ctx))
1116 }
1117}
1118
1119#[cfg(test)]
1120#[allow(clippy::disallowed_methods)]
1121mod tests {
1122 use super::*;
1123
1124 #[test]
1125 fn verify_commit_result_failure_helper() {
1126 let r = VerifyCommitResult::failure("abc123".into(), "bad sig".into());
1127 assert!(!r.valid);
1128 assert_eq!(r.commit, "abc123");
1129 assert_eq!(r.error.as_deref(), Some("bad sig"));
1130 assert!(r.ssh_valid.is_none());
1131 assert!(r.chain_valid.is_none());
1132 assert!(r.witness_quorum.is_none());
1133 }
1134
1135 #[test]
1136 fn verify_commit_result_json_includes_new_fields() {
1137 let r = VerifyCommitResult {
1138 commit: "abc123".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: None,
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!["expiring soon".into()],
1155 };
1156 let json = serde_json::to_string(&r).unwrap();
1157 assert!(json.contains("\"ssh_valid\":true"));
1158 assert!(json.contains("\"chain_valid\":true"));
1159 assert!(json.contains("\"witness_quorum\""));
1160 assert!(json.contains("\"warnings\":[\"expiring soon\"]"));
1161 }
1162
1163 #[test]
1164 fn verify_commit_result_json_omits_none_fields() {
1165 let r = VerifyCommitResult::failure("abc".into(), "err".into());
1166 let json = serde_json::to_string(&r).unwrap();
1167 assert!(!json.contains("ssh_valid"));
1168 assert!(!json.contains("chain_valid"));
1169 assert!(!json.contains("chain_report"));
1170 assert!(!json.contains("witness_quorum"));
1171 assert!(!json.contains("warnings"));
1172 }
1173
1174 #[test]
1175 fn format_result_text_valid_ssh_only() {
1176 let r = VerifyCommitResult {
1177 commit: "abc12345".into(),
1178 valid: true,
1179 status: Some("valid".into()),
1180 freshness: None,
1181 ssh_valid: Some(true),
1182 chain_valid: None,
1183 chain_report: None,
1184 witness_quorum: None,
1185 witness_gate: None,
1186 signer: Some("did:keri:test".into()),
1187 oidc_binding: None,
1188 error: None,
1189 warnings: vec![],
1190 };
1191 let text = format_result_text(&r);
1192 assert!(text.contains("valid"));
1193 assert!(text.contains("signer: did:keri:test"));
1194 }
1195
1196 #[test]
1197 fn format_result_text_valid_with_chain_and_witnesses() {
1198 let r = VerifyCommitResult {
1199 commit: "abc12345".into(),
1200 valid: true,
1201 status: Some("valid".into()),
1202 freshness: None,
1203 ssh_valid: Some(true),
1204 chain_valid: Some(true),
1205 chain_report: Some(VerificationReport::valid(vec![])),
1206 witness_quorum: Some(WitnessQuorum {
1207 required: 2,
1208 verified: 2,
1209 receipts: vec![],
1210 }),
1211 witness_gate: Some("met".into()),
1212 signer: Some("did:keri:test".into()),
1213 oidc_binding: None,
1214 error: None,
1215 warnings: vec![],
1216 };
1217 let text = format_result_text(&r);
1218 assert!(text.contains("chain: valid"));
1219 assert!(text.contains("witnesses: 2/2"));
1220 assert!(text.contains("witness-gate: met"));
1221 }
1222
1223 #[test]
1224 fn verify_output_shows_quorum() {
1225 let mut r = VerifyCommitResult::failure("abc".into(), String::new());
1226 r.valid = true;
1227 r.error = None;
1228 r.witness_gate = Some("2 of 3 (under quorum)".into());
1229
1230 let text = format_result_text(&r);
1231 assert!(text.contains("witness-gate: 2 of 3 (under quorum)"));
1232 let json = serde_json::to_string(&r).unwrap();
1233 assert!(json.contains("\"witness_gate\":\"2 of 3 (under quorum)\""));
1234 }
1235
1236 #[test]
1237 fn verify_output_fails_closed_on_fork() {
1238 let result = verdict_to_result(
1241 "sha".into(),
1242 CommitVerdict::Valid {
1243 signer_did: "did:keri:dev".into(),
1244 root_did: "did:keri:root".into(),
1245 duplicitous_root: true,
1246 as_of: 0,
1247 freshness: auths_verifier::freshness::Freshness::Unknown,
1248 },
1249 );
1250 assert!(!result.valid, "a duplicitous root must fail closed");
1251 assert!(
1252 result
1253 .error
1254 .as_deref()
1255 .unwrap_or_default()
1256 .to_lowercase()
1257 .contains("duplicity"),
1258 "expected a duplicity error, got {:?}",
1259 result.error
1260 );
1261 }
1262
1263 #[test]
1264 fn format_result_text_invalid_with_error() {
1265 let r = VerifyCommitResult::failure("abc12345".into(), "No signature found".into());
1266 let text = format_result_text(&r);
1267 assert!(text.contains("INVALID"));
1268 assert!(text.contains("No signature found"));
1269 }
1270
1271 #[test]
1272 fn absent_signer_kel_message_carries_code_and_fetch_remedy() {
1273 let msg = SignerKelError::Unavailable {
1276 did: "did:keri:Eteammate".into(),
1277 reason: "KEL not found".into(),
1278 }
1279 .into_message();
1280 assert!(
1281 msg.contains("AUTHS-E6301"),
1282 "message must carry the code: {msg}"
1283 );
1284 assert!(
1285 msg.contains("git fetch"),
1286 "message must name the fetch remedy: {msg}"
1287 );
1288 assert!(msg.contains("refs/auths/*"));
1289 }
1290}