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::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#[derive(Debug, Clone, Copy, Default, ValueEnum)]
18pub enum CliTrustPolicy {
19 #[default]
21 Tofu,
22 Explicit,
24}
25
26#[derive(Parser, Debug, Clone)]
27#[command(about = "Verify device authorization signatures.")]
28pub struct VerifyCommand {
29 #[arg(long, value_parser, required = true)]
31 pub attestation: String,
32
33 #[arg(long = "signer-key", value_parser)]
38 pub issuer_pk: Option<String>,
39
40 #[arg(long = "signer", visible_alias = "issuer-did", value_parser)]
45 pub issuer_did: Option<String>,
46
47 #[arg(long, value_enum)]
57 pub trust: Option<CliTrustPolicy>,
58
59 #[arg(long = "roots-file", value_parser)]
63 pub roots_file: Option<PathBuf>,
64
65 #[arg(long = "witness-signatures")]
67 pub witness_receipts: Option<PathBuf>,
68
69 #[arg(long = "witnesses-required", default_value = "1")]
71 pub witness_threshold: usize,
72
73 #[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
91pub 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 Ok(())
107 } else {
108 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 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
137fn 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
155fn 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
165fn resolve_issuer_key(
173 now: chrono::DateTime<Utc>,
174 cmd: &VerifyCommand,
175 att: &Attestation,
176) -> Result<auths_verifier::DevicePublicKey> {
177 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 let did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
191
192 let policy = effective_trust_policy(cmd);
194 let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
195
196 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 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 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 match policy {
239 TrustPolicy::Tofu => {
240 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 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 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 let issuer_pk = resolve_issuer_key(now, cmd, &att)?;
291
292 let verify_result = verify_with_keys(&att, &issuer_pk).await;
296
297 match verify_result {
298 Ok(_) => {
299 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
373pub 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}