1use anyhow::{Context, Result, anyhow};
2use serde::Serialize;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use auths_keri::witness::SignedReceipt;
7use auths_verifier::core::Attestation;
8use auths_verifier::evidence_pack::{
9 TransparencyInclusion, parse_log_key_hex, verify_artifact_log_inclusion,
10};
11use auths_verifier::freshness::FreshnessPolicy;
12use auths_verifier::oidc_policy::{OidcPolicyJoin, OidcSubjectPolicy};
13use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig};
14use auths_verifier::{
15 CanonicalDid, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses,
16};
17
18use super::core::{ArtifactMetadata, ArtifactSource};
19use super::file::FileArtifact;
20use crate::commands::verify_helpers::parse_witness_keys;
21use crate::ux::format::is_json_mode;
22
23#[derive(Serialize)]
25struct VerifyArtifactResult {
26 file: String,
27 valid: bool,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 digest_match: Option<bool>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 chain_valid: Option<bool>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 chain_report: Option<VerificationReport>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 witness_quorum: Option<WitnessQuorum>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 issuer: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 commit_sha: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 commit_verified: Option<bool>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 oidc_join: Option<OidcPolicyJoin>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 error: Option<String>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum EphemeralAnchor {
58 Required,
61 SignatureOnly,
66}
67
68pub struct VerifyArtifactArgs {
72 pub signature: Option<PathBuf>,
74 pub identity_bundle: Option<PathBuf>,
76 pub witness_receipts: Option<PathBuf>,
78 pub witness_keys: Vec<String>,
80 pub witness_threshold: usize,
82 pub verify_commit: bool,
84 pub ephemeral_anchor: EphemeralAnchor,
86 pub oidc_policy: Option<PathBuf>,
88 pub oidc_policy_did: Option<String>,
91 pub log_evidence: Option<PathBuf>,
93 pub log_key: Option<String>,
96 pub expect_signer: Option<String>,
100}
101
102fn expected_signer_mismatch(issuer: &str, expect: Option<&str>) -> Option<String> {
111 match expect {
112 Some(want) if want != issuer => Some(format!(
113 "Signer mismatch: verified signer {issuer} is not the expected signer {want}"
114 )),
115 _ => None,
116 }
117}
118
119pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> {
123 let VerifyArtifactArgs {
124 signature,
125 identity_bundle,
126 witness_receipts,
127 witness_keys,
128 witness_threshold,
129 verify_commit,
130 ephemeral_anchor,
131 oidc_policy,
132 oidc_policy_did,
133 log_evidence,
134 log_key,
135 expect_signer,
136 } = args;
137 let witness_keys = &witness_keys;
138 let file_str = file.to_string_lossy().to_string();
139
140 let oidc_policy = match (&oidc_policy, &oidc_policy_did) {
144 (None, None) => None,
145 (Some(path), _) => {
146 let raw = match fs::read_to_string(path) {
147 Ok(r) => r,
148 Err(e) => {
149 return output_error(
150 &file_str,
151 2,
152 &format!("Failed to read OIDC policy {path:?}: {e}"),
153 );
154 }
155 };
156 match OidcSubjectPolicy::parse(&raw) {
157 Ok(p) => Some(p),
158 Err(e) => {
159 return output_error(&file_str, 2, &format!("{e}"));
160 }
161 }
162 }
163 (None, Some(org_did)) => match resolve_anchored_oidc_policy(org_did) {
164 Ok(p) => Some(p),
165 Err((exit_code, message)) => {
166 return output_error(&file_str, exit_code, &message);
167 }
168 },
169 };
170
171 let sig_path = signature.unwrap_or_else(|| {
173 let mut p = file.to_path_buf();
174 let new_name = format!(
175 "{}.auths.json",
176 p.file_name().unwrap_or_default().to_string_lossy()
177 );
178 p.set_file_name(new_name);
179 p
180 });
181
182 let sig_content = match fs::read_to_string(&sig_path) {
183 Ok(c) => c,
184 Err(e) => {
185 return output_error(
186 &file_str,
187 2,
188 &format!("Failed to read signature file {:?}: {}", sig_path, e),
189 );
190 }
191 };
192
193 let attestation: Attestation = match serde_json::from_str(&sig_content) {
195 Ok(a) => a,
196 Err(e) => {
197 return output_error(&file_str, 2, &format!("Failed to parse attestation: {}", e));
198 }
199 };
200
201 let artifact_meta: ArtifactMetadata = match &attestation.payload {
203 Some(payload) => match serde_json::from_value(payload.clone()) {
204 Ok(m) => m,
205 Err(e) => {
206 return output_error(
207 &file_str,
208 2,
209 &format!("Failed to parse artifact metadata from payload: {}", e),
210 );
211 }
212 },
213 None => {
214 return output_error(
215 &file_str,
216 2,
217 "Attestation has no payload (expected artifact metadata)",
218 );
219 }
220 };
221
222 let file_artifact = FileArtifact::new(file);
224 let file_digest = match file_artifact.digest() {
225 Ok(d) => d,
226 Err(e) => {
227 return output_error(
228 &file_str,
229 2,
230 &format!("Failed to compute file digest: {}", e),
231 );
232 }
233 };
234
235 if file_digest != artifact_meta.digest {
236 return output_result(
237 1,
238 VerifyArtifactResult {
239 file: file_str.clone(),
240 valid: false,
241 digest_match: Some(false),
242 chain_valid: None,
243 chain_report: None,
244 witness_quorum: None,
245 issuer: Some(attestation.issuer.to_string()),
246 commit_sha: attestation.commit_sha.clone(),
247 commit_verified: None,
248 oidc_join: None,
249 error: Some(format!(
250 "Digest mismatch: file={}, attestation={}",
251 file_digest.hex, artifact_meta.digest.hex
252 )),
253 },
254 );
255 }
256
257 let (root_pk, identity_did) = match resolve_identity_key(&identity_bundle, &attestation) {
262 Ok(v) => v,
263 Err(e) => {
264 return output_error(&file_str, 1, &format!("{e:#}"));
265 }
266 };
267
268 let chain = vec![attestation.clone()];
272 let chain_result = verify_chain(&chain, &root_pk).await;
273
274 let (chain_valid, mut chain_report) = match chain_result {
275 Ok(mut report) => {
276 if let Ok(home) = auths_sdk::paths::auths_home() {
277 let storage = auths_sdk::storage::RegistryAttestationStorage::new(&home);
278 if let Ok(enriched) = storage.load_all_enriched() {
279 let anchor_set: std::collections::HashSet<auths_keri::Said> = enriched
280 .iter()
281 .filter(|e| e.anchor == auths_keri::AnchorStatus::Anchored)
282 .map(|e| e.said.clone())
283 .collect();
284 let all_anchored = chain.iter().all(|att| {
285 auths_sdk::attestation::canonical_said(att)
286 .is_some_and(|s| anchor_set.contains(&s))
287 });
288 report.anchored = Some(if all_anchored {
289 auths_keri::AnchorStatus::Anchored
290 } else {
291 auths_keri::AnchorStatus::NotAnchored
292 });
293 }
294 }
295 let is_valid = report.is_valid();
296 (Some(is_valid), Some(report))
297 }
298 Err(e) => {
299 return output_error(&file_str, 1, &format!("Chain verification failed: {}", e));
300 }
301 };
302
303 if let Some(msg) =
306 expected_signer_mismatch(attestation.issuer.as_str(), expect_signer.as_deref())
307 {
308 return output_result(
309 1,
310 VerifyArtifactResult {
311 file: file_str.clone(),
312 valid: false,
313 digest_match: Some(true),
314 chain_valid,
315 chain_report: chain_report.clone(),
316 witness_quorum: None,
317 issuer: Some(attestation.issuer.to_string()),
318 commit_sha: attestation.commit_sha.clone(),
319 commit_verified: None,
320 oidc_join: None,
321 error: Some(msg),
322 },
323 );
324 }
325
326 let mut log_anchor_error: Option<String> = None;
333 if let Some(evidence_path) = &log_evidence {
334 let raw = match fs::read_to_string(evidence_path) {
335 Ok(r) => r,
336 Err(e) => {
337 return output_error(
338 &file_str,
339 2,
340 &format!("Failed to read log evidence {evidence_path:?}: {e}"),
341 );
342 }
343 };
344 let evidence: TransparencyInclusion = match serde_json::from_str(&raw) {
345 Ok(t) => t,
346 Err(e) => {
347 return output_error(&file_str, 2, &format!("Failed to parse log evidence: {e}"));
348 }
349 };
350 let Some(key_hex) = log_key.as_deref() else {
352 return output_error(&file_str, 2, "--log-evidence requires --log-key");
353 };
354 let pinned_key = match parse_log_key_hex(key_hex) {
355 Ok(k) => k,
356 Err(e) => return output_error(&file_str, 2, &format!("{e}")),
357 };
358 let canonical_digest = match auths_sdk::workflows::compliance::ArtifactDigest::parse(
361 &format!("{}:{}", file_digest.algorithm, file_digest.hex),
362 ) {
363 Ok(d) => d,
364 Err(e) => {
365 return output_error(
366 &file_str,
367 2,
368 &format!("Cannot derive the artifact's canonical log leaf: {e}"),
369 );
370 }
371 };
372 match verify_artifact_log_inclusion(canonical_digest.as_str(), &evidence, &pinned_key) {
373 Ok(()) => {
374 if let Some(report) = chain_report.as_mut() {
375 report.anchored = Some(auths_keri::AnchorStatus::Anchored);
376 }
377 if !is_json_mode() {
378 eprintln!(
379 " Transparency: {} anchored in log '{}' \
380 (offline inclusion proof, operator key pinned)",
381 canonical_digest.as_str(),
382 evidence.signed_checkpoint.checkpoint.origin
383 );
384 }
385 }
386 Err(e) => {
387 if let Some(report) = chain_report.as_mut() {
388 report.anchored = Some(auths_keri::AnchorStatus::NotAnchored);
389 }
390 log_anchor_error = Some(format!("Transparency anchoring failed: {e}"));
391 }
392 }
393 }
394
395 let witness_quorum = match verify_witnesses(
397 &chain,
398 &root_pk,
399 &witness_receipts,
400 witness_keys,
401 witness_threshold,
402 )
403 .await
404 {
405 Ok(q) => q,
406 Err(e) => {
407 return output_error(&file_str, 2, &format!("Witness verification error: {}", e));
408 }
409 };
410
411 let mut valid = chain_valid.unwrap_or(false);
413
414 if let Some(ref q) = witness_quorum
415 && q.verified < q.required
416 {
417 valid = false;
418 }
419
420 if let Some(ref msg) = log_anchor_error {
421 valid = false;
422 if !is_json_mode() {
423 eprintln!(" {msg}");
424 }
425 }
426
427 let is_ephemeral = attestation.issuer.as_str().starts_with("did:key:");
433 if is_ephemeral && valid {
434 match ephemeral_anchor {
435 EphemeralAnchor::SignatureOnly => {
436 if !is_json_mode() {
437 eprintln!(
438 " Signature-only: ephemeral signature over the artifact digest \
439 verifies; commit-anchor leg NOT checked (signer self-check)."
440 );
441 }
442 }
443 EphemeralAnchor::Required => match &attestation.commit_sha {
444 None => {
445 if !is_json_mode() {
446 eprintln!(
447 "Error: ephemeral attestation (did:key issuer) requires commit_sha. \
448 This attestation is unsigned provenance without a commit anchor."
449 );
450 }
451 valid = false;
452 }
453 Some(sha) => {
454 let commit_sig_ok = verify_commit_in_process(sha).await;
457
458 if !commit_sig_ok {
459 valid = false;
460 }
461
462 if !is_json_mode() {
463 if commit_sig_ok {
464 eprintln!(
465 " Trust chain: artifact <- ephemeral key <- commit {} <- maintainer",
466 &sha[..8.min(sha.len())]
467 );
468 } else {
469 eprintln!(
470 " Commit {} is not signed by a trusted maintainer.",
471 &sha[..8.min(sha.len())]
472 );
473 }
474 }
475 }
476 },
477 }
478 }
479
480 let mut oidc_error: Option<String> = None;
486 let oidc_join = match &oidc_policy {
487 None => None,
488 Some(_) if !valid => {
489 None
492 }
493 Some(policy) => match &attestation.oidc_binding {
494 None => {
495 valid = false;
496 oidc_error = Some(
497 "OIDC policy join failed: attestation carries no OIDC binding \
498 — signer presented no verified OIDC identity"
499 .to_string(),
500 );
501 None
502 }
503 Some(binding) => match policy.join(binding) {
504 Ok(join) => {
505 if !is_json_mode() {
506 eprintln!(
507 " OIDC policy join: {} via {} (issuer {})",
508 join.repository,
509 join.workflow_ref.as_deref().unwrap_or("any workflow"),
510 join.issuer
511 );
512 }
513 Some(join)
514 }
515 Err(e) => {
516 valid = false;
517 oidc_error = Some(format!("OIDC policy join failed: {e}"));
518 None
519 }
520 },
521 },
522 };
523 if let Some(ref msg) = oidc_error
524 && !is_json_mode()
525 {
526 eprintln!(" {msg}");
527 }
528
529 let commit_sha_val = attestation.commit_sha.clone();
531 if let Some(ref sha) = commit_sha_val
532 && !is_json_mode()
533 && !is_ephemeral
534 {
535 eprintln!(" Commit: {}", sha);
536 }
537
538 let commit_verified = if verify_commit {
540 match &commit_sha_val {
541 None => {
542 if !is_json_mode() {
543 eprintln!(
544 "warning: artifact attestation has no commit_sha field; \
545 re-sign with: auths artifact sign --commit <SHA>"
546 );
547 }
548 None
549 }
550 Some(sha) => {
551 let commit_ref = format!("refs/auths/commits/{}", sha);
553 let lookup = crate::subprocess::git_command(&[
554 "show",
555 &format!("{}:attestation.json", commit_ref),
556 ])
557 .output();
558 match lookup {
559 Ok(output) if output.status.success() => {
560 if !is_json_mode() {
561 eprintln!(" Commit {}: signing attestation found", &sha[..12]);
562 }
563 Some(true)
564 }
565 _ => {
566 if !is_json_mode() {
567 eprintln!(
568 "warning: no signing attestation found for commit {}",
569 &sha[..std::cmp::min(sha.len(), 12)]
570 );
571 }
572 Some(false)
573 }
574 }
575 }
576 }
577 } else {
578 None
579 };
580
581 let exit_code = if valid { 0 } else { 1 };
582
583 output_result(
584 exit_code,
585 VerifyArtifactResult {
586 file: file_str,
587 valid,
588 digest_match: Some(true),
589 chain_valid,
590 chain_report,
591 witness_quorum,
592 issuer: Some(identity_did.to_string()),
593 commit_sha: commit_sha_val,
594 commit_verified,
595 oidc_join,
596 error: log_anchor_error.or(oidc_error),
597 },
598 )
599}
600
601fn resolve_anchored_oidc_policy(org_did: &str) -> Result<OidcSubjectPolicy, (i32, String)> {
609 use auths_sdk::workflows::org::load_org_oidc_policy;
610
611 let Some(prefix) = org_did.strip_prefix("did:keri:") else {
612 return Err((
613 2,
614 format!("--oidc-policy-did requires a did:keri: identifier, got '{org_did}'"),
615 ));
616 };
617 let auths_home = auths_sdk::paths::auths_home()
618 .map_err(|e| (2, format!("Could not locate ~/.auths: {e}")))?;
619 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
620 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
621 );
622 let org_prefix = auths_verifier::Prefix::new_unchecked(prefix.to_string());
623
624 match load_org_oidc_policy(®istry, &org_prefix) {
625 Ok(Some(loaded)) => {
626 if !is_json_mode() {
627 eprintln!(
628 " OIDC policy resolved from the org KEL (digest {})",
629 loaded.policy_digest
630 );
631 }
632 Ok(loaded.policy)
633 }
634 Ok(None) => Err((
635 2,
636 format!(
637 "organization {org_did} has no OIDC-subject policy anchored on its KEL \
638 — anchor one with `auths org anchor-oidc-policy`"
639 ),
640 )),
641 Err(e @ auths_sdk::domains::org::error::OrgError::PolicyIntegrity { .. }) => {
642 Err((1, format!("OIDC policy resolution failed: {e}")))
643 }
644 Err(e) => Err((
645 2,
646 format!("Failed to resolve the anchored OIDC policy: {e}"),
647 )),
648 }
649}
650
651fn resolve_identity_key(
653 identity_bundle: &Option<PathBuf>,
654 attestation: &Attestation,
655) -> Result<(auths_verifier::DevicePublicKey, CanonicalDid)> {
656 if let Some(bundle_path) = identity_bundle {
657 let bundle_content = fs::read_to_string(bundle_path)
658 .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?;
659 let bundle: IdentityBundle = serde_json::from_str(&bundle_content)
660 .with_context(|| format!("Failed to parse identity bundle: {:?}", bundle_path))?;
661
662 let trust = auths_verifier::BundleTrust::parse(&bundle, chrono::Utc::now())
672 .map_err(|e| anyhow!("identity bundle is not a trustworthy anchor: {e}"))?;
673
674 let state = auths_keri::TrustedKel::from_trusted_source(trust.kel())
682 .replay()
683 .map_err(|e| anyhow!("identity bundle KEL is not replayable: {e}"))?;
684 let key = state
685 .current_key()
686 .ok_or_else(|| anyhow!("identity bundle KEL has no current key"))?;
687 let (key_bytes, curve) = match key
688 .parse()
689 .map_err(|e| anyhow!("identity bundle current key is unsupported: {e}"))?
690 {
691 auths_keri::KeriPublicKey::Ed25519 { key, .. } => {
692 (key.to_vec(), auths_crypto::CurveType::Ed25519)
693 }
694 auths_keri::KeriPublicKey::P256 { key, .. } => {
695 (key.to_vec(), auths_crypto::CurveType::P256)
696 }
697 };
698 let pk = auths_verifier::DevicePublicKey::try_new(curve, &key_bytes)
699 .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
700
701 let (root_did, _kel, _device_kels) = trust.into_parts();
704 Ok((pk, CanonicalDid::new_unchecked(root_did)))
705 } else {
706 let issuer = &attestation.issuer;
708 let (pk_bytes, curve) = resolve_pk_from_did(issuer)
709 .with_context(|| format!("Failed to resolve public key from issuer DID '{}'. Use --identity-bundle for stateless verification.", issuer))?;
710 let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
711 .map_err(|e| anyhow!("Invalid issuer public key resolved from DID: {e}"))?;
712 Ok((pk, issuer.clone()))
713 }
714}
715
716fn resolve_pk_from_did(did: &str) -> Result<(Vec<u8>, auths_crypto::CurveType)> {
724 if did.starts_with("did:keri:") {
725 let auths_home = auths_sdk::paths::auths_home()
726 .map_err(|e| anyhow!("Could not locate ~/.auths: {e}"))?;
727 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
728 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
729 );
730 let (pk, curve) = auths_sdk::keri::resolve_current_public_key(®istry, did)?;
731 Ok((pk, curve))
732 } else if did.starts_with("did:key:z") {
733 match auths_crypto::did_key_decode(did) {
734 Ok(auths_crypto::DecodedDidKey::Ed25519(pk)) => {
735 Ok((pk.to_vec(), auths_crypto::CurveType::Ed25519))
736 }
737 Ok(auths_crypto::DecodedDidKey::P256(pk)) => Ok((pk, auths_crypto::CurveType::P256)),
738 Err(e) => Err(anyhow!("Failed to resolve did:key: {}", e)),
739 }
740 } else {
741 Err(anyhow!(
742 "Unsupported DID method: {}. Use --identity-bundle instead.",
743 did
744 ))
745 }
746}
747
748async fn verify_witnesses(
750 chain: &[Attestation],
751 root_pk: &auths_verifier::DevicePublicKey,
752 receipts_path: &Option<PathBuf>,
753 witness_keys_raw: &[String],
754 threshold: usize,
755) -> Result<Option<WitnessQuorum>> {
756 let receipts_path = match receipts_path {
757 Some(p) => p,
758 None => return Ok(None),
759 };
760
761 let receipts_bytes = fs::read(receipts_path)
762 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
763 let receipts: Vec<SignedReceipt> =
764 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
765
766 let witness_keys = parse_witness_keys(witness_keys_raw)?;
767
768 let config = WitnessVerifyConfig {
769 receipts: &receipts,
770 witness_keys: &witness_keys,
771 threshold,
772 };
773
774 let report = verify_chain_with_witnesses(chain, root_pk, &config)
775 .await
776 .context("Witness chain verification failed")?;
777
778 Ok(report.witness_quorum)
779}
780
781fn output_error(file: &str, exit_code: i32, message: &str) -> Result<()> {
782 if is_json_mode() {
783 let result = VerifyArtifactResult {
784 file: file.to_string(),
785 valid: false,
786 digest_match: None,
787 chain_valid: None,
788 chain_report: None,
789 witness_quorum: None,
790 issuer: None,
791 commit_sha: None,
792 commit_verified: None,
793 oidc_join: None,
794 error: Some(message.to_string()),
795 };
796 println!("{}", serde_json::to_string(&result)?);
797 } else {
798 eprintln!("Error: {}", message);
799 }
800 std::process::exit(exit_code);
801}
802
803use crate::commands::verify_helpers::freshness_label;
804
805fn verified_summary(result: &VerifyArtifactResult) -> String {
814 let mut line = String::from("Artifact verified");
815 if let Some(ref issuer) = result.issuer {
816 line.push_str(&format!(": signed by {issuer}"));
817 }
818 if let Some(freshness) = result.chain_report.as_ref().map(|r| r.freshness()) {
819 line.push_str(&format!(" (freshness {})", freshness_label(freshness)));
820 }
821 if let Some(ref q) = result.witness_quorum {
822 line.push_str(&format!(" (witnesses: {}/{})", q.verified, q.required));
823 }
824 line
825}
826
827fn output_result(exit_code: i32, result: VerifyArtifactResult) -> Result<()> {
829 if is_json_mode() {
830 println!("{}", serde_json::to_string(&result)?);
831 } else if result.valid {
832 println!("{}", verified_summary(&result));
833 } else {
834 eprint!("Verification failed");
835 if let Some(ref error) = result.error {
836 eprint!(": {}", error);
837 }
838 eprintln!();
839 }
840
841 if exit_code != 0 {
842 std::process::exit(exit_code);
843 }
844 Ok(())
845}
846
847async fn verify_commit_in_process(sha: &str) -> bool {
854 let repo = match git2::Repository::discover(".") {
856 Ok(r) => r,
857 Err(e) => {
858 if !is_json_mode() {
859 eprintln!("Failed to open git repository: {e}");
860 }
861 return false;
862 }
863 };
864
865 let oid = match git2::Oid::from_str(sha) {
867 Ok(o) => o,
868 Err(e) => {
869 if !is_json_mode() {
870 eprintln!("Invalid commit SHA '{}': {e}", &sha[..8.min(sha.len())]);
871 }
872 return false;
873 }
874 };
875
876 let commit_content = match raw_commit_bytes(&repo, oid) {
881 Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
882 Err(e) => {
883 if !is_json_mode() {
884 eprintln!("Commit {} not found: {e}", &sha[..8.min(sha.len())]);
885 }
886 return false;
887 }
888 };
889
890 let provider = auths_crypto::RingCryptoProvider;
893 let auths_home = match auths_sdk::paths::auths_home() {
894 Ok(h) => h,
895 Err(e) => {
896 if !is_json_mode() {
897 eprintln!("Could not locate ~/.auths: {e}");
898 }
899 return false;
900 }
901 };
902 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
903 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
904 );
905 let pinned_roots = crate::commands::verify_helpers::load_project_pinned_roots();
906
907 let short = &sha[..8.min(sha.len())];
908 match auths_sdk::workflows::commit_trust::verify_commit_local(
909 ®istry,
910 &pinned_roots,
911 commit_content.as_bytes(),
912 &provider,
913 )
914 .await
915 {
916 Ok(verdict) if verdict.is_trusted(&FreshnessPolicy::default()) => true,
917 Ok(verdict) => {
918 if !is_json_mode() {
919 eprintln!("Commit {short} is not authorized by a pinned trusted root: {verdict:?}");
920 }
921 false
922 }
923 Err(e) => {
924 if !is_json_mode() {
925 eprintln!("Commit {short} trust could not be resolved: {e}");
926 }
927 false
928 }
929 }
930}
931
932pub fn handle_offline_verify(
952 file: &Path,
953 roots: Option<&Path>,
954 member: Option<&str>,
955 signed_at: Option<u128>,
956 json: bool,
957) -> Result<()> {
958 use auths_sdk::workflows::org::{AirGappedOrgBundle, AuthorityAtSigning, verify_org_bundle};
959 use auths_sdk::workflows::roots::parse_roots_typed;
960 use auths_verifier::Prefix;
961 use auths_verifier::types::IdentityDID;
962
963 let bundle_json =
964 fs::read_to_string(file).with_context(|| format!("Failed to read bundle file {file:?}"))?;
965 let bundle = AirGappedOrgBundle::from_json(&bundle_json)
966 .context("Failed to parse air-gapped org bundle")?;
967
968 let roots_path = roots
969 .map(Path::to_path_buf)
970 .unwrap_or_else(|| PathBuf::from(".auths/roots"));
971 let pinned_roots: Vec<IdentityDID> = if roots_path.exists() {
972 let content = fs::read_to_string(&roots_path)
973 .with_context(|| format!("Failed to read roots file {roots_path:?}"))?;
974 parse_roots_typed(&content).context("Failed to parse pinned roots")?
975 } else {
976 bundle.pinned_roots.clone()
979 };
980
981 let member_prefix =
982 member.map(|m| Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(m).to_string()));
983 let query = member_prefix.as_ref().map(|p| (p, signed_at));
984
985 let report =
986 verify_org_bundle(&bundle, &pinned_roots, query).context("Offline verification failed")?;
987
988 if json {
989 println!("{}", serde_json::to_string_pretty(&report)?);
990 } else {
991 println!("Air-gapped verification of {file:?}");
992 println!(" Org: {}", report.org_did.as_str());
993 println!(
994 " Verified as-of: KEL seq {} (by position, not wall-clock)",
995 report.as_of_org_seq
996 );
997 let root = if report.root_pinned {
998 "✅ yes"
999 } else {
1000 "🛑 NO (untrusted root)"
1001 };
1002 println!(" Root pinned: {root}");
1003 let dup = if report.duplicity_detected {
1004 "🛑 DETECTED"
1005 } else {
1006 "✅ none"
1007 };
1008 println!(" Duplicity: {dup}");
1009 if let Some(authority) = &report.authority {
1010 match authority {
1011 AuthorityAtSigning::AuthorizedBeforeRevocation => {
1012 println!(" Authority: ✅ AuthorizedBeforeRevocation")
1013 }
1014 AuthorityAtSigning::RejectedAfterRevocation { revoked_at } => {
1015 println!(
1016 " Authority: 🛑 RejectedAfterRevocation {{ revoked_at: {revoked_at} }}"
1017 )
1018 }
1019 AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at } => {
1020 println!(
1021 " Authority: 🛑 RejectedRevokedPositionUnknown {{ revoked_at: {revoked_at} }}"
1022 )
1023 }
1024 AuthorityAtSigning::NeverDelegated => {
1025 println!(" Authority: ❌ NeverDelegated")
1026 }
1027 }
1028 }
1029 }
1030
1031 if !report.root_pinned {
1034 return Err(anyhow!(
1035 "unauthorized: the bundle's org is not in the pinned trust roots"
1036 ));
1037 }
1038 if report.duplicity_detected {
1039 return Err(anyhow!(
1040 "org KEL duplicity detected — divergent history; resolve before trusting"
1041 ));
1042 }
1043 match report.authority {
1044 None | Some(AuthorityAtSigning::AuthorizedBeforeRevocation) => Ok(()),
1045 Some(AuthorityAtSigning::RejectedAfterRevocation { revoked_at }) => Err(anyhow!(
1046 "unauthorized: signed at/after revocation (KEL seq {revoked_at})"
1047 )),
1048 Some(AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at }) => Err(anyhow!(
1049 "unauthorized: member revoked at KEL seq {revoked_at}; artifact has no in-band signing position"
1050 )),
1051 Some(AuthorityAtSigning::NeverDelegated) => {
1052 Err(anyhow!("unauthorized: the org never delegated this member"))
1053 }
1054 }
1055}
1056
1057pub(crate) fn raw_commit_bytes(repo: &git2::Repository, oid: git2::Oid) -> Result<Vec<u8>> {
1069 let odb = repo.odb().context("open git object database")?;
1070 let obj = odb.read(oid).context("read commit object")?;
1071 Ok(obj.data().to_vec())
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::expected_signer_mismatch;
1077 use super::raw_commit_bytes;
1078 use super::{VerifyArtifactResult, verified_summary};
1079 use std::process::Command;
1080
1081 fn verified_result(report: Option<auths_verifier::VerificationReport>) -> VerifyArtifactResult {
1083 VerifyArtifactResult {
1084 file: "x.tar".into(),
1085 valid: true,
1086 digest_match: Some(true),
1087 chain_valid: Some(true),
1088 chain_report: report,
1089 witness_quorum: None,
1090 issuer: Some("did:keri:Esigner".into()),
1091 commit_sha: None,
1092 commit_verified: None,
1093 oidc_join: None,
1094 error: None,
1095 }
1096 }
1097
1098 #[test]
1099 fn verified_summary_names_offline_freshness_never_bare() {
1100 let report = auths_verifier::VerificationReport::valid(vec![]);
1103 let line = verified_summary(&verified_result(Some(report)));
1104 assert!(
1105 line.contains("freshness unknown"),
1106 "offline verify must surface freshness, got: {line}"
1107 );
1108 }
1109
1110 #[test]
1111 fn verified_summary_names_a_fresh_verdict_when_carried() {
1112 let report =
1113 auths_verifier::VerificationReport::valid(vec![]).with_freshness(Freshness::Fresh);
1114 let line = verified_summary(&verified_result(Some(report)));
1115 assert!(line.contains("freshness fresh"), "got: {line}");
1116 }
1117
1118 use auths_verifier::Freshness;
1119
1120 #[test]
1125 fn raw_commit_bytes_matches_git_cat_file() {
1126 let (dir, repo) = auths_test_utils::git::init_test_repo();
1127 let sig = git2::Signature::now("t", "t@example.com").expect("sig");
1128 let tree_id = {
1129 let mut index = repo.index().expect("index");
1130 index.write_tree().expect("tree")
1131 };
1132 let tree = repo.find_tree(tree_id).expect("find tree");
1133 let oid = repo
1134 .commit(
1135 Some("HEAD"),
1136 &sig,
1137 &sig,
1138 "subject line\n\nbody with trailing newline drift potential\n",
1139 &tree,
1140 &[],
1141 )
1142 .expect("commit");
1143
1144 let via_helper = raw_commit_bytes(&repo, oid).expect("helper");
1145 let via_git = Command::new("git")
1146 .args(["cat-file", "commit", &oid.to_string()])
1147 .current_dir(dir.path())
1148 .output()
1149 .expect("git cat-file");
1150 assert!(via_git.status.success());
1151 assert_eq!(
1152 via_helper, via_git.stdout,
1153 "verifier payload must be byte-identical to git cat-file commit"
1154 );
1155 }
1156
1157 #[test]
1158 fn expect_signer_is_an_allowlist_applied_after_verification() {
1159 assert!(expected_signer_mismatch("did:keri:Erelease", None).is_none());
1161 assert!(expected_signer_mismatch("did:keri:Erelease", Some("did:keri:Erelease")).is_none());
1163 let m = expected_signer_mismatch("did:keri:Eattacker", Some("did:keri:Erelease"));
1165 assert!(
1166 m.as_deref().is_some_and(
1167 |s| s.contains("did:keri:Erelease") && s.contains("did:keri:Eattacker")
1168 ),
1169 "a non-expected signer must be rejected with a message naming both, got {m:?}"
1170 );
1171 }
1172}