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::witness::{WitnessQuorum, WitnessVerifyConfig};
9use auths_verifier::{
10 CanonicalDid, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses,
11};
12
13use super::core::{ArtifactMetadata, ArtifactSource};
14use super::file::FileArtifact;
15use crate::commands::verify_helpers::parse_witness_keys;
16use crate::ux::format::is_json_mode;
17
18#[derive(Serialize)]
20struct VerifyArtifactResult {
21 file: String,
22 valid: bool,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 digest_match: Option<bool>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 chain_valid: Option<bool>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 chain_report: Option<VerificationReport>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 witness_quorum: Option<WitnessQuorum>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 issuer: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 commit_sha: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 commit_verified: Option<bool>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 error: Option<String>,
39}
40
41pub async fn handle_verify(
45 file: &Path,
46 signature: Option<PathBuf>,
47 identity_bundle: Option<PathBuf>,
48 witness_receipts: Option<PathBuf>,
49 witness_keys: &[String],
50 witness_threshold: usize,
51 verify_commit: bool,
52) -> Result<()> {
53 let file_str = file.to_string_lossy().to_string();
54
55 let sig_path = signature.unwrap_or_else(|| {
57 let mut p = file.to_path_buf();
58 let new_name = format!(
59 "{}.auths.json",
60 p.file_name().unwrap_or_default().to_string_lossy()
61 );
62 p.set_file_name(new_name);
63 p
64 });
65
66 let sig_content = match fs::read_to_string(&sig_path) {
67 Ok(c) => c,
68 Err(e) => {
69 return output_error(
70 &file_str,
71 2,
72 &format!("Failed to read signature file {:?}: {}", sig_path, e),
73 );
74 }
75 };
76
77 let attestation: Attestation = match serde_json::from_str(&sig_content) {
79 Ok(a) => a,
80 Err(e) => {
81 return output_error(&file_str, 2, &format!("Failed to parse attestation: {}", e));
82 }
83 };
84
85 let artifact_meta: ArtifactMetadata = match &attestation.payload {
87 Some(payload) => match serde_json::from_value(payload.clone()) {
88 Ok(m) => m,
89 Err(e) => {
90 return output_error(
91 &file_str,
92 2,
93 &format!("Failed to parse artifact metadata from payload: {}", e),
94 );
95 }
96 },
97 None => {
98 return output_error(
99 &file_str,
100 2,
101 "Attestation has no payload (expected artifact metadata)",
102 );
103 }
104 };
105
106 let file_artifact = FileArtifact::new(file);
108 let file_digest = match file_artifact.digest() {
109 Ok(d) => d,
110 Err(e) => {
111 return output_error(
112 &file_str,
113 2,
114 &format!("Failed to compute file digest: {}", e),
115 );
116 }
117 };
118
119 if file_digest != artifact_meta.digest {
120 return output_result(
121 1,
122 VerifyArtifactResult {
123 file: file_str.clone(),
124 valid: false,
125 digest_match: Some(false),
126 chain_valid: None,
127 chain_report: None,
128 witness_quorum: None,
129 issuer: Some(attestation.issuer.to_string()),
130 commit_sha: attestation.commit_sha.clone(),
131 commit_verified: None,
132 error: Some(format!(
133 "Digest mismatch: file={}, attestation={}",
134 file_digest.hex, artifact_meta.digest.hex
135 )),
136 },
137 );
138 }
139
140 let (root_pk, identity_did) = match resolve_identity_key(&identity_bundle, &attestation) {
145 Ok(v) => v,
146 Err(e) => {
147 return output_error(&file_str, 1, &format!("{e:#}"));
148 }
149 };
150
151 let chain = vec![attestation.clone()];
155 let chain_result = verify_chain(&chain, &root_pk).await;
156
157 let (chain_valid, chain_report) = match chain_result {
158 Ok(mut report) => {
159 if let Ok(home) = auths_sdk::paths::auths_home() {
160 let storage = auths_sdk::storage::RegistryAttestationStorage::new(&home);
161 if let Ok(enriched) = storage.load_all_enriched() {
162 let anchor_set: std::collections::HashSet<auths_keri::Said> = enriched
163 .iter()
164 .filter(|e| e.anchor == auths_keri::AnchorStatus::Anchored)
165 .map(|e| e.said.clone())
166 .collect();
167 let all_anchored = chain.iter().all(|att| {
168 auths_sdk::attestation::canonical_said(att)
169 .is_some_and(|s| anchor_set.contains(&s))
170 });
171 report.anchored = Some(if all_anchored {
172 auths_keri::AnchorStatus::Anchored
173 } else {
174 auths_keri::AnchorStatus::NotAnchored
175 });
176 }
177 }
178 let is_valid = report.is_valid();
179 (Some(is_valid), Some(report))
180 }
181 Err(e) => {
182 return output_error(&file_str, 1, &format!("Chain verification failed: {}", e));
183 }
184 };
185
186 let witness_quorum = match verify_witnesses(
188 &chain,
189 &root_pk,
190 &witness_receipts,
191 witness_keys,
192 witness_threshold,
193 )
194 .await
195 {
196 Ok(q) => q,
197 Err(e) => {
198 return output_error(&file_str, 2, &format!("Witness verification error: {}", e));
199 }
200 };
201
202 let mut valid = chain_valid.unwrap_or(false);
204
205 if let Some(ref q) = witness_quorum
206 && q.verified < q.required
207 {
208 valid = false;
209 }
210
211 let is_ephemeral = attestation.issuer.as_str().starts_with("did:key:");
213 if is_ephemeral && valid {
214 match &attestation.commit_sha {
215 None => {
216 if !is_json_mode() {
217 eprintln!(
218 "Error: ephemeral attestation (did:key issuer) requires commit_sha. \
219 This attestation is unsigned provenance without a commit anchor."
220 );
221 }
222 valid = false;
223 }
224 Some(sha) => {
225 let commit_sig_ok = verify_commit_in_process(sha).await;
228
229 if !commit_sig_ok {
230 valid = false;
231 }
232
233 if !is_json_mode() {
234 if commit_sig_ok {
235 eprintln!(
236 " Trust chain: artifact <- ephemeral key <- commit {} <- maintainer",
237 &sha[..8.min(sha.len())]
238 );
239 } else {
240 eprintln!(
241 " Commit {} is not signed by a trusted maintainer.",
242 &sha[..8.min(sha.len())]
243 );
244 }
245 }
246 }
247 }
248 }
249
250 let commit_sha_val = attestation.commit_sha.clone();
252 if let Some(ref sha) = commit_sha_val
253 && !is_json_mode()
254 && !is_ephemeral
255 {
256 eprintln!(" Commit: {}", sha);
257 }
258
259 let commit_verified = if verify_commit {
261 match &commit_sha_val {
262 None => {
263 if !is_json_mode() {
264 eprintln!(
265 "warning: artifact attestation has no commit_sha field; \
266 re-sign with: auths artifact sign --commit <SHA>"
267 );
268 }
269 None
270 }
271 Some(sha) => {
272 let commit_ref = format!("refs/auths/commits/{}", sha);
274 let lookup = crate::subprocess::git_command(&[
275 "show",
276 &format!("{}:attestation.json", commit_ref),
277 ])
278 .output();
279 match lookup {
280 Ok(output) if output.status.success() => {
281 if !is_json_mode() {
282 eprintln!(" Commit {}: signing attestation found", &sha[..12]);
283 }
284 Some(true)
285 }
286 _ => {
287 if !is_json_mode() {
288 eprintln!(
289 "warning: no signing attestation found for commit {}",
290 &sha[..std::cmp::min(sha.len(), 12)]
291 );
292 }
293 Some(false)
294 }
295 }
296 }
297 }
298 } else {
299 None
300 };
301
302 let exit_code = if valid { 0 } else { 1 };
303
304 output_result(
305 exit_code,
306 VerifyArtifactResult {
307 file: file_str,
308 valid,
309 digest_match: Some(true),
310 chain_valid,
311 chain_report,
312 witness_quorum,
313 issuer: Some(identity_did.to_string()),
314 commit_sha: commit_sha_val,
315 commit_verified,
316 error: None,
317 },
318 )
319}
320
321fn resolve_identity_key(
323 identity_bundle: &Option<PathBuf>,
324 attestation: &Attestation,
325) -> Result<(auths_verifier::DevicePublicKey, CanonicalDid)> {
326 if let Some(bundle_path) = identity_bundle {
327 let bundle_content = fs::read_to_string(bundle_path)
328 .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?;
329 let bundle: IdentityBundle = serde_json::from_str(&bundle_content)
330 .with_context(|| format!("Failed to parse identity bundle: {:?}", bundle_path))?;
331 let pk_bytes = hex::decode(bundle.public_key_hex.as_str())
332 .context("Invalid public key hex in bundle")?;
333 let pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &pk_bytes)
334 .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
335 Ok((pk, bundle.identity_did.into()))
336 } else {
337 let issuer = &attestation.issuer;
339 let (pk_bytes, curve) = resolve_pk_from_did(issuer)
340 .with_context(|| format!("Failed to resolve public key from issuer DID '{}'. Use --identity-bundle for stateless verification.", issuer))?;
341 let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
342 .map_err(|e| anyhow!("Invalid issuer public key resolved from DID: {e}"))?;
343 Ok((pk, issuer.clone()))
344 }
345}
346
347fn resolve_pk_from_did(did: &str) -> Result<(Vec<u8>, auths_crypto::CurveType)> {
355 if did.starts_with("did:keri:") {
356 let auths_home = auths_sdk::paths::auths_home()
357 .map_err(|e| anyhow!("Could not locate ~/.auths: {e}"))?;
358 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
359 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
360 );
361 let (pk, curve) = auths_sdk::keri::resolve_current_public_key(®istry, did)?;
362 Ok((pk, curve))
363 } else if did.starts_with("did:key:z") {
364 match auths_crypto::did_key_decode(did) {
365 Ok(auths_crypto::DecodedDidKey::Ed25519(pk)) => {
366 Ok((pk.to_vec(), auths_crypto::CurveType::Ed25519))
367 }
368 Ok(auths_crypto::DecodedDidKey::P256(pk)) => Ok((pk, auths_crypto::CurveType::P256)),
369 Err(e) => Err(anyhow!("Failed to resolve did:key: {}", e)),
370 }
371 } else {
372 Err(anyhow!(
373 "Unsupported DID method: {}. Use --identity-bundle instead.",
374 did
375 ))
376 }
377}
378
379async fn verify_witnesses(
381 chain: &[Attestation],
382 root_pk: &auths_verifier::DevicePublicKey,
383 receipts_path: &Option<PathBuf>,
384 witness_keys_raw: &[String],
385 threshold: usize,
386) -> Result<Option<WitnessQuorum>> {
387 let receipts_path = match receipts_path {
388 Some(p) => p,
389 None => return Ok(None),
390 };
391
392 let receipts_bytes = fs::read(receipts_path)
393 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
394 let receipts: Vec<SignedReceipt> =
395 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
396
397 let witness_keys = parse_witness_keys(witness_keys_raw)?;
398
399 let config = WitnessVerifyConfig {
400 receipts: &receipts,
401 witness_keys: &witness_keys,
402 threshold,
403 };
404
405 let report = verify_chain_with_witnesses(chain, root_pk, &config)
406 .await
407 .context("Witness chain verification failed")?;
408
409 Ok(report.witness_quorum)
410}
411
412fn output_error(file: &str, exit_code: i32, message: &str) -> Result<()> {
413 if is_json_mode() {
414 let result = VerifyArtifactResult {
415 file: file.to_string(),
416 valid: false,
417 digest_match: None,
418 chain_valid: None,
419 chain_report: None,
420 witness_quorum: None,
421 issuer: None,
422 commit_sha: None,
423 commit_verified: None,
424 error: Some(message.to_string()),
425 };
426 println!("{}", serde_json::to_string(&result)?);
427 } else {
428 eprintln!("Error: {}", message);
429 }
430 std::process::exit(exit_code);
431}
432
433fn output_result(exit_code: i32, result: VerifyArtifactResult) -> Result<()> {
435 if is_json_mode() {
436 println!("{}", serde_json::to_string(&result)?);
437 } else if result.valid {
438 print!("Artifact verified");
439 if let Some(ref issuer) = result.issuer {
440 print!(": signed by {}", issuer);
441 }
442 if let Some(ref q) = result.witness_quorum {
443 print!(" (witnesses: {}/{})", q.verified, q.required);
444 }
445 println!();
446 } else {
447 eprint!("Verification failed");
448 if let Some(ref error) = result.error {
449 eprint!(": {}", error);
450 }
451 eprintln!();
452 }
453
454 if exit_code != 0 {
455 std::process::exit(exit_code);
456 }
457 Ok(())
458}
459
460async fn verify_commit_in_process(sha: &str) -> bool {
467 let repo = match git2::Repository::discover(".") {
469 Ok(r) => r,
470 Err(e) => {
471 if !is_json_mode() {
472 eprintln!("Failed to open git repository: {e}");
473 }
474 return false;
475 }
476 };
477
478 let oid = match git2::Oid::from_str(sha) {
480 Ok(o) => o,
481 Err(e) => {
482 if !is_json_mode() {
483 eprintln!("Invalid commit SHA '{}': {e}", &sha[..8.min(sha.len())]);
484 }
485 return false;
486 }
487 };
488
489 let commit_content = match raw_commit_bytes(&repo, oid) {
494 Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
495 Err(e) => {
496 if !is_json_mode() {
497 eprintln!("Commit {} not found: {e}", &sha[..8.min(sha.len())]);
498 }
499 return false;
500 }
501 };
502
503 let provider = auths_crypto::RingCryptoProvider;
506 let auths_home = match auths_sdk::paths::auths_home() {
507 Ok(h) => h,
508 Err(e) => {
509 if !is_json_mode() {
510 eprintln!("Could not locate ~/.auths: {e}");
511 }
512 return false;
513 }
514 };
515 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
516 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
517 );
518 let pinned_roots = crate::commands::verify_helpers::load_project_pinned_roots();
519
520 let short = &sha[..8.min(sha.len())];
521 match auths_sdk::workflows::commit_trust::verify_commit_local(
522 ®istry,
523 &pinned_roots,
524 commit_content.as_bytes(),
525 &provider,
526 )
527 .await
528 {
529 Ok(verdict) if verdict.is_valid() => true,
530 Ok(verdict) => {
531 if !is_json_mode() {
532 eprintln!("Commit {short} is not authorized by a pinned trusted root: {verdict:?}");
533 }
534 false
535 }
536 Err(e) => {
537 if !is_json_mode() {
538 eprintln!("Commit {short} trust could not be resolved: {e}");
539 }
540 false
541 }
542 }
543}
544
545pub fn handle_offline_verify(
565 file: &Path,
566 roots: Option<&Path>,
567 member: Option<&str>,
568 signed_at: Option<u128>,
569 json: bool,
570) -> Result<()> {
571 use auths_sdk::workflows::org::{AirGappedOrgBundle, AuthorityAtSigning, verify_org_bundle};
572 use auths_sdk::workflows::roots::parse_roots_typed;
573 use auths_verifier::Prefix;
574 use auths_verifier::types::IdentityDID;
575
576 let bundle_json =
577 fs::read_to_string(file).with_context(|| format!("Failed to read bundle file {file:?}"))?;
578 let bundle = AirGappedOrgBundle::from_json(&bundle_json)
579 .context("Failed to parse air-gapped org bundle")?;
580
581 let roots_path = roots
582 .map(Path::to_path_buf)
583 .unwrap_or_else(|| PathBuf::from(".auths/roots"));
584 let pinned_roots: Vec<IdentityDID> = if roots_path.exists() {
585 let content = fs::read_to_string(&roots_path)
586 .with_context(|| format!("Failed to read roots file {roots_path:?}"))?;
587 parse_roots_typed(&content).context("Failed to parse pinned roots")?
588 } else {
589 bundle.pinned_roots.clone()
592 };
593
594 let member_prefix =
595 member.map(|m| Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(m).to_string()));
596 let query = member_prefix.as_ref().map(|p| (p, signed_at));
597
598 let report =
599 verify_org_bundle(&bundle, &pinned_roots, query).context("Offline verification failed")?;
600
601 if json {
602 println!("{}", serde_json::to_string_pretty(&report)?);
603 } else {
604 println!("Air-gapped verification of {file:?}");
605 println!(" Org: {}", report.org_did.as_str());
606 println!(
607 " Verified as-of: KEL seq {} (by position, not wall-clock)",
608 report.as_of_org_seq
609 );
610 let root = if report.root_pinned {
611 "✅ yes"
612 } else {
613 "🛑 NO (untrusted root)"
614 };
615 println!(" Root pinned: {root}");
616 let dup = if report.duplicity_detected {
617 "🛑 DETECTED"
618 } else {
619 "✅ none"
620 };
621 println!(" Duplicity: {dup}");
622 if let Some(authority) = &report.authority {
623 match authority {
624 AuthorityAtSigning::AuthorizedBeforeRevocation => {
625 println!(" Authority: ✅ AuthorizedBeforeRevocation")
626 }
627 AuthorityAtSigning::RejectedAfterRevocation { revoked_at } => {
628 println!(
629 " Authority: 🛑 RejectedAfterRevocation {{ revoked_at: {revoked_at} }}"
630 )
631 }
632 AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at } => {
633 println!(
634 " Authority: 🛑 RejectedRevokedPositionUnknown {{ revoked_at: {revoked_at} }}"
635 )
636 }
637 AuthorityAtSigning::NeverDelegated => {
638 println!(" Authority: ❌ NeverDelegated")
639 }
640 }
641 }
642 }
643
644 if !report.root_pinned {
647 return Err(anyhow!(
648 "unauthorized: the bundle's org is not in the pinned trust roots"
649 ));
650 }
651 if report.duplicity_detected {
652 return Err(anyhow!(
653 "org KEL duplicity detected — divergent history; resolve before trusting"
654 ));
655 }
656 match report.authority {
657 None | Some(AuthorityAtSigning::AuthorizedBeforeRevocation) => Ok(()),
658 Some(AuthorityAtSigning::RejectedAfterRevocation { revoked_at }) => Err(anyhow!(
659 "unauthorized: signed at/after revocation (KEL seq {revoked_at})"
660 )),
661 Some(AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at }) => Err(anyhow!(
662 "unauthorized: member revoked at KEL seq {revoked_at}; artifact has no in-band signing position"
663 )),
664 Some(AuthorityAtSigning::NeverDelegated) => {
665 Err(anyhow!("unauthorized: the org never delegated this member"))
666 }
667 }
668}
669
670pub(crate) fn raw_commit_bytes(repo: &git2::Repository, oid: git2::Oid) -> Result<Vec<u8>> {
682 let odb = repo.odb().context("open git object database")?;
683 let obj = odb.read(oid).context("read commit object")?;
684 Ok(obj.data().to_vec())
685}
686
687#[cfg(test)]
688mod tests {
689 use super::raw_commit_bytes;
690 use std::process::Command;
691
692 #[test]
697 fn raw_commit_bytes_matches_git_cat_file() {
698 let (dir, repo) = auths_test_utils::git::init_test_repo();
699 let sig = git2::Signature::now("t", "t@example.com").expect("sig");
700 let tree_id = {
701 let mut index = repo.index().expect("index");
702 index.write_tree().expect("tree")
703 };
704 let tree = repo.find_tree(tree_id).expect("find tree");
705 let oid = repo
706 .commit(
707 Some("HEAD"),
708 &sig,
709 &sig,
710 "subject line\n\nbody with trailing newline drift potential\n",
711 &tree,
712 &[],
713 )
714 .expect("commit");
715
716 let via_helper = raw_commit_bytes(&repo, oid).expect("helper");
717 let via_git = Command::new("git")
718 .args(["cat-file", "commit", &oid.to_string()])
719 .current_dir(dir.path())
720 .output()
721 .expect("git cat-file");
722 assert!(via_git.status.success());
723 assert_eq!(
724 via_helper, via_git.stdout,
725 "verifier payload must be byte-identical to git cat-file commit"
726 );
727 }
728}