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                None,
550                auths_crypto::CurveType::default(),
551            ) {
552                Ok((controller_did_keri, alias)) => {
553                    println!("\n✅ Identity created.");
554                    println!(
555                        "   Repository:         {:?}",
556                        repo_path
557                            .canonicalize()
558                            .unwrap_or_else(|_| repo_path.clone())
559                    );
560                    println!("   Identity:           {}", controller_did_keri);
561                    println!(
562                        "   Key name:         {} (use this for signing and rotations)",
563                        alias
564                    );
565                    println!("   Metadata stored from: {:?}", metadata_file_path);
566                    println!("🔑 Keep your passphrase secure!");
567                    Ok(())
568                }
569                Err(e) => Err(e).context("Failed to create identity"),
570            }
571        }
572
573        IdSubcommand::Show | IdSubcommand::List => {
574            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
575
576            let identity = identity_storage
577                .load_identity()
578                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
579
580            let cmd_name = match cmd.subcommand {
581                IdSubcommand::List => "id list",
582                _ => "id show",
583            };
584
585            if is_json_mode() {
586                let response = JsonResponse::success(
587                    cmd_name,
588                    IdShowResponse {
589                        controller_did: identity.controller_did.to_string(),
590                        storage_id: identity.storage_id.clone(),
591                        metadata: identity.metadata.clone(),
592                    },
593                );
594                response.print()?;
595            } else {
596                println!("Identity:   {}", identity.controller_did);
597                println!("Storage ID: {}", identity.storage_id);
598                println!("Metadata:");
599                if let Some(meta) = &identity.metadata {
600                    println!(
601                        "{}",
602                        serde_json::to_string_pretty(meta)
603                            .unwrap_or_else(|_| "  <Error serializing metadata>".to_string())
604                    );
605                } else {
606                    println!("  (None)");
607                }
608
609                println!("\nUse 'auths device list' to see authorized devices");
610            }
611            Ok(())
612        }
613
614        IdSubcommand::Rotate {
615            alias,
616            current_key_alias,
617            next_key_alias,
618            add_witness,
619            remove_witness,
620            witness_threshold,
621            dry_run,
622            add_device,
623            remove_device,
624            signing_threshold,
625            rotation_threshold,
626            #[cfg(feature = "lan-pairing")]
627            from_device,
628            #[cfg(feature = "lan-pairing")]
629            no_qr,
630            #[cfg(feature = "lan-pairing")]
631            print_uri,
632            #[cfg(feature = "lan-pairing")]
633            no_mdns,
634            #[cfg(feature = "lan-pairing")]
635            timeout,
636        } => {
637            #[cfg(feature = "lan-pairing")]
638            if from_device {
639                return handle_rotate_from_device(
640                    &repo_path, no_qr, print_uri, no_mdns, timeout, now, env_config,
641                );
642            }
643            if !add_device.is_empty()
644                || !remove_device.is_empty()
645                || signing_threshold.is_some()
646                || rotation_threshold.is_some()
647            {
648                return Err(anyhow!(
649                    "multi-device rotation (--add-device / --remove-device / --signing-threshold / \
650                     --rotation-threshold) is not wired through `auths id rotate`. Add a device \
651                     with `auths device pair` (each device is a delegated identity under your \
652                     root), or omit these flags for a standard rotation."
653                ));
654            }
655            let identity_key_alias = alias.or(current_key_alias);
656
657            if dry_run {
658                return display_dry_run_rotate(
659                    &repo_path,
660                    identity_key_alias.as_deref(),
661                    next_key_alias.as_deref(),
662                );
663            }
664
665            println!("🔄 Rotating keys...");
666            println!("   Using Repository: {:?}", repo_path);
667            if let Some(ref a) = identity_key_alias {
668                println!("   Current key name: {}", a);
669            }
670            if let Some(ref a) = next_key_alias {
671                println!("   New key name: {}", a);
672            }
673            if !add_witness.is_empty() {
674                println!("   Adding witnesses: {:?}", add_witness);
675            }
676            if !remove_witness.is_empty() {
677                println!("   Removing witnesses: {:?}", remove_witness);
678            }
679            if let Some(thresh) = witness_threshold {
680                println!("   Witnesses required: {}", thresh);
681            }
682
683            let rotation_config_next_alias = next_key_alias.clone();
684            let rotation_config = auths_sdk::types::IdentityRotationConfig {
685                repo_path: repo_path.clone(),
686                identity_key_alias: identity_key_alias.map(KeyAlias::new_unchecked),
687                next_key_alias: next_key_alias.map(KeyAlias::new_unchecked),
688            };
689            let rotation_ctx = {
690                use auths_sdk::attestation::AttestationSink;
691                use auths_sdk::context::AuthsContext;
692                use auths_sdk::keychain::get_platform_keychain_with_config;
693                use auths_sdk::ports::IdentityStorage;
694                use auths_sdk::storage::{
695                    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig,
696                    RegistryIdentityStorage,
697                };
698                let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> =
699                    Arc::new(GitRegistryBackend::from_config_unchecked(
700                        RegistryConfig::single_tenant(&repo_path),
701                    ));
702                let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
703                    Arc::new(RegistryIdentityStorage::new(repo_path.clone()));
704                let attestation_store = Arc::new(RegistryAttestationStorage::new(&repo_path));
705                let attestation_sink: Arc<dyn AttestationSink + Send + Sync> =
706                    Arc::clone(&attestation_store) as Arc<dyn AttestationSink + Send + Sync>;
707                let attestation_source: Arc<dyn AttestationSource + Send + Sync> =
708                    attestation_store as Arc<dyn AttestationSource + Send + Sync>;
709                let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
710                    get_platform_keychain_with_config(env_config)
711                        .context("Failed to access keychain")?,
712                );
713                AuthsContext::builder()
714                    .registry(backend)
715                    .key_storage(key_storage)
716                    .clock(Arc::new(auths_sdk::ports::SystemClock))
717                    .identity_storage(identity_storage)
718                    .attestation_sink(attestation_sink)
719                    .attestation_source(attestation_source)
720                    .passphrase_provider(Arc::clone(&passphrase_provider))
721                    .build()
722            };
723            let result = auths_sdk::workflows::rotation::rotate_identity(
724                rotation_config,
725                &rotation_ctx,
726                &auths_sdk::ports::SystemClock,
727            )
728            .with_context(|| "Failed to rotate keys")?;
729
730            println!("\n✅ Keys rotated.");
731            println!("   Identity: {}", result.controller_did);
732            println!(
733                "   Old key fingerprint: {}...",
734                result.previous_key_fingerprint
735            );
736            println!("   New key fingerprint: {}...", result.new_key_fingerprint);
737            println!("   Key name: {} (unchanged)", result.new_key_alias);
738
739            // The rotation advanced the KEL — restamp the commit-trailers file so
740            // hook-stamped commits carry the new anchor position.
741            if let Err(e) = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(
742                &rotation_ctx,
743                &repo_path,
744            ) {
745                println!("⚠️  Could not refresh commit trailers ({e}); run `auths doctor`.");
746            }
747
748            // Stable alias keeps git signing working untouched. Only an explicit
749            // --next-key-alias changes the name — repoint user.signingkey then.
750            if let Some(ref explicit) = rotation_config_next_alias {
751                let new_signing_key = format!("auths:{explicit}");
752                let updated = crate::subprocess::git_command(&[
753                    "config",
754                    "--global",
755                    "user.signingKey",
756                    &new_signing_key,
757                ])
758                .output()
759                .map(|o| o.status.success())
760                .unwrap_or(false);
761                if updated {
762                    println!("   Git signing key updated to {new_signing_key}");
763                } else {
764                    println!(
765                        "⚠️  Could not update git user.signingKey — set it manually: \
766                         git config --global user.signingKey {new_signing_key}"
767                    );
768                }
769            }
770
771            log::info!(
772                "Key rotation completed: old_key={}, new_key={}, alias={}",
773                result.previous_key_fingerprint,
774                result.new_key_fingerprint,
775                result.new_key_alias,
776            );
777
778            Ok(())
779        }
780
781        IdSubcommand::ExportBundle {
782            alias,
783            output_file,
784            max_age_secs,
785        } => {
786            println!("📦 Exporting identity bundle...");
787            println!("   Using Repository:  {:?}", repo_path);
788            println!("   Key name:          {}", alias);
789            println!("   Output File:       {:?}", output_file);
790
791            // Load identity
792            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
793            let identity = identity_storage
794                .load_identity()
795                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
796
797            println!("   Identity DID:      {}", identity.controller_did);
798
799            // Load attestations
800            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
801            let attestations = attestation_storage
802                .load_all_enriched()
803                .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
804                .unwrap_or_default();
805
806            // Load the public key from keychain (handles SE and software keys)
807            let keychain = get_platform_keychain()?;
808            let alias_typed = KeyAlias::new_unchecked(&alias);
809            let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
810                keychain.as_ref(),
811                &alias_typed,
812                passphrase_provider.as_ref(),
813            )
814            .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
815            #[allow(clippy::disallowed_methods)]
816            // INVARIANT: hex::encode of pubkey bytes always produces valid hex
817            let public_key_hex =
818                auths_verifier::PublicKeyHex::new_unchecked(hex::encode(&public_key_bytes));
819
820            // Embed the identity's KEL so verifiers stay stateless: KEL-native
821            // commit verification on a CI runner has no identity store to
822            // resolve the signer from.
823            let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
824                auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
825            );
826            let prefix = auths_sdk::keri::parse_did_keri(identity.controller_did.as_str())
827                .context("identity DID is not did:keri")?;
828            // Collect the KEL events, then their stored CESR signature
829            // attachments (seq-indexed), so the exported bundle can be
830            // AUTHENTICATED by a stateless verifier — not just replayed
831            // structurally (RT-002).
832            let mut events = Vec::new();
833            let _ = registry.visit_events(&prefix, 0, &mut |e| {
834                events.push(e.clone());
835                std::ops::ControlFlow::Continue(())
836            });
837            if events.is_empty() {
838                return Err(anyhow::anyhow!(
839                    "no KEL events found for {} — cannot export a verifiable bundle",
840                    identity.controller_did
841                ));
842            }
843            let mut kel: Vec<auths_keri::Event> = Vec::with_capacity(events.len());
844            let mut kel_attachments: Vec<String> = Vec::with_capacity(events.len());
845            for e in &events {
846                let seq = e.sequence().value();
847                let att = registry
848                    .get_attachment(&prefix, seq)
849                    .map_err(|err| anyhow::anyhow!("failed to read KEL signature: {err}"))?
850                    .ok_or_else(|| {
851                        anyhow::anyhow!(
852                            "KEL event at seq {seq} has no stored signature attachment; \
853                             cannot export a verifiable bundle (re-initialize this identity)"
854                        )
855                    })?;
856                kel.push(e.clone());
857                kel_attachments.push(hex::encode(att));
858            }
859
860            // Delegated device KELs: commits are signed by a DEVICE AID, so a
861            // stateless verifier needs each device KEL the root anchored — not
862            // only the root's. Enumerate the registry for dip-rooted KELs whose
863            // delegator is this identity, exporting each with its attachments.
864            let root_prefix_str = prefix.as_str().to_string();
865            let mut device_prefixes: Vec<String> = Vec::new();
866            let _ = registry.visit_identities(&mut |candidate| {
867                if candidate != root_prefix_str {
868                    device_prefixes.push(candidate.to_string());
869                }
870                std::ops::ControlFlow::Continue(())
871            });
872            let mut device_kels = Vec::new();
873            for device in device_prefixes {
874                let device_prefix = auths_keri::Prefix::new_unchecked(device.clone());
875                let mut device_events: Vec<auths_keri::Event> = Vec::new();
876                let _ = registry.visit_events(&device_prefix, 0, &mut |e| {
877                    device_events.push(e.clone());
878                    std::ops::ControlFlow::Continue(())
879                });
880                let delegated_by_this_root = device_events
881                    .first()
882                    .and_then(|e| e.delegator())
883                    .is_some_and(|d| d.as_str() == root_prefix_str);
884                if !delegated_by_this_root {
885                    continue;
886                }
887                let mut device_attachments = Vec::with_capacity(device_events.len());
888                for e in &device_events {
889                    let seq = e.sequence().value();
890                    let att = registry
891                        .get_attachment(&device_prefix, seq)
892                        .map_err(|err| anyhow::anyhow!("failed to read device KEL signature: {err}"))?
893                        .ok_or_else(|| {
894                            anyhow::anyhow!(
895                                "device {device} KEL event at seq {seq} has no stored signature                                  attachment; cannot export a verifiable bundle"
896                            )
897                        })?;
898                    device_attachments.push(hex::encode(att));
899                }
900                device_kels.push(auths_verifier::BundleDeviceKel {
901                    did: format!("did:keri:{device}"),
902                    kel: device_events,
903                    kel_attachments: device_attachments,
904                });
905            }
906
907            // Create the bundle. Curve flows in-band from the typed keychain
908            // extraction so verifiers never re-derive it from byte length.
909            let bundle = IdentityBundle {
910                identity_did: identity.controller_did.clone(),
911                public_key_hex,
912                curve,
913                attestation_chain: attestations,
914                kel,
915                kel_attachments,
916                device_kels,
917                bundle_timestamp: now,
918                max_valid_for_secs: max_age_secs,
919            };
920
921            // Write to output file
922            let json = serde_json::to_string_pretty(&bundle)
923                .context("Failed to serialize identity bundle")?;
924            fs::write(&output_file, &json)
925                .with_context(|| format!("Failed to write bundle to {:?}", output_file))?;
926
927            println!("\n✅ Identity bundle exported successfully!");
928            println!("   Output:            {:?}", output_file);
929            println!("   Attestations:      {}", bundle.attestation_chain.len());
930            println!("\nUsage in CI:");
931            println!("   auths verify --identity-bundle {:?} HEAD", output_file);
932
933            Ok(())
934        }
935
936        IdSubcommand::AllowedSigners {
937            alias,
938            principal,
939            output_file,
940        } => {
941            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
942            let identity = identity_storage
943                .load_identity()
944                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
945            let principal =
946                principal.unwrap_or_else(|| identity.controller_did.as_str().to_string());
947
948            let keychain = get_platform_keychain()?;
949            let alias_typed = KeyAlias::new_unchecked(&alias);
950            let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
951                keychain.as_ref(),
952                &alias_typed,
953                passphrase_provider.as_ref(),
954            )
955            .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
956
957            let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &public_key_bytes)
958                .with_context(|| format!("'{}' is not a valid signing key", alias))?;
959            let openssh = auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
960                .context("Failed to encode signing key as OpenSSH")?;
961            // allowed_signers line: `<principal> <keytype> <base64>`. The OpenSSH
962            // encoding already carries `<keytype> <base64>`; trim any trailing
963            // comment so the line is exactly the two fields ssh expects.
964            let key_field = openssh
965                .split_whitespace()
966                .take(2)
967                .collect::<Vec<_>>()
968                .join(" ");
969            let line = format!("{principal} {key_field}\n");
970
971            match &output_file {
972                Some(path) => {
973                    fs::write(path, &line).with_context(|| {
974                        format!("Failed to write allowed_signers to {:?}", path)
975                    })?;
976                    println!("✅ Wrote allowed_signers for {} to {:?}", principal, path);
977                    println!("   git config gpg.ssh.allowedSignersFile {:?}", path);
978                }
979                None => print!("{line}"),
980            }
981            Ok(())
982        }
983
984        IdSubcommand::Register { registry } => super::register::handle_register(
985            &repo_path,
986            &crate::commands::verify_helpers::require_registry(registry.clone())?,
987        ),
988
989        IdSubcommand::Claim(claim_cmd) => {
990            super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, env_config, now)
991        }
992
993        IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd, now),
994
995        IdSubcommand::BindIdp(bind_cmd) => super::bind_idp::handle_bind_idp(bind_cmd),
996
997        IdSubcommand::UpdateScope { platform } => {
998            if platform.to_lowercase() != "github" {
999                return Err(anyhow!(
1000                    "Platform '{}' is not supported yet. Currently only 'github' is available.",
1001                    platform
1002                ));
1003            }
1004
1005            use crate::constants::GITHUB_SSH_UPLOAD_SCOPES;
1006            use auths_infra_http::{HttpGitHubOAuthProvider, HttpGitHubSshKeyUploader};
1007            use auths_sdk::keychain::extract_public_key_bytes;
1008            use auths_sdk::ports::platform::OAuthDeviceFlowProvider;
1009            use std::time::Duration;
1010
1011            const GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
1012            #[allow(clippy::disallowed_methods)]
1013            let client_id = std::env::var("AUTHS_GITHUB_CLIENT_ID")
1014                .unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string());
1015
1016            // Get ~/.auths directory
1017            let home =
1018                dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
1019            let auths_dir = home.join(".auths");
1020            let ctx = crate::factories::storage::build_auths_context(
1021                &auths_dir,
1022                env_config,
1023                Some(passphrase_provider.clone()),
1024            )?;
1025
1026            let oauth = HttpGitHubOAuthProvider::new();
1027            let ssh_uploader = HttpGitHubSshKeyUploader::new();
1028
1029            let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
1030
1031            let out = crate::ux::format::Output::new();
1032            out.print_info(&format!("Re-authorizing with {}", platform));
1033
1034            let device_code = rt
1035                .block_on(oauth.request_device_code(&client_id, GITHUB_SSH_UPLOAD_SCOPES))
1036                .map_err(anyhow::Error::from)?;
1037
1038            out.println(&format!(
1039                "  Enter this code: {}",
1040                out.bold(&device_code.user_code)
1041            ));
1042            out.println(&format!(
1043                "  At: {}",
1044                out.info(&device_code.verification_uri)
1045            ));
1046            if let Err(e) = open::that(&device_code.verification_uri) {
1047                out.print_warn(&format!("Could not open browser automatically: {e}"));
1048                out.println("  Please open the URL above manually.");
1049            } else {
1050                out.println("  Browser opened — waiting for authorization...");
1051            }
1052
1053            let expires_in = Duration::from_secs(device_code.expires_in);
1054            let interval = Duration::from_secs(device_code.interval);
1055
1056            let access_token = rt
1057                .block_on(oauth.poll_for_token(
1058                    &client_id,
1059                    &device_code.device_code,
1060                    interval,
1061                    expires_in,
1062                ))
1063                .map_err(anyhow::Error::from)?;
1064
1065            let profile = rt
1066                .block_on(oauth.fetch_user_profile(&access_token))
1067                .map_err(anyhow::Error::from)?;
1068
1069            out.print_success(&format!("Re-authenticated as @{}", profile.login));
1070
1071            // Try to get device public key and upload SSH key
1072            let controller_did =
1073                auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref())
1074                    .map_err(anyhow::Error::from)?;
1075
1076            let identity_did = IdentityDID::parse(&controller_did)
1077                .context("controller DID is not a valid did:keri identifier")?;
1078            let aliases = ctx
1079                .key_storage
1080                .list_aliases_for_identity(&identity_did)
1081                .context("failed to list key aliases")?;
1082            let key_alias = aliases
1083                .into_iter()
1084                .find(|a| !a.contains("--next-"))
1085                .ok_or_else(|| anyhow::anyhow!("no signing key found for {controller_did}"))?;
1086
1087            // Get device public key and encode
1088            let device_key_result = extract_public_key_bytes(
1089                ctx.key_storage.as_ref(),
1090                &key_alias,
1091                passphrase_provider.as_ref(),
1092            );
1093
1094            if let Ok((pk_bytes, curve)) = device_key_result {
1095                let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
1096                    .map_err(|e| anyhow::anyhow!("Invalid device key: {e}"))?;
1097                let public_key =
1098                    auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
1099                        .map_err(|e| anyhow::anyhow!("Failed to encode SSH key: {e}"))?;
1100
1101                out.println("  Uploading SSH signing key...");
1102                #[allow(clippy::disallowed_methods)]
1103                let now = chrono::Utc::now();
1104                #[allow(clippy::disallowed_methods)]
1105                let hostname = gethostname::gethostname();
1106                let hostname_str = hostname.to_string_lossy().to_string();
1107                let result = rt.block_on(
1108                    auths_sdk::workflows::platform::upload_github_ssh_signing_key(
1109                        &ssh_uploader,
1110                        &access_token,
1111                        &public_key,
1112                        &key_alias,
1113                        &hostname_str,
1114                        ctx.identity_storage.as_ref(),
1115                        now,
1116                    ),
1117                );
1118
1119                match result {
1120                    Ok(()) => {
1121                        out.print_success("SSH signing key uploaded to GitHub");
1122                        out.println("  View at: https://github.com/settings/keys");
1123                    }
1124                    Err(e) => {
1125                        out.print_warn(&format!("SSH key upload failed: {e}"));
1126                        out.println(
1127                            "  You can upload manually at https://github.com/settings/keys",
1128                        );
1129                    }
1130                }
1131            } else {
1132                out.print_warn("Could not extract device public key for SSH upload");
1133            }
1134
1135            out.print_success("Scope update complete");
1136            Ok(())
1137        }
1138
1139        IdSubcommand::Agent(agent_cmd) => {
1140            super::agent::handle_agent(agent_cmd, repo_path, env_config, passphrase_provider)
1141        }
1142    }
1143}
1144
1145#[cfg(all(test, feature = "lan-pairing"))]
1146mod tests {
1147    use super::*;
1148    use clap::Parser;
1149
1150    #[derive(Parser)]
1151    struct Harness {
1152        #[command(subcommand)]
1153        sub: IdSubcommand,
1154    }
1155
1156    #[test]
1157    fn rotate_from_device_parses() {
1158        let h = Harness::parse_from(["id", "rotate", "--from-device"]);
1159        match h.sub {
1160            IdSubcommand::Rotate { from_device, .. } => assert!(from_device),
1161            other => panic!("expected Rotate, got {other:?}"),
1162        }
1163    }
1164
1165    #[test]
1166    fn rotate_from_device_conflicts_with_local_authoring_flags() {
1167        // The device authors the rotation — naming a local key is a
1168        // contradiction and must be refused at parse time.
1169        assert!(
1170            Harness::try_parse_from(["id", "rotate", "--from-device", "--alias", "main"]).is_err()
1171        );
1172        assert!(Harness::try_parse_from(["id", "rotate", "--from-device", "--dry-run"]).is_err());
1173    }
1174
1175    #[test]
1176    fn rotate_session_toggles_require_from_device() {
1177        assert!(Harness::try_parse_from(["id", "rotate", "--no-qr"]).is_err());
1178        assert!(Harness::try_parse_from(["id", "rotate", "--print-uri"]).is_err());
1179        assert!(
1180            Harness::try_parse_from(["id", "rotate", "--from-device", "--no-qr", "--print-uri"])
1181                .is_ok()
1182        );
1183    }
1184}