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