Skip to main content

auths_cli/commands/id/
identity.rs

1use anyhow::{Context, Result, anyhow};
2use clap::{ArgAction, Parser, Subcommand};
3use serde::Serialize;
4use serde_json;
5use std::fs;
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use auths_sdk::{
10    core_config::EnvironmentConfig,
11    keychain::{KeyAlias, get_platform_keychain},
12    signing::PassphraseProvider,
13};
14use auths_verifier::{IdentityBundle, IdentityDID};
15use clap::ValueEnum;
16
17use super::register::DEFAULT_REGISTRY_URL;
18use crate::commands::registry_overrides::RegistryOverrides;
19use crate::ux::format::{JsonResponse, is_json_mode};
20
21/// JSON response for id show command.
22#[derive(Debug, Serialize)]
23struct IdShowResponse {
24    controller_did: String,
25    storage_id: String,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    metadata: Option<serde_json::Value>,
28}
29
30use auths_sdk::storage::{
31    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
32};
33use auths_sdk::{
34    identity::initialize_registry_identity,
35    ports::{AttestationSource, IdentityStorage, RegistryBackend},
36    storage_layout::{StorageLayoutConfig, layout},
37};
38
39/// Storage layout presets for different ecosystems.
40#[derive(Debug, Clone, Copy, ValueEnum, Default)]
41pub enum LayoutPreset {
42    /// Standard Auths layout (refs/auths/identity, refs/auths/keys)
43    #[default]
44    Default,
45    /// Radicle-compatible layout (refs/rad/id, refs/keys)
46    Radicle,
47    /// Gitoxide-compatible layout (refs/auths/id, refs/auths/devices)
48    Gitoxide,
49}
50
51impl LayoutPreset {
52    /// Convert the preset to a StorageLayoutConfig.
53    pub fn to_config(self) -> StorageLayoutConfig {
54        match self {
55            LayoutPreset::Default => StorageLayoutConfig::default(),
56            LayoutPreset::Radicle => StorageLayoutConfig::radicle(),
57            LayoutPreset::Gitoxide => StorageLayoutConfig::gitoxide(),
58        }
59    }
60}
61
62#[derive(Parser, Debug, Clone)]
63#[command(
64    about = "Manage your signing identity.",
65    after_help = "Examples:
66  auths id show             # Show current identity details
67  auths id list             # List identities (same as show)
68  auths id create           # Create a new identity
69  auths id export-bundle    # Export identity bundle for verification
70
71Related:
72  auths init    — Initialize identity with setup wizard
73  auths device  — Manage linked devices
74  auths key     — Manage cryptographic keys"
75)]
76pub struct IdCommand {
77    #[clap(subcommand)]
78    pub subcommand: IdSubcommand,
79
80    #[command(flatten)]
81    pub overrides: RegistryOverrides,
82}
83
84#[derive(Subcommand, Debug, Clone)]
85pub enum IdSubcommand {
86    /// Create a new signing identity.
87    #[command(name = "create")]
88    Create {
89        /// Path to JSON file with arbitrary identity metadata.
90        #[arg(
91            long,
92            value_parser,
93            help = "Path to JSON file with arbitrary identity metadata."
94        )]
95        metadata_file: PathBuf,
96
97        /// Name for the new signing key in secure storage.
98        #[arg(long, help = "Name for the new signing key in secure storage.")]
99        local_key_alias: String,
100
101        /// Storage layout preset for ecosystem compatibility.
102        /// Use 'radicle' for Radicle repositories, 'gitoxide' for gitoxide,
103        /// or 'default' for standard Auths layout.
104        #[arg(
105            long,
106            value_enum,
107            default_value = "default",
108            help = "Storage layout preset (default, radicle, gitoxide)"
109        )]
110        preset: LayoutPreset,
111    },
112
113    /// Show primary identity details (identity ID, metadata) from the Git repository.
114    Show,
115
116    /// List identities (currently same as show, forward-compatible for future multi-identity support).
117    List,
118
119    /// Rotate identity keys. Stores the new key under a new alias.
120    Rotate {
121        /// Name of the key to rotate. Defaults to the next rotation key automatically.
122        #[arg(long, help = "Name of the key to rotate.")]
123        alias: Option<String>,
124
125        /// Current signing key name (alternative to --alias).
126        #[arg(
127            long,
128            help = "Current signing key name (alternative to --alias).",
129            conflicts_with = "alias"
130        )]
131        current_key_alias: Option<String>,
132
133        /// Name for the new signing key after rotation.
134        #[arg(long, help = "Name for the new signing key after rotation.")]
135        next_key_alias: Option<String>,
136
137        /// Add a witness server address (repeatable).
138        #[arg(
139            long,
140            action = ArgAction::Append,
141            help = "Add a witness server address (repeatable)."
142        )]
143        add_witness: Vec<String>,
144
145        /// Remove a witness server address (repeatable).
146        #[arg(
147            long,
148            action = ArgAction::Append,
149            help = "Remove a witness server address (repeatable)."
150        )]
151        remove_witness: Vec<String>,
152
153        /// Number of witnesses required to accept this rotation (e.g., 1).
154        #[arg(
155            long,
156            help = "Number of witnesses required to accept this rotation (e.g., 1)."
157        )]
158        witness_threshold: Option<u64>,
159
160        /// Preview actions without making changes.
161        #[arg(long)]
162        dry_run: bool,
163
164        /// Add a device slot on this rotation (repeatable). Value is the
165        /// curve for the new slot (`P256` or `Ed25519`).
166        #[arg(long, action = ArgAction::Append, value_name = "CURVE")]
167        add_device: Vec<String>,
168
169        /// Remove a device slot by index on this rotation (repeatable).
170        /// Currently rejected — requires CESR indexed-signature support.
171        #[arg(long, action = ArgAction::Append, value_name = "INDEX")]
172        remove_device: Vec<u32>,
173
174        /// New signing threshold (scalar like `"2"` or fractions like
175        /// `"1/2,1/2,1/2"`). Omit to keep the prior `kt`.
176        #[arg(long)]
177        signing_threshold: Option<String>,
178
179        /// New rotation (next) threshold, same format as
180        /// `--signing-threshold`. Omit to keep the prior `nt`.
181        #[arg(long)]
182        rotation_threshold: Option<String>,
183    },
184
185    /// Expand a single-device identity into multi-device via one rotation.
186    Expand {
187        /// Add a device slot (repeatable). Curve name: `P256` or `Ed25519`.
188        #[arg(long, action = ArgAction::Append, value_name = "CURVE")]
189        add_device: Vec<String>,
190
191        /// Signing threshold after expansion. Required.
192        #[arg(long)]
193        signing_threshold: String,
194
195        /// Rotation threshold after expansion. Required.
196        #[arg(long)]
197        rotation_threshold: String,
198
199        /// Base alias for the existing single-key identity.
200        #[arg(long, default_value = "main")]
201        alias: String,
202
203        /// Alias for the new multi-key identity set.
204        #[arg(long, default_value = "main")]
205        next_alias: String,
206    },
207
208    /// Export an identity bundle for stateless CI/CD verification.
209    ///
210    /// Creates a portable JSON bundle containing the identity ID, public key,
211    /// and authorization chain. This bundle can be used in CI pipelines to verify
212    /// commit signatures without requiring access to the identity repository.
213    ExportBundle {
214        /// Key alias to include in the bundle.
215        #[arg(long, help = "Key alias to include in bundle")]
216        alias: String,
217
218        /// Output file path for the JSON bundle.
219        #[arg(long = "output", short = 'o')]
220        output_file: PathBuf,
221
222        /// TTL in seconds. The bundle will fail verification after this many seconds.
223        #[arg(
224            long,
225            help = "Maximum bundle age in seconds before it is considered stale"
226        )]
227        max_age_secs: u64,
228    },
229
230    /// Publish this identity to a public registry for discovery.
231    Register {
232        /// Registry URL to publish to.
233        #[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
234        registry: String,
235    },
236
237    /// Add a platform claim to an already-registered identity.
238    Claim(super::claim::ClaimCommand),
239
240    /// Import existing GPG or SSH keys into Auths.
241    Migrate(super::migrate::MigrateCommand),
242
243    /// Bind this identity to an enterprise IdP (Okta, Entra ID, Google Workspace, SAML).
244    ///
245    /// Requires the `auths-cloud` binary on $PATH. If not installed,
246    /// prints information about Auths Cloud.
247    BindIdp(super::bind_idp::BindIdpStubCommand),
248
249    /// Re-authorize with a platform and optionally upload SSH signing key.
250    ///
251    /// Use this when you need to update OAuth scopes or re-authenticate
252    /// with a platform (e.g., GitHub). Automatically uploads the SSH signing key
253    /// if the `write:ssh_signing_key` scope is included.
254    #[command(name = "update-scope")]
255    UpdateScope {
256        /// Platform to re-authorize with (e.g., github).
257        #[arg(help = "Platform name (currently supports 'github')")]
258        platform: String,
259    },
260
261    /// Manage AI agents delegated by this identity (KERI delegated identifiers).
262    Agent(super::agent::AgentCommand),
263}
264
265fn display_dry_run_rotate(
266    repo_path: &std::path::Path,
267    current_alias: Option<&str>,
268    next_alias: Option<&str>,
269) -> Result<()> {
270    if is_json_mode() {
271        JsonResponse::success(
272            "id rotate",
273            &serde_json::json!({
274                "dry_run": true,
275                "repo_path": repo_path.display().to_string(),
276                "current_key_alias": current_alias,
277                "next_key_alias": next_alias,
278                "actions": [
279                    "Generate new signing key",
280                    "Record rotation in identity log",
281                    "Update key name mappings",
282                    "All devices will need to re-authorize"
283                ]
284            }),
285        )
286        .print()
287        .map_err(anyhow::Error::from)
288    } else {
289        let out = crate::ux::format::Output::new();
290        out.print_info("Dry run mode — no changes will be made");
291        out.newline();
292        out.println(&format!("   Repository: {:?}", repo_path));
293        if let Some(alias) = current_alias {
294            out.println(&format!("   Current key name: {}", alias));
295        }
296        if let Some(alias) = next_alias {
297            out.println(&format!("   New key name: {}", alias));
298        }
299        out.newline();
300        out.println("Would perform the following actions:");
301        out.println("  1. Generate new signing key");
302        out.println("  2. Record rotation in identity log");
303        out.println("  3. Update key name mappings");
304        out.println("  4. All devices will need to re-authorize");
305        Ok(())
306    }
307}
308
309// --- Command Handler ---
310
311/// Handles the `id` subcommand, accepting the specific subcommand details
312/// and the global configuration overrides passed from `main`.
313#[allow(clippy::too_many_arguments)]
314pub fn handle_id(
315    cmd: IdCommand,
316    repo_opt: Option<PathBuf>,
317    identity_ref_override: Option<String>,
318    identity_blob_name_override: Option<String>,
319    attestation_prefix_override: Option<String>,
320    attestation_blob_name_override: Option<String>,
321    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
322    env_config: &EnvironmentConfig,
323    now: chrono::DateTime<chrono::Utc>,
324) -> Result<()> {
325    // Determine repo path using the passed Option
326    let repo_path = layout::resolve_repo_path(repo_opt)?;
327
328    // Build StorageLayoutConfig from defaults and function arguments
329    // Used by non-Init subcommands
330    let mut config = StorageLayoutConfig::default();
331    if let Some(ref identity_ref) = identity_ref_override {
332        config.identity_ref = identity_ref.clone().into();
333    }
334    if let Some(ref blob_name) = identity_blob_name_override {
335        config.identity_blob_name = blob_name.clone().into();
336    }
337    if let Some(ref prefix) = attestation_prefix_override {
338        config.device_attestation_prefix = prefix.clone().into();
339    }
340    if let Some(ref blob_name) = attestation_blob_name_override {
341        config.attestation_blob_name = blob_name.clone().into();
342    }
343
344    match cmd.subcommand {
345        IdSubcommand::Create {
346            metadata_file,
347            local_key_alias,
348            preset,
349        } => {
350            // Apply preset first, then override with explicit flags
351            let mut config = preset.to_config();
352            if let Some(ref identity_ref) = identity_ref_override {
353                config.identity_ref = identity_ref.clone().into();
354            }
355            if let Some(ref blob_name) = identity_blob_name_override {
356                config.identity_blob_name = blob_name.clone().into();
357            }
358            if let Some(ref prefix) = attestation_prefix_override {
359                config.device_attestation_prefix = prefix.clone().into();
360            }
361            if let Some(ref blob_name) = attestation_blob_name_override {
362                config.attestation_blob_name = blob_name.clone().into();
363            }
364            let metadata_file_path = metadata_file;
365
366            // --- Common Setup: Repo Init Check & Metadata Loading ---
367            println!("🔐 Creating identity...");
368            println!("   Repository path:   {:?}", repo_path);
369            println!("   Key name:          {}", local_key_alias);
370            println!("   Metadata File:     {:?}", metadata_file_path);
371
372            // Ensure Git Repository Exists and is Initialized
373            use crate::factories::storage::{ensure_git_repo, open_git_repo};
374
375            let identity_storage_check = RegistryIdentityStorage::new(repo_path.clone());
376            if repo_path.exists() {
377                match open_git_repo(&repo_path) {
378                    Ok(_repo) => {
379                        println!("   Git repository found at {:?}.", repo_path);
380                        if identity_storage_check.load_identity().is_ok() {
381                            eprintln!(
382                                "⚠️ Primary identity already initialized and loadable at {:?} using ref '{}'. Aborting.",
383                                repo_path,
384                                identity_storage_check.get_identity_ref()?
385                            );
386                            return Err(anyhow!("Identity already exists in this repository"));
387                        } else {
388                            println!(
389                                "   Repository exists, but primary identity ref/data is missing or invalid. Proceeding..."
390                            );
391                        }
392                    }
393                    Err(_) => {
394                        println!(
395                            "   Path {:?} exists but is not a Git repository. Initializing...",
396                            repo_path
397                        );
398                        ensure_git_repo(&repo_path).map_err(|e| {
399                            anyhow!(
400                                "Path {:?} exists but failed to initialize as Git repository: {}",
401                                repo_path,
402                                e
403                            )
404                        })?;
405                        println!("   Successfully initialized Git repository.");
406                    }
407                }
408            } else {
409                println!("   Initializing Git repository at {:?}...", repo_path);
410                ensure_git_repo(&repo_path).map_err(|e| {
411                    anyhow!(
412                        "Failed to initialize Git repository at {:?}: {}",
413                        repo_path,
414                        e
415                    )
416                })?;
417                println!("   Successfully initialized Git repository.");
418            }
419
420            // Load and Parse Metadata File (common logic)
421            if !metadata_file_path.exists() {
422                return Err(anyhow!("Metadata file not found: {:?}", metadata_file_path));
423            }
424            let metadata_content = fs::read_to_string(&metadata_file_path).with_context(|| {
425                format!("Failed to read metadata file: {:?}", metadata_file_path)
426            })?;
427            let metadata_value: serde_json::Value = serde_json::from_str(&metadata_content)
428                .with_context(|| {
429                    format!(
430                        "Failed to parse JSON from metadata file: {:?}",
431                        metadata_file_path
432                    )
433                })?;
434            println!("   Metadata loaded successfully from file.");
435
436            // --- Always Use KERI Initialization Logic ---
437
438            // Call the initialize_registry_identity function from auths_id
439            let _metadata_value = metadata_value; // metadata stored separately if needed
440            let backend: Arc<dyn RegistryBackend + Send + Sync> =
441                Arc::new(GitRegistryBackend::from_config_unchecked(
442                    RegistryConfig::single_tenant(&repo_path),
443                ));
444            let local_key_alias = KeyAlias::new_unchecked(local_key_alias);
445            match initialize_registry_identity(
446                backend,
447                &local_key_alias,
448                passphrase_provider.as_ref(),
449                &get_platform_keychain()?,
450                None,
451                auths_crypto::CurveType::default(),
452            ) {
453                Ok((controller_did_keri, alias)) => {
454                    println!("\n✅ Identity created.");
455                    println!(
456                        "   Repository:         {:?}",
457                        repo_path
458                            .canonicalize()
459                            .unwrap_or_else(|_| repo_path.clone())
460                    );
461                    println!("   Identity:           {}", controller_did_keri);
462                    println!(
463                        "   Key name:         {} (use this for signing and rotations)",
464                        alias
465                    );
466                    println!("   Metadata stored from: {:?}", metadata_file_path);
467                    println!("🔑 Keep your passphrase secure!");
468                    Ok(())
469                }
470                Err(e) => Err(e).context("Failed to create identity"),
471            }
472        }
473
474        IdSubcommand::Show | IdSubcommand::List => {
475            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
476
477            let identity = identity_storage
478                .load_identity()
479                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
480
481            let cmd_name = match cmd.subcommand {
482                IdSubcommand::List => "id list",
483                _ => "id show",
484            };
485
486            if is_json_mode() {
487                let response = JsonResponse::success(
488                    cmd_name,
489                    IdShowResponse {
490                        controller_did: identity.controller_did.to_string(),
491                        storage_id: identity.storage_id.clone(),
492                        metadata: identity.metadata.clone(),
493                    },
494                );
495                response.print()?;
496            } else {
497                println!("Identity:   {}", identity.controller_did);
498                println!("Storage ID: {}", identity.storage_id);
499                println!("Metadata:");
500                if let Some(meta) = &identity.metadata {
501                    println!(
502                        "{}",
503                        serde_json::to_string_pretty(meta)
504                            .unwrap_or_else(|_| "  <Error serializing metadata>".to_string())
505                    );
506                } else {
507                    println!("  (None)");
508                }
509
510                println!("\nUse 'auths device list' to see authorized devices");
511            }
512            Ok(())
513        }
514
515        IdSubcommand::Rotate {
516            alias,
517            current_key_alias,
518            next_key_alias,
519            add_witness,
520            remove_witness,
521            witness_threshold,
522            dry_run,
523            add_device,
524            remove_device,
525            signing_threshold,
526            rotation_threshold,
527        } => {
528            if !add_device.is_empty()
529                || !remove_device.is_empty()
530                || signing_threshold.is_some()
531                || rotation_threshold.is_some()
532            {
533                return Err(anyhow!(
534                    "multi-device rotation (--add-device / --remove-device / --signing-threshold / \
535                     --rotation-threshold) is not yet wired through `auths id rotate`. Use \
536                     `auths id expand` to add devices, or omit these flags for a standard rotation."
537                ));
538            }
539            let identity_key_alias = alias.or(current_key_alias);
540
541            if dry_run {
542                return display_dry_run_rotate(
543                    &repo_path,
544                    identity_key_alias.as_deref(),
545                    next_key_alias.as_deref(),
546                );
547            }
548
549            println!("🔄 Rotating keys...");
550            println!("   Using Repository: {:?}", repo_path);
551            if let Some(ref a) = identity_key_alias {
552                println!("   Current key name: {}", a);
553            }
554            if let Some(ref a) = next_key_alias {
555                println!("   New key name: {}", a);
556            }
557            if !add_witness.is_empty() {
558                println!("   Adding witnesses: {:?}", add_witness);
559            }
560            if !remove_witness.is_empty() {
561                println!("   Removing witnesses: {:?}", remove_witness);
562            }
563            if let Some(thresh) = witness_threshold {
564                println!("   Witnesses required: {}", thresh);
565            }
566
567            let rotation_config = auths_sdk::types::IdentityRotationConfig {
568                repo_path: repo_path.clone(),
569                identity_key_alias: identity_key_alias.map(KeyAlias::new_unchecked),
570                next_key_alias: next_key_alias.map(KeyAlias::new_unchecked),
571            };
572            let rotation_ctx = {
573                use auths_sdk::attestation::AttestationSink;
574                use auths_sdk::context::AuthsContext;
575                use auths_sdk::keychain::get_platform_keychain_with_config;
576                use auths_sdk::ports::IdentityStorage;
577                use auths_sdk::storage::{
578                    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig,
579                    RegistryIdentityStorage,
580                };
581                let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> =
582                    Arc::new(GitRegistryBackend::from_config_unchecked(
583                        RegistryConfig::single_tenant(&repo_path),
584                    ));
585                let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
586                    Arc::new(RegistryIdentityStorage::new(repo_path.clone()));
587                let attestation_store = Arc::new(RegistryAttestationStorage::new(&repo_path));
588                let attestation_sink: Arc<dyn AttestationSink + Send + Sync> =
589                    Arc::clone(&attestation_store) as Arc<dyn AttestationSink + Send + Sync>;
590                let attestation_source: Arc<dyn AttestationSource + Send + Sync> =
591                    attestation_store as Arc<dyn AttestationSource + Send + Sync>;
592                let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
593                    get_platform_keychain_with_config(env_config)
594                        .context("Failed to access keychain")?,
595                );
596                AuthsContext::builder()
597                    .registry(backend)
598                    .key_storage(key_storage)
599                    .clock(Arc::new(auths_sdk::ports::SystemClock))
600                    .identity_storage(identity_storage)
601                    .attestation_sink(attestation_sink)
602                    .attestation_source(attestation_source)
603                    .passphrase_provider(Arc::clone(&passphrase_provider))
604                    .build()
605            };
606            let result = auths_sdk::workflows::rotation::rotate_identity(
607                rotation_config,
608                &rotation_ctx,
609                &auths_sdk::ports::SystemClock,
610            )
611            .with_context(|| "Failed to rotate keys")?;
612
613            println!("\n✅ Keys rotated.");
614            println!("   Identity: {}", result.controller_did);
615            println!(
616                "   Old key fingerprint: {}...",
617                result.previous_key_fingerprint
618            );
619            println!("   New key fingerprint: {}...", result.new_key_fingerprint);
620            println!(
621                "⚠️  Your old key name is no longer active. Update any scripts that reference it."
622            );
623
624            log::info!(
625                "Key rotation completed: old_key={}, new_key={}",
626                result.previous_key_fingerprint,
627                result.new_key_fingerprint,
628            );
629
630            Ok(())
631        }
632
633        IdSubcommand::Expand {
634            add_device,
635            signing_threshold,
636            rotation_threshold,
637            alias,
638            next_alias,
639        } => {
640            if add_device.is_empty() {
641                return Err(anyhow!(
642                    "`auths id expand` requires at least one --add-device; use `auths id rotate` for key-set-preserving rotations"
643                ));
644            }
645
646            // Parse curves from --add-device string values.
647            let curves: Result<Vec<auths_crypto::CurveType>, _> = add_device
648                .iter()
649                .map(|s| match s.to_ascii_lowercase().as_str() {
650                    "p256" | "p-256" => Ok(auths_crypto::CurveType::P256),
651                    "ed25519" => Ok(auths_crypto::CurveType::Ed25519),
652                    other => Err(anyhow!(
653                        "unknown curve {:?}: expected P256 or Ed25519",
654                        other
655                    )),
656                })
657                .collect();
658            let curves = curves?;
659
660            // Current + added count determines post-expansion device count.
661            // Without state read here, assume current is single-key (the
662            // common `expand` case). If state is already multi-key, the
663            // threshold validator in the SDK rejects mismatches.
664            let estimated_count = 1 + curves.len();
665            let new_kt =
666                crate::commands::init::parse_threshold_cli(&signing_threshold, estimated_count)?;
667            let new_nt =
668                crate::commands::init::parse_threshold_cli(&rotation_threshold, estimated_count)?;
669
670            let base_alias = KeyAlias::new_unchecked(alias.clone());
671
672            // Atomically migrate the legacy {alias} slot to {alias}--0 before
673            // the rotation starts. Idempotent: safe if already migrated.
674            let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
675                auths_sdk::keychain::get_platform_keychain_with_config(env_config)
676                    .context("Failed to access keychain")?,
677            );
678            let _migrated =
679                auths_sdk::keychain::migrate_legacy_alias(key_storage.as_ref(), &base_alias)
680                    .context("Failed to migrate legacy key alias before expansion")?;
681
682            // Build the registry backend and run the multi-device rotation.
683            let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> = Arc::new(
684                auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
685                    auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
686                ),
687            );
688
689            let shape = auths_sdk::identity::RotationShape {
690                add_devices: curves,
691                remove_indices: vec![],
692                new_kt: Some(new_kt),
693                new_nt: Some(new_nt),
694            };
695            let layout = auths_sdk::storage_layout::StorageLayoutConfig::default();
696            let next_alias_obj = KeyAlias::new_unchecked(next_alias.clone());
697            let result = auths_sdk::identity::rotate_registry_identity_multi(
698                backend,
699                &base_alias,
700                &next_alias_obj,
701                passphrase_provider.as_ref(),
702                &layout,
703                key_storage.as_ref(),
704                None,
705                shape,
706            )
707            .context("Failed to expand identity")?;
708
709            println!(
710                "[OK] Identity expanded — rotation at sequence {} with {} new device(s)",
711                result.sequence,
712                add_device.len()
713            );
714            println!(
715                "     Base alias: {} → slots {}..{}",
716                alias, 0, estimated_count
717            );
718            Ok(())
719        }
720
721        IdSubcommand::ExportBundle {
722            alias,
723            output_file,
724            max_age_secs,
725        } => {
726            println!("📦 Exporting identity bundle...");
727            println!("   Using Repository:  {:?}", repo_path);
728            println!("   Key name:          {}", alias);
729            println!("   Output File:       {:?}", output_file);
730
731            // Load identity
732            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
733            let identity = identity_storage
734                .load_identity()
735                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
736
737            println!("   Identity DID:      {}", identity.controller_did);
738
739            // Load attestations
740            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
741            let attestations = attestation_storage
742                .load_all_enriched()
743                .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
744                .unwrap_or_default();
745
746            // Load the public key from keychain (handles SE and software keys)
747            let keychain = get_platform_keychain()?;
748            let alias_typed = KeyAlias::new_unchecked(&alias);
749            let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
750                keychain.as_ref(),
751                &alias_typed,
752                passphrase_provider.as_ref(),
753            )
754            .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
755            #[allow(clippy::disallowed_methods)]
756            // INVARIANT: hex::encode of pubkey bytes always produces valid hex
757            let public_key_hex =
758                auths_verifier::PublicKeyHex::new_unchecked(hex::encode(&public_key_bytes));
759
760            // Embed the identity's KEL so verifiers stay stateless: KEL-native
761            // commit verification on a CI runner has no identity store to
762            // resolve the signer from.
763            let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
764                auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
765            );
766            let prefix = auths_sdk::keri::parse_did_keri(identity.controller_did.as_str())
767                .context("identity DID is not did:keri")?;
768            let mut kel: Vec<serde_json::Value> = Vec::new();
769            let mut kel_err: Option<String> = None;
770            let _ = registry.visit_events(&prefix, 0, &mut |e| {
771                match serde_json::to_value(e) {
772                    Ok(v) => kel.push(v),
773                    Err(err) => kel_err = Some(err.to_string()),
774                }
775                std::ops::ControlFlow::Continue(())
776            });
777            if let Some(err) = kel_err {
778                return Err(anyhow::anyhow!("failed to serialize KEL event: {err}"));
779            }
780            if kel.is_empty() {
781                return Err(anyhow::anyhow!(
782                    "no KEL events found for {} — cannot export a verifiable bundle",
783                    identity.controller_did
784                ));
785            }
786
787            // Create the bundle. Curve flows in-band from the typed keychain
788            // extraction so verifiers never re-derive it from byte length.
789            let bundle = IdentityBundle {
790                #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did from storage
791                identity_did: IdentityDID::new_unchecked(identity.controller_did.to_string()),
792                public_key_hex,
793                curve,
794                attestation_chain: attestations,
795                kel,
796                bundle_timestamp: now,
797                max_valid_for_secs: max_age_secs,
798            };
799
800            // Write to output file
801            let json = serde_json::to_string_pretty(&bundle)
802                .context("Failed to serialize identity bundle")?;
803            fs::write(&output_file, &json)
804                .with_context(|| format!("Failed to write bundle to {:?}", output_file))?;
805
806            println!("\n✅ Identity bundle exported successfully!");
807            println!("   Output:            {:?}", output_file);
808            println!("   Attestations:      {}", bundle.attestation_chain.len());
809            println!("\nUsage in CI:");
810            println!("   auths verify --identity-bundle {:?} HEAD", output_file);
811
812            Ok(())
813        }
814
815        IdSubcommand::Register { registry } => {
816            super::register::handle_register(&repo_path, &registry)
817        }
818
819        IdSubcommand::Claim(claim_cmd) => {
820            super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, env_config, now)
821        }
822
823        IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd, now),
824
825        IdSubcommand::BindIdp(bind_cmd) => super::bind_idp::handle_bind_idp(bind_cmd),
826
827        IdSubcommand::UpdateScope { platform } => {
828            if platform.to_lowercase() != "github" {
829                return Err(anyhow!(
830                    "Platform '{}' is not supported yet. Currently only 'github' is available.",
831                    platform
832                ));
833            }
834
835            use crate::constants::GITHUB_SSH_UPLOAD_SCOPES;
836            use auths_infra_http::{HttpGitHubOAuthProvider, HttpGitHubSshKeyUploader};
837            use auths_sdk::keychain::extract_public_key_bytes;
838            use auths_sdk::ports::platform::OAuthDeviceFlowProvider;
839            use std::time::Duration;
840
841            const GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
842            #[allow(clippy::disallowed_methods)]
843            let client_id = std::env::var("AUTHS_GITHUB_CLIENT_ID")
844                .unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string());
845
846            // Get ~/.auths directory
847            let home =
848                dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
849            let auths_dir = home.join(".auths");
850            let ctx = crate::factories::storage::build_auths_context(
851                &auths_dir,
852                env_config,
853                Some(passphrase_provider.clone()),
854            )?;
855
856            let oauth = HttpGitHubOAuthProvider::new();
857            let ssh_uploader = HttpGitHubSshKeyUploader::new();
858
859            let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
860
861            let out = crate::ux::format::Output::new();
862            out.print_info(&format!("Re-authorizing with {}", platform));
863
864            let device_code = rt
865                .block_on(oauth.request_device_code(&client_id, GITHUB_SSH_UPLOAD_SCOPES))
866                .map_err(anyhow::Error::from)?;
867
868            out.println(&format!(
869                "  Enter this code: {}",
870                out.bold(&device_code.user_code)
871            ));
872            out.println(&format!(
873                "  At: {}",
874                out.info(&device_code.verification_uri)
875            ));
876            if let Err(e) = open::that(&device_code.verification_uri) {
877                out.print_warn(&format!("Could not open browser automatically: {e}"));
878                out.println("  Please open the URL above manually.");
879            } else {
880                out.println("  Browser opened — waiting for authorization...");
881            }
882
883            let expires_in = Duration::from_secs(device_code.expires_in);
884            let interval = Duration::from_secs(device_code.interval);
885
886            let access_token = rt
887                .block_on(oauth.poll_for_token(
888                    &client_id,
889                    &device_code.device_code,
890                    interval,
891                    expires_in,
892                ))
893                .map_err(anyhow::Error::from)?;
894
895            let profile = rt
896                .block_on(oauth.fetch_user_profile(&access_token))
897                .map_err(anyhow::Error::from)?;
898
899            out.print_success(&format!("Re-authenticated as @{}", profile.login));
900
901            // Try to get device public key and upload SSH key
902            let controller_did =
903                auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref())
904                    .map_err(anyhow::Error::from)?;
905
906            #[allow(clippy::disallowed_methods)]
907            let identity_did = IdentityDID::new_unchecked(controller_did.clone());
908            let aliases = ctx
909                .key_storage
910                .list_aliases_for_identity(&identity_did)
911                .context("failed to list key aliases")?;
912            let key_alias = aliases
913                .into_iter()
914                .find(|a| !a.contains("--next-"))
915                .ok_or_else(|| anyhow::anyhow!("no signing key found for {controller_did}"))?;
916
917            // Get device public key and encode
918            let device_key_result = extract_public_key_bytes(
919                ctx.key_storage.as_ref(),
920                &key_alias,
921                passphrase_provider.as_ref(),
922            );
923
924            if let Ok((pk_bytes, curve)) = device_key_result {
925                let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
926                    .map_err(|e| anyhow::anyhow!("Invalid device key: {e}"))?;
927                let public_key =
928                    auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
929                        .map_err(|e| anyhow::anyhow!("Failed to encode SSH key: {e}"))?;
930
931                out.println("  Uploading SSH signing key...");
932                #[allow(clippy::disallowed_methods)]
933                let now = chrono::Utc::now();
934                #[allow(clippy::disallowed_methods)]
935                let hostname = gethostname::gethostname();
936                let hostname_str = hostname.to_string_lossy().to_string();
937                let result = rt.block_on(
938                    auths_sdk::workflows::platform::upload_github_ssh_signing_key(
939                        &ssh_uploader,
940                        &access_token,
941                        &public_key,
942                        &key_alias,
943                        &hostname_str,
944                        ctx.identity_storage.as_ref(),
945                        now,
946                    ),
947                );
948
949                match result {
950                    Ok(()) => {
951                        out.print_success("SSH signing key uploaded to GitHub");
952                        out.println("  View at: https://github.com/settings/keys");
953                    }
954                    Err(e) => {
955                        out.print_warn(&format!("SSH key upload failed: {e}"));
956                        out.println(
957                            "  You can upload manually at https://github.com/settings/keys",
958                        );
959                    }
960                }
961            } else {
962                out.print_warn("Could not extract device public key for SSH upload");
963            }
964
965            out.print_success("Scope update complete");
966            Ok(())
967        }
968
969        IdSubcommand::Agent(agent_cmd) => {
970            super::agent::handle_agent(agent_cmd, repo_path, env_config, passphrase_provider)
971        }
972    }
973}