auths_cli/commands/artifact/
verify.rs1use anyhow::{Context, Result, anyhow};
2use serde::Serialize;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use auths_verifier::core::Attestation;
7use auths_verifier::witness::{WitnessQuorum, WitnessReceipt, WitnessVerifyConfig};
8use auths_verifier::{
9 Capability, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_capability,
10 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 capability_valid: Option<bool>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 witness_quorum: Option<WitnessQuorum>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 issuer: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 error: Option<String>,
37}
38
39pub async fn handle_verify(
43 file: &Path,
44 signature: Option<PathBuf>,
45 identity_bundle: Option<PathBuf>,
46 witness_receipts: Option<PathBuf>,
47 witness_keys: &[String],
48 witness_threshold: usize,
49) -> Result<()> {
50 let file_str = file.to_string_lossy().to_string();
51
52 let sig_path = signature.unwrap_or_else(|| {
54 let mut p = file.to_path_buf();
55 let new_name = format!(
56 "{}.auths.json",
57 p.file_name().unwrap_or_default().to_string_lossy()
58 );
59 p.set_file_name(new_name);
60 p
61 });
62
63 let sig_content = match fs::read_to_string(&sig_path) {
64 Ok(c) => c,
65 Err(e) => {
66 return output_error(
67 &file_str,
68 2,
69 &format!("Failed to read signature file {:?}: {}", sig_path, e),
70 );
71 }
72 };
73
74 let attestation: Attestation = match serde_json::from_str(&sig_content) {
76 Ok(a) => a,
77 Err(e) => {
78 return output_error(&file_str, 2, &format!("Failed to parse attestation: {}", e));
79 }
80 };
81
82 let artifact_meta: ArtifactMetadata = match &attestation.payload {
84 Some(payload) => match serde_json::from_value(payload.clone()) {
85 Ok(m) => m,
86 Err(e) => {
87 return output_error(
88 &file_str,
89 2,
90 &format!("Failed to parse artifact metadata from payload: {}", e),
91 );
92 }
93 },
94 None => {
95 return output_error(
96 &file_str,
97 2,
98 "Attestation has no payload (expected artifact metadata)",
99 );
100 }
101 };
102
103 let file_artifact = FileArtifact::new(file);
105 let file_digest = match file_artifact.digest() {
106 Ok(d) => d,
107 Err(e) => {
108 return output_error(
109 &file_str,
110 2,
111 &format!("Failed to compute file digest: {}", e),
112 );
113 }
114 };
115
116 if file_digest != artifact_meta.digest {
117 return output_result(
118 1,
119 VerifyArtifactResult {
120 file: file_str.clone(),
121 valid: false,
122 digest_match: Some(false),
123 chain_valid: None,
124 chain_report: None,
125 capability_valid: None,
126 witness_quorum: None,
127 issuer: Some(attestation.issuer.to_string()),
128 error: Some(format!(
129 "Digest mismatch: file={}, attestation={}",
130 file_digest.hex, artifact_meta.digest.hex
131 )),
132 },
133 );
134 }
135
136 let (root_pk, identity_did) = match resolve_identity_key(&identity_bundle, &attestation) {
138 Ok(v) => v,
139 Err(e) => {
140 return output_error(&file_str, 2, &e.to_string());
141 }
142 };
143
144 let chain = vec![attestation.clone()];
146 let chain_result =
147 verify_chain_with_capability(&chain, &Capability::sign_release(), &root_pk).await;
148
149 let (chain_valid, chain_report, capability_valid) = match chain_result {
150 Ok(report) => {
151 let is_valid = report.is_valid();
152 (Some(is_valid), Some(report), Some(true))
153 }
154 Err(auths_verifier::error::AttestationError::MissingCapability { .. }) => {
155 let report = verify_chain(&chain, &root_pk).await.ok();
157 let chain_ok = report.as_ref().map(|r| r.is_valid());
158 (chain_ok, report, Some(false))
159 }
160 Err(e) => {
161 return output_error(&file_str, 1, &format!("Chain verification failed: {}", e));
162 }
163 };
164
165 let witness_quorum = match verify_witnesses(
167 &chain,
168 &root_pk,
169 &witness_receipts,
170 witness_keys,
171 witness_threshold,
172 )
173 .await
174 {
175 Ok(q) => q,
176 Err(e) => {
177 return output_error(&file_str, 2, &format!("Witness verification error: {}", e));
178 }
179 };
180
181 let mut valid = chain_valid.unwrap_or(false) && capability_valid.unwrap_or(true);
183
184 if let Some(ref q) = witness_quorum
185 && q.verified < q.required
186 {
187 valid = false;
188 }
189
190 let exit_code = if valid { 0 } else { 1 };
191
192 output_result(
193 exit_code,
194 VerifyArtifactResult {
195 file: file_str,
196 valid,
197 digest_match: Some(true),
198 chain_valid,
199 chain_report,
200 capability_valid,
201 witness_quorum,
202 issuer: Some(identity_did),
203 error: None,
204 },
205 )
206}
207
208fn resolve_identity_key(
210 identity_bundle: &Option<PathBuf>,
211 attestation: &Attestation,
212) -> Result<(Vec<u8>, String)> {
213 if let Some(bundle_path) = identity_bundle {
214 let bundle_content = fs::read_to_string(bundle_path)
215 .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?;
216 let bundle: IdentityBundle = serde_json::from_str(&bundle_content)
217 .with_context(|| format!("Failed to parse identity bundle: {:?}", bundle_path))?;
218 let pk = hex::decode(&bundle.public_key_hex).context("Invalid public key hex in bundle")?;
219 Ok((pk, bundle.identity_did))
220 } else {
221 let issuer = &attestation.issuer;
223 let pk = resolve_pk_from_did(issuer)
224 .with_context(|| format!("Failed to resolve public key from issuer DID '{}'. Use --identity-bundle for stateless verification.", issuer))?;
225 Ok((pk, issuer.to_string()))
226 }
227}
228
229fn resolve_pk_from_did(did: &str) -> Result<Vec<u8>> {
233 if let Some(encoded) = did.strip_prefix("did:keri:") {
234 let pk = bs58::decode(encoded)
235 .into_vec()
236 .context("Invalid base58 in did:keri")?;
237 if pk.len() != 32 {
238 return Err(anyhow!(
239 "Expected 32-byte Ed25519 key from did:keri, got {}",
240 pk.len()
241 ));
242 }
243 Ok(pk)
244 } else if did.starts_with("did:key:z") {
245 auths_crypto::did_key_to_ed25519(did)
246 .map(|k| k.to_vec())
247 .map_err(|e| anyhow!("Failed to resolve did:key: {}", e))
248 } else {
249 Err(anyhow!(
250 "Unsupported DID method: {}. Use --identity-bundle instead.",
251 did
252 ))
253 }
254}
255
256async fn verify_witnesses(
258 chain: &[Attestation],
259 root_pk: &[u8],
260 receipts_path: &Option<PathBuf>,
261 witness_keys_raw: &[String],
262 threshold: usize,
263) -> Result<Option<WitnessQuorum>> {
264 let receipts_path = match receipts_path {
265 Some(p) => p,
266 None => return Ok(None),
267 };
268
269 let receipts_bytes = fs::read(receipts_path)
270 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
271 let receipts: Vec<WitnessReceipt> =
272 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
273
274 let witness_keys = parse_witness_keys(witness_keys_raw)?;
275
276 let config = WitnessVerifyConfig {
277 receipts: &receipts,
278 witness_keys: &witness_keys,
279 threshold,
280 };
281
282 let report = verify_chain_with_witnesses(chain, root_pk, &config)
283 .await
284 .context("Witness chain verification failed")?;
285
286 Ok(report.witness_quorum)
287}
288
289fn output_error(file: &str, exit_code: i32, message: &str) -> Result<()> {
291 if is_json_mode() {
292 let result = VerifyArtifactResult {
293 file: file.to_string(),
294 valid: false,
295 digest_match: None,
296 chain_valid: None,
297 chain_report: None,
298 capability_valid: None,
299 witness_quorum: None,
300 issuer: None,
301 error: Some(message.to_string()),
302 };
303 println!("{}", serde_json::to_string(&result).unwrap());
304 } else {
305 eprintln!("Error: {}", message);
306 }
307 std::process::exit(exit_code);
308}
309
310fn output_result(exit_code: i32, result: VerifyArtifactResult) -> Result<()> {
312 if is_json_mode() {
313 println!("{}", serde_json::to_string(&result).unwrap());
314 } else if result.valid {
315 print!("Artifact verified");
316 if let Some(ref issuer) = result.issuer {
317 print!(": signed by {}", issuer);
318 }
319 if let Some(ref q) = result.witness_quorum {
320 print!(" (witnesses: {}/{})", q.verified, q.required);
321 }
322 println!();
323 } else {
324 eprint!("Verification failed");
325 if let Some(ref error) = result.error {
326 eprint!(": {}", error);
327 }
328 if let Some(false) = result.capability_valid {
329 eprint!(" (missing sign_release capability)");
330 }
331 eprintln!();
332 }
333
334 if exit_code != 0 {
335 std::process::exit(exit_code);
336 }
337 Ok(())
338}