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_sdk::core_config::EnvironmentConfig;
11use auths_sdk::identity::ManagedIdentity;
12use auths_sdk::keychain::KeyAlias;
13use auths_sdk::ports::{AttestationSource, IdentityStorage};
14use auths_sdk::signing::{PassphraseProvider, UnifiedPassphraseProvider};
15use auths_sdk::storage::{RegistryAttestationStorage, RegistryIdentityStorage};
16use auths_sdk::storage_layout::{StorageLayoutConfig, layout};
17
18use crate::commands::registry_overrides::RegistryOverrides;
19use crate::factories::storage::build_auths_context;
20use crate::ux::format::{JsonResponse, is_json_mode};
21
22#[derive(Serialize)]
23struct DeviceEntry {
24 id: String,
25 status: String,
28 anchored: bool,
30}
31
32#[derive(Args, Debug, Clone)]
33#[command(
34 about = "Manage which devices can sign with your identity.",
35 after_help = "Examples:
36 auths device list # List authorized devices
37 auths device link --key identity-key --device-key device-key --device did:key:...
38 # Authorize a new device
39 auths device revoke # Revoke a device
40 auths device extend # Extend device expiry
41
42Related:
43 auths status — Show device status and expiry
44 auths init — Set up identity and signing"
45)]
46pub struct DeviceCommand {
47 #[command(subcommand)]
48 pub command: DeviceSubcommand,
49
50 #[command(flatten)]
51 pub overrides: RegistryOverrides,
52}
53
54#[derive(Subcommand, Debug, Clone)]
55pub enum DeviceSubcommand {
56 List {
58 #[arg(
60 long,
61 help = "Include devices with revoked or expired authorizations in the output."
62 )]
63 include_revoked: bool,
64 },
65
66 Add {
72 #[arg(long, help = "Your identity's signing key name.")]
73 key: String,
74
75 #[arg(long, help = "Keychain alias to store the new device's key under.")]
76 device_key: String,
77
78 #[arg(
79 long,
80 default_value = "p256",
81 help = "Curve for the new device key (p256 or ed25519)."
82 )]
83 curve: String,
84 },
85
86 Link {
88 #[arg(long, help = "Your identity's key name.")]
89 key: String,
90
91 #[arg(
92 long,
93 help = "The new device's key name (import first with: auths key import)."
94 )]
95 device_key: String,
96
97 #[arg(
98 long,
99 visible_alias = "device",
100 help = "The device's ID (must match --device-key)."
101 )]
102 device_did: String,
103
104 #[arg(
105 long,
106 value_name = "PAYLOAD_PATH",
107 help = "Optional path to a JSON file containing arbitrary payload data for the authorization."
108 )]
109 payload: Option<PathBuf>,
110
111 #[arg(
112 long,
113 value_name = "SCHEMA_PATH",
114 help = "Optional path to a JSON schema for validating the payload (experimental)."
115 )]
116 schema: Option<PathBuf>,
117
118 #[arg(
120 long = "expires-in",
121 value_name = "SECS",
122 help = "Optional number of seconds until this device authorization expires."
123 )]
124 expires_in: Option<u64>,
125
126 #[arg(
127 long,
128 help = "Optional description/note for this device authorization."
129 )]
130 note: Option<String>,
131 },
132
133 Remove {
146 #[arg(long, visible_alias = "device", help = "The controller DID to remove.")]
148 device_did: String,
149
150 #[arg(long, help = "Your identity's signing key name.")]
151 key: String,
152 },
153
154 Revoke {
156 #[arg(long, visible_alias = "device", help = "The device's ID to revoke.")]
157 device_did: String,
158
159 #[arg(long, help = "Your identity's key name.")]
160 key: String,
161
162 #[arg(long, help = "Optional note explaining the revocation.")]
163 note: Option<String>,
164
165 #[arg(long, help = "Preview actions without making changes.")]
166 dry_run: bool,
167 },
168
169 Resolve {
171 #[arg(
172 long,
173 visible_alias = "device",
174 help = "The device ID to resolve (e.g. did:key:z6Mk...)."
175 )]
176 device_did: String,
177 },
178
179 Pair(super::pair::PairCommand),
181
182 #[command(name = "verify")]
184 VerifyAttestation(super::verify_attestation::VerifyCommand),
185
186 Extend {
188 #[arg(long, visible_alias = "device", help = "The device's ID to extend.")]
189 device_did: String,
190
191 #[arg(
193 long = "expires-in",
194 value_name = "SECS",
195 help = "Number of seconds to extend the expiration by (from now)."
196 )]
197 expires_in: u64,
198
199 #[arg(long, help = "Your identity's key name.")]
200 key: String,
201
202 #[arg(long, help = "The device's key name.")]
203 device_key: String,
204 },
205}
206
207#[allow(clippy::too_many_arguments)]
208pub fn handle_device(
209 cmd: DeviceCommand,
210 repo_opt: Option<PathBuf>,
211 identity_ref_override: Option<String>,
212 identity_blob_name_override: Option<String>,
213 attestation_prefix_override: Option<String>,
214 attestation_blob_name_override: Option<String>,
215 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
216 env_config: &EnvironmentConfig,
217) -> Result<()> {
218 let repo_path = layout::resolve_repo_path(repo_opt)?;
219
220 let mut config = StorageLayoutConfig::default();
221 if let Some(identity_ref) = identity_ref_override {
222 config.identity_ref = identity_ref.into();
223 }
224 if let Some(blob_name) = identity_blob_name_override {
225 config.identity_blob_name = blob_name.into();
226 }
227 if let Some(prefix) = attestation_prefix_override {
228 config.device_attestation_prefix = prefix.into();
229 }
230 if let Some(blob_name) = attestation_blob_name_override {
231 config.attestation_blob_name = blob_name.into();
232 }
233
234 match cmd.command {
235 DeviceSubcommand::List { include_revoked } => {
236 list_devices(&repo_path, env_config, include_revoked)
237 }
238 DeviceSubcommand::Resolve { device_did } => resolve_device(&repo_path, &device_did),
239 DeviceSubcommand::Pair(pair_cmd) => {
240 super::pair::handle_pair(pair_cmd, passphrase_provider.clone(), env_config)
241 }
242 DeviceSubcommand::VerifyAttestation(verify_cmd) => {
243 let rt = tokio::runtime::Runtime::new()?;
244 rt.block_on(super::verify_attestation::handle_verify(verify_cmd))
245 }
246 DeviceSubcommand::Link {
247 key,
248 device_key,
249 device_did,
250 payload: payload_path_opt,
251 schema: schema_path_opt,
252 expires_in,
253 note,
254 } => {
255 let payload = read_payload_file(payload_path_opt.as_deref())?;
256 validate_payload_schema(schema_path_opt.as_deref(), &payload)?;
257
258 let link_config = auths_sdk::types::DeviceLinkConfig {
259 identity_key_alias: KeyAlias::new_unchecked(key),
260 device_key_alias: Some(KeyAlias::new_unchecked(device_key)),
261 device_did: Some(device_did.clone()),
262 expires_in,
263 note,
264 payload,
265 };
266
267 let passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync> =
268 Arc::new(UnifiedPassphraseProvider::new(passphrase_provider));
269 let ctx = build_auths_context(
270 &repo_path,
271 env_config,
272 Some(Arc::clone(&passphrase_provider)),
273 )?;
274
275 let result = auths_sdk::domains::device::service::link_device(
276 link_config,
277 &ctx,
278 &auths_sdk::ports::SystemClock,
279 )
280 .map_err(anyhow::Error::new)?;
281
282 display_link_result(&result, &device_did)
283 }
284
285 DeviceSubcommand::Add {
286 key,
287 device_key,
288 curve,
289 } => {
290 let curve = parse_curve(&curve)?;
291 let ctx = build_auths_context(
292 &repo_path,
293 env_config,
294 Some(Arc::clone(&passphrase_provider)),
295 )?;
296 let root_alias = KeyAlias::new_unchecked(key);
297 let device_alias = KeyAlias::new_unchecked(device_key);
298 let result =
299 auths_sdk::domains::device::add_device(&ctx, &root_alias, &device_alias, curve)
300 .map_err(anyhow::Error::new)?;
301 let _ = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(&ctx, &repo_path);
303 display_add_result(&result.device_did, &repo_path)
304 }
305
306 DeviceSubcommand::Remove { device_did, key } => {
307 let ctx = build_auths_context(
315 &repo_path,
316 env_config,
317 Some(Arc::clone(&passphrase_provider)),
318 )?;
319 let identity = ctx.identity_storage.load_identity().map_err(|e| {
320 anyhow::anyhow!("failed to load identity for self-removal check: {e}")
321 })?;
322 if device_did == identity.controller_did.as_str() {
323 return Err(anyhow::anyhow!(
324 "Cannot remove the root identity itself. \
325 Use `auths reset` to delete this device's copy of the identity."
326 ));
327 }
328 let root_alias = KeyAlias::new_unchecked(key);
329 auths_sdk::domains::device::remove_device(&ctx, &root_alias, &device_did)
330 .map_err(anyhow::Error::new)?;
331 let _ = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(&ctx, &repo_path);
332 display_revoke_result(&device_did, &repo_path)
333 }
334
335 DeviceSubcommand::Revoke {
336 device_did,
337 key,
338 note,
339 dry_run,
340 } => {
341 if dry_run {
342 return display_dry_run_revoke(&device_did, &key);
343 }
344
345 let ctx = build_auths_context(
346 &repo_path,
347 env_config,
348 Some(Arc::clone(&passphrase_provider)),
349 )?;
350
351 let identity_key_alias = KeyAlias::new_unchecked(key);
352 auths_sdk::domains::device::service::revoke_device(
353 &device_did,
354 &identity_key_alias,
355 &ctx,
356 note,
357 &auths_sdk::ports::SystemClock,
358 )
359 .map_err(anyhow::Error::new)?;
360
361 display_revoke_result(&device_did, &repo_path)
362 }
363
364 DeviceSubcommand::Extend {
365 device_did,
366 expires_in,
367 key,
368 device_key,
369 } => handle_extend(
370 &repo_path,
371 &config,
372 &device_did,
373 expires_in,
374 &key,
375 &device_key,
376 passphrase_provider,
377 env_config,
378 ),
379 }
380}
381
382fn display_link_result(
383 result: &auths_sdk::result::DeviceLinkResult,
384 device_did: &str,
385) -> Result<()> {
386 if is_json_mode() {
387 return JsonResponse::success(
388 "device link",
389 &serde_json::json!({
390 "device": device_did,
391 "attestation_id": result.attestation_id,
392 }),
393 )
394 .print()
395 .map_err(anyhow::Error::from);
396 }
397 println!(
398 "\n✅ Device authorized. (Attestation: {})",
399 result.attestation_id
400 );
401 Ok(())
402}
403
404fn display_dry_run_revoke(device_did: &str, identity_key_alias: &str) -> Result<()> {
405 if is_json_mode() {
406 JsonResponse::success(
407 "device revoke",
408 &serde_json::json!({
409 "dry_run": true,
410 "device_did": device_did,
411 "identity_key_alias": identity_key_alias,
412 "actions": [
413 "Revoke device authorization",
414 "Create signed revocation attestation",
415 "Store revocation in Git repository"
416 ]
417 }),
418 )
419 .print()
420 .map_err(anyhow::Error::from)
421 } else {
422 let out = crate::ux::format::Output::new();
423 out.print_info("Dry run mode — no changes will be made");
424 out.newline();
425 out.println("Would perform the following actions:");
426 out.println(&format!(
427 " 1. Revoke device authorization for {}",
428 device_did
429 ));
430 out.println(" 2. Create signed revocation attestation");
431 out.println(" 3. Store revocation in Git repository");
432 Ok(())
433 }
434}
435
436fn parse_curve(s: &str) -> Result<auths_crypto::CurveType> {
437 match s.to_ascii_lowercase().as_str() {
438 "p256" | "p-256" => Ok(auths_crypto::CurveType::P256),
439 "ed25519" => Ok(auths_crypto::CurveType::Ed25519),
440 other => Err(anyhow::anyhow!(
441 "unknown curve {:?}: expected p256 or ed25519",
442 other
443 )),
444 }
445}
446
447fn display_add_result(device_did: &str, repo_path: &Path) -> Result<()> {
448 let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
449 let identity: ManagedIdentity = identity_storage
450 .load_identity()
451 .context("Failed to load identity")?;
452 println!(
453 "\n✅ Delegated device {} added to identity {}",
454 device_did, identity.controller_did
455 );
456 Ok(())
457}
458
459fn display_revoke_result(device_did: &str, repo_path: &Path) -> Result<()> {
460 let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
461 let identity: ManagedIdentity = identity_storage
462 .load_identity()
463 .context("Failed to load identity")?;
464
465 println!(
466 "\n✅ Successfully revoked device {} for identity {}",
467 device_did, identity.controller_did
468 );
469 Ok(())
470}
471
472fn read_payload_file(path: Option<&Path>) -> Result<Option<Value>> {
473 match path {
474 Some(p) => {
475 let content = fs::read_to_string(p)
476 .with_context(|| format!("Failed to read payload file {:?}", p))?;
477 let value: Value = serde_json::from_str(&content)
478 .with_context(|| format!("Failed to parse JSON from payload file {:?}", p))?;
479 Ok(Some(value))
480 }
481 None => Ok(None),
482 }
483}
484
485fn validate_payload_schema(schema_path: Option<&Path>, payload: &Option<Value>) -> Result<()> {
486 match (schema_path, payload) {
487 (Some(schema_path), Some(payload_val)) => {
488 let schema_content = fs::read_to_string(schema_path)
489 .with_context(|| format!("Failed to read schema file {:?}", schema_path))?;
490 let schema_json: serde_json::Value = serde_json::from_str(&schema_content)
491 .with_context(|| format!("Failed to parse JSON schema from {:?}", schema_path))?;
492 let validator = jsonschema::validator_for(&schema_json)
493 .map_err(|e| anyhow!("Invalid JSON schema in {:?}: {}", schema_path, e))?;
494 let errors: Vec<String> = validator
495 .iter_errors(payload_val)
496 .map(|e| format!(" - {}", e))
497 .collect();
498 if !errors.is_empty() {
499 return Err(anyhow!(
500 "Payload does not conform to schema:\n{}",
501 errors.join("\n")
502 ));
503 }
504 Ok(())
505 }
506 (Some(_), None) => {
507 warn!("--schema specified but no --payload provided; skipping validation.");
508 Ok(())
509 }
510 _ => Ok(()),
511 }
512}
513
514#[allow(clippy::too_many_arguments)]
515fn handle_extend(
516 repo_path: &Path,
517 _config: &StorageLayoutConfig,
518 device_did: &str,
519 expires_in: u64,
520 key: &str,
521 device_key: &str,
522 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
523 env_config: &EnvironmentConfig,
524) -> Result<()> {
525 let config = auths_sdk::types::DeviceExtensionConfig {
526 repo_path: repo_path.to_path_buf(),
527 #[allow(clippy::disallowed_methods)] device_did: auths_verifier::types::CanonicalDid::new_unchecked(device_did),
529 expires_in,
530 identity_key_alias: KeyAlias::new_unchecked(key),
531 device_key_alias: Some(KeyAlias::new_unchecked(device_key)),
532 };
533 let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider))?;
534
535 let result = auths_sdk::domains::device::service::extend_device(
536 config,
537 &ctx,
538 &auths_sdk::ports::SystemClock,
539 )
540 .with_context(|| format!("Failed to extend device authorization for '{}'", device_did))?;
541
542 println!(
543 "Successfully extended expiration for {} to {}",
544 result.device_did,
545 result.new_expires_at.date_naive()
546 );
547 Ok(())
548}
549
550fn resolve_device(repo_path: &Path, device_did_str: &str) -> Result<()> {
551 let attestation_storage = RegistryAttestationStorage::new(repo_path.to_path_buf());
552 #[allow(clippy::disallowed_methods)] let device_did = auths_verifier::types::CanonicalDid::new_unchecked(device_did_str);
554 let attestations = attestation_storage
555 .load_attestations_for_device(&device_did)
556 .with_context(|| format!("Failed to load attestations for device {device_did_str}"))?;
557
558 let latest = attestations
559 .last()
560 .ok_or_else(|| anyhow!("No attestation found for device {device_did_str}"))?;
561
562 println!("{}", latest.issuer);
563 Ok(())
564}
565
566fn list_devices(
567 repo_path: &Path,
568 env_config: &EnvironmentConfig,
569 include_revoked: bool,
570) -> Result<()> {
571 let ctx = build_auths_context(repo_path, env_config, None)
572 .context("Failed to build auths context")?;
573
574 let identity = ctx
575 .identity_storage
576 .load_identity()
577 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
578
579 let devices = auths_sdk::domains::device::list_delegated_devices(&ctx)
582 .map_err(anyhow::Error::from)
583 .context("Could not list delegated devices")?;
584
585 let mut entries: Vec<DeviceEntry> = Vec::new();
586 for device in devices {
587 if !include_revoked && device.revoked {
588 continue;
589 }
590 entries.push(DeviceEntry {
591 id: device.device_did,
592 status: if device.revoked {
593 "revoked".to_string()
594 } else {
595 "active".to_string()
596 },
597 anchored: true,
598 });
599 }
600
601 let duplicity_warning = root_duplicity_warning(&ctx, identity.controller_did.as_str());
602
603 if is_json_mode() {
604 return JsonResponse::success(
605 "device list",
606 &serde_json::json!({
607 "identity": identity.controller_did.to_string(),
608 "devices": entries,
609 }),
610 )
611 .print()
612 .map_err(anyhow::Error::from);
613 }
614
615 if let Some(warning) = &duplicity_warning {
616 println!("{warning}");
617 println!();
618 }
619
620 println!("Authorized devices for: {}", identity.controller_did);
621 if entries.is_empty() {
622 if include_revoked {
623 println!(" No delegated devices found.");
624 } else {
625 println!(" (No active devices. Use --include-revoked to see all.)");
626 }
627 return Ok(());
628 }
629 for (i, entry) in entries.iter().enumerate() {
630 println!("{:>2}. {} {}", i + 1, entry.id, entry.status);
631 }
632 Ok(())
633}
634
635fn root_duplicity_warning(
648 ctx: &auths_sdk::context::AuthsContext,
649 controller_did: &str,
650) -> Option<String> {
651 use auths_sdk::verify::{DuplicityReport, KelEventRef, detect_duplicity};
652
653 let prefix_str = controller_did.strip_prefix("did:keri:")?;
654 let prefix = auths_keri::Prefix::new_unchecked(prefix_str.to_string());
655 let tip = ctx.registry.get_tip(&prefix).ok()?;
656
657 let mut events = Vec::new();
658 for seq in 0..=tip.sequence {
659 events.push(ctx.registry.get_event(&prefix, seq).ok()?);
660 }
661 let refs: Vec<KelEventRef> = events
662 .iter()
663 .enumerate()
664 .map(|(seq, event)| KelEventRef {
665 prefix: controller_did,
666 seq: seq as u64,
667 said: event.said().as_str(),
668 })
669 .collect();
670
671 match detect_duplicity(&refs) {
672 DuplicityReport::Diverging { seq, .. } => {
673 Some(auths_sdk::keri::copy::format_duplicity_warning(seq))
674 }
675 _ => None,
676 }
677}