Skip to main content

auths_cli/commands/device/
verify_attestation.rs

1use crate::ux::format::is_json_mode;
2use anyhow::{Context, Result, anyhow};
3use auths_keri::witness::SignedReceipt;
4use auths_sdk::ports::IdentityStorage;
5use auths_sdk::trust::{PinnedIdentity, PinnedIdentityStore, RootsFile, TrustLevel, TrustPolicy};
6use auths_verifier::core::Attestation;
7use auths_verifier::verify::{verify_chain_with_witnesses, verify_with_keys};
8use auths_verifier::witness::WitnessVerifyConfig;
9use chrono::Utc;
10use clap::{Parser, ValueEnum};
11use serde::Serialize;
12use std::fs;
13use std::io::{self, IsTerminal, Read};
14use std::path::PathBuf;
15use std::process;
16
17/// Trust policy for identity verification.
18#[derive(Debug, Clone, Copy, Default, ValueEnum)]
19pub enum CliTrustPolicy {
20    /// Trust-on-first-use: prompt interactively on first encounter.
21    #[default]
22    Tofu,
23    /// Explicit trust: require identity in pinned store or roots.json.
24    Explicit,
25}
26
27#[derive(Parser, Debug, Clone)]
28#[command(about = "Verify device authorization signatures.")]
29pub struct VerifyCommand {
30    /// Path to authorization JSON file, or "-" to read from stdin.
31    #[arg(long, value_parser, required = true)]
32    pub attestation: String,
33
34    /// Signer public key in hex format (64 hex chars = 32 bytes).
35    ///
36    /// If provided, bypasses trust resolution and uses this key directly.
37    /// Takes precedence over --signer and trust policy.
38    #[arg(long = "signer-key", value_parser)]
39    pub issuer_pk: Option<String>,
40
41    /// Signer identity ID for trust-based key resolution.
42    ///
43    /// Looks up the public key from pinned identity store or roots.json.
44    /// Uses --trust policy to determine behavior for unknown identities.
45    #[arg(long = "signer", visible_alias = "issuer-did", value_parser)]
46    pub issuer_did: Option<String>,
47
48    /// Trust policy for unknown identities.
49    ///
50    /// Resolution precedence:
51    ///   1. --issuer-pk (direct key, bypasses trust)
52    ///   2. Pinned identity store (~/.auths/known_identities.json)
53    ///   3. Repository roots.json (.auths/roots.json)
54    ///   4. TOFU prompt (if TTY) or explicit rejection (if non-TTY)
55    ///
56    /// Defaults: tofu on TTY, explicit on non-TTY (CI).
57    #[arg(long, value_enum)]
58    pub trust: Option<CliTrustPolicy>,
59
60    /// Path to roots.json file for explicit trust.
61    ///
62    /// Overrides default .auths/roots.json lookup.
63    #[arg(long = "roots-file", value_parser)]
64    pub roots_file: Option<PathBuf>,
65
66    /// Path to witness signatures JSON file.
67    #[arg(long = "witness-signatures")]
68    pub witness_receipts: Option<PathBuf>,
69
70    /// Number of witnesses required (default: 1).
71    #[arg(long = "witnesses-required", default_value = "1")]
72    pub witness_threshold: usize,
73
74    /// Witness public keys as DID:hex pairs (e.g., "did:key:z6Mk...:abcd1234...").
75    #[arg(long, num_args = 1..)]
76    pub witness_keys: Vec<String>,
77}
78
79#[derive(Serialize)]
80struct VerifyResult {
81    valid: bool,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    error: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    issuer: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    subject: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    witness_quorum: Option<auths_verifier::witness::WitnessQuorum>,
90}
91
92/// Handle verify command. Returns Ok(()) on success, Err on error.
93/// Uses exit codes: 0=valid, 1=invalid, 2=error
94pub async fn handle_verify(cmd: VerifyCommand) -> Result<()> {
95    #[allow(clippy::disallowed_methods)]
96    let now = Utc::now();
97    let result = run_verify(now, &cmd).await;
98
99    match result {
100        Ok(verify_result) => {
101            if is_json_mode() {
102                println!("{}", serde_json::to_string(&verify_result)?);
103            }
104
105            if verify_result.valid {
106                // Exit code 0 for valid
107                Ok(())
108            } else {
109                // Exit code 1 for invalid attestation
110                if !is_json_mode() {
111                    eprintln!(
112                        "Attestation verification failed: {}",
113                        verify_result.error.as_deref().unwrap_or("unknown error")
114                    );
115                }
116                process::exit(1);
117            }
118        }
119        Err(e) => {
120            // Exit code 2 for errors (file not found, parse error, etc.)
121            if is_json_mode() {
122                let error_result = VerifyResult {
123                    valid: false,
124                    error: Some(e.to_string()),
125                    issuer: None,
126                    subject: None,
127                    witness_quorum: None,
128                };
129                println!("{}", serde_json::to_string(&error_result)?);
130            } else {
131                eprintln!("Error: {}", e);
132            }
133            process::exit(2);
134        }
135    }
136}
137
138/// Determine effective trust policy.
139///
140/// If explicitly set via --trust, use that.
141/// Otherwise: TOFU on TTY, Explicit on non-TTY (CI).
142fn effective_trust_policy(cmd: &VerifyCommand) -> TrustPolicy {
143    match cmd.trust {
144        Some(CliTrustPolicy::Tofu) => TrustPolicy::Tofu,
145        Some(CliTrustPolicy::Explicit) => TrustPolicy::Explicit,
146        None => {
147            if io::stdin().is_terminal() {
148                TrustPolicy::Tofu
149            } else {
150                TrustPolicy::Explicit
151            }
152        }
153    }
154}
155
156/// Wrap raw pubkey bytes into a curve-tagged `DevicePublicKey`.
157fn bytes_to_device_public_key(
158    bytes: &[u8],
159    curve: auths_crypto::CurveType,
160    source: &str,
161) -> Result<auths_verifier::DevicePublicKey> {
162    auths_verifier::DevicePublicKey::try_new(curve, bytes)
163        .map_err(|e| anyhow!("Invalid {} public key: {e}", source))
164}
165
166/// Resolve the issuer key via self-trust: when the issuer is the verifier's own
167/// local identity, replay its KEL from the local registry for the current key.
168///
169/// Self-trust is the floor of the trust model — a verifier always trusts keys it
170/// itself controls, so signing and verifying your own attestation needs zero
171/// pinning ceremony. Returns `None` for any other issuer (normal trust
172/// resolution applies).
173fn resolve_self_issuer_key(did: &str) -> Option<auths_verifier::DevicePublicKey> {
174    let auths_home = auths_sdk::paths::auths_home().ok()?;
175    let local = auths_sdk::storage::RegistryIdentityStorage::new(auths_home.clone())
176        .load_identity()
177        .ok()?;
178    if local.controller_did.as_str() != did {
179        return None;
180    }
181    let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
182        auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
183    );
184    let (pk, curve) = auths_sdk::keri::resolve_current_public_key(&registry, did).ok()?;
185    auths_verifier::DevicePublicKey::try_new(curve, &pk).ok()
186}
187
188/// Resolve the issuer public key from various sources.
189///
190/// Resolution precedence:
191/// 1. `--issuer-pk` (direct key, bypasses trust)
192/// 2. Self-trust (the verifier's own identity, resolved from its local KEL)
193/// 3. Pinned identity store
194/// 4. `roots.json` file
195/// 5. Trust policy (TOFU prompt or explicit rejection)
196fn resolve_issuer_key(
197    now: chrono::DateTime<Utc>,
198    cmd: &VerifyCommand,
199    att: &Attestation,
200) -> Result<auths_verifier::DevicePublicKey> {
201    // 1. Direct key takes precedence
202    if let Some(ref pk_hex) = cmd.issuer_pk {
203        let pk_bytes =
204            hex::decode(pk_hex).context("Invalid hex string provided for issuer public key")?;
205        let issuer_did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
206        let curve = auths_crypto::did_key_decode(issuer_did)
207            .map(|d| d.curve())
208            .unwrap_or_default();
209        return auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
210            .map_err(|e| anyhow!("Invalid issuer public key: {e}"));
211    }
212
213    // Determine the DID to look up
214    let did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
215
216    // 2. Self-trust: the verifier's own identity resolves from its local KEL.
217    if let Some(pk) = resolve_self_issuer_key(did) {
218        if !is_json_mode() {
219            println!("Using local identity (self-trust): {}", did);
220        }
221        return Ok(pk);
222    }
223
224    // Get trust policy
225    let policy = effective_trust_policy(cmd);
226    let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
227
228    // 2. Check pinned identity store first
229    if let Some(pin) = store.lookup(did)? {
230        if !is_json_mode() {
231            println!("Using pinned identity: {}", did);
232        }
233        return bytes_to_device_public_key(&pin.public_key_bytes()?, pin.curve, "pinned identity");
234    }
235
236    // 3. Check roots.json file
237    let roots_path = cmd.roots_file.clone().unwrap_or_else(|| {
238        std::env::current_dir()
239            .unwrap_or_default()
240            .join(".auths/roots.json")
241    });
242
243    if roots_path.exists() {
244        let roots = RootsFile::load(&roots_path)?;
245        if let Some(root) = roots.find(did) {
246            if !is_json_mode() {
247                println!(
248                    "Using root from {}: {}",
249                    roots_path.display(),
250                    root.note.as_deref().unwrap_or(did)
251                );
252            }
253            // Pin from roots.json for future use
254            let pin = PinnedIdentity {
255                did: did.to_string(),
256                public_key_hex: root.public_key_hex.clone(),
257                curve: root.curve,
258                kel_tip_said: root.kel_tip_said.clone(),
259                kel_sequence: None,
260                first_seen: now,
261                origin: format!("roots.json:{}", roots_path.display()),
262                trust_level: TrustLevel::OrgPolicy,
263            };
264            store.pin(pin)?;
265            return bytes_to_device_public_key(&root.public_key_bytes()?, root.curve, "roots.json");
266        }
267    }
268
269    // 4. Apply trust policy
270    match policy {
271        TrustPolicy::Tofu => {
272            // Need to extract key from attestation for TOFU
273            // The attestation itself doesn't contain the issuer's public key directly,
274            // so we need it from --issuer-pk or the user needs to provide it
275            anyhow::bail!(
276                "Unknown identity '{}'. Provide --signer-key to trust on first use, \
277                 or add to .auths/roots.json for explicit trust.",
278                did
279            );
280        }
281        TrustPolicy::Explicit => {
282            anyhow::bail!(
283                "Unknown identity '{}' and trust policy is 'explicit'.\n\
284                 Options:\n  \
285                 1. Add to .auths/roots.json in the repository\n  \
286                 2. Pin manually: auths trust pin --did {} --key <hex>\n  \
287                 3. Provide --signer-key <hex> to bypass trust resolution",
288                did,
289                did
290            );
291        }
292    }
293}
294
295use crate::commands::verify_helpers::parse_witness_keys;
296
297async fn run_verify(now: chrono::DateTime<Utc>, cmd: &VerifyCommand) -> Result<VerifyResult> {
298    // 1. Read attestation from file or stdin
299    let attestation_bytes = if cmd.attestation == "-" {
300        let mut buffer = Vec::new();
301        io::stdin()
302            .read_to_end(&mut buffer)
303            .context("Failed to read attestation from stdin")?;
304        buffer
305    } else {
306        let path = PathBuf::from(&cmd.attestation);
307        fs::read(&path).with_context(|| format!("Failed to read attestation file: {:?}", path))?
308    };
309
310    // 2. Deserialize attestation JSON
311    let att: Attestation =
312        serde_json::from_slice(&attestation_bytes).context("Failed to parse JSON attestation")?;
313
314    if !is_json_mode() {
315        println!(
316            "Verifying attestation: issuer={}, subject={}",
317            att.issuer, att.subject
318        );
319    }
320
321    // 3. Resolve issuer public key
322    let issuer_pk = resolve_issuer_key(now, cmd, &att)?;
323
324    // 4. Verify the attestation's authenticity (signatures, expiry, linkage).
325    //    Capability authority is no longer gated here: a capability grant must come
326    //    from a holder-verified ACDC credential, not the attestation's caps field.
327    let verify_result = verify_with_keys(&att, &issuer_pk).await;
328
329    match verify_result {
330        Ok(_) => {
331            // 6. If witness receipts are provided, do witness chain verification
332            let witness_quorum = if let Some(ref receipts_path) = cmd.witness_receipts {
333                let receipts_bytes = fs::read(receipts_path).with_context(|| {
334                    format!("Failed to read witness receipts: {:?}", receipts_path)
335                })?;
336                let receipts: Vec<SignedReceipt> = serde_json::from_slice(&receipts_bytes)
337                    .context("Failed to parse witness receipts JSON")?;
338                let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
339
340                let config = WitnessVerifyConfig {
341                    receipts: &receipts,
342                    witness_keys: &witness_keys,
343                    threshold: cmd.witness_threshold,
344                };
345
346                let report =
347                    verify_chain_with_witnesses(std::slice::from_ref(&att), &issuer_pk, &config)
348                        .await
349                        .context("Witness chain verification failed")?;
350
351                if !report.is_valid() {
352                    if !is_json_mode()
353                        && let auths_verifier::VerificationStatus::InsufficientWitnesses {
354                            required,
355                            verified,
356                        } = &report.status
357                    {
358                        eprintln!("Witness quorum not met: {}/{} verified", verified, required);
359                    }
360                    return Ok(VerifyResult {
361                        valid: false,
362                        error: Some(format!(
363                            "Witness quorum not met: {}/{} verified",
364                            report.witness_quorum.as_ref().map_or(0, |q| q.verified),
365                            cmd.witness_threshold
366                        )),
367                        issuer: Some(att.issuer.to_string()),
368                        subject: Some(att.subject.to_string()),
369                        witness_quorum: report.witness_quorum,
370                    });
371                }
372
373                if !is_json_mode()
374                    && let Some(ref q) = report.witness_quorum
375                {
376                    println!("Witness quorum met: {}/{} verified", q.verified, q.required);
377                }
378
379                report.witness_quorum
380            } else {
381                None
382            };
383
384            if !is_json_mode() {
385                println!("Attestation verified successfully.");
386            }
387            Ok(VerifyResult {
388                valid: true,
389                error: None,
390                issuer: Some(att.issuer.to_string()),
391                subject: Some(att.subject.to_string()),
392                witness_quorum,
393            })
394        }
395        Err(e) => Ok(VerifyResult {
396            valid: false,
397            error: Some(e.to_string()),
398            issuer: Some(att.issuer.to_string()),
399            subject: Some(att.subject.to_string()),
400            witness_quorum: None,
401        }),
402    }
403}
404
405/// Legacy handler for backward compatibility (kept for potential internal use).
406pub async fn handle_verify_attestation(
407    attestation_path: &PathBuf,
408    issuer_pubkey_hex: &str,
409) -> Result<()> {
410    println!("Verifying attestation from file: {:?}", attestation_path);
411    println!(
412        "   Using issuer public key (hex): {}...",
413        &issuer_pubkey_hex[..8.min(issuer_pubkey_hex.len())]
414    );
415
416    let attestation_bytes = fs::read(attestation_path)
417        .with_context(|| format!("Failed to read attestation file: {:?}", attestation_path))?;
418
419    let att: Attestation = serde_json::from_slice(&attestation_bytes).with_context(|| {
420        format!(
421            "Failed to parse JSON attestation from file: {:?}",
422            attestation_path
423        )
424    })?;
425    println!(
426        "   Attestation loaded successfully. Issuer: {}, Subject: {}",
427        att.issuer, att.subject
428    );
429
430    let issuer_pk_bytes = hex::decode(issuer_pubkey_hex)
431        .context("Invalid hex string provided for issuer public key")?;
432    let issuer_curve = auths_crypto::did_key_decode(att.issuer.as_str())
433        .map(|d| d.curve())
434        .unwrap_or_default();
435    let issuer_pk = bytes_to_device_public_key(&issuer_pk_bytes, issuer_curve, "issuer")?;
436
437    match verify_with_keys(&att, &issuer_pk).await {
438        Ok(_) => {
439            println!("Attestation verified successfully.");
440            Ok(())
441        }
442        Err(e) => Err(anyhow!("Attestation verification failed: {}", e)),
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn verify_result_serializes_correctly() {
452        let result = VerifyResult {
453            valid: true,
454            error: None,
455            issuer: Some("did:key:issuer".to_string()),
456            subject: Some("did:key:subject".to_string()),
457            witness_quorum: None,
458        };
459        let json = serde_json::to_string(&result).unwrap();
460        assert!(json.contains("\"valid\":true"));
461        assert!(json.contains("\"issuer\":\"did:key:issuer\""));
462    }
463
464    #[test]
465    fn verify_result_error_serializes_correctly() {
466        let result = VerifyResult {
467            valid: false,
468            error: Some("signature mismatch".to_string()),
469            issuer: None,
470            subject: None,
471            witness_quorum: None,
472        };
473        let json = serde_json::to_string(&result).unwrap();
474        assert!(json.contains("\"valid\":false"));
475        assert!(json.contains("\"error\":\"signature mismatch\""));
476    }
477}