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) {
142 Ok(v) => v,
143 Err(e) => {
144 return output_error(&file_str, 2, &e.to_string());
145 }
146 };
147
148 let chain = vec![attestation.clone()];
152 let chain_result = verify_chain(&chain, &root_pk).await;
153
154 let (chain_valid, chain_report) = match chain_result {
155 Ok(mut report) => {
156 if let Ok(home) = auths_sdk::paths::auths_home() {
157 let storage = auths_sdk::storage::RegistryAttestationStorage::new(&home);
158 if let Ok(enriched) = storage.load_all_enriched() {
159 let anchor_set: std::collections::HashSet<auths_keri::Said> = enriched
160 .iter()
161 .filter(|e| e.anchor == auths_keri::AnchorStatus::Anchored)
162 .map(|e| e.said.clone())
163 .collect();
164 let all_anchored = chain.iter().all(|att| {
165 auths_sdk::attestation::canonical_said(att)
166 .is_some_and(|s| anchor_set.contains(&s))
167 });
168 report.anchored = Some(if all_anchored {
169 auths_keri::AnchorStatus::Anchored
170 } else {
171 auths_keri::AnchorStatus::NotAnchored
172 });
173 }
174 }
175 let is_valid = report.is_valid();
176 (Some(is_valid), Some(report))
177 }
178 Err(e) => {
179 return output_error(&file_str, 1, &format!("Chain verification failed: {}", e));
180 }
181 };
182
183 let witness_quorum = match verify_witnesses(
185 &chain,
186 &root_pk,
187 &witness_receipts,
188 witness_keys,
189 witness_threshold,
190 )
191 .await
192 {
193 Ok(q) => q,
194 Err(e) => {
195 return output_error(&file_str, 2, &format!("Witness verification error: {}", e));
196 }
197 };
198
199 let mut valid = chain_valid.unwrap_or(false);
201
202 if let Some(ref q) = witness_quorum
203 && q.verified < q.required
204 {
205 valid = false;
206 }
207
208 let is_ephemeral = attestation.issuer.as_str().starts_with("did:key:");
210 if is_ephemeral && valid {
211 match &attestation.commit_sha {
212 None => {
213 if !is_json_mode() {
214 eprintln!(
215 "Error: ephemeral attestation (did:key issuer) requires commit_sha. \
216 This attestation is unsigned provenance without a commit anchor."
217 );
218 }
219 valid = false;
220 }
221 Some(sha) => {
222 let commit_sig_ok = verify_commit_in_process(sha).await;
225
226 if !commit_sig_ok {
227 valid = false;
228 }
229
230 if !is_json_mode() {
231 if commit_sig_ok {
232 eprintln!(
233 " Trust chain: artifact <- ephemeral key <- commit {} <- maintainer",
234 &sha[..8.min(sha.len())]
235 );
236 } else {
237 eprintln!(
238 " Commit {} is not signed by a trusted maintainer.",
239 &sha[..8.min(sha.len())]
240 );
241 }
242 }
243 }
244 }
245 }
246
247 let commit_sha_val = attestation.commit_sha.clone();
249 if let Some(ref sha) = commit_sha_val
250 && !is_json_mode()
251 && !is_ephemeral
252 {
253 eprintln!(" Commit: {}", sha);
254 }
255
256 let commit_verified = if verify_commit {
258 match &commit_sha_val {
259 None => {
260 if !is_json_mode() {
261 eprintln!(
262 "warning: artifact attestation has no commit_sha field; \
263 re-sign with: auths artifact sign --commit <SHA>"
264 );
265 }
266 None
267 }
268 Some(sha) => {
269 let commit_ref = format!("refs/auths/commits/{}", sha);
271 let lookup = crate::subprocess::git_command(&[
272 "show",
273 &format!("{}:attestation.json", commit_ref),
274 ])
275 .output();
276 match lookup {
277 Ok(output) if output.status.success() => {
278 if !is_json_mode() {
279 eprintln!(" Commit {}: signing attestation found", &sha[..12]);
280 }
281 Some(true)
282 }
283 _ => {
284 if !is_json_mode() {
285 eprintln!(
286 "warning: no signing attestation found for commit {}",
287 &sha[..std::cmp::min(sha.len(), 12)]
288 );
289 }
290 Some(false)
291 }
292 }
293 }
294 }
295 } else {
296 None
297 };
298
299 let exit_code = if valid { 0 } else { 1 };
300
301 output_result(
302 exit_code,
303 VerifyArtifactResult {
304 file: file_str,
305 valid,
306 digest_match: Some(true),
307 chain_valid,
308 chain_report,
309 witness_quorum,
310 issuer: Some(identity_did.to_string()),
311 commit_sha: commit_sha_val,
312 commit_verified,
313 error: None,
314 },
315 )
316}
317
318fn resolve_identity_key(
320 identity_bundle: &Option<PathBuf>,
321 attestation: &Attestation,
322) -> Result<(auths_verifier::DevicePublicKey, CanonicalDid)> {
323 if let Some(bundle_path) = identity_bundle {
324 let bundle_content = fs::read_to_string(bundle_path)
325 .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?;
326 let bundle: IdentityBundle = serde_json::from_str(&bundle_content)
327 .with_context(|| format!("Failed to parse identity bundle: {:?}", bundle_path))?;
328 let pk_bytes = hex::decode(bundle.public_key_hex.as_str())
329 .context("Invalid public key hex in bundle")?;
330 let pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &pk_bytes)
331 .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
332 Ok((pk, bundle.identity_did.into()))
333 } else {
334 let issuer = &attestation.issuer;
336 let (pk_bytes, curve) = resolve_pk_from_did(issuer)
337 .with_context(|| format!("Failed to resolve public key from issuer DID '{}'. Use --identity-bundle for stateless verification.", issuer))?;
338 let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
339 .map_err(|e| anyhow!("Invalid issuer public key resolved from DID: {e}"))?;
340 Ok((pk, issuer.clone()))
341 }
342}
343
344fn resolve_pk_from_did(did: &str) -> Result<(Vec<u8>, auths_crypto::CurveType)> {
348 if let Some(encoded) = did.strip_prefix("did:keri:") {
349 let pk = bs58::decode(encoded)
350 .into_vec()
351 .context("Invalid base58 in did:keri")?;
352 Ok((pk, auths_crypto::CurveType::Ed25519))
354 } else if did.starts_with("did:key:z") {
355 match auths_crypto::did_key_decode(did) {
356 Ok(auths_crypto::DecodedDidKey::Ed25519(pk)) => {
357 Ok((pk.to_vec(), auths_crypto::CurveType::Ed25519))
358 }
359 Ok(auths_crypto::DecodedDidKey::P256(pk)) => Ok((pk, auths_crypto::CurveType::P256)),
360 Err(e) => Err(anyhow!("Failed to resolve did:key: {}", e)),
361 }
362 } else {
363 Err(anyhow!(
364 "Unsupported DID method: {}. Use --identity-bundle instead.",
365 did
366 ))
367 }
368}
369
370async fn verify_witnesses(
372 chain: &[Attestation],
373 root_pk: &auths_verifier::DevicePublicKey,
374 receipts_path: &Option<PathBuf>,
375 witness_keys_raw: &[String],
376 threshold: usize,
377) -> Result<Option<WitnessQuorum>> {
378 let receipts_path = match receipts_path {
379 Some(p) => p,
380 None => return Ok(None),
381 };
382
383 let receipts_bytes = fs::read(receipts_path)
384 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
385 let receipts: Vec<SignedReceipt> =
386 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
387
388 let witness_keys = parse_witness_keys(witness_keys_raw)?;
389
390 let config = WitnessVerifyConfig {
391 receipts: &receipts,
392 witness_keys: &witness_keys,
393 threshold,
394 };
395
396 let report = verify_chain_with_witnesses(chain, root_pk, &config)
397 .await
398 .context("Witness chain verification failed")?;
399
400 Ok(report.witness_quorum)
401}
402
403fn output_error(file: &str, exit_code: i32, message: &str) -> Result<()> {
404 if is_json_mode() {
405 let result = VerifyArtifactResult {
406 file: file.to_string(),
407 valid: false,
408 digest_match: None,
409 chain_valid: None,
410 chain_report: None,
411 witness_quorum: None,
412 issuer: None,
413 commit_sha: None,
414 commit_verified: None,
415 error: Some(message.to_string()),
416 };
417 println!("{}", serde_json::to_string(&result)?);
418 } else {
419 eprintln!("Error: {}", message);
420 }
421 std::process::exit(exit_code);
422}
423
424fn output_result(exit_code: i32, result: VerifyArtifactResult) -> Result<()> {
426 if is_json_mode() {
427 println!("{}", serde_json::to_string(&result)?);
428 } else if result.valid {
429 print!("Artifact verified");
430 if let Some(ref issuer) = result.issuer {
431 print!(": signed by {}", issuer);
432 }
433 if let Some(ref q) = result.witness_quorum {
434 print!(" (witnesses: {}/{})", q.verified, q.required);
435 }
436 println!();
437 } else {
438 eprint!("Verification failed");
439 if let Some(ref error) = result.error {
440 eprint!(": {}", error);
441 }
442 eprintln!();
443 }
444
445 if exit_code != 0 {
446 std::process::exit(exit_code);
447 }
448 Ok(())
449}
450
451async fn verify_commit_in_process(sha: &str) -> bool {
458 let repo = match git2::Repository::discover(".") {
460 Ok(r) => r,
461 Err(e) => {
462 if !is_json_mode() {
463 eprintln!("Failed to open git repository: {e}");
464 }
465 return false;
466 }
467 };
468
469 let oid = match git2::Oid::from_str(sha) {
471 Ok(o) => o,
472 Err(e) => {
473 if !is_json_mode() {
474 eprintln!("Invalid commit SHA '{}': {e}", &sha[..8.min(sha.len())]);
475 }
476 return false;
477 }
478 };
479
480 let commit_content = match raw_commit_bytes(&repo, oid) {
485 Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
486 Err(e) => {
487 if !is_json_mode() {
488 eprintln!("Commit {} not found: {e}", &sha[..8.min(sha.len())]);
489 }
490 return false;
491 }
492 };
493
494 let provider = auths_crypto::RingCryptoProvider;
497 let auths_home = match auths_sdk::paths::auths_home() {
498 Ok(h) => h,
499 Err(e) => {
500 if !is_json_mode() {
501 eprintln!("Could not locate ~/.auths: {e}");
502 }
503 return false;
504 }
505 };
506 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
507 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
508 );
509 let pinned_roots = crate::commands::verify_helpers::load_project_pinned_roots();
510
511 let short = &sha[..8.min(sha.len())];
512 match auths_sdk::workflows::commit_trust::verify_commit_local(
513 ®istry,
514 &pinned_roots,
515 commit_content.as_bytes(),
516 &provider,
517 )
518 .await
519 {
520 Ok(verdict) if verdict.is_valid() => true,
521 Ok(verdict) => {
522 if !is_json_mode() {
523 eprintln!("Commit {short} is not authorized by a pinned trusted root: {verdict:?}");
524 }
525 false
526 }
527 Err(e) => {
528 if !is_json_mode() {
529 eprintln!("Commit {short} trust could not be resolved: {e}");
530 }
531 false
532 }
533 }
534}
535
536pub fn handle_offline_verify(
556 file: &Path,
557 roots: Option<&Path>,
558 member: Option<&str>,
559 signed_at: Option<u128>,
560 json: bool,
561) -> Result<()> {
562 use auths_sdk::workflows::org::{AirGappedOrgBundle, AuthorityAtSigning, verify_org_bundle};
563 use auths_sdk::workflows::roots::parse_roots_typed;
564 use auths_verifier::Prefix;
565 use auths_verifier::types::IdentityDID;
566
567 let bundle_json =
568 fs::read_to_string(file).with_context(|| format!("Failed to read bundle file {file:?}"))?;
569 let bundle = AirGappedOrgBundle::from_json(&bundle_json)
570 .context("Failed to parse air-gapped org bundle")?;
571
572 let roots_path = roots
573 .map(Path::to_path_buf)
574 .unwrap_or_else(|| PathBuf::from(".auths/roots"));
575 let pinned_roots: Vec<IdentityDID> = if roots_path.exists() {
576 let content = fs::read_to_string(&roots_path)
577 .with_context(|| format!("Failed to read roots file {roots_path:?}"))?;
578 parse_roots_typed(&content).context("Failed to parse pinned roots")?
579 } else {
580 bundle.pinned_roots.clone()
583 };
584
585 let member_prefix =
586 member.map(|m| Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(m).to_string()));
587 let query = member_prefix.as_ref().map(|p| (p, signed_at));
588
589 let report =
590 verify_org_bundle(&bundle, &pinned_roots, query).context("Offline verification failed")?;
591
592 if json {
593 println!("{}", serde_json::to_string_pretty(&report)?);
594 } else {
595 println!("Air-gapped verification of {file:?}");
596 println!(" Org: {}", report.org_did.as_str());
597 println!(
598 " Verified as-of: KEL seq {} (by position, not wall-clock)",
599 report.as_of_org_seq
600 );
601 let root = if report.root_pinned {
602 "✅ yes"
603 } else {
604 "🛑 NO (untrusted root)"
605 };
606 println!(" Root pinned: {root}");
607 let dup = if report.duplicity_detected {
608 "🛑 DETECTED"
609 } else {
610 "✅ none"
611 };
612 println!(" Duplicity: {dup}");
613 if let Some(authority) = &report.authority {
614 match authority {
615 AuthorityAtSigning::AuthorizedBeforeRevocation => {
616 println!(" Authority: ✅ AuthorizedBeforeRevocation")
617 }
618 AuthorityAtSigning::RejectedAfterRevocation { revoked_at } => {
619 println!(
620 " Authority: 🛑 RejectedAfterRevocation {{ revoked_at: {revoked_at} }}"
621 )
622 }
623 AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at } => {
624 println!(
625 " Authority: 🛑 RejectedRevokedPositionUnknown {{ revoked_at: {revoked_at} }}"
626 )
627 }
628 AuthorityAtSigning::NeverDelegated => {
629 println!(" Authority: ❌ NeverDelegated")
630 }
631 }
632 }
633 }
634
635 if !report.root_pinned {
638 return Err(anyhow!(
639 "unauthorized: the bundle's org is not in the pinned trust roots"
640 ));
641 }
642 if report.duplicity_detected {
643 return Err(anyhow!(
644 "org KEL duplicity detected — divergent history; resolve before trusting"
645 ));
646 }
647 match report.authority {
648 None | Some(AuthorityAtSigning::AuthorizedBeforeRevocation) => Ok(()),
649 Some(AuthorityAtSigning::RejectedAfterRevocation { revoked_at }) => Err(anyhow!(
650 "unauthorized: signed at/after revocation (KEL seq {revoked_at})"
651 )),
652 Some(AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at }) => Err(anyhow!(
653 "unauthorized: member revoked at KEL seq {revoked_at}; artifact has no in-band signing position"
654 )),
655 Some(AuthorityAtSigning::NeverDelegated) => {
656 Err(anyhow!("unauthorized: the org never delegated this member"))
657 }
658 }
659}
660
661pub(crate) fn raw_commit_bytes(repo: &git2::Repository, oid: git2::Oid) -> Result<Vec<u8>> {
673 let odb = repo.odb().context("open git object database")?;
674 let obj = odb.read(oid).context("read commit object")?;
675 Ok(obj.data().to_vec())
676}
677
678#[cfg(test)]
679mod tests {
680 use super::raw_commit_bytes;
681 use std::process::Command;
682
683 #[test]
688 fn raw_commit_bytes_matches_git_cat_file() {
689 let (dir, repo) = auths_test_utils::git::init_test_repo();
690 let sig = git2::Signature::now("t", "t@example.com").expect("sig");
691 let tree_id = {
692 let mut index = repo.index().expect("index");
693 index.write_tree().expect("tree")
694 };
695 let tree = repo.find_tree(tree_id).expect("find tree");
696 let oid = repo
697 .commit(
698 Some("HEAD"),
699 &sig,
700 &sig,
701 "subject line\n\nbody with trailing newline drift potential\n",
702 &tree,
703 &[],
704 )
705 .expect("commit");
706
707 let via_helper = raw_commit_bytes(&repo, oid).expect("helper");
708 let via_git = Command::new("git")
709 .args(["cat-file", "commit", &oid.to_string()])
710 .current_dir(dir.path())
711 .output()
712 .expect("git cat-file");
713 assert!(via_git.status.success());
714 assert_eq!(
715 via_helper, via_git.stdout,
716 "verifier payload must be byte-identical to git cat-file commit"
717 );
718 }
719}