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    /// The verdict's surfaced freshness (ADR 009) — present only on a honored verdict. An
91    /// offline attestation verify cannot confirm freshness, so it names `Unknown`, never a
92    /// bare success that reads as real-time fresh.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    freshness: Option<auths_verifier::Freshness>,
95}
96
97/// Handle verify command. Returns Ok(()) on success, Err on error.
98/// Uses exit codes: 0=valid, 1=invalid, 2=error
99pub async fn handle_verify(cmd: VerifyCommand) -> Result<()> {
100    #[allow(clippy::disallowed_methods)]
101    let now = Utc::now();
102    let result = run_verify(now, &cmd).await;
103
104    match result {
105        Ok(verify_result) => {
106            if is_json_mode() {
107                println!("{}", serde_json::to_string(&verify_result)?);
108            }
109
110            if verify_result.valid {
111                // Exit code 0 for valid
112                Ok(())
113            } else {
114                // Exit code 1 for invalid attestation
115                if !is_json_mode() {
116                    eprintln!(
117                        "Attestation verification failed: {}",
118                        verify_result.error.as_deref().unwrap_or("unknown error")
119                    );
120                }
121                process::exit(1);
122            }
123        }
124        Err(e) => {
125            // Exit code 2 for errors (file not found, parse error, etc.)
126            if is_json_mode() {
127                let error_result = VerifyResult {
128                    valid: false,
129                    error: Some(e.to_string()),
130                    issuer: None,
131                    subject: None,
132                    witness_quorum: None,
133                    freshness: None,
134                };
135                println!("{}", serde_json::to_string(&error_result)?);
136            } else {
137                eprintln!("Error: {}", e);
138            }
139            process::exit(2);
140        }
141    }
142}
143
144/// Determine effective trust policy.
145///
146/// If explicitly set via --trust, use that.
147/// Otherwise: TOFU on TTY, Explicit on non-TTY (CI).
148fn effective_trust_policy(cmd: &VerifyCommand) -> TrustPolicy {
149    match cmd.trust {
150        Some(CliTrustPolicy::Tofu) => TrustPolicy::Tofu,
151        Some(CliTrustPolicy::Explicit) => TrustPolicy::Explicit,
152        None => {
153            if io::stdin().is_terminal() {
154                TrustPolicy::Tofu
155            } else {
156                TrustPolicy::Explicit
157            }
158        }
159    }
160}
161
162/// Wrap raw pubkey bytes into a curve-tagged `DevicePublicKey`.
163fn bytes_to_device_public_key(
164    bytes: &[u8],
165    curve: auths_crypto::CurveType,
166    source: &str,
167) -> Result<auths_verifier::DevicePublicKey> {
168    auths_verifier::DevicePublicKey::try_new(curve, bytes)
169        .map_err(|e| anyhow!("Invalid {} public key: {e}", source))
170}
171
172/// Resolve the issuer key via self-trust: when the issuer is the verifier's own
173/// local identity, replay its KEL from the local registry for the current key.
174///
175/// Self-trust is the floor of the trust model — a verifier always trusts keys it
176/// itself controls, so signing and verifying your own attestation needs zero
177/// pinning ceremony. Returns `None` for any other issuer (normal trust
178/// resolution applies).
179fn resolve_self_issuer_key(did: &str) -> Option<auths_verifier::DevicePublicKey> {
180    let auths_home = auths_sdk::paths::auths_home().ok()?;
181    let local = auths_sdk::storage::RegistryIdentityStorage::new(auths_home.clone())
182        .load_identity()
183        .ok()?;
184    if local.controller_did.as_str() != did {
185        return None;
186    }
187    let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
188        auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
189    );
190    let (pk, curve) = auths_sdk::keri::resolve_current_public_key(&registry, did).ok()?;
191    auths_verifier::DevicePublicKey::try_new(curve, &pk).ok()
192}
193
194/// Resolve the issuer public key from various sources.
195///
196/// Resolution precedence:
197/// 1. `--issuer-pk` (direct key, bypasses trust)
198/// 2. Self-trust (the verifier's own identity, resolved from its local KEL)
199/// 3. Pinned identity store
200/// 4. `roots.json` file
201/// 5. Trust policy (TOFU prompt or explicit rejection)
202fn resolve_issuer_key(
203    now: chrono::DateTime<Utc>,
204    cmd: &VerifyCommand,
205    att: &Attestation,
206) -> Result<auths_verifier::DevicePublicKey> {
207    // 1. Direct key takes precedence
208    if let Some(ref pk_hex) = cmd.issuer_pk {
209        let pk_bytes =
210            hex::decode(pk_hex).context("Invalid hex string provided for issuer public key")?;
211        let issuer_did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
212        let curve = auths_crypto::did_key_decode(issuer_did)
213            .map(|d| d.curve())
214            .unwrap_or_default();
215        return auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
216            .map_err(|e| anyhow!("Invalid issuer public key: {e}"));
217    }
218
219    // Determine the DID to look up
220    let did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
221
222    // 2. Self-trust: the verifier's own identity resolves from its local KEL.
223    if let Some(pk) = resolve_self_issuer_key(did) {
224        if !is_json_mode() {
225            println!("Using local identity (self-trust): {}", did);
226        }
227        return Ok(pk);
228    }
229
230    // Get trust policy
231    let policy = effective_trust_policy(cmd);
232    let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
233
234    // 2. Check pinned identity store first
235    if let Some(pin) = store.lookup(did)? {
236        if !is_json_mode() {
237            println!("Using pinned identity: {}", did);
238        }
239        return bytes_to_device_public_key(&pin.public_key_bytes()?, pin.curve, "pinned identity");
240    }
241
242    // 3. Check roots.json file
243    let roots_path = cmd.roots_file.clone().unwrap_or_else(|| {
244        std::env::current_dir()
245            .unwrap_or_default()
246            .join(".auths/roots.json")
247    });
248
249    if roots_path.exists() {
250        let roots = RootsFile::load(&roots_path)?;
251        if let Some(root) = roots.find(did) {
252            if !is_json_mode() {
253                println!(
254                    "Using root from {}: {}",
255                    roots_path.display(),
256                    root.note.as_deref().unwrap_or(did)
257                );
258            }
259            // Pin from roots.json for future use
260            let pin = PinnedIdentity {
261                did: did.to_string(),
262                public_key_hex: root.public_key_hex.clone(),
263                curve: root.curve,
264                kel_tip_said: root.kel_tip_said.clone(),
265                kel_sequence: None,
266                first_seen: now,
267                origin: format!("roots.json:{}", roots_path.display()),
268                trust_level: TrustLevel::OrgPolicy,
269            };
270            store.pin(pin)?;
271            return bytes_to_device_public_key(&root.public_key_bytes()?, root.curve, "roots.json");
272        }
273    }
274
275    // 4. Apply trust policy
276    match policy {
277        TrustPolicy::Tofu => {
278            // Need to extract key from attestation for TOFU
279            // The attestation itself doesn't contain the issuer's public key directly,
280            // so we need it from --issuer-pk or the user needs to provide it
281            anyhow::bail!(
282                "Unknown identity '{}'. Provide --signer-key to trust on first use, \
283                 or add to .auths/roots.json for explicit trust.",
284                did
285            );
286        }
287        TrustPolicy::Explicit => {
288            anyhow::bail!(
289                "Unknown identity '{}' and trust policy is 'explicit'.\n\
290                 Options:\n  \
291                 1. Add to .auths/roots.json in the repository\n  \
292                 2. Pin manually: auths trust pin --did {} --key <hex>\n  \
293                 3. Provide --signer-key <hex> to bypass trust resolution",
294                did,
295                did
296            );
297        }
298    }
299}
300
301use crate::commands::verify_helpers::parse_witness_keys;
302
303async fn run_verify(now: chrono::DateTime<Utc>, cmd: &VerifyCommand) -> Result<VerifyResult> {
304    // 1. Read attestation from file or stdin
305    let attestation_bytes = if cmd.attestation == "-" {
306        let mut buffer = Vec::new();
307        io::stdin()
308            .read_to_end(&mut buffer)
309            .context("Failed to read attestation from stdin")?;
310        buffer
311    } else {
312        let path = PathBuf::from(&cmd.attestation);
313        fs::read(&path).with_context(|| format!("Failed to read attestation file: {:?}", path))?
314    };
315
316    // 2. Deserialize attestation JSON
317    let att: Attestation =
318        serde_json::from_slice(&attestation_bytes).context("Failed to parse JSON attestation")?;
319
320    if !is_json_mode() {
321        println!(
322            "Verifying attestation: issuer={}, subject={}",
323            att.issuer, att.subject
324        );
325    }
326
327    // 3. Resolve issuer public key
328    let issuer_pk = resolve_issuer_key(now, cmd, &att)?;
329
330    // 4. Verify the attestation's authenticity (signatures, expiry, linkage).
331    //    Capability authority is no longer gated here: a capability grant must come
332    //    from a holder-verified ACDC credential, not the attestation's caps field.
333    let verify_result = verify_with_keys(&att, &issuer_pk).await;
334
335    match verify_result {
336        Ok(_) => {
337            // An offline attestation verify consults no fresher source, so the honest default
338            // is `Unknown` (ADR 009); the witness path below upgrades it from the report it
339            // already computes. The verdict is never surfaced as a bare success.
340            let mut verdict_freshness = auths_verifier::Freshness::Unknown;
341            // 6. If witness receipts are provided, do witness chain verification
342            let witness_quorum = if let Some(ref receipts_path) = cmd.witness_receipts {
343                let receipts_bytes = fs::read(receipts_path).with_context(|| {
344                    format!("Failed to read witness receipts: {:?}", receipts_path)
345                })?;
346                let receipts: Vec<SignedReceipt> = serde_json::from_slice(&receipts_bytes)
347                    .context("Failed to parse witness receipts JSON")?;
348                let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
349
350                let config = WitnessVerifyConfig {
351                    receipts: &receipts,
352                    witness_keys: &witness_keys,
353                    threshold: cmd.witness_threshold,
354                };
355
356                let report =
357                    verify_chain_with_witnesses(std::slice::from_ref(&att), &issuer_pk, &config)
358                        .await
359                        .context("Witness chain verification failed")?;
360
361                verdict_freshness = report.freshness();
362                if !report.is_valid() {
363                    if !is_json_mode()
364                        && let auths_verifier::VerificationStatus::InsufficientWitnesses {
365                            required,
366                            verified,
367                        } = &report.status
368                    {
369                        eprintln!("Witness quorum not met: {}/{} verified", verified, required);
370                    }
371                    return Ok(VerifyResult {
372                        valid: false,
373                        error: Some(format!(
374                            "Witness quorum not met: {}/{} verified",
375                            report.witness_quorum.as_ref().map_or(0, |q| q.verified),
376                            cmd.witness_threshold
377                        )),
378                        issuer: Some(att.issuer.to_string()),
379                        subject: Some(att.subject.to_string()),
380                        witness_quorum: report.witness_quorum,
381                        freshness: None,
382                    });
383                }
384
385                if !is_json_mode()
386                    && let Some(ref q) = report.witness_quorum
387                {
388                    println!("Witness quorum met: {}/{} verified", q.verified, q.required);
389                }
390
391                report.witness_quorum
392            } else {
393                None
394            };
395
396            if !is_json_mode() {
397                println!(
398                    "Attestation verified successfully (freshness {}).",
399                    crate::commands::verify_helpers::freshness_label(verdict_freshness)
400                );
401            }
402            Ok(VerifyResult {
403                valid: true,
404                error: None,
405                issuer: Some(att.issuer.to_string()),
406                subject: Some(att.subject.to_string()),
407                witness_quorum,
408                freshness: Some(verdict_freshness),
409            })
410        }
411        Err(e) => Ok(VerifyResult {
412            valid: false,
413            error: Some(e.to_string()),
414            issuer: Some(att.issuer.to_string()),
415            subject: Some(att.subject.to_string()),
416            witness_quorum: None,
417            freshness: None,
418        }),
419    }
420}
421
422/// Legacy handler for backward compatibility (kept for potential internal use).
423pub async fn handle_verify_attestation(
424    attestation_path: &PathBuf,
425    issuer_pubkey_hex: &str,
426) -> Result<()> {
427    println!("Verifying attestation from file: {:?}", attestation_path);
428    println!(
429        "   Using issuer public key (hex): {}...",
430        &issuer_pubkey_hex[..8.min(issuer_pubkey_hex.len())]
431    );
432
433    let attestation_bytes = fs::read(attestation_path)
434        .with_context(|| format!("Failed to read attestation file: {:?}", attestation_path))?;
435
436    let att: Attestation = serde_json::from_slice(&attestation_bytes).with_context(|| {
437        format!(
438            "Failed to parse JSON attestation from file: {:?}",
439            attestation_path
440        )
441    })?;
442    println!(
443        "   Attestation loaded successfully. Issuer: {}, Subject: {}",
444        att.issuer, att.subject
445    );
446
447    let issuer_pk_bytes = hex::decode(issuer_pubkey_hex)
448        .context("Invalid hex string provided for issuer public key")?;
449    let issuer_curve = auths_crypto::did_key_decode(att.issuer.as_str())
450        .map(|d| d.curve())
451        .unwrap_or_default();
452    let issuer_pk = bytes_to_device_public_key(&issuer_pk_bytes, issuer_curve, "issuer")?;
453
454    match verify_with_keys(&att, &issuer_pk).await {
455        Ok(_) => {
456            println!("Attestation verified successfully.");
457            Ok(())
458        }
459        Err(e) => Err(anyhow!("Attestation verification failed: {}", e)),
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn verify_result_serializes_correctly() {
469        let result = VerifyResult {
470            valid: true,
471            error: None,
472            issuer: Some("did:key:issuer".to_string()),
473            subject: Some("did:key:subject".to_string()),
474            witness_quorum: None,
475            freshness: Some(auths_verifier::Freshness::Unknown),
476        };
477        let json = serde_json::to_string(&result).unwrap();
478        assert!(json.contains("\"valid\":true"));
479        assert!(json.contains("\"issuer\":\"did:key:issuer\""));
480        assert!(
481            json.contains("\"freshness\":\"unknown\""),
482            "a honored verdict surfaces its freshness, never a bare valid: {json}"
483        );
484    }
485
486    #[test]
487    fn verify_result_error_serializes_correctly() {
488        let result = VerifyResult {
489            valid: false,
490            error: Some("signature mismatch".to_string()),
491            issuer: None,
492            subject: None,
493            witness_quorum: None,
494            freshness: None,
495        };
496        let json = serde_json::to_string(&result).unwrap();
497        assert!(json.contains("\"valid\":false"));
498        assert!(json.contains("\"error\":\"signature mismatch\""));
499        assert!(
500            !json.contains("freshness"),
501            "a failed verdict carries no freshness to surface: {json}"
502        );
503    }
504}