Skip to main content

auths_cli/commands/artifact/
verify.rs

1use 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/// JSON output for `artifact verify --json`.
19#[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
39/// Execute the `artifact verify` command.
40///
41/// Exit codes: 0=valid, 1=invalid, 2=error.
42pub 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    // 1. Locate and load signature file
53    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    // 2. Parse attestation
75    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    // 3. Extract artifact metadata from payload
83    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    // 4. Compute file digest and compare
104    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    // 5. Resolve identity public key
137    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    // 6. Verify attestation chain with sign_release capability
145    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            // Chain signature is valid but capability is missing
156            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    // 7. Optional witness verification
166    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    // 8. Compute overall verdict
182    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
208/// Resolve identity public key from bundle or from the attestation's issuer DID.
209fn 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        // Resolve public key from the issuer DID
222        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
229/// Extract raw Ed25519 public key bytes from a DID string.
230///
231/// Supports `did:keri:<base58>` and `did:key:z<base58multicodec>`.
232fn 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
256/// Verify witness receipts if provided.
257async 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
289/// Output error with appropriate formatting and exit code.
290fn 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
310/// Output the verification result.
311fn 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}