Skip to main content

auths_cli/commands/device/
authorization.rs

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    /// `active` or `revoked` — a delegated device carries no expiry (KERI
26    /// delegation has no timestamps).
27    status: String,
28    /// Always true for a delegated device: its `dip` is anchored by the root.
29    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 all authorized devices for the current identity.
57    List {
58        /// Include devices with revoked or expired authorizations in the output.
59        #[arg(
60            long,
61            help = "Include devices with revoked or expired authorizations in the output."
62        )]
63        include_revoked: bool,
64    },
65
66    /// Add a device as a delegated identifier of the identity.
67    ///
68    /// The new device gets its own KERI KEL (a delegated inception) that the
69    /// root identity anchors — the keripy-native, device-bound way to grant a
70    /// device signing authority. (Use `link` for the legacy attestation flow.)
71    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    /// Authorize a new device to act on behalf of the identity (legacy attestation).
87    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        /// Duration in seconds until expiration (per RFC 6749).
119        #[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 a device from the shared identity's controller set by
134    /// signing a rotation on the shared KEL.
135    ///
136    /// Semantically distinct from `revoke`: `remove` changes *who can
137    /// sign for the identity* by producing a new `rot` event; `revoke`
138    /// produces an attestation revocation that marks a specific
139    /// attestation inactive without touching the controller set.
140    ///
141    /// Self-removal is rejected at the CLI with a pointer to
142    /// `auths reset`. The authoritative guard lives in the
143    /// SDK — even callers that bypass the CLI check hit
144    /// `SharedKelError::WouldOrphanIdentity`.
145    Remove {
146        /// The controller DID (`did:keri:E…`) to drop.
147        #[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 an existing device authorization using the identity key.
155    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 a device to its owner identity.
170    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    /// Link devices to your identity via QR code or short code.
180    Pair(super::pair::PairCommand),
181
182    /// Verify device authorization signatures (attestation).
183    #[command(name = "verify")]
184    VerifyAttestation(super::verify_attestation::VerifyCommand),
185
186    /// Extend the expiration date of an existing device authorization.
187    Extend {
188        #[arg(long, visible_alias = "device", help = "The device's ID to extend.")]
189        device_did: String,
190
191        /// Duration in seconds until expiration (per RFC 6749).
192        #[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            // KEL advanced — restamp the trailer file's anchor position.
302            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            // Remove = revoke the device's KERI delegation: the root anchors a
308            // revocation marker so verifiers stop honouring the device. Single-
309            // author (the root's key signs); the device's key is not needed.
310            //
311            // Self-removal pre-flight (UX only — the SDK is the authoritative
312            // guard): reject removing the root identity itself; point the caller
313            // at `auths reset` to wipe their own state.
314            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)] // INVARIANT: device_did from CLI arg validated upstream
528        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)] // INVARIANT: device_did_str from attestation storage
553    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    // The delegation set is the source of truth: live = delegated − revoked. A
580    // delegated device is inherently anchored and carries no expiry.
581    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
635/// Run duplicity detection over the root KEL and return a non-fatal warning if the
636/// local registry has recorded a fork (concurrent rotations on different
637/// controllers). A linear KEL reports `Clean` and yields `None`.
638///
639/// Args:
640/// * `ctx`: Auths context (its registry holds the root KEL).
641/// * `controller_did`: The root identity's `did:keri:`.
642///
643/// Usage:
644/// ```ignore
645/// if let Some(w) = root_duplicity_warning(&ctx, &controller_did) { println!("{w}"); }
646/// ```
647fn 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}