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