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