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}
91
92pub async fn handle_verify(cmd: VerifyCommand) -> Result<()> {
95 #[allow(clippy::disallowed_methods)]
96 let now = Utc::now();
97 let result = run_verify(now, &cmd).await;
98
99 match result {
100 Ok(verify_result) => {
101 if is_json_mode() {
102 println!("{}", serde_json::to_string(&verify_result)?);
103 }
104
105 if verify_result.valid {
106 Ok(())
108 } else {
109 if !is_json_mode() {
111 eprintln!(
112 "Attestation verification failed: {}",
113 verify_result.error.as_deref().unwrap_or("unknown error")
114 );
115 }
116 process::exit(1);
117 }
118 }
119 Err(e) => {
120 if is_json_mode() {
122 let error_result = VerifyResult {
123 valid: false,
124 error: Some(e.to_string()),
125 issuer: None,
126 subject: None,
127 witness_quorum: None,
128 };
129 println!("{}", serde_json::to_string(&error_result)?);
130 } else {
131 eprintln!("Error: {}", e);
132 }
133 process::exit(2);
134 }
135 }
136}
137
138fn effective_trust_policy(cmd: &VerifyCommand) -> TrustPolicy {
143 match cmd.trust {
144 Some(CliTrustPolicy::Tofu) => TrustPolicy::Tofu,
145 Some(CliTrustPolicy::Explicit) => TrustPolicy::Explicit,
146 None => {
147 if io::stdin().is_terminal() {
148 TrustPolicy::Tofu
149 } else {
150 TrustPolicy::Explicit
151 }
152 }
153 }
154}
155
156fn bytes_to_device_public_key(
158 bytes: &[u8],
159 curve: auths_crypto::CurveType,
160 source: &str,
161) -> Result<auths_verifier::DevicePublicKey> {
162 auths_verifier::DevicePublicKey::try_new(curve, bytes)
163 .map_err(|e| anyhow!("Invalid {} public key: {e}", source))
164}
165
166fn resolve_self_issuer_key(did: &str) -> Option<auths_verifier::DevicePublicKey> {
174 let auths_home = auths_sdk::paths::auths_home().ok()?;
175 let local = auths_sdk::storage::RegistryIdentityStorage::new(auths_home.clone())
176 .load_identity()
177 .ok()?;
178 if local.controller_did.as_str() != did {
179 return None;
180 }
181 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
182 auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
183 );
184 let (pk, curve) = auths_sdk::keri::resolve_current_public_key(®istry, did).ok()?;
185 auths_verifier::DevicePublicKey::try_new(curve, &pk).ok()
186}
187
188fn resolve_issuer_key(
197 now: chrono::DateTime<Utc>,
198 cmd: &VerifyCommand,
199 att: &Attestation,
200) -> Result<auths_verifier::DevicePublicKey> {
201 if let Some(ref pk_hex) = cmd.issuer_pk {
203 let pk_bytes =
204 hex::decode(pk_hex).context("Invalid hex string provided for issuer public key")?;
205 let issuer_did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
206 let curve = auths_crypto::did_key_decode(issuer_did)
207 .map(|d| d.curve())
208 .unwrap_or_default();
209 return auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
210 .map_err(|e| anyhow!("Invalid issuer public key: {e}"));
211 }
212
213 let did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
215
216 if let Some(pk) = resolve_self_issuer_key(did) {
218 if !is_json_mode() {
219 println!("Using local identity (self-trust): {}", did);
220 }
221 return Ok(pk);
222 }
223
224 let policy = effective_trust_policy(cmd);
226 let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
227
228 if let Some(pin) = store.lookup(did)? {
230 if !is_json_mode() {
231 println!("Using pinned identity: {}", did);
232 }
233 return bytes_to_device_public_key(&pin.public_key_bytes()?, pin.curve, "pinned identity");
234 }
235
236 let roots_path = cmd.roots_file.clone().unwrap_or_else(|| {
238 std::env::current_dir()
239 .unwrap_or_default()
240 .join(".auths/roots.json")
241 });
242
243 if roots_path.exists() {
244 let roots = RootsFile::load(&roots_path)?;
245 if let Some(root) = roots.find(did) {
246 if !is_json_mode() {
247 println!(
248 "Using root from {}: {}",
249 roots_path.display(),
250 root.note.as_deref().unwrap_or(did)
251 );
252 }
253 let pin = PinnedIdentity {
255 did: did.to_string(),
256 public_key_hex: root.public_key_hex.clone(),
257 curve: root.curve,
258 kel_tip_said: root.kel_tip_said.clone(),
259 kel_sequence: None,
260 first_seen: now,
261 origin: format!("roots.json:{}", roots_path.display()),
262 trust_level: TrustLevel::OrgPolicy,
263 };
264 store.pin(pin)?;
265 return bytes_to_device_public_key(&root.public_key_bytes()?, root.curve, "roots.json");
266 }
267 }
268
269 match policy {
271 TrustPolicy::Tofu => {
272 anyhow::bail!(
276 "Unknown identity '{}'. Provide --signer-key to trust on first use, \
277 or add to .auths/roots.json for explicit trust.",
278 did
279 );
280 }
281 TrustPolicy::Explicit => {
282 anyhow::bail!(
283 "Unknown identity '{}' and trust policy is 'explicit'.\n\
284 Options:\n \
285 1. Add to .auths/roots.json in the repository\n \
286 2. Pin manually: auths trust pin --did {} --key <hex>\n \
287 3. Provide --signer-key <hex> to bypass trust resolution",
288 did,
289 did
290 );
291 }
292 }
293}
294
295use crate::commands::verify_helpers::parse_witness_keys;
296
297async fn run_verify(now: chrono::DateTime<Utc>, cmd: &VerifyCommand) -> Result<VerifyResult> {
298 let attestation_bytes = if cmd.attestation == "-" {
300 let mut buffer = Vec::new();
301 io::stdin()
302 .read_to_end(&mut buffer)
303 .context("Failed to read attestation from stdin")?;
304 buffer
305 } else {
306 let path = PathBuf::from(&cmd.attestation);
307 fs::read(&path).with_context(|| format!("Failed to read attestation file: {:?}", path))?
308 };
309
310 let att: Attestation =
312 serde_json::from_slice(&attestation_bytes).context("Failed to parse JSON attestation")?;
313
314 if !is_json_mode() {
315 println!(
316 "Verifying attestation: issuer={}, subject={}",
317 att.issuer, att.subject
318 );
319 }
320
321 let issuer_pk = resolve_issuer_key(now, cmd, &att)?;
323
324 let verify_result = verify_with_keys(&att, &issuer_pk).await;
328
329 match verify_result {
330 Ok(_) => {
331 let witness_quorum = if let Some(ref receipts_path) = cmd.witness_receipts {
333 let receipts_bytes = fs::read(receipts_path).with_context(|| {
334 format!("Failed to read witness receipts: {:?}", receipts_path)
335 })?;
336 let receipts: Vec<SignedReceipt> = serde_json::from_slice(&receipts_bytes)
337 .context("Failed to parse witness receipts JSON")?;
338 let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
339
340 let config = WitnessVerifyConfig {
341 receipts: &receipts,
342 witness_keys: &witness_keys,
343 threshold: cmd.witness_threshold,
344 };
345
346 let report =
347 verify_chain_with_witnesses(std::slice::from_ref(&att), &issuer_pk, &config)
348 .await
349 .context("Witness chain verification failed")?;
350
351 if !report.is_valid() {
352 if !is_json_mode()
353 && let auths_verifier::VerificationStatus::InsufficientWitnesses {
354 required,
355 verified,
356 } = &report.status
357 {
358 eprintln!("Witness quorum not met: {}/{} verified", verified, required);
359 }
360 return Ok(VerifyResult {
361 valid: false,
362 error: Some(format!(
363 "Witness quorum not met: {}/{} verified",
364 report.witness_quorum.as_ref().map_or(0, |q| q.verified),
365 cmd.witness_threshold
366 )),
367 issuer: Some(att.issuer.to_string()),
368 subject: Some(att.subject.to_string()),
369 witness_quorum: report.witness_quorum,
370 });
371 }
372
373 if !is_json_mode()
374 && let Some(ref q) = report.witness_quorum
375 {
376 println!("Witness quorum met: {}/{} verified", q.verified, q.required);
377 }
378
379 report.witness_quorum
380 } else {
381 None
382 };
383
384 if !is_json_mode() {
385 println!("Attestation verified successfully.");
386 }
387 Ok(VerifyResult {
388 valid: true,
389 error: None,
390 issuer: Some(att.issuer.to_string()),
391 subject: Some(att.subject.to_string()),
392 witness_quorum,
393 })
394 }
395 Err(e) => Ok(VerifyResult {
396 valid: false,
397 error: Some(e.to_string()),
398 issuer: Some(att.issuer.to_string()),
399 subject: Some(att.subject.to_string()),
400 witness_quorum: None,
401 }),
402 }
403}
404
405pub async fn handle_verify_attestation(
407 attestation_path: &PathBuf,
408 issuer_pubkey_hex: &str,
409) -> Result<()> {
410 println!("Verifying attestation from file: {:?}", attestation_path);
411 println!(
412 " Using issuer public key (hex): {}...",
413 &issuer_pubkey_hex[..8.min(issuer_pubkey_hex.len())]
414 );
415
416 let attestation_bytes = fs::read(attestation_path)
417 .with_context(|| format!("Failed to read attestation file: {:?}", attestation_path))?;
418
419 let att: Attestation = serde_json::from_slice(&attestation_bytes).with_context(|| {
420 format!(
421 "Failed to parse JSON attestation from file: {:?}",
422 attestation_path
423 )
424 })?;
425 println!(
426 " Attestation loaded successfully. Issuer: {}, Subject: {}",
427 att.issuer, att.subject
428 );
429
430 let issuer_pk_bytes = hex::decode(issuer_pubkey_hex)
431 .context("Invalid hex string provided for issuer public key")?;
432 let issuer_curve = auths_crypto::did_key_decode(att.issuer.as_str())
433 .map(|d| d.curve())
434 .unwrap_or_default();
435 let issuer_pk = bytes_to_device_public_key(&issuer_pk_bytes, issuer_curve, "issuer")?;
436
437 match verify_with_keys(&att, &issuer_pk).await {
438 Ok(_) => {
439 println!("Attestation verified successfully.");
440 Ok(())
441 }
442 Err(e) => Err(anyhow!("Attestation verification failed: {}", e)),
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn verify_result_serializes_correctly() {
452 let result = VerifyResult {
453 valid: true,
454 error: None,
455 issuer: Some("did:key:issuer".to_string()),
456 subject: Some("did:key:subject".to_string()),
457 witness_quorum: None,
458 };
459 let json = serde_json::to_string(&result).unwrap();
460 assert!(json.contains("\"valid\":true"));
461 assert!(json.contains("\"issuer\":\"did:key:issuer\""));
462 }
463
464 #[test]
465 fn verify_result_error_serializes_correctly() {
466 let result = VerifyResult {
467 valid: false,
468 error: Some("signature mismatch".to_string()),
469 issuer: None,
470 subject: None,
471 witness_quorum: None,
472 };
473 let json = serde_json::to_string(&result).unwrap();
474 assert!(json.contains("\"valid\":false"));
475 assert!(json.contains("\"error\":\"signature mismatch\""));
476 }
477}