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 pub require_rooted_signer: bool,
104}
105
106fn expected_signer_mismatch(issuer: &str, expect: Option<&str>) -> Option<String> {
115 match expect {
116 Some(want) if want != issuer => Some(format!(
117 "Signer mismatch: verified signer {issuer} is not the expected signer {want}"
118 )),
119 _ => None,
120 }
121}
122
123fn unrooted_signer_rejected(issuer: &str, require_rooted: bool) -> Option<String> {
136 if require_rooted && !issuer.starts_with("did:keri:") {
137 Some(format!(
138 "Signer not root-authorized: {issuer} is a did:key self-attestation, not a \
139 rotatable did:keri identity backed by a key-state log"
140 ))
141 } else {
142 None
143 }
144}
145
146pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> {
150 let VerifyArtifactArgs {
151 signature,
152 identity_bundle,
153 witness_receipts,
154 witness_keys,
155 witness_threshold,
156 verify_commit,
157 ephemeral_anchor,
158 oidc_policy,
159 oidc_policy_did,
160 log_evidence,
161 log_key,
162 expect_signer,
163 require_rooted_signer,
164 } = args;
165 let witness_keys = &witness_keys;
166 let file_str = file.to_string_lossy().to_string();
167
168 let oidc_policy = match (&oidc_policy, &oidc_policy_did) {
172 (None, None) => None,
173 (Some(path), _) => {
174 let raw = match fs::read_to_string(path) {
175 Ok(r) => r,
176 Err(e) => {
177 return output_error(
178 &file_str,
179 2,
180 &format!("Failed to read OIDC policy {path:?}: {e}"),
181 );
182 }
183 };
184 match OidcSubjectPolicy::parse(&raw) {
185 Ok(p) => Some(p),
186 Err(e) => {
187 return output_error(&file_str, 2, &format!("{e}"));
188 }
189 }
190 }
191 (None, Some(org_did)) => match resolve_anchored_oidc_policy(org_did) {
192 Ok(p) => Some(p),
193 Err((exit_code, message)) => {
194 return output_error(&file_str, exit_code, &message);
195 }
196 },
197 };
198
199 let sig_path = signature.unwrap_or_else(|| {
201 let mut p = file.to_path_buf();
202 let new_name = format!(
203 "{}.auths.json",
204 p.file_name().unwrap_or_default().to_string_lossy()
205 );
206 p.set_file_name(new_name);
207 p
208 });
209
210 let sig_content = match fs::read_to_string(&sig_path) {
211 Ok(c) => c,
212 Err(e) => {
213 return output_error(
214 &file_str,
215 2,
216 &format!("Failed to read signature file {:?}: {}", sig_path, e),
217 );
218 }
219 };
220
221 let attestation: Attestation = match serde_json::from_str(&sig_content) {
223 Ok(a) => a,
224 Err(e) => {
225 return output_error(&file_str, 2, &format!("Failed to parse attestation: {}", e));
226 }
227 };
228
229 let artifact_meta: ArtifactMetadata = match &attestation.payload {
231 Some(payload) => match serde_json::from_value(payload.clone()) {
232 Ok(m) => m,
233 Err(e) => {
234 return output_error(
235 &file_str,
236 2,
237 &format!("Failed to parse artifact metadata from payload: {}", e),
238 );
239 }
240 },
241 None => {
242 return output_error(
243 &file_str,
244 2,
245 "Attestation has no payload (expected artifact metadata)",
246 );
247 }
248 };
249
250 let file_artifact = FileArtifact::new(file);
252 let file_digest = match file_artifact.digest() {
253 Ok(d) => d,
254 Err(e) => {
255 return output_error(
256 &file_str,
257 2,
258 &format!("Failed to compute file digest: {}", e),
259 );
260 }
261 };
262
263 if file_digest != artifact_meta.digest {
264 return output_result(
265 1,
266 VerifyArtifactResult {
267 file: file_str.clone(),
268 valid: false,
269 digest_match: Some(false),
270 chain_valid: None,
271 chain_report: None,
272 witness_quorum: None,
273 issuer: Some(attestation.issuer.to_string()),
274 commit_sha: attestation.commit_sha.clone(),
275 commit_verified: None,
276 oidc_join: None,
277 error: Some(format!(
278 "Digest mismatch: file={}, attestation={}",
279 file_digest.hex, artifact_meta.digest.hex
280 )),
281 },
282 );
283 }
284
285 let (root_pk, identity_did) = match resolve_identity_key(&identity_bundle, &attestation) {
290 Ok(v) => v,
291 Err(e) => {
292 return output_error(&file_str, 1, &format!("{e:#}"));
293 }
294 };
295
296 let chain = vec![attestation.clone()];
300 let chain_result = verify_chain(&chain, &root_pk).await;
301
302 let (chain_valid, mut chain_report) = match chain_result {
303 Ok(mut report) => {
304 if let Ok(home) = auths_sdk::paths::auths_home() {
305 let storage = auths_sdk::storage::RegistryAttestationStorage::new(&home);
306 if let Ok(enriched) = storage.load_all_enriched() {
307 let anchor_set: std::collections::HashSet<auths_keri::Said> = enriched
308 .iter()
309 .filter(|e| e.anchor == auths_keri::AnchorStatus::Anchored)
310 .map(|e| e.said.clone())
311 .collect();
312 let all_anchored = chain.iter().all(|att| {
313 auths_sdk::attestation::canonical_said(att)
314 .is_some_and(|s| anchor_set.contains(&s))
315 });
316 report.anchored = Some(if all_anchored {
317 auths_keri::AnchorStatus::Anchored
318 } else {
319 auths_keri::AnchorStatus::NotAnchored
320 });
321 }
322 }
323 let is_valid = report.is_valid();
324 (Some(is_valid), Some(report))
325 }
326 Err(e) => {
327 return output_error(&file_str, 1, &format!("Chain verification failed: {}", e));
328 }
329 };
330
331 if let Some(msg) =
334 expected_signer_mismatch(attestation.issuer.as_str(), expect_signer.as_deref())
335 {
336 return output_result(
337 1,
338 VerifyArtifactResult {
339 file: file_str.clone(),
340 valid: false,
341 digest_match: Some(true),
342 chain_valid,
343 chain_report: chain_report.clone(),
344 witness_quorum: None,
345 issuer: Some(attestation.issuer.to_string()),
346 commit_sha: attestation.commit_sha.clone(),
347 commit_verified: None,
348 oidc_join: None,
349 error: Some(msg),
350 },
351 );
352 }
353
354 if let Some(msg) = unrooted_signer_rejected(attestation.issuer.as_str(), require_rooted_signer)
357 {
358 return output_result(
359 1,
360 VerifyArtifactResult {
361 file: file_str.clone(),
362 valid: false,
363 digest_match: Some(true),
364 chain_valid,
365 chain_report: chain_report.clone(),
366 witness_quorum: None,
367 issuer: Some(attestation.issuer.to_string()),
368 commit_sha: attestation.commit_sha.clone(),
369 commit_verified: None,
370 oidc_join: None,
371 error: Some(msg),
372 },
373 );
374 }
375
376 let mut log_anchor_error: Option<String> = None;
383 if let Some(evidence_path) = &log_evidence {
384 let raw = match fs::read_to_string(evidence_path) {
385 Ok(r) => r,
386 Err(e) => {
387 return output_error(
388 &file_str,
389 2,
390 &format!("Failed to read log evidence {evidence_path:?}: {e}"),
391 );
392 }
393 };
394 let evidence: TransparencyInclusion = match serde_json::from_str(&raw) {
395 Ok(t) => t,
396 Err(e) => {
397 return output_error(&file_str, 2, &format!("Failed to parse log evidence: {e}"));
398 }
399 };
400 let Some(key_hex) = log_key.as_deref() else {
402 return output_error(&file_str, 2, "--log-evidence requires --log-key");
403 };
404 let pinned_key = match parse_log_key_hex(key_hex) {
405 Ok(k) => k,
406 Err(e) => return output_error(&file_str, 2, &format!("{e}")),
407 };
408 let canonical_digest = match auths_sdk::workflows::compliance::ArtifactDigest::parse(
411 &format!("{}:{}", file_digest.algorithm, file_digest.hex),
412 ) {
413 Ok(d) => d,
414 Err(e) => {
415 return output_error(
416 &file_str,
417 2,
418 &format!("Cannot derive the artifact's canonical log leaf: {e}"),
419 );
420 }
421 };
422 match verify_artifact_log_inclusion(canonical_digest.as_str(), &evidence, &pinned_key) {
423 Ok(()) => {
424 if let Some(report) = chain_report.as_mut() {
425 report.anchored = Some(auths_keri::AnchorStatus::Anchored);
426 }
427 if !is_json_mode() {
428 eprintln!(
429 " Transparency: {} anchored in log '{}' \
430 (offline inclusion proof, operator key pinned)",
431 canonical_digest.as_str(),
432 evidence.signed_checkpoint.checkpoint.origin
433 );
434 }
435 }
436 Err(e) => {
437 if let Some(report) = chain_report.as_mut() {
438 report.anchored = Some(auths_keri::AnchorStatus::NotAnchored);
439 }
440 log_anchor_error = Some(format!("Transparency anchoring failed: {e}"));
441 }
442 }
443 }
444
445 let witness_quorum = match verify_witnesses(
447 &chain,
448 &root_pk,
449 &witness_receipts,
450 witness_keys,
451 witness_threshold,
452 )
453 .await
454 {
455 Ok(q) => q,
456 Err(e) => {
457 return output_error(&file_str, 2, &format!("Witness verification error: {}", e));
458 }
459 };
460
461 let mut valid = chain_valid.unwrap_or(false);
463
464 if let Some(ref q) = witness_quorum
465 && q.verified < q.required
466 {
467 valid = false;
468 }
469
470 if let Some(ref msg) = log_anchor_error {
471 valid = false;
472 if !is_json_mode() {
473 eprintln!(" {msg}");
474 }
475 }
476
477 let is_ephemeral = attestation.issuer.as_str().starts_with("did:key:");
483 if is_ephemeral && valid {
484 match ephemeral_anchor {
485 EphemeralAnchor::SignatureOnly => {
486 if !is_json_mode() {
487 eprintln!(
488 " Signature-only: ephemeral signature over the artifact digest \
489 verifies; commit-anchor leg NOT checked (signer self-check)."
490 );
491 }
492 }
493 EphemeralAnchor::Required => match &attestation.commit_sha {
494 None => {
495 if !is_json_mode() {
496 eprintln!(
497 "Error: ephemeral attestation (did:key issuer) requires commit_sha. \
498 This attestation is unsigned provenance without a commit anchor."
499 );
500 }
501 valid = false;
502 }
503 Some(sha) => {
504 let commit_sig_ok = match &identity_bundle {
510 Some(bundle_path) => {
511 match crate::commands::verify_commit::commit_trusted_via_bundle(
512 sha,
513 bundle_path,
514 )
515 .await
516 {
517 Ok(()) => true,
518 Err(reason) => {
519 if !is_json_mode() {
520 eprintln!(" Commit-anchor leg failed: {reason}");
521 }
522 false
523 }
524 }
525 }
526 None => verify_commit_in_process(sha).await,
527 };
528
529 if !commit_sig_ok {
530 valid = false;
531 }
532
533 if !is_json_mode() {
534 if commit_sig_ok {
535 eprintln!(
536 " Trust chain: artifact <- ephemeral key <- commit {} <- maintainer",
537 &sha[..8.min(sha.len())]
538 );
539 } else {
540 eprintln!(
541 " Commit {} is not signed by a trusted maintainer.",
542 &sha[..8.min(sha.len())]
543 );
544 }
545 }
546 }
547 },
548 }
549 }
550
551 let mut oidc_error: Option<String> = None;
557 let oidc_join = match &oidc_policy {
558 None => None,
559 Some(_) if !valid => {
560 None
563 }
564 Some(policy) => match &attestation.oidc_binding {
565 None => {
566 valid = false;
567 oidc_error = Some(
568 "OIDC policy join failed: attestation carries no OIDC binding \
569 — signer presented no verified OIDC identity"
570 .to_string(),
571 );
572 None
573 }
574 Some(binding) => match policy.join(binding) {
575 Ok(join) => {
576 if !is_json_mode() {
577 eprintln!(
578 " OIDC policy join: {} via {} (issuer {})",
579 join.repository,
580 join.workflow_ref.as_deref().unwrap_or("any workflow"),
581 join.issuer
582 );
583 }
584 Some(join)
585 }
586 Err(e) => {
587 valid = false;
588 oidc_error = Some(format!("OIDC policy join failed: {e}"));
589 None
590 }
591 },
592 },
593 };
594 if let Some(ref msg) = oidc_error
595 && !is_json_mode()
596 {
597 eprintln!(" {msg}");
598 }
599
600 let commit_sha_val = attestation.commit_sha.clone();
602 if let Some(ref sha) = commit_sha_val
603 && !is_json_mode()
604 && !is_ephemeral
605 {
606 eprintln!(" Commit: {}", sha);
607 }
608
609 let commit_verified = if verify_commit {
611 match &commit_sha_val {
612 None => {
613 if !is_json_mode() {
614 eprintln!(
615 "warning: artifact attestation has no commit_sha field; \
616 re-sign with: auths artifact sign --commit <SHA>"
617 );
618 }
619 None
620 }
621 Some(sha) => {
622 let commit_ref = format!("refs/auths/commits/{}", sha);
624 let lookup = crate::subprocess::git_command(&[
625 "show",
626 &format!("{}:attestation.json", commit_ref),
627 ])
628 .output();
629 match lookup {
630 Ok(output) if output.status.success() => {
631 if !is_json_mode() {
632 eprintln!(" Commit {}: signing attestation found", &sha[..12]);
633 }
634 Some(true)
635 }
636 _ => {
637 if !is_json_mode() {
638 eprintln!(
639 "warning: no signing attestation found for commit {}",
640 &sha[..std::cmp::min(sha.len(), 12)]
641 );
642 }
643 Some(false)
644 }
645 }
646 }
647 }
648 } else {
649 None
650 };
651
652 let exit_code = if valid { 0 } else { 1 };
653
654 output_result(
655 exit_code,
656 VerifyArtifactResult {
657 file: file_str,
658 valid,
659 digest_match: Some(true),
660 chain_valid,
661 chain_report,
662 witness_quorum,
663 issuer: Some(identity_did.to_string()),
664 commit_sha: commit_sha_val,
665 commit_verified,
666 oidc_join,
667 error: log_anchor_error.or(oidc_error).or_else(|| {
668 (!valid).then(|| {
669 "attestation chain or its commit-anchor leg did not verify (see lines above)"
670 .to_string()
671 })
672 }),
673 },
674 )
675}
676
677fn resolve_anchored_oidc_policy(org_did: &str) -> Result<OidcSubjectPolicy, (i32, String)> {
685 use auths_sdk::workflows::org::load_org_oidc_policy;
686
687 let Some(prefix) = org_did.strip_prefix("did:keri:") else {
688 return Err((
689 2,
690 format!("--oidc-policy-did requires a did:keri: identifier, got '{org_did}'"),
691 ));
692 };
693 let auths_home = auths_sdk::paths::auths_home()
694 .map_err(|e| (2, format!("Could not locate ~/.auths: {e}")))?;
695 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
696 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
697 );
698 let org_prefix = auths_verifier::Prefix::new_unchecked(prefix.to_string());
699
700 match load_org_oidc_policy(®istry, &org_prefix) {
701 Ok(Some(loaded)) => {
702 if !is_json_mode() {
703 eprintln!(
704 " OIDC policy resolved from the org KEL (digest {})",
705 loaded.policy_digest
706 );
707 }
708 Ok(loaded.policy)
709 }
710 Ok(None) => Err((
711 2,
712 format!(
713 "organization {org_did} has no OIDC-subject policy anchored on its KEL \
714 — anchor one with `auths org anchor-oidc-policy`"
715 ),
716 )),
717 Err(e @ auths_sdk::domains::org::error::OrgError::PolicyIntegrity { .. }) => {
718 Err((1, format!("OIDC policy resolution failed: {e}")))
719 }
720 Err(e) => Err((
721 2,
722 format!("Failed to resolve the anchored OIDC policy: {e}"),
723 )),
724 }
725}
726
727fn resolve_identity_key(
729 identity_bundle: &Option<PathBuf>,
730 attestation: &Attestation,
731) -> Result<(auths_verifier::DevicePublicKey, CanonicalDid)> {
732 if attestation.issuer.as_str().starts_with("did:key:") {
737 let issuer = &attestation.issuer;
738 let (pk_bytes, curve) = resolve_pk_from_did(issuer)
739 .with_context(|| format!("Failed to resolve public key from issuer DID '{issuer}'"))?;
740 let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
741 .map_err(|e| anyhow!("Invalid issuer public key resolved from DID: {e}"))?;
742 return Ok((pk, issuer.clone()));
743 }
744 if let Some(bundle_path) = identity_bundle {
745 let bundle_content = fs::read_to_string(bundle_path)
746 .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?;
747 let bundle: IdentityBundle = serde_json::from_str(&bundle_content)
748 .with_context(|| format!("Failed to parse identity bundle: {:?}", bundle_path))?;
749
750 let trust = auths_verifier::BundleTrust::parse(&bundle, chrono::Utc::now())
760 .map_err(|e| anyhow!("identity bundle is not a trustworthy anchor: {e}"))?;
761
762 let state = auths_keri::TrustedKel::from_trusted_source(trust.kel())
770 .replay()
771 .map_err(|e| anyhow!("identity bundle KEL is not replayable: {e}"))?;
772 let key = state
773 .current_key()
774 .ok_or_else(|| anyhow!("identity bundle KEL has no current key"))?;
775 let (key_bytes, curve) = match key
776 .parse()
777 .map_err(|e| anyhow!("identity bundle current key is unsupported: {e}"))?
778 {
779 auths_keri::KeriPublicKey::Ed25519 { key, .. } => {
780 (key.to_vec(), auths_crypto::CurveType::Ed25519)
781 }
782 auths_keri::KeriPublicKey::P256 { key, .. } => {
783 (key.to_vec(), auths_crypto::CurveType::P256)
784 }
785 };
786 let pk = auths_verifier::DevicePublicKey::try_new(curve, &key_bytes)
787 .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
788
789 let (root_did, _kel, _device_kels) = trust.into_parts();
792 Ok((pk, CanonicalDid::new_unchecked(root_did)))
793 } else {
794 let issuer = &attestation.issuer;
796 let (pk_bytes, curve) = resolve_pk_from_did(issuer)
797 .with_context(|| format!("Failed to resolve public key from issuer DID '{}'. Use --identity-bundle for stateless verification.", issuer))?;
798 let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
799 .map_err(|e| anyhow!("Invalid issuer public key resolved from DID: {e}"))?;
800 Ok((pk, issuer.clone()))
801 }
802}
803
804fn resolve_pk_from_did(did: &str) -> Result<(Vec<u8>, auths_crypto::CurveType)> {
812 if did.starts_with("did:keri:") {
813 let auths_home = auths_sdk::paths::auths_home()
814 .map_err(|e| anyhow!("Could not locate ~/.auths: {e}"))?;
815 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
816 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
817 );
818 let (pk, curve) = auths_sdk::keri::resolve_current_public_key(®istry, did)?;
819 Ok((pk, curve))
820 } else if did.starts_with("did:key:z") {
821 match auths_crypto::did_key_decode(did) {
822 Ok(auths_crypto::DecodedDidKey::Ed25519(pk)) => {
823 Ok((pk.to_vec(), auths_crypto::CurveType::Ed25519))
824 }
825 Ok(auths_crypto::DecodedDidKey::P256(pk)) => Ok((pk, auths_crypto::CurveType::P256)),
826 Err(e) => Err(anyhow!("Failed to resolve did:key: {}", e)),
827 }
828 } else {
829 Err(anyhow!(
830 "Unsupported DID method: {}. Use --identity-bundle instead.",
831 did
832 ))
833 }
834}
835
836async fn verify_witnesses(
838 chain: &[Attestation],
839 root_pk: &auths_verifier::DevicePublicKey,
840 receipts_path: &Option<PathBuf>,
841 witness_keys_raw: &[String],
842 threshold: usize,
843) -> Result<Option<WitnessQuorum>> {
844 let receipts_path = match receipts_path {
845 Some(p) => p,
846 None => return Ok(None),
847 };
848
849 let receipts_bytes = fs::read(receipts_path)
850 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
851 let receipts: Vec<SignedReceipt> =
852 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
853
854 let witness_keys = parse_witness_keys(witness_keys_raw)?;
855
856 let config = WitnessVerifyConfig {
857 receipts: &receipts,
858 witness_keys: &witness_keys,
859 threshold,
860 };
861
862 let report = verify_chain_with_witnesses(chain, root_pk, &config)
863 .await
864 .context("Witness chain verification failed")?;
865
866 Ok(report.witness_quorum)
867}
868
869fn output_error(file: &str, exit_code: i32, message: &str) -> Result<()> {
870 if is_json_mode() {
871 let result = VerifyArtifactResult {
872 file: file.to_string(),
873 valid: false,
874 digest_match: None,
875 chain_valid: None,
876 chain_report: None,
877 witness_quorum: None,
878 issuer: None,
879 commit_sha: None,
880 commit_verified: None,
881 oidc_join: None,
882 error: Some(message.to_string()),
883 };
884 println!("{}", serde_json::to_string(&result)?);
885 } else {
886 eprintln!("Error: {}", message);
887 }
888 std::process::exit(exit_code);
889}
890
891use crate::commands::verify_helpers::freshness_label;
892
893fn verified_summary(result: &VerifyArtifactResult) -> String {
902 let mut line = String::from("Artifact verified");
903 if let Some(ref issuer) = result.issuer {
904 line.push_str(&format!(": signed by {issuer}"));
905 }
906 if let Some(freshness) = result.chain_report.as_ref().map(|r| r.freshness()) {
907 line.push_str(&format!(" (freshness {})", freshness_label(freshness)));
908 }
909 if let Some(ref q) = result.witness_quorum {
910 line.push_str(&format!(" (witnesses: {}/{})", q.verified, q.required));
911 }
912 line
913}
914
915fn output_result(exit_code: i32, result: VerifyArtifactResult) -> Result<()> {
917 if is_json_mode() {
918 println!("{}", serde_json::to_string(&result)?);
919 } else if result.valid {
920 println!("{}", verified_summary(&result));
921 } else {
922 eprint!("Verification failed");
923 if let Some(ref error) = result.error {
924 eprint!(": {}", error);
925 }
926 eprintln!();
927 }
928
929 if exit_code != 0 {
930 std::process::exit(exit_code);
931 }
932 Ok(())
933}
934
935async fn verify_commit_in_process(sha: &str) -> bool {
942 let repo = match git2::Repository::discover(".") {
944 Ok(r) => r,
945 Err(e) => {
946 if !is_json_mode() {
947 eprintln!("Failed to open git repository: {e}");
948 }
949 return false;
950 }
951 };
952
953 let oid = match git2::Oid::from_str(sha) {
955 Ok(o) => o,
956 Err(e) => {
957 if !is_json_mode() {
958 eprintln!("Invalid commit SHA '{}': {e}", &sha[..8.min(sha.len())]);
959 }
960 return false;
961 }
962 };
963
964 let commit_content = match raw_commit_bytes(&repo, oid) {
969 Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
970 Err(e) => {
971 if !is_json_mode() {
972 eprintln!("Commit {} not found: {e}", &sha[..8.min(sha.len())]);
973 }
974 return false;
975 }
976 };
977
978 let provider = auths_crypto::RingCryptoProvider;
981 let auths_home = match auths_sdk::paths::auths_home() {
982 Ok(h) => h,
983 Err(e) => {
984 if !is_json_mode() {
985 eprintln!("Could not locate ~/.auths: {e}");
986 }
987 return false;
988 }
989 };
990 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
991 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
992 );
993 let pinned_roots = crate::commands::verify_helpers::load_project_pinned_roots();
994
995 let short = &sha[..8.min(sha.len())];
996 match auths_sdk::workflows::commit_trust::verify_commit_local(
997 ®istry,
998 &pinned_roots,
999 commit_content.as_bytes(),
1000 &provider,
1001 )
1002 .await
1003 {
1004 Ok(verdict) if verdict.is_trusted(&FreshnessPolicy::default()) => true,
1005 Ok(verdict) => {
1006 if !is_json_mode() {
1007 eprintln!("Commit {short} is not authorized by a pinned trusted root: {verdict:?}");
1008 }
1009 false
1010 }
1011 Err(e) => {
1012 if !is_json_mode() {
1013 eprintln!("Commit {short} trust could not be resolved: {e}");
1014 }
1015 false
1016 }
1017 }
1018}
1019
1020pub fn handle_offline_verify(
1040 file: &Path,
1041 roots: Option<&Path>,
1042 member: Option<&str>,
1043 signed_at: Option<u128>,
1044 json: bool,
1045) -> Result<()> {
1046 use auths_sdk::workflows::org::{AirGappedOrgBundle, AuthorityAtSigning, verify_org_bundle};
1047 use auths_sdk::workflows::roots::parse_roots_typed;
1048 use auths_verifier::Prefix;
1049 use auths_verifier::types::IdentityDID;
1050
1051 let bundle_json =
1052 fs::read_to_string(file).with_context(|| format!("Failed to read bundle file {file:?}"))?;
1053 let bundle = AirGappedOrgBundle::from_json(&bundle_json)
1054 .context("Failed to parse air-gapped org bundle")?;
1055
1056 let roots_path = roots
1057 .map(Path::to_path_buf)
1058 .unwrap_or_else(|| PathBuf::from(".auths/roots"));
1059 let pinned_roots: Vec<IdentityDID> = if roots_path.exists() {
1060 let content = fs::read_to_string(&roots_path)
1061 .with_context(|| format!("Failed to read roots file {roots_path:?}"))?;
1062 parse_roots_typed(&content).context("Failed to parse pinned roots")?
1063 } else {
1064 bundle.pinned_roots.clone()
1067 };
1068
1069 let member_prefix =
1070 member.map(|m| Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(m).to_string()));
1071 let query = member_prefix.as_ref().map(|p| (p, signed_at));
1072
1073 let report =
1074 verify_org_bundle(&bundle, &pinned_roots, query).context("Offline verification failed")?;
1075
1076 if json {
1077 println!("{}", serde_json::to_string_pretty(&report)?);
1078 } else {
1079 println!("Air-gapped verification of {file:?}");
1080 println!(" Org: {}", report.org_did.as_str());
1081 println!(
1082 " Verified as-of: KEL seq {} (by position, not wall-clock)",
1083 report.as_of_org_seq
1084 );
1085 let root = if report.root_pinned {
1086 "✅ yes"
1087 } else {
1088 "🛑 NO (untrusted root)"
1089 };
1090 println!(" Root pinned: {root}");
1091 let dup = if report.duplicity_detected {
1092 "🛑 DETECTED"
1093 } else {
1094 "✅ none"
1095 };
1096 println!(" Duplicity: {dup}");
1097 if let Some(authority) = &report.authority {
1098 match authority {
1099 AuthorityAtSigning::AuthorizedBeforeRevocation => {
1100 println!(" Authority: ✅ AuthorizedBeforeRevocation")
1101 }
1102 AuthorityAtSigning::RejectedAfterRevocation { revoked_at } => {
1103 println!(
1104 " Authority: 🛑 RejectedAfterRevocation {{ revoked_at: {revoked_at} }}"
1105 )
1106 }
1107 AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at } => {
1108 println!(
1109 " Authority: 🛑 RejectedRevokedPositionUnknown {{ revoked_at: {revoked_at} }}"
1110 )
1111 }
1112 AuthorityAtSigning::NeverDelegated => {
1113 println!(" Authority: ❌ NeverDelegated")
1114 }
1115 }
1116 }
1117 }
1118
1119 if !report.root_pinned {
1122 return Err(anyhow!(
1123 "unauthorized: the bundle's org is not in the pinned trust roots"
1124 ));
1125 }
1126 if report.duplicity_detected {
1127 return Err(anyhow!(
1128 "org KEL duplicity detected — divergent history; resolve before trusting"
1129 ));
1130 }
1131 match report.authority {
1132 None | Some(AuthorityAtSigning::AuthorizedBeforeRevocation) => Ok(()),
1133 Some(AuthorityAtSigning::RejectedAfterRevocation { revoked_at }) => Err(anyhow!(
1134 "unauthorized: signed at/after revocation (KEL seq {revoked_at})"
1135 )),
1136 Some(AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at }) => Err(anyhow!(
1137 "unauthorized: member revoked at KEL seq {revoked_at}; artifact has no in-band signing position"
1138 )),
1139 Some(AuthorityAtSigning::NeverDelegated) => {
1140 Err(anyhow!("unauthorized: the org never delegated this member"))
1141 }
1142 }
1143}
1144
1145pub(crate) fn raw_commit_bytes(repo: &git2::Repository, oid: git2::Oid) -> Result<Vec<u8>> {
1157 let odb = repo.odb().context("open git object database")?;
1158 let obj = odb.read(oid).context("read commit object")?;
1159 Ok(obj.data().to_vec())
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::expected_signer_mismatch;
1165 use super::raw_commit_bytes;
1166 use super::unrooted_signer_rejected;
1167 use super::{VerifyArtifactResult, verified_summary};
1168 use auths_verifier::Freshness;
1169 use std::process::Command;
1170
1171 #[test]
1172 fn unrooted_signer_rejected_blocks_did_key_self_attestation() {
1173 assert!(
1176 unrooted_signer_rejected("did:key:z6MkExample", true).is_some(),
1177 "a did:key self-attestation must be rejected when a rooted signer is required"
1178 );
1179 assert!(
1181 unrooted_signer_rejected("did:keri:EExample", true).is_none(),
1182 "a did:keri signer is root-authorized and must pass"
1183 );
1184 assert!(unrooted_signer_rejected("did:key:z6MkExample", false).is_none());
1186 }
1187
1188 fn verified_result(report: Option<auths_verifier::VerificationReport>) -> VerifyArtifactResult {
1190 VerifyArtifactResult {
1191 file: "x.tar".into(),
1192 valid: true,
1193 digest_match: Some(true),
1194 chain_valid: Some(true),
1195 chain_report: report,
1196 witness_quorum: None,
1197 issuer: Some("did:keri:Esigner".into()),
1198 commit_sha: None,
1199 commit_verified: None,
1200 oidc_join: None,
1201 error: None,
1202 }
1203 }
1204
1205 #[test]
1206 fn verified_summary_names_offline_freshness_never_bare() {
1207 let report = auths_verifier::VerificationReport::valid(vec![]);
1210 let line = verified_summary(&verified_result(Some(report)));
1211 assert!(
1212 line.contains("freshness unknown"),
1213 "offline verify must surface freshness, got: {line}"
1214 );
1215 }
1216
1217 #[test]
1218 fn verified_summary_names_a_fresh_verdict_when_carried() {
1219 let report =
1220 auths_verifier::VerificationReport::valid(vec![]).with_freshness(Freshness::Fresh);
1221 let line = verified_summary(&verified_result(Some(report)));
1222 assert!(line.contains("freshness fresh"), "got: {line}");
1223 }
1224
1225 #[test]
1230 fn raw_commit_bytes_matches_git_cat_file() {
1231 let (dir, repo) = auths_test_utils::git::init_test_repo();
1232 let sig = git2::Signature::now("t", "t@example.com").expect("sig");
1233 let tree_id = {
1234 let mut index = repo.index().expect("index");
1235 index.write_tree().expect("tree")
1236 };
1237 let tree = repo.find_tree(tree_id).expect("find tree");
1238 let oid = repo
1239 .commit(
1240 Some("HEAD"),
1241 &sig,
1242 &sig,
1243 "subject line\n\nbody with trailing newline drift potential\n",
1244 &tree,
1245 &[],
1246 )
1247 .expect("commit");
1248
1249 let via_helper = raw_commit_bytes(&repo, oid).expect("helper");
1250 let via_git = Command::new("git")
1251 .args(["cat-file", "commit", &oid.to_string()])
1252 .current_dir(dir.path())
1253 .output()
1254 .expect("git cat-file");
1255 assert!(via_git.status.success());
1256 assert_eq!(
1257 via_helper, via_git.stdout,
1258 "verifier payload must be byte-identical to git cat-file commit"
1259 );
1260 }
1261
1262 #[test]
1263 fn expect_signer_is_an_allowlist_applied_after_verification() {
1264 assert!(expected_signer_mismatch("did:keri:Erelease", None).is_none());
1266 assert!(expected_signer_mismatch("did:keri:Erelease", Some("did:keri:Erelease")).is_none());
1268 let m = expected_signer_mismatch("did:keri:Eattacker", Some("did:keri:Erelease"));
1270 assert!(
1271 m.as_deref().is_some_and(
1272 |s| s.contains("did:keri:Erelease") && s.contains("did:keri:Eattacker")
1273 ),
1274 "a non-expected signer must be rejected with a message naming both, got {m:?}"
1275 );
1276 }
1277}