auths_cli/commands/device/
verify_attestation.rs1use 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#[derive(Debug, Clone, Copy, Default, ValueEnum)]
19pub enum CliTrustPolicy {
20 #[default]
22 Tofu,
23 Explicit,
25}
26
27#[derive(Parser, Debug, Clone)]
28#[command(about = "Verify device authorization signatures.")]
29pub struct VerifyCommand {
30 #[arg(long, value_parser, required = true)]
32 pub attestation: String,
33
34 #[arg(long = "signer-key", value_parser)]
39 pub issuer_pk: Option<String>,
40
41 #[arg(long = "signer", visible_alias = "issuer-did", value_parser)]
46 pub issuer_did: Option<String>,
47
48 #[arg(long, value_enum)]
58 pub trust: Option<CliTrustPolicy>,
59
60 #[arg(long = "roots-file", value_parser)]
64 pub roots_file: Option<PathBuf>,
65
66 #[arg(long = "witness-signatures")]
68 pub witness_receipts: Option<PathBuf>,
69
70 #[arg(long = "witnesses-required", default_value = "1")]
72 pub witness_threshold: usize,
73
74 #[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 #[serde(skip_serializing_if = "Option::is_none")]
94 freshness: Option<auths_verifier::Freshness>,
95}
96
97pub 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 Ok(())
113 } else {
114 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 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
144fn 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
162fn 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
172fn 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(®istry, did).ok()?;
191 auths_verifier::DevicePublicKey::try_new(curve, &pk).ok()
192}
193
194fn resolve_issuer_key(
203 now: chrono::DateTime<Utc>,
204 cmd: &VerifyCommand,
205 att: &Attestation,
206) -> Result<auths_verifier::DevicePublicKey> {
207 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 let did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
221
222 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 let policy = effective_trust_policy(cmd);
232 let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
233
234 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 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 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 match policy {
277 TrustPolicy::Tofu => {
278 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 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 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 let issuer_pk = resolve_issuer_key(now, cmd, &att)?;
329
330 let verify_result = verify_with_keys(&att, &issuer_pk).await;
334
335 match verify_result {
336 Ok(_) => {
337 let mut verdict_freshness = auths_verifier::Freshness::Unknown;
341 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
422pub 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}