1use anyhow::{Context, Result, anyhow};
2use clap::{Args, Subcommand};
3use log::warn;
4use serde::Serialize;
5use serde_json::Value;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use auths_core::config::EnvironmentConfig;
11use auths_core::signing::{PassphraseProvider, UnifiedPassphraseProvider};
12use auths_core::storage::keychain::KeyAlias;
13use auths_id::attestation::group::AttestationGroup;
14use auths_id::identity::helpers::ManagedIdentity;
15use auths_id::storage::attestation::AttestationSource;
16use auths_id::storage::identity::IdentityStorage;
17use auths_id::storage::layout::{self, StorageLayoutConfig};
18use auths_storage::git::{
19 GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
20};
21use chrono::Utc;
22
23use crate::commands::registry_overrides::RegistryOverrides;
24use crate::factories::storage::build_auths_context;
25use crate::ux::format::{JsonResponse, is_json_mode};
26
27#[derive(Serialize)]
28struct DeviceEntry {
29 id: String,
30 status: String,
31 public_key: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 created_at: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 expires_at: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 note: Option<String>,
38}
39
40#[derive(Args, Debug, Clone)]
41#[command(about = "Manage device authorizations within an identity repository.")]
42pub struct DeviceCommand {
43 #[command(subcommand)]
44 pub command: DeviceSubcommand,
45
46 #[command(flatten)]
47 pub overrides: RegistryOverrides,
48}
49
50#[derive(Subcommand, Debug, Clone)]
51pub enum DeviceSubcommand {
52 List {
54 #[arg(
56 long,
57 help = "Include devices with revoked or expired authorizations in the output."
58 )]
59 include_revoked: bool,
60 },
61
62 #[command(visible_alias = "add")]
64 Link {
65 #[arg(
66 long,
67 visible_alias = "ika",
68 help = "Local alias of the *identity's* key (used for signing)."
69 )]
70 identity_key_alias: String,
71
72 #[arg(
73 long,
74 visible_alias = "dka",
75 help = "Local alias of the *new device's* key (must be imported first)."
76 )]
77 device_key_alias: String,
78
79 #[arg(
80 long,
81 visible_alias = "device",
82 help = "Identity ID of the new device being authorized (must match device-key-alias)."
83 )]
84 device_did: String,
85
86 #[arg(
87 long,
88 value_name = "PAYLOAD_PATH",
89 help = "Optional path to a JSON file containing arbitrary payload data for the authorization."
90 )]
91 payload: Option<PathBuf>,
92
93 #[arg(
94 long,
95 value_name = "SCHEMA_PATH",
96 help = "Optional path to a JSON schema for validating the payload (experimental)."
97 )]
98 schema: Option<PathBuf>,
99
100 #[arg(
101 long,
102 visible_alias = "days",
103 value_name = "DAYS",
104 help = "Optional number of days until this device authorization expires."
105 )]
106 expires_in_days: Option<i64>,
107
108 #[arg(
109 long,
110 help = "Optional description/note for this device authorization."
111 )]
112 note: Option<String>,
113
114 #[arg(
115 long,
116 value_delimiter = ',',
117 help = "Permissions to grant this device (comma-separated)"
118 )]
119 capabilities: Option<Vec<String>>,
120 },
121
122 Revoke {
124 #[arg(
125 long,
126 visible_alias = "device",
127 help = "Identity ID of the device authorization to revoke."
128 )]
129 device_did: String,
130
131 #[arg(
132 long,
133 help = "Local alias of the *identity's* key (required to authorize revocation)."
134 )]
135 identity_key_alias: String,
136
137 #[arg(long, help = "Optional note explaining the revocation.")]
138 note: Option<String>,
139
140 #[arg(long, help = "Preview actions without making changes.")]
141 dry_run: bool,
142 },
143
144 Resolve {
146 #[arg(
147 long,
148 visible_alias = "device",
149 help = "The device DID to resolve (e.g. did:key:z6Mk...)."
150 )]
151 device_did: String,
152 },
153
154 Pair(super::pair::PairCommand),
156
157 #[command(name = "verify")]
159 VerifyAttestation(super::verify_attestation::VerifyCommand),
160
161 Extend {
163 #[arg(
164 long,
165 visible_alias = "device",
166 help = "Identity ID of the device authorization to extend."
167 )]
168 device_did: String,
169
170 #[arg(
171 long = "expires-in-days",
172 visible_alias = "days",
173 value_name = "DAYS",
174 help = "Number of days to extend the expiration by (from now)."
175 )]
176 expires_in_days: i64,
177
178 #[arg(
179 long = "identity-key-alias",
180 visible_alias = "ika",
181 help = "Local alias of the *identity's* key (required for re-signing)."
182 )]
183 identity_key_alias: String,
184
185 #[arg(
186 long = "device-key-alias",
187 visible_alias = "dka",
188 help = "Local alias of the *device's* key (required for re-signing)."
189 )]
190 device_key_alias: String,
191 },
192}
193
194#[allow(clippy::too_many_arguments)]
195pub fn handle_device(
196 cmd: DeviceCommand,
197 repo_opt: Option<PathBuf>,
198 identity_ref_override: Option<String>,
199 identity_blob_name_override: Option<String>,
200 attestation_prefix_override: Option<String>,
201 attestation_blob_name_override: Option<String>,
202 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
203 env_config: &EnvironmentConfig,
204) -> Result<()> {
205 let repo_path = layout::resolve_repo_path(repo_opt)?;
206
207 let mut config = StorageLayoutConfig::default();
208 if let Some(identity_ref) = identity_ref_override {
209 config.identity_ref = identity_ref.into();
210 }
211 if let Some(blob_name) = identity_blob_name_override {
212 config.identity_blob_name = blob_name.into();
213 }
214 if let Some(prefix) = attestation_prefix_override {
215 config.device_attestation_prefix = prefix.into();
216 }
217 if let Some(blob_name) = attestation_blob_name_override {
218 config.attestation_blob_name = blob_name.into();
219 }
220
221 match cmd.command {
222 DeviceSubcommand::List { include_revoked } => {
223 list_devices(&repo_path, &config, include_revoked)
224 }
225 DeviceSubcommand::Resolve { device_did } => resolve_device(&repo_path, &device_did),
226 DeviceSubcommand::Pair(pair_cmd) => super::pair::handle_pair(pair_cmd, env_config),
227 DeviceSubcommand::VerifyAttestation(verify_cmd) => {
228 let rt = tokio::runtime::Runtime::new()?;
229 rt.block_on(super::verify_attestation::handle_verify(verify_cmd))
230 }
231 DeviceSubcommand::Link {
232 identity_key_alias,
233 device_key_alias,
234 device_did,
235 payload: payload_path_opt,
236 schema: schema_path_opt,
237 expires_in_days,
238 note,
239 capabilities,
240 } => {
241 let payload = read_payload_file(payload_path_opt.as_deref())?;
242 validate_payload_schema(schema_path_opt.as_deref(), &payload)?;
243
244 let caps: Vec<auths_verifier::Capability> = capabilities
245 .unwrap_or_default()
246 .into_iter()
247 .filter_map(|s| auths_verifier::Capability::parse(&s).ok())
248 .collect();
249
250 let link_config = auths_sdk::types::DeviceLinkConfig {
251 identity_key_alias: KeyAlias::new_unchecked(identity_key_alias),
252 device_key_alias: Some(KeyAlias::new_unchecked(device_key_alias)),
253 device_did: Some(device_did.clone()),
254 capabilities: caps,
255 expires_in_days: expires_in_days.map(|d| d as u32),
256 note,
257 payload,
258 };
259
260 let passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync> =
261 Arc::new(UnifiedPassphraseProvider::new(passphrase_provider));
262 let ctx = build_auths_context(
263 &repo_path,
264 env_config,
265 Some(Arc::clone(&passphrase_provider)),
266 )?;
267
268 let result = auths_sdk::device::link_device(
269 link_config,
270 &ctx,
271 &auths_core::ports::clock::SystemClock,
272 )
273 .map_err(|e| anyhow!("{e}"))?;
274
275 display_link_result(&result, &device_did)
276 }
277
278 DeviceSubcommand::Revoke {
279 device_did,
280 identity_key_alias,
281 note,
282 dry_run,
283 } => {
284 if dry_run {
285 return display_dry_run_revoke(&device_did, &identity_key_alias);
286 }
287
288 let ctx = build_auths_context(
289 &repo_path,
290 env_config,
291 Some(Arc::clone(&passphrase_provider)),
292 )?;
293
294 let identity_key_alias = KeyAlias::new_unchecked(identity_key_alias);
295 auths_sdk::device::revoke_device(
296 &device_did,
297 &identity_key_alias,
298 &ctx,
299 note,
300 &auths_core::ports::clock::SystemClock,
301 )
302 .map_err(|e| anyhow!("{e}"))?;
303
304 display_revoke_result(&device_did, &repo_path)
305 }
306
307 DeviceSubcommand::Extend {
308 device_did,
309 expires_in_days,
310 identity_key_alias,
311 device_key_alias,
312 } => handle_extend(
313 &repo_path,
314 &config,
315 &device_did,
316 expires_in_days,
317 &identity_key_alias,
318 &device_key_alias,
319 passphrase_provider,
320 env_config,
321 ),
322 }
323}
324
325fn display_link_result(
326 result: &auths_sdk::result::DeviceLinkResult,
327 device_did: &str,
328) -> Result<()> {
329 println!(
330 "\nā
Successfully linked device {} (attestation: {})",
331 device_did, result.attestation_id
332 );
333 Ok(())
334}
335
336fn display_dry_run_revoke(device_did: &str, identity_key_alias: &str) -> Result<()> {
337 if is_json_mode() {
338 JsonResponse::success(
339 "device revoke",
340 &serde_json::json!({
341 "dry_run": true,
342 "device_did": device_did,
343 "identity_key_alias": identity_key_alias,
344 "actions": [
345 "Revoke device authorization",
346 "Create signed revocation attestation",
347 "Store revocation in Git repository"
348 ]
349 }),
350 )
351 .print()
352 .map_err(|e| anyhow!("{e}"))
353 } else {
354 let out = crate::ux::format::Output::new();
355 out.print_info("Dry run mode ā no changes will be made");
356 out.newline();
357 out.println("Would perform the following actions:");
358 out.println(&format!(
359 " 1. Revoke device authorization for {}",
360 device_did
361 ));
362 out.println(" 2. Create signed revocation attestation");
363 out.println(" 3. Store revocation in Git repository");
364 Ok(())
365 }
366}
367
368fn display_revoke_result(device_did: &str, repo_path: &Path) -> Result<()> {
369 let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
370 let identity: ManagedIdentity = identity_storage
371 .load_identity()
372 .context("Failed to load identity")?;
373
374 println!(
375 "\nā
Successfully revoked device {} for identity {}",
376 device_did, identity.controller_did
377 );
378 Ok(())
379}
380
381fn read_payload_file(path: Option<&Path>) -> Result<Option<Value>> {
382 match path {
383 Some(p) => {
384 let content = fs::read_to_string(p)
385 .with_context(|| format!("Failed to read payload file {:?}", p))?;
386 let value: Value = serde_json::from_str(&content)
387 .with_context(|| format!("Failed to parse JSON from payload file {:?}", p))?;
388 Ok(Some(value))
389 }
390 None => Ok(None),
391 }
392}
393
394fn validate_payload_schema(schema_path: Option<&Path>, payload: &Option<Value>) -> Result<()> {
395 match (schema_path, payload) {
396 (Some(schema_path), Some(payload_val)) => {
397 let schema_content = fs::read_to_string(schema_path)
398 .with_context(|| format!("Failed to read schema file {:?}", schema_path))?;
399 let schema_json: serde_json::Value = serde_json::from_str(&schema_content)
400 .with_context(|| format!("Failed to parse JSON schema from {:?}", schema_path))?;
401 let validator = jsonschema::validator_for(&schema_json)
402 .map_err(|e| anyhow!("Invalid JSON schema in {:?}: {}", schema_path, e))?;
403 let errors: Vec<String> = validator
404 .iter_errors(payload_val)
405 .map(|e| format!(" - {}", e))
406 .collect();
407 if !errors.is_empty() {
408 return Err(anyhow!(
409 "Payload does not conform to schema:\n{}",
410 errors.join("\n")
411 ));
412 }
413 Ok(())
414 }
415 (Some(_), None) => {
416 warn!("--schema specified but no --payload provided; skipping validation.");
417 Ok(())
418 }
419 _ => Ok(()),
420 }
421}
422
423#[allow(clippy::too_many_arguments)]
424fn handle_extend(
425 repo_path: &Path,
426 _config: &StorageLayoutConfig,
427 device_did: &str,
428 days: i64,
429 identity_key_alias: &str,
430 device_key_alias: &str,
431 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
432 env_config: &EnvironmentConfig,
433) -> Result<()> {
434 let config = auths_sdk::types::DeviceExtensionConfig {
435 repo_path: repo_path.to_path_buf(),
436 device_did: device_did.to_string(),
437 days: days as u32,
438 identity_key_alias: KeyAlias::new_unchecked(identity_key_alias),
439 device_key_alias: Some(KeyAlias::new_unchecked(device_key_alias)),
440 };
441 let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider))?;
442
443 let result =
444 auths_sdk::device::extend_device(config, &ctx, &auths_core::ports::clock::SystemClock)
445 .with_context(|| {
446 format!("Failed to extend device authorization for '{}'", device_did)
447 })?;
448
449 println!(
450 "Successfully extended expiration for {} to {}",
451 result.device_did,
452 result.new_expires_at.date_naive()
453 );
454 Ok(())
455}
456
457fn resolve_device(repo_path: &Path, device_did_str: &str) -> Result<()> {
458 let attestation_storage = RegistryAttestationStorage::new(repo_path.to_path_buf());
459 let device_did = auths_verifier::types::DeviceDID::new(device_did_str);
460 let attestations = attestation_storage
461 .load_attestations_for_device(&device_did)
462 .with_context(|| format!("Failed to load attestations for device {device_did_str}"))?;
463
464 let latest = attestations
465 .last()
466 .ok_or_else(|| anyhow!("No attestation found for device {device_did_str}"))?;
467
468 println!("{}", latest.issuer);
469 Ok(())
470}
471
472fn list_devices(
473 repo_path: &Path,
474 _config: &StorageLayoutConfig,
475 include_revoked: bool,
476) -> Result<()> {
477 let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
478 let attestation_storage = RegistryAttestationStorage::new(repo_path.to_path_buf());
479 let backend = Arc::new(GitRegistryBackend::from_config_unchecked(
480 RegistryConfig::single_tenant(repo_path),
481 )) as Arc<dyn auths_id::ports::registry::RegistryBackend + Send + Sync>;
482 let resolver = auths_id::identity::resolve::RegistryDidResolver::new(backend);
483
484 let identity: ManagedIdentity = identity_storage
485 .load_identity()
486 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
487
488 let attestations = attestation_storage
489 .load_all_attestations()
490 .context("Could not load device attestations")?;
491
492 let grouped = AttestationGroup::from_list(attestations);
493
494 let mut entries: Vec<DeviceEntry> = Vec::new();
495 for (device_did_str, att_entries) in grouped.by_device.iter() {
496 let latest = att_entries
497 .last()
498 .expect("Grouped attestations should not be empty");
499
500 let verification_result = auths_id::attestation::verify::verify_with_resolver(
501 chrono::Utc::now(),
502 &resolver,
503 latest,
504 None,
505 );
506
507 let status_string = match verification_result {
508 Ok(()) => {
509 if latest.is_revoked() {
510 "revoked".to_string()
511 } else if let Some(expiry) = latest.expires_at {
512 if Utc::now() > expiry {
513 "expired".to_string()
514 } else {
515 format!("active (expires {})", expiry.date_naive())
516 }
517 } else {
518 "active".to_string()
519 }
520 }
521 Err(err) => {
522 let err_msg = err.to_string().to_lowercase();
523 if err_msg.contains("revoked") {
524 format!(
525 "revoked{}",
526 latest
527 .timestamp
528 .map(|ts| format!(" ({})", ts.date_naive()))
529 .unwrap_or_default()
530 )
531 } else if err_msg.contains("expired") {
532 format!(
533 "expired{}",
534 latest
535 .expires_at
536 .map(|ts| format!(" ({})", ts.date_naive()))
537 .unwrap_or_default()
538 )
539 } else {
540 format!("invalid ({})", err)
541 }
542 }
543 };
544
545 let is_inactive = latest.is_revoked() || latest.expires_at.is_some_and(|e| Utc::now() > e);
546 if !include_revoked && is_inactive {
547 continue;
548 }
549
550 entries.push(DeviceEntry {
551 id: device_did_str.clone(),
552 status: status_string,
553 public_key: hex::encode(latest.device_public_key.as_bytes()),
554 created_at: latest.timestamp.map(|ts| ts.to_rfc3339()),
555 expires_at: latest.expires_at.map(|ts| ts.to_rfc3339()),
556 note: latest.note.clone().filter(|n| !n.is_empty()),
557 });
558 }
559
560 if is_json_mode() {
561 return JsonResponse::success(
562 "device list",
563 &serde_json::json!({
564 "identity": identity.controller_did.to_string(),
565 "devices": entries,
566 }),
567 )
568 .print()
569 .map_err(|e| anyhow!("{e}"));
570 }
571
572 println!("Devices for identity: {}", identity.controller_did);
573 if entries.is_empty() {
574 if include_revoked {
575 println!(" No authorized devices found.");
576 } else {
577 println!(" (No active devices. Use --include-revoked to see all.)");
578 }
579 return Ok(());
580 }
581 for (i, entry) in entries.iter().enumerate() {
582 println!("{:>2}. {} {}", i + 1, entry.id, entry.status);
583 if let Some(note) = &entry.note {
584 println!(" Note: {}", note);
585 }
586 }
587 Ok(())
588}