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#[derive(Debug, Clone, Copy, Default, ValueEnum)]
20pub enum CliTrustPolicy {
21 #[default]
23 Tofu,
24 Explicit,
26}
27
28#[derive(Parser, Debug, Clone)]
29#[command(about = "Verify device authorization signatures.")]
30pub struct VerifyCommand {
31 #[arg(long, value_parser, required = true)]
33 pub attestation: String,
34
35 #[arg(long = "issuer-pk", value_parser)]
40 pub issuer_pk: Option<String>,
41
42 #[arg(long = "issuer-did", value_parser)]
47 pub issuer_did: Option<String>,
48
49 #[arg(long, value_enum)]
59 pub trust: Option<CliTrustPolicy>,
60
61 #[arg(long = "roots-file", value_parser)]
65 pub roots_file: Option<PathBuf>,
66
67 #[arg(long = "require-capability")]
69 pub require_capability: Option<String>,
70
71 #[arg(long)]
73 pub witness_receipts: Option<PathBuf>,
74
75 #[arg(long, default_value = "1")]
77 pub witness_threshold: usize,
78
79 #[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
101pub 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 Ok(())
115 } else {
116 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 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
147fn 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
165fn resolve_issuer_key(cmd: &VerifyCommand, att: &Attestation) -> Result<Vec<u8>> {
173 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 let did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
188
189 let policy = effective_trust_policy(cmd);
191 let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
192
193 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 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 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 match policy {
235 TrustPolicy::Tofu => {
236 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 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 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 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 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 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
412pub 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}