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 crate::commands::registry_overrides::RegistryOverrides;
18use crate::ux::format::{JsonResponse, is_json_mode};
19
20/// JSON response for id show command.
21#[derive(Debug, Serialize)]
22struct IdShowResponse {
23    controller_did: String,
24    storage_id: String,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    metadata: Option<serde_json::Value>,
27}
28
29use auths_sdk::storage::{
30    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
31};
32use auths_sdk::{
33    identity::initialize_registry_identity,
34    ports::{AttestationSource, IdentityStorage, RegistryBackend},
35    storage_layout::{StorageLayoutConfig, layout},
36};
37
38/// Storage layout presets for different ecosystems.
39#[derive(Debug, Clone, Copy, ValueEnum, Default)]
40pub enum LayoutPreset {
41    /// Standard Auths layout (refs/auths/identity, refs/auths/keys)
42    #[default]
43    Default,
44    /// Radicle-compatible layout (refs/rad/id, refs/keys)
45    Radicle,
46    /// Gitoxide-compatible layout (refs/auths/id, refs/auths/devices)
47    Gitoxide,
48}
49
50impl LayoutPreset {
51    /// Convert the preset to a StorageLayoutConfig.
52    pub fn to_config(self) -> StorageLayoutConfig {
53        match self {
54            LayoutPreset::Default => StorageLayoutConfig::default(),
55            LayoutPreset::Radicle => StorageLayoutConfig::radicle(),
56            LayoutPreset::Gitoxide => StorageLayoutConfig::gitoxide(),
57        }
58    }
59}
60
61#[derive(Parser, Debug, Clone)]
62#[command(
63    about = "Manage your signing identity.",
64    after_help = "Examples:
65  auths id show             # Show current identity details
66  auths id list             # List identities (same as show)
67  auths id create           # Create a new identity
68  auths id export-bundle    # Export identity bundle for verification
69
70Related:
71  auths init    — Initialize identity with setup wizard
72  auths device  — Manage linked devices
73  auths key     — Manage cryptographic keys"
74)]
75pub struct IdCommand {
76    #[clap(subcommand)]
77    pub subcommand: IdSubcommand,
78
79    #[command(flatten)]
80    pub overrides: RegistryOverrides,
81}
82
83#[derive(Subcommand, Debug, Clone)]
84pub enum IdSubcommand {
85    /// Create a new signing identity.
86    #[command(name = "create")]
87    Create {
88        /// Path to JSON file with arbitrary identity metadata.
89        #[arg(
90            long,
91            value_parser,
92            help = "Path to JSON file with arbitrary identity metadata."
93        )]
94        metadata_file: PathBuf,
95
96        /// Name for the new signing key in secure storage.
97        #[arg(long, help = "Name for the new signing key in secure storage.")]
98        local_key_alias: String,
99
100        /// Storage layout preset for ecosystem compatibility.
101        /// Use 'radicle' for Radicle repositories, 'gitoxide' for gitoxide,
102        /// or 'default' for standard Auths layout.
103        #[arg(
104            long,
105            value_enum,
106            default_value = "default",
107            help = "Storage layout preset (default, radicle, gitoxide)"
108        )]
109        preset: LayoutPreset,
110    },
111
112    /// Show primary identity details (identity ID, metadata) from the Git repository.
113    Show,
114
115    /// List identities (currently same as show, forward-compatible for future multi-identity support).
116    List,
117
118    /// Rotate identity keys. Stores the new key under a new alias.
119    Rotate {
120        /// Name of the key to rotate. Defaults to the next rotation key automatically.
121        #[arg(long, help = "Name of the key to rotate.")]
122        alias: Option<String>,
123
124        /// Current signing key name (alternative to --alias).
125        #[arg(
126            long,
127            help = "Current signing key name (alternative to --alias).",
128            conflicts_with = "alias"
129        )]
130        current_key_alias: Option<String>,
131
132        /// Name for the new signing key after rotation.
133        #[arg(long, help = "Name for the new signing key after rotation.")]
134        next_key_alias: Option<String>,
135
136        /// Add a witness server address (repeatable).
137        #[arg(
138            long,
139            action = ArgAction::Append,
140            help = "Add a witness server address (repeatable)."
141        )]
142        add_witness: Vec<String>,
143
144        /// Remove a witness server address (repeatable).
145        #[arg(
146            long,
147            action = ArgAction::Append,
148            help = "Remove a witness server address (repeatable)."
149        )]
150        remove_witness: Vec<String>,
151
152        /// Number of witnesses required to accept this rotation (e.g., 1).
153        #[arg(
154            long,
155            help = "Number of witnesses required to accept this rotation (e.g., 1)."
156        )]
157        witness_threshold: Option<u64>,
158
159        /// Preview actions without making changes.
160        #[arg(long)]
161        dry_run: bool,
162
163        /// Add a device slot on this rotation (repeatable). Value is the
164        /// curve for the new slot (`P256` or `Ed25519`).
165        #[arg(long, action = ArgAction::Append, value_name = "CURVE")]
166        add_device: Vec<String>,
167
168        /// Remove a device slot by index on this rotation (repeatable).
169        /// Currently rejected — requires CESR indexed-signature support.
170        #[arg(long, action = ArgAction::Append, value_name = "INDEX")]
171        remove_device: Vec<u32>,
172
173        /// New signing threshold (scalar like `"2"` or fractions like
174        /// `"1/2,1/2,1/2"`). Omit to keep the prior `kt`.
175        #[arg(long)]
176        signing_threshold: Option<String>,
177
178        /// New rotation (next) threshold, same format as
179        /// `--signing-threshold`. Omit to keep the prior `nt`.
180        #[arg(long)]
181        rotation_threshold: Option<String>,
182
183        /// Receive the rotation from a paired device over LAN instead of
184        /// authoring it locally. The device builds and co-signs the
185        /// shared-KEL rotation; this host only validates it against the
186        /// registry's prior key state and appends it. No local key
187        /// material is touched.
188        #[cfg(feature = "lan-pairing")]
189        #[arg(long, conflicts_with_all = ["alias", "current_key_alias", "next_key_alias", "add_witness", "remove_witness", "witness_threshold", "dry_run"])]
190        from_device: bool,
191
192        /// Don't display the QR code for `--from-device` (only the short code).
193        #[cfg(feature = "lan-pairing")]
194        #[arg(long = "no-qr", requires = "from_device", hide_short_help = true)]
195        no_qr: bool,
196
197        /// Print the session token URI for `--from-device` so scripts can
198        /// deliver it to the device out of band.
199        #[cfg(feature = "lan-pairing")]
200        #[arg(long = "print-uri", requires = "from_device", hide_short_help = true)]
201        print_uri: bool,
202
203        /// Disable mDNS advertisement for `--from-device`.
204        #[cfg(feature = "lan-pairing")]
205        #[arg(long = "no-mdns", requires = "from_device", hide_short_help = true)]
206        no_mdns: bool,
207
208        /// Session lifetime in seconds for `--from-device`.
209        #[cfg(feature = "lan-pairing")]
210        #[arg(
211            long = "timeout",
212            requires = "from_device",
213            default_value = "300",
214            hide_short_help = true
215        )]
216        timeout: u64,
217    },
218
219    /// Export an identity bundle for stateless CI/CD verification.
220    ///
221    /// Creates a portable JSON bundle containing the identity ID, public key,
222    /// and authorization chain. This bundle can be used in CI pipelines to verify
223    /// commit signatures without requiring access to the identity repository.
224    ExportBundle {
225        /// Key alias to include in the bundle.
226        #[arg(long, help = "Key alias to include in bundle")]
227        alias: String,
228
229        /// Output file path for the JSON bundle.
230        #[arg(long = "output", short = 'o')]
231        output_file: PathBuf,
232
233        /// TTL in seconds. The bundle will fail verification after this many seconds.
234        #[arg(
235            long,
236            help = "Maximum bundle age in seconds before it is considered stale"
237        )]
238        max_age_secs: u64,
239    },
240
241    /// Export a KEL-derived `allowed_signers` file so plain `git` / `ssh` can
242    /// verify this identity's signatures on machines without auths installed.
243    ///
244    /// Point git at it with:
245    ///   git config gpg.ssh.allowedSignersFile <file>
246    AllowedSigners {
247        /// Key alias whose current public key to export.
248        #[arg(long, help = "Key alias to export the signing key for")]
249        alias: String,
250
251        /// Principal to bind the key to (usually the committer email). Defaults
252        /// to the identity DID. Must match the signer identity git checks.
253        #[arg(
254            long,
255            help = "Principal for the allowed_signers line (default: identity DID)"
256        )]
257        principal: Option<String>,
258
259        /// Write to this file instead of stdout.
260        #[arg(long = "output", short = 'o', help = "Output file (default: stdout)")]
261        output_file: Option<PathBuf>,
262    },
263
264    /// Publish this identity to a public registry for discovery.
265    Register {
266        /// Registry URL to publish to.
267        #[arg(long, env = "AUTHS_REGISTRY_URL")]
268        registry: Option<String>,
269    },
270
271    /// Add a platform claim to an already-registered identity.
272    Claim(super::claim::ClaimCommand),
273
274    /// Import existing GPG or SSH keys into Auths.
275    Migrate(super::migrate::MigrateCommand),
276
277    /// Bind this identity to an enterprise IdP (Okta, Entra ID, Google Workspace, SAML).
278    ///
279    /// Requires the `auths-cloud` binary on $PATH. If not installed,
280    /// prints information about Auths Cloud.
281    BindIdp(super::bind_idp::BindIdpStubCommand),
282
283    /// Re-authorize with a platform and optionally upload SSH signing key.
284    ///
285    /// Use this when you need to update OAuth scopes or re-authenticate
286    /// with a platform (e.g., GitHub). Automatically uploads the SSH signing key
287    /// if the `write:ssh_signing_key` scope is included.
288    #[command(name = "update-scope")]
289    UpdateScope {
290        /// Platform to re-authorize with (e.g., github).
291        #[arg(help = "Platform name (currently supports 'github')")]
292        platform: String,
293    },
294
295    /// Manage AI agents delegated by this identity (KERI delegated identifiers).
296    Agent(super::agent::AgentCommand),
297}
298
299/// `auths id rotate --from-device`: apply a rotation a paired device
300/// authored, instead of authoring one locally.
301///
302/// The device builds and co-signs the shared-KEL rotation (proving
303/// controllership by revealing a pre-committed key); this host receives the
304/// envelope over an ephemeral LAN session, validates it against the
305/// registry's prior key state, and appends it. No local key material is
306/// read or written — the host contributes only its registry.
307#[cfg(feature = "lan-pairing")]
308fn handle_rotate_from_device(
309    repo_path: &std::path::Path,
310    no_qr: bool,
311    print_uri: bool,
312    no_mdns: bool,
313    timeout: u64,
314    now: chrono::DateTime<chrono::Utc>,
315    env_config: &EnvironmentConfig,
316) -> Result<()> {
317    use auths_sdk::domains::identity::shared_rot::apply_shared_kel_rot;
318
319    let rt = tokio::runtime::Runtime::new()?;
320    let envelope = rt.block_on(crate::commands::device::pair::handle_receive_shared_rot(
321        now, repo_path, no_qr, print_uri, no_mdns, timeout,
322    ))?;
323
324    let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
325    let controller_did =
326        auths_sdk::pairing::load_controller_did(&identity_storage).map_err(anyhow::Error::from)?;
327    let prefix_str = controller_did
328        .strip_prefix("did:keri:")
329        .ok_or_else(|| anyhow!("invalid DID format, expected 'did:keri:': {controller_did}"))?;
330    let prefix = auths_keri::Prefix::new_unchecked(prefix_str.to_string());
331
332    let ctx = crate::factories::storage::build_auths_context(repo_path, env_config, None)?;
333    let applied = apply_shared_kel_rot(&envelope, &prefix, ctx.registry.as_ref())
334        .with_context(|| "Failed to apply the device-authored rotation")?;
335
336    if is_json_mode() {
337        JsonResponse::success(
338            "id rotate",
339            &serde_json::json!({
340                "from_device": true,
341                "controller_did": controller_did,
342                "sequence": applied.sequence.to_string(),
343                "event_said": applied.said.as_str(),
344                "controller_count": applied.controller_count,
345            }),
346        )
347        .print()
348        .map_err(anyhow::Error::from)?;
349    } else {
350        println!("\n✅ Device-authored rotation applied.");
351        println!("   Identity:    {}", controller_did);
352        println!("   KEL advanced to seq {}", applied.sequence);
353        println!("   Controllers in force: {}", applied.controller_count);
354    }
355
356    // The rotation advanced the KEL — restamp the commit-trailers file so
357    // hook-stamped commits carry the new anchor position.
358    if let Err(e) = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(&ctx, repo_path) {
359        println!("⚠️  Could not refresh commit trailers ({e}); run `auths doctor`.");
360    }
361    Ok(())
362}
363
364fn display_dry_run_rotate(
365    repo_path: &std::path::Path,
366    current_alias: Option<&str>,
367    next_alias: Option<&str>,
368) -> Result<()> {
369    if is_json_mode() {
370        JsonResponse::success(
371            "id rotate",
372            &serde_json::json!({
373                "dry_run": true,
374                "repo_path": repo_path.display().to_string(),
375                "current_key_alias": current_alias,
376                "next_key_alias": next_alias,
377                "actions": [
378                    "Generate new signing key",
379                    "Record rotation in identity log",
380                    "Update key name mappings",
381                    "All devices will need to re-authorize"
382                ]
383            }),
384        )
385        .print()
386        .map_err(anyhow::Error::from)
387    } else {
388        let out = crate::ux::format::Output::new();
389        out.print_info("Dry run mode — no changes will be made");
390        out.newline();
391        out.println(&format!("   Repository: {:?}", repo_path));
392        if let Some(alias) = current_alias {
393            out.println(&format!("   Current key name: {}", alias));
394        }
395        if let Some(alias) = next_alias {
396            out.println(&format!("   New key name: {}", alias));
397        }
398        out.newline();
399        out.println("Would perform the following actions:");
400        out.println("  1. Generate new signing key");
401        out.println("  2. Record rotation in identity log");
402        out.println("  3. Update key name mappings");
403        out.println("  4. All devices will need to re-authorize");
404        Ok(())
405    }
406}
407
408// --- Command Handler ---
409
410/// Handles the `id` subcommand, accepting the specific subcommand details
411/// and the global configuration overrides passed from `main`.
412#[allow(clippy::too_many_arguments)]
413pub fn handle_id(
414    cmd: IdCommand,
415    repo_opt: Option<PathBuf>,
416    identity_ref_override: Option<String>,
417    identity_blob_name_override: Option<String>,
418    attestation_prefix_override: Option<String>,
419    attestation_blob_name_override: Option<String>,
420    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
421    env_config: &EnvironmentConfig,
422    now: chrono::DateTime<chrono::Utc>,
423) -> Result<()> {
424    // Determine repo path using the passed Option
425    let repo_path = layout::resolve_repo_path(repo_opt)?;
426
427    // Build StorageLayoutConfig from defaults and function arguments
428    // Used by non-Init subcommands
429    let mut config = StorageLayoutConfig::default();
430    if let Some(ref identity_ref) = identity_ref_override {
431        config.identity_ref = identity_ref.clone().into();
432    }
433    if let Some(ref blob_name) = identity_blob_name_override {
434        config.identity_blob_name = blob_name.clone().into();
435    }
436    if let Some(ref prefix) = attestation_prefix_override {
437        config.device_attestation_prefix = prefix.clone().into();
438    }
439    if let Some(ref blob_name) = attestation_blob_name_override {
440        config.attestation_blob_name = blob_name.clone().into();
441    }
442
443    match cmd.subcommand {
444        IdSubcommand::Create {
445            metadata_file,
446            local_key_alias,
447            preset,
448        } => {
449            // Apply preset first, then override with explicit flags
450            let mut config = preset.to_config();
451            if let Some(ref identity_ref) = identity_ref_override {
452                config.identity_ref = identity_ref.clone().into();
453            }
454            if let Some(ref blob_name) = identity_blob_name_override {
455                config.identity_blob_name = blob_name.clone().into();
456            }
457            if let Some(ref prefix) = attestation_prefix_override {
458                config.device_attestation_prefix = prefix.clone().into();
459            }
460            if let Some(ref blob_name) = attestation_blob_name_override {
461                config.attestation_blob_name = blob_name.clone().into();
462            }
463            let metadata_file_path = metadata_file;
464
465            // --- Common Setup: Repo Init Check & Metadata Loading ---
466            println!("🔐 Creating identity...");
467            println!("   Repository path:   {:?}", repo_path);
468            println!("   Key name:          {}", local_key_alias);
469            println!("   Metadata File:     {:?}", metadata_file_path);
470
471            // Ensure Git Repository Exists and is Initialized
472            use crate::factories::storage::{ensure_git_repo, open_git_repo};
473
474            let identity_storage_check = RegistryIdentityStorage::new(repo_path.clone());
475            if repo_path.exists() {
476                match open_git_repo(&repo_path) {
477                    Ok(_repo) => {
478                        println!("   Git repository found at {:?}.", repo_path);
479                        if identity_storage_check.load_identity().is_ok() {
480                            eprintln!(
481                                "⚠️ Primary identity already initialized and loadable at {:?} using ref '{}'. Aborting.",
482                                repo_path,
483                                identity_storage_check.get_identity_ref()?
484                            );
485                            return Err(anyhow!("Identity already exists in this repository"));
486                        } else {
487                            println!(
488                                "   Repository exists, but primary identity ref/data is missing or invalid. Proceeding..."
489                            );
490                        }
491                    }
492                    Err(_) => {
493                        println!(
494                            "   Path {:?} exists but is not a Git repository. Initializing...",
495                            repo_path
496                        );
497                        ensure_git_repo(&repo_path).map_err(|e| {
498                            anyhow!(
499                                "Path {:?} exists but failed to initialize as Git repository: {}",
500                                repo_path,
501                                e
502                            )
503                        })?;
504                        println!("   Successfully initialized Git repository.");
505                    }
506                }
507            } else {
508                println!("   Initializing Git repository at {:?}...", repo_path);
509                ensure_git_repo(&repo_path).map_err(|e| {
510                    anyhow!(
511                        "Failed to initialize Git repository at {:?}: {}",
512                        repo_path,
513                        e
514                    )
515                })?;
516                println!("   Successfully initialized Git repository.");
517            }
518
519            // Load and Parse Metadata File (common logic)
520            if !metadata_file_path.exists() {
521                return Err(anyhow!("Metadata file not found: {:?}", metadata_file_path));
522            }
523            let metadata_content = fs::read_to_string(&metadata_file_path).with_context(|| {
524                format!("Failed to read metadata file: {:?}", metadata_file_path)
525            })?;
526            let metadata_value: serde_json::Value = serde_json::from_str(&metadata_content)
527                .with_context(|| {
528                    format!(
529                        "Failed to parse JSON from metadata file: {:?}",
530                        metadata_file_path
531                    )
532                })?;
533            println!("   Metadata loaded successfully from file.");
534
535            // --- Always Use KERI Initialization Logic ---
536
537            // Call the initialize_registry_identity function from auths_id
538            let _metadata_value = metadata_value; // metadata stored separately if needed
539            let backend: Arc<dyn RegistryBackend + Send + Sync> =
540                Arc::new(GitRegistryBackend::from_config_unchecked(
541                    RegistryConfig::single_tenant(&repo_path),
542                ));
543            let local_key_alias = KeyAlias::new_unchecked(local_key_alias);
544            match initialize_registry_identity(
545                backend,
546                &local_key_alias,
547                passphrase_provider.as_ref(),
548                &get_platform_keychain()?,
549                auths_sdk::witness::WitnessParams::Disabled,
550                auths_crypto::CurveType::default(),
551                chrono::Utc::now(),
552            ) {
553                Ok((controller_did_keri, alias)) => {
554                    println!("\n✅ Identity created.");
555                    println!(
556                        "   Repository:         {:?}",
557                        repo_path
558                            .canonicalize()
559                            .unwrap_or_else(|_| repo_path.clone())
560                    );
561                    println!("   Identity:           {}", controller_did_keri);
562                    println!(
563                        "   Key name:         {} (use this for signing and rotations)",
564                        alias
565                    );
566                    println!("   Metadata stored from: {:?}", metadata_file_path);
567                    println!("🔑 Keep your passphrase secure!");
568                    Ok(())
569                }
570                Err(e) => Err(e).context("Failed to create identity"),
571            }
572        }
573
574        IdSubcommand::Show | IdSubcommand::List => {
575            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
576
577            let identity = identity_storage
578                .load_identity()
579                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
580
581            let cmd_name = match cmd.subcommand {
582                IdSubcommand::List => "id list",
583                _ => "id show",
584            };
585
586            if is_json_mode() {
587                let response = JsonResponse::success(
588                    cmd_name,
589                    IdShowResponse {
590                        controller_did: identity.controller_did.to_string(),
591                        storage_id: identity.storage_id.clone(),
592                        metadata: identity.metadata.clone(),
593                    },
594                );
595                response.print()?;
596            } else {
597                println!("Identity:   {}", identity.controller_did);
598                println!("Storage ID: {}", identity.storage_id);
599                println!("Metadata:");
600                if let Some(meta) = &identity.metadata {
601                    println!(
602                        "{}",
603                        serde_json::to_string_pretty(meta)
604                            .unwrap_or_else(|_| "  <Error serializing metadata>".to_string())
605                    );
606                } else {
607                    println!("  (None)");
608                }
609
610                println!("\nUse 'auths device list' to see authorized devices");
611            }
612            Ok(())
613        }
614
615        IdSubcommand::Rotate {
616            alias,
617            current_key_alias,
618            next_key_alias,
619            add_witness,
620            remove_witness,
621            witness_threshold,
622            dry_run,
623            add_device,
624            remove_device,
625            signing_threshold,
626            rotation_threshold,
627            #[cfg(feature = "lan-pairing")]
628            from_device,
629            #[cfg(feature = "lan-pairing")]
630            no_qr,
631            #[cfg(feature = "lan-pairing")]
632            print_uri,
633            #[cfg(feature = "lan-pairing")]
634            no_mdns,
635            #[cfg(feature = "lan-pairing")]
636            timeout,
637        } => {
638            #[cfg(feature = "lan-pairing")]
639            if from_device {
640                return handle_rotate_from_device(
641                    &repo_path, no_qr, print_uri, no_mdns, timeout, now, env_config,
642                );
643            }
644            if !add_device.is_empty()
645                || !remove_device.is_empty()
646                || signing_threshold.is_some()
647                || rotation_threshold.is_some()
648            {
649                return Err(anyhow!(
650                    "multi-device rotation (--add-device / --remove-device / --signing-threshold / \
651                     --rotation-threshold) is not wired through `auths id rotate`. Add a device \
652                     with `auths device pair` (each device is a delegated identity under your \
653                     root), or omit these flags for a standard rotation."
654                ));
655            }
656            let identity_key_alias = alias.or(current_key_alias);
657
658            if dry_run {
659                return display_dry_run_rotate(
660                    &repo_path,
661                    identity_key_alias.as_deref(),
662                    next_key_alias.as_deref(),
663                );
664            }
665
666            println!("🔄 Rotating keys...");
667            println!("   Using Repository: {:?}", repo_path);
668            if let Some(ref a) = identity_key_alias {
669                println!("   Current key name: {}", a);
670            }
671            if let Some(ref a) = next_key_alias {
672                println!("   New key name: {}", a);
673            }
674            if !add_witness.is_empty() {
675                println!("   Adding witnesses: {:?}", add_witness);
676            }
677            if !remove_witness.is_empty() {
678                println!("   Removing witnesses: {:?}", remove_witness);
679            }
680            if let Some(thresh) = witness_threshold {
681                println!("   Witnesses required: {}", thresh);
682            }
683
684            let rotation_config_next_alias = next_key_alias.clone();
685            let rotation_config = auths_sdk::types::IdentityRotationConfig {
686                repo_path: repo_path.clone(),
687                identity_key_alias: identity_key_alias.map(KeyAlias::new_unchecked),
688                next_key_alias: next_key_alias.map(KeyAlias::new_unchecked),
689            };
690            let rotation_ctx = {
691                use auths_sdk::attestation::AttestationSink;
692                use auths_sdk::context::AuthsContext;
693                use auths_sdk::keychain::get_platform_keychain_with_config;
694                use auths_sdk::ports::IdentityStorage;
695                use auths_sdk::storage::{
696                    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig,
697                    RegistryIdentityStorage,
698                };
699                let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> =
700                    Arc::new(GitRegistryBackend::from_config_unchecked(
701                        RegistryConfig::single_tenant(&repo_path),
702                    ));
703                let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
704                    Arc::new(RegistryIdentityStorage::new(repo_path.clone()));
705                let attestation_store = Arc::new(RegistryAttestationStorage::new(&repo_path));
706                let attestation_sink: Arc<dyn AttestationSink + Send + Sync> =
707                    Arc::clone(&attestation_store) as Arc<dyn AttestationSink + Send + Sync>;
708                let attestation_source: Arc<dyn AttestationSource + Send + Sync> =
709                    attestation_store as Arc<dyn AttestationSource + Send + Sync>;
710                let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
711                    get_platform_keychain_with_config(env_config)
712                        .context("Failed to access keychain")?,
713                );
714                AuthsContext::builder()
715                    .registry(backend)
716                    .key_storage(key_storage)
717                    .clock(Arc::new(auths_sdk::ports::SystemClock))
718                    .identity_storage(identity_storage)
719                    .attestation_sink(attestation_sink)
720                    .attestation_source(attestation_source)
721                    .passphrase_provider(Arc::clone(&passphrase_provider))
722                    .build()
723            };
724            let result = auths_sdk::workflows::rotation::rotate_identity(
725                rotation_config,
726                &rotation_ctx,
727                &auths_sdk::ports::SystemClock,
728            )
729            .with_context(|| "Failed to rotate keys")?;
730
731            println!("\n✅ Keys rotated.");
732            println!("   Identity: {}", result.controller_did);
733            println!(
734                "   Old key fingerprint: {}...",
735                result.previous_key_fingerprint
736            );
737            println!("   New key fingerprint: {}...", result.new_key_fingerprint);
738            println!("   Key name: {} (unchanged)", result.new_key_alias);
739
740            // The rotation advanced the KEL — restamp the commit-trailers file so
741            // hook-stamped commits carry the new anchor position.
742            if let Err(e) = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(
743                &rotation_ctx,
744                &repo_path,
745            ) {
746                println!("⚠️  Could not refresh commit trailers ({e}); run `auths doctor`.");
747            }
748
749            // Stable alias keeps git signing working untouched. Only an explicit
750            // --next-key-alias changes the name — repoint user.signingkey then.
751            if let Some(ref explicit) = rotation_config_next_alias {
752                let new_signing_key = format!("auths:{explicit}");
753                let updated = crate::subprocess::git_command(&[
754                    "config",
755                    "--global",
756                    "user.signingKey",
757                    &new_signing_key,
758                ])
759                .output()
760                .map(|o| o.status.success())
761                .unwrap_or(false);
762                if updated {
763                    println!("   Git signing key updated to {new_signing_key}");
764                } else {
765                    println!(
766                        "⚠️  Could not update git user.signingKey — set it manually: \
767                         git config --global user.signingKey {new_signing_key}"
768                    );
769                }
770            }
771
772            log::info!(
773                "Key rotation completed: old_key={}, new_key={}, alias={}",
774                result.previous_key_fingerprint,
775                result.new_key_fingerprint,
776                result.new_key_alias,
777            );
778
779            Ok(())
780        }
781
782        IdSubcommand::ExportBundle {
783            alias,
784            output_file,
785            max_age_secs,
786        } => {
787            println!("📦 Exporting identity bundle...");
788            println!("   Using Repository:  {:?}", repo_path);
789            println!("   Key name:          {}", alias);
790            println!("   Output File:       {:?}", output_file);
791
792            // Load identity
793            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
794            let identity = identity_storage
795                .load_identity()
796                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
797
798            println!("   Identity DID:      {}", identity.controller_did);
799
800            // Load attestations
801            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
802            let attestations = attestation_storage
803                .load_all_enriched()
804                .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
805                .unwrap_or_default();
806
807            // Load the public key from keychain (handles SE and software keys)
808            let keychain = get_platform_keychain()?;
809            let alias_typed = KeyAlias::new_unchecked(&alias);
810            let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
811                keychain.as_ref(),
812                &alias_typed,
813                passphrase_provider.as_ref(),
814            )
815            .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
816            #[allow(clippy::disallowed_methods)]
817            // INVARIANT: hex::encode of pubkey bytes always produces valid hex
818            let public_key_hex =
819                auths_verifier::PublicKeyHex::new_unchecked(hex::encode(&public_key_bytes));
820
821            // Embed the identity's KEL so verifiers stay stateless: KEL-native
822            // commit verification on a CI runner has no identity store to
823            // resolve the signer from.
824            let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
825                auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
826            );
827            let prefix = auths_sdk::keri::parse_did_keri(identity.controller_did.as_str())
828                .context("identity DID is not did:keri")?;
829            // Collect the KEL events, then their stored CESR signature
830            // attachments (seq-indexed), so the exported bundle can be
831            // AUTHENTICATED by a stateless verifier — not just replayed
832            // structurally (RT-002).
833            let mut events = Vec::new();
834            let _ = registry.visit_events(&prefix, 0, &mut |e| {
835                events.push(e.clone());
836                std::ops::ControlFlow::Continue(())
837            });
838            if events.is_empty() {
839                return Err(anyhow::anyhow!(
840                    "no KEL events found for {} — cannot export a verifiable bundle",
841                    identity.controller_did
842                ));
843            }
844            let mut kel: Vec<auths_keri::Event> = Vec::with_capacity(events.len());
845            let mut kel_attachments: Vec<String> = Vec::with_capacity(events.len());
846            for e in &events {
847                let seq = e.sequence().value();
848                let att = registry
849                    .get_attachment(&prefix, seq)
850                    .map_err(|err| anyhow::anyhow!("failed to read KEL signature: {err}"))?
851                    .ok_or_else(|| {
852                        anyhow::anyhow!(
853                            "KEL event at seq {seq} has no stored signature attachment; \
854                             cannot export a verifiable bundle (re-initialize this identity)"
855                        )
856                    })?;
857                kel.push(e.clone());
858                kel_attachments.push(hex::encode(att));
859            }
860
861            // Delegated device KELs: commits are signed by a DEVICE AID, so a
862            // stateless verifier needs each device KEL the root anchored — not
863            // only the root's. Enumerate the registry for dip-rooted KELs whose
864            // delegator is this identity, exporting each with its attachments.
865            let root_prefix_str = prefix.as_str().to_string();
866            let mut device_prefixes: Vec<String> = Vec::new();
867            let _ = registry.visit_identities(&mut |candidate| {
868                if candidate != root_prefix_str {
869                    device_prefixes.push(candidate.to_string());
870                }
871                std::ops::ControlFlow::Continue(())
872            });
873            let mut device_kels = Vec::new();
874            for device in device_prefixes {
875                let device_prefix = auths_keri::Prefix::new_unchecked(device.clone());
876                let mut device_events: Vec<auths_keri::Event> = Vec::new();
877                let _ = registry.visit_events(&device_prefix, 0, &mut |e| {
878                    device_events.push(e.clone());
879                    std::ops::ControlFlow::Continue(())
880                });
881                let delegated_by_this_root = device_events
882                    .first()
883                    .and_then(|e| e.delegator())
884                    .is_some_and(|d| d.as_str() == root_prefix_str);
885                if !delegated_by_this_root {
886                    continue;
887                }
888                let mut device_attachments = Vec::with_capacity(device_events.len());
889                for e in &device_events {
890                    let seq = e.sequence().value();
891                    let att = registry
892                        .get_attachment(&device_prefix, seq)
893                        .map_err(|err| anyhow::anyhow!("failed to read device KEL signature: {err}"))?
894                        .ok_or_else(|| {
895                            anyhow::anyhow!(
896                                "device {device} KEL event at seq {seq} has no stored signature                                  attachment; cannot export a verifiable bundle"
897                            )
898                        })?;
899                    device_attachments.push(hex::encode(att));
900                }
901                device_kels.push(auths_verifier::BundleDeviceKel {
902                    did: format!("did:keri:{device}"),
903                    kel: device_events,
904                    kel_attachments: device_attachments,
905                });
906            }
907
908            // Create the bundle. Curve flows in-band from the typed keychain
909            // extraction so verifiers never re-derive it from byte length.
910            let bundle = IdentityBundle {
911                identity_did: identity.controller_did.clone(),
912                public_key_hex,
913                curve,
914                attestation_chain: attestations,
915                kel,
916                kel_attachments,
917                device_kels,
918                bundle_timestamp: now,
919                max_valid_for_secs: max_age_secs,
920            };
921
922            // Write to output file
923            let json = serde_json::to_string_pretty(&bundle)
924                .context("Failed to serialize identity bundle")?;
925            fs::write(&output_file, &json)
926                .with_context(|| format!("Failed to write bundle to {:?}", output_file))?;
927
928            println!("\n✅ Identity bundle exported successfully!");
929            println!("   Output:            {:?}", output_file);
930            println!("   Attestations:      {}", bundle.attestation_chain.len());
931            println!("\nUsage in CI:");
932            println!("   auths verify --identity-bundle {:?} HEAD", output_file);
933
934            Ok(())
935        }
936
937        IdSubcommand::AllowedSigners {
938            alias,
939            principal,
940            output_file,
941        } => {
942            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
943            let identity = identity_storage
944                .load_identity()
945                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
946            let principal =
947                principal.unwrap_or_else(|| identity.controller_did.as_str().to_string());
948
949            let keychain = get_platform_keychain()?;
950            let alias_typed = KeyAlias::new_unchecked(&alias);
951            let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
952                keychain.as_ref(),
953                &alias_typed,
954                passphrase_provider.as_ref(),
955            )
956            .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
957
958            let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &public_key_bytes)
959                .with_context(|| format!("'{}' is not a valid signing key", alias))?;
960            let openssh = auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
961                .context("Failed to encode signing key as OpenSSH")?;
962            // allowed_signers line: `<principal> <keytype> <base64>`. The OpenSSH
963            // encoding already carries `<keytype> <base64>`; trim any trailing
964            // comment so the line is exactly the two fields ssh expects.
965            let key_field = openssh
966                .split_whitespace()
967                .take(2)
968                .collect::<Vec<_>>()
969                .join(" ");
970            let line = format!("{principal} {key_field}\n");
971
972            match &output_file {
973                Some(path) => {
974                    fs::write(path, &line).with_context(|| {
975                        format!("Failed to write allowed_signers to {:?}", path)
976                    })?;
977                    println!("✅ Wrote allowed_signers for {} to {:?}", principal, path);
978                    println!("   git config gpg.ssh.allowedSignersFile {:?}", path);
979                }
980                None => print!("{line}"),
981            }
982            Ok(())
983        }
984
985        IdSubcommand::Register { registry } => super::register::handle_register(
986            &repo_path,
987            &crate::commands::verify_helpers::require_registry(registry.clone())?,
988        ),
989
990        IdSubcommand::Claim(claim_cmd) => {
991            super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, env_config, now)
992        }
993
994        IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd, now),
995
996        IdSubcommand::BindIdp(bind_cmd) => super::bind_idp::handle_bind_idp(bind_cmd),
997
998        IdSubcommand::UpdateScope { platform } => {
999            if platform.to_lowercase() != "github" {
1000                return Err(anyhow!(
1001                    "Platform '{}' is not supported yet. Currently only 'github' is available.",
1002                    platform
1003                ));
1004            }
1005
1006            use crate::constants::GITHUB_SSH_UPLOAD_SCOPES;
1007            use auths_infra_http::{HttpGitHubOAuthProvider, HttpGitHubSshKeyUploader};
1008            use auths_sdk::keychain::extract_public_key_bytes;
1009            use auths_sdk::ports::platform::OAuthDeviceFlowProvider;
1010            use std::time::Duration;
1011
1012            const GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
1013            #[allow(clippy::disallowed_methods)]
1014            let client_id = std::env::var("AUTHS_GITHUB_CLIENT_ID")
1015                .unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string());
1016
1017            // Get ~/.auths directory
1018            let home =
1019                dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
1020            let auths_dir = home.join(".auths");
1021            let ctx = crate::factories::storage::build_auths_context(
1022                &auths_dir,
1023                env_config,
1024                Some(passphrase_provider.clone()),
1025            )?;
1026
1027            let oauth = HttpGitHubOAuthProvider::new();
1028            let ssh_uploader = HttpGitHubSshKeyUploader::new();
1029
1030            let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
1031
1032            let out = crate::ux::format::Output::new();
1033            out.print_info(&format!("Re-authorizing with {}", platform));
1034
1035            let device_code = rt
1036                .block_on(oauth.request_device_code(&client_id, GITHUB_SSH_UPLOAD_SCOPES))
1037                .map_err(anyhow::Error::from)?;
1038
1039            out.println(&format!(
1040                "  Enter this code: {}",
1041                out.bold(&device_code.user_code)
1042            ));
1043            out.println(&format!(
1044                "  At: {}",
1045                out.info(&device_code.verification_uri)
1046            ));
1047            if let Err(e) = open::that(&device_code.verification_uri) {
1048                out.print_warn(&format!("Could not open browser automatically: {e}"));
1049                out.println("  Please open the URL above manually.");
1050            } else {
1051                out.println("  Browser opened — waiting for authorization...");
1052            }
1053
1054            let expires_in = Duration::from_secs(device_code.expires_in);
1055            let interval = Duration::from_secs(device_code.interval);
1056
1057            let access_token = rt
1058                .block_on(oauth.poll_for_token(
1059                    &client_id,
1060                    &device_code.device_code,
1061                    interval,
1062                    expires_in,
1063                ))
1064                .map_err(anyhow::Error::from)?;
1065
1066            let profile = rt
1067                .block_on(oauth.fetch_user_profile(&access_token))
1068                .map_err(anyhow::Error::from)?;
1069
1070            out.print_success(&format!("Re-authenticated as @{}", profile.login));
1071
1072            // Try to get device public key and upload SSH key
1073            let controller_did =
1074                auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref())
1075                    .map_err(anyhow::Error::from)?;
1076
1077            let identity_did = IdentityDID::parse(&controller_did)
1078                .context("controller DID is not a valid did:keri identifier")?;
1079            let aliases = ctx
1080                .key_storage
1081                .list_aliases_for_identity(&identity_did)
1082                .context("failed to list key aliases")?;
1083            let key_alias = aliases
1084                .into_iter()
1085                .find(|a| !a.contains("--next-"))
1086                .ok_or_else(|| anyhow::anyhow!("no signing key found for {controller_did}"))?;
1087
1088            // Get device public key and encode
1089            let device_key_result = extract_public_key_bytes(
1090                ctx.key_storage.as_ref(),
1091                &key_alias,
1092                passphrase_provider.as_ref(),
1093            );
1094
1095            if let Ok((pk_bytes, curve)) = device_key_result {
1096                let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
1097                    .map_err(|e| anyhow::anyhow!("Invalid device key: {e}"))?;
1098                let public_key =
1099                    auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
1100                        .map_err(|e| anyhow::anyhow!("Failed to encode SSH key: {e}"))?;
1101
1102                out.println("  Uploading SSH signing key...");
1103                #[allow(clippy::disallowed_methods)]
1104                let now = chrono::Utc::now();
1105                #[allow(clippy::disallowed_methods)]
1106                let hostname = gethostname::gethostname();
1107                let hostname_str = hostname.to_string_lossy().to_string();
1108                let result = rt.block_on(
1109                    auths_sdk::workflows::platform::upload_github_ssh_signing_key(
1110                        &ssh_uploader,
1111                        &access_token,
1112                        &public_key,
1113                        &key_alias,
1114                        &hostname_str,
1115                        ctx.identity_storage.as_ref(),
1116                        now,
1117                    ),
1118                );
1119
1120                match result {
1121                    Ok(()) => {
1122                        out.print_success("SSH signing key uploaded to GitHub");
1123                        out.println("  View at: https://github.com/settings/keys");
1124                    }
1125                    Err(e) => {
1126                        out.print_warn(&format!("SSH key upload failed: {e}"));
1127                        out.println(
1128                            "  You can upload manually at https://github.com/settings/keys",
1129                        );
1130                    }
1131                }
1132            } else {
1133                out.print_warn("Could not extract device public key for SSH upload");
1134            }
1135
1136            out.print_success("Scope update complete");
1137            Ok(())
1138        }
1139
1140        IdSubcommand::Agent(agent_cmd) => {
1141            super::agent::handle_agent(agent_cmd, repo_path, env_config, passphrase_provider)
1142        }
1143    }
1144}
1145
1146#[cfg(all(test, feature = "lan-pairing"))]
1147mod tests {
1148    use super::*;
1149    use clap::Parser;
1150
1151    #[derive(Parser)]
1152    struct Harness {
1153        #[command(subcommand)]
1154        sub: IdSubcommand,
1155    }
1156
1157    #[test]
1158    fn rotate_from_device_parses() {
1159        let h = Harness::parse_from(["id", "rotate", "--from-device"]);
1160        match h.sub {
1161            IdSubcommand::Rotate { from_device, .. } => assert!(from_device),
1162            other => panic!("expected Rotate, got {other:?}"),
1163        }
1164    }
1165
1166    #[test]
1167    fn rotate_from_device_conflicts_with_local_authoring_flags() {
1168        // The device authors the rotation — naming a local key is a
1169        // contradiction and must be refused at parse time.
1170        assert!(
1171            Harness::try_parse_from(["id", "rotate", "--from-device", "--alias", "main"]).is_err()
1172        );
1173        assert!(Harness::try_parse_from(["id", "rotate", "--from-device", "--dry-run"]).is_err());
1174    }
1175
1176    #[test]
1177    fn rotate_session_toggles_require_from_device() {
1178        assert!(Harness::try_parse_from(["id", "rotate", "--no-qr"]).is_err());
1179        assert!(Harness::try_parse_from(["id", "rotate", "--print-uri"]).is_err());
1180        assert!(
1181            Harness::try_parse_from(["id", "rotate", "--from-device", "--no-qr", "--print-uri"])
1182                .is_ok()
1183        );
1184    }
1185}