Skip to main content

auths_cli/commands/id/
identity.rs

1use anyhow::{Context, Result, anyhow};
2use chrono::Utc;
3use clap::{ArgAction, Parser, Subcommand};
4use ring::signature::KeyPair;
5use serde::Serialize;
6use serde_json;
7use std::fs;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use auths_core::{
12    config::EnvironmentConfig,
13    signing::PassphraseProvider,
14    storage::keychain::{KeyAlias, get_platform_keychain},
15};
16use auths_verifier::{IdentityBundle, Prefix};
17use clap::ValueEnum;
18
19use crate::commands::registry_overrides::RegistryOverrides;
20use crate::ux::format::{JsonResponse, is_json_mode};
21
22/// JSON response for id show command.
23#[derive(Debug, Serialize)]
24struct IdShowResponse {
25    controller_did: String,
26    storage_id: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    metadata: Option<serde_json::Value>,
29}
30
31use auths_id::{
32    identity::initialize::initialize_registry_identity,
33    ports::registry::RegistryBackend,
34    storage::{
35        attestation::AttestationSource,
36        identity::IdentityStorage,
37        layout::{self, BlobName, GitRef, StorageLayoutConfig},
38    },
39};
40use auths_storage::git::{
41    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
42};
43
44/// Storage layout presets for different ecosystems.
45#[derive(Debug, Clone, Copy, ValueEnum, Default)]
46pub enum LayoutPreset {
47    /// RIP-X layout (refs/rad/id, refs/keys)
48    #[default]
49    Default,
50    /// Alias for default — explicitly Radicle-compatible
51    Radicle,
52    /// Gitoxide-compatible layout (refs/auths/id, refs/auths/devices)
53    Gitoxide,
54}
55
56impl LayoutPreset {
57    /// Convert the preset to a StorageLayoutConfig.
58    pub fn to_config(self) -> StorageLayoutConfig {
59        match self {
60            LayoutPreset::Default | LayoutPreset::Radicle => StorageLayoutConfig {
61                identity_ref: GitRef::new("refs/rad/id"),
62                device_attestation_prefix: GitRef::new("refs/keys"),
63                attestation_blob_name: BlobName::new(layout::ATTESTATION_JSON),
64                identity_blob_name: BlobName::new(layout::IDENTITY_JSON),
65            },
66            LayoutPreset::Gitoxide => StorageLayoutConfig {
67                identity_ref: GitRef::new("refs/auths/id"),
68                device_attestation_prefix: GitRef::new("refs/auths/devices"),
69                attestation_blob_name: BlobName::new(layout::ATTESTATION_JSON),
70                identity_blob_name: BlobName::new(layout::IDENTITY_JSON),
71            },
72        }
73    }
74}
75
76#[derive(Parser, Debug, Clone)]
77#[command(about = "Manage identities stored in Git repositories.")]
78pub struct IdCommand {
79    #[clap(subcommand)]
80    pub subcommand: IdSubcommand,
81
82    #[command(flatten)]
83    pub overrides: RegistryOverrides,
84}
85
86#[derive(Subcommand, Debug, Clone)]
87pub enum IdSubcommand {
88    /// Create a new cryptographic identity with secure key storage.
89    #[command(name = "create")]
90    Create {
91        /// Path to JSON file with arbitrary identity metadata.
92        #[arg(
93            long,
94            value_parser,
95            help = "Path to JSON file with arbitrary identity metadata."
96        )]
97        metadata_file: PathBuf,
98
99        /// Alias for storing the NEWLY generated private key in the secure keychain.
100        #[arg(
101            long,
102            help = "Alias for storing the NEWLY generated private key in the secure keychain."
103        )]
104        local_key_alias: String,
105
106        /// Storage layout preset for ecosystem compatibility.
107        /// Use 'radicle' for Radicle repositories, 'gitoxide' for gitoxide,
108        /// or 'default' for standard Auths layout.
109        #[arg(
110            long,
111            value_enum,
112            default_value = "default",
113            help = "Storage layout preset (default, radicle, gitoxide)"
114        )]
115        preset: LayoutPreset,
116    },
117
118    /// Show primary identity details (identity ID, metadata) from the Git repository.
119    Show,
120
121    /// Rotate identity keys. Stores the new key under a new alias.
122    Rotate {
123        /// Alias of the identity key to rotate. If provided alone, next-key-alias defaults to <alias>-rotated-<timestamp>.
124        #[arg(long, help = "Alias of the identity key to rotate.")]
125        alias: Option<String>,
126
127        /// Alias of the CURRENT private key controlling the identity (alternative to --alias).
128        #[arg(
129            long,
130            help = "Alias of the CURRENT private key controlling the identity.",
131            conflicts_with = "alias"
132        )]
133        current_key_alias: Option<String>,
134
135        /// Alias to store the NEWLY generated private key under.
136        #[arg(long, help = "Alias to store the NEWLY generated private key under.")]
137        next_key_alias: Option<String>,
138
139        /// Verification server prefix to add (e.g., B...). Can be specified multiple times.
140        #[arg(
141            long,
142            action = ArgAction::Append,
143            help = "Verification server prefix to add (e.g., B...). Can be specified multiple times."
144        )]
145        add_witness: Vec<String>,
146
147        /// Verification server prefix to remove (e.g., B...). Can be specified multiple times.
148        #[arg(
149            long,
150            action = ArgAction::Append,
151            help = "Verification server prefix to remove (e.g., B...). Can be specified multiple times."
152        )]
153        remove_witness: Vec<String>,
154
155        /// New simple verification threshold count (e.g., 1 for 1-of-N). If omitted, the existing simple count is reused if possible.
156        #[arg(
157            long,
158            help = "New simple verification threshold count (e.g., 1 for 1-of-N)."
159        )]
160        witness_threshold: Option<u64>,
161
162        /// Preview actions without making changes.
163        #[arg(long)]
164        dry_run: bool,
165    },
166
167    /// Export an identity bundle for stateless CI/CD verification.
168    ///
169    /// Creates a portable JSON bundle containing the identity ID, public key,
170    /// and authorization chain. This bundle can be used in CI pipelines to verify
171    /// commit signatures without requiring access to the identity repository.
172    ExportBundle {
173        /// Key alias to include in the bundle.
174        #[arg(long, help = "Key alias to include in bundle")]
175        alias: String,
176
177        /// Output file path for the JSON bundle.
178        #[arg(long = "output", short = 'o')]
179        output_file: PathBuf,
180
181        /// TTL in seconds. The bundle will fail verification after this many seconds.
182        #[arg(
183            long,
184            help = "Maximum bundle age in seconds before it is considered stale"
185        )]
186        max_age_secs: u64,
187    },
188
189    /// Publish this identity to a public registry for discovery.
190    Register {
191        /// Registry URL to publish to.
192        #[arg(long, default_value = "https://auths-registry.fly.dev")]
193        registry: String,
194    },
195
196    /// Add a platform claim to an already-registered identity.
197    Claim(super::claim::ClaimCommand),
198
199    /// Import existing GPG or SSH keys into Auths.
200    Migrate(super::migrate::MigrateCommand),
201}
202
203fn display_dry_run_rotate(
204    repo_path: &std::path::Path,
205    current_alias: Option<&str>,
206    next_alias: Option<&str>,
207) -> Result<()> {
208    if is_json_mode() {
209        JsonResponse::success(
210            "id rotate",
211            &serde_json::json!({
212                "dry_run": true,
213                "repo_path": repo_path.display().to_string(),
214                "current_key_alias": current_alias,
215                "next_key_alias": next_alias,
216                "actions": [
217                    "Generate new Ed25519 keypair",
218                    "Create rotation event in KERI event log",
219                    "Update key alias mappings",
220                    "All devices will need to re-authorize"
221                ]
222            }),
223        )
224        .print()
225        .map_err(|e| anyhow!("{e}"))
226    } else {
227        let out = crate::ux::format::Output::new();
228        out.print_info("Dry run mode — no changes will be made");
229        out.newline();
230        out.println(&format!("   Repository: {:?}", repo_path));
231        if let Some(alias) = current_alias {
232            out.println(&format!("   Current Key Alias: {}", alias));
233        }
234        if let Some(alias) = next_alias {
235            out.println(&format!("   New Key Alias: {}", alias));
236        }
237        out.newline();
238        out.println("Would perform the following actions:");
239        out.println("  1. Generate new Ed25519 keypair");
240        out.println("  2. Create rotation event in KERI event log");
241        out.println("  3. Update key alias mappings");
242        out.println("  4. All devices will need to re-authorize");
243        Ok(())
244    }
245}
246
247// --- Command Handler ---
248
249/// Handles the `id` subcommand, accepting the specific subcommand details
250/// and the global configuration overrides passed from `main`.
251#[allow(clippy::too_many_arguments)]
252pub fn handle_id(
253    cmd: IdCommand,
254    repo_opt: Option<PathBuf>,
255    identity_ref_override: Option<String>,
256    identity_blob_name_override: Option<String>,
257    attestation_prefix_override: Option<String>,
258    attestation_blob_name_override: Option<String>,
259    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
260    env_config: &EnvironmentConfig,
261) -> Result<()> {
262    // Determine repo path using the passed Option
263    let repo_path = layout::resolve_repo_path(repo_opt)?;
264
265    // Build StorageLayoutConfig from defaults and function arguments
266    // Used by non-Init subcommands
267    let mut config = StorageLayoutConfig::default();
268    if let Some(ref identity_ref) = identity_ref_override {
269        config.identity_ref = identity_ref.clone().into();
270    }
271    if let Some(ref blob_name) = identity_blob_name_override {
272        config.identity_blob_name = blob_name.clone().into();
273    }
274    if let Some(ref prefix) = attestation_prefix_override {
275        config.device_attestation_prefix = prefix.clone().into();
276    }
277    if let Some(ref blob_name) = attestation_blob_name_override {
278        config.attestation_blob_name = blob_name.clone().into();
279    }
280
281    match cmd.subcommand {
282        IdSubcommand::Create {
283            metadata_file,
284            local_key_alias,
285            preset,
286        } => {
287            // Apply preset first, then override with explicit flags
288            let mut config = preset.to_config();
289            if let Some(ref identity_ref) = identity_ref_override {
290                config.identity_ref = identity_ref.clone().into();
291            }
292            if let Some(ref blob_name) = identity_blob_name_override {
293                config.identity_blob_name = blob_name.clone().into();
294            }
295            if let Some(ref prefix) = attestation_prefix_override {
296                config.device_attestation_prefix = prefix.clone().into();
297            }
298            if let Some(ref blob_name) = attestation_blob_name_override {
299                config.attestation_blob_name = blob_name.clone().into();
300            }
301            let metadata_file_path = metadata_file;
302
303            // --- Common Setup: Repo Init Check & Metadata Loading ---
304            println!("šŸ” Creating new cryptographic identity...");
305            println!("   Repository path:   {:?}", repo_path);
306            println!("   Local Key Alias:   {}", local_key_alias);
307            println!("   Metadata File:     {:?}", metadata_file_path);
308            println!("   Using Identity Ref: '{}'", config.identity_ref);
309            println!("   Using Identity Blob: '{}'", config.identity_blob_name);
310
311            // Ensure Git Repository Exists and is Initialized
312            use crate::factories::storage::{ensure_git_repo, open_git_repo};
313
314            let identity_storage_check = RegistryIdentityStorage::new(repo_path.clone());
315            if repo_path.exists() {
316                match open_git_repo(&repo_path) {
317                    Ok(_repo) => {
318                        println!("   Git repository found at {:?}.", repo_path);
319                        if identity_storage_check.load_identity().is_ok() {
320                            eprintln!(
321                                "āš ļø Primary identity already initialized and loadable at {:?} using ref '{}'. Aborting.",
322                                repo_path,
323                                identity_storage_check.get_identity_ref()?
324                            );
325                            return Err(anyhow!("Identity already exists in this repository"));
326                        } else {
327                            println!(
328                                "   Repository exists, but primary identity ref/data is missing or invalid. Proceeding..."
329                            );
330                        }
331                    }
332                    Err(_) => {
333                        println!(
334                            "   Path {:?} exists but is not a Git repository. Initializing...",
335                            repo_path
336                        );
337                        ensure_git_repo(&repo_path).map_err(|e| {
338                            anyhow!(
339                                "Path {:?} exists but failed to initialize as Git repository: {}",
340                                repo_path,
341                                e
342                            )
343                        })?;
344                        println!("   Successfully initialized Git repository.");
345                    }
346                }
347            } else {
348                println!("   Initializing Git repository at {:?}...", repo_path);
349                ensure_git_repo(&repo_path).map_err(|e| {
350                    anyhow!(
351                        "Failed to initialize Git repository at {:?}: {}",
352                        repo_path,
353                        e
354                    )
355                })?;
356                println!("   Successfully initialized Git repository.");
357            }
358
359            // Load and Parse Metadata File (common logic)
360            if !metadata_file_path.exists() {
361                return Err(anyhow!("Metadata file not found: {:?}", metadata_file_path));
362            }
363            let metadata_content = fs::read_to_string(&metadata_file_path).with_context(|| {
364                format!("Failed to read metadata file: {:?}", metadata_file_path)
365            })?;
366            let metadata_value: serde_json::Value = serde_json::from_str(&metadata_content)
367                .with_context(|| {
368                    format!(
369                        "Failed to parse JSON from metadata file: {:?}",
370                        metadata_file_path
371                    )
372                })?;
373            println!("   Metadata loaded successfully from file.");
374
375            // --- Always Use KERI Initialization Logic ---
376            println!("   Initializing using did:keri method (default)...");
377
378            // Call the initialize_registry_identity function from auths_id
379            let _metadata_value = metadata_value; // metadata stored separately if needed
380            let backend: Arc<dyn RegistryBackend + Send + Sync> =
381                Arc::new(GitRegistryBackend::from_config_unchecked(
382                    RegistryConfig::single_tenant(&repo_path),
383                ));
384            let local_key_alias = KeyAlias::new_unchecked(local_key_alias);
385            match initialize_registry_identity(
386                backend,
387                &local_key_alias,
388                passphrase_provider.as_ref(),
389                &get_platform_keychain()?,
390                None,
391            ) {
392                Ok((controller_did_keri, alias)) => {
393                    println!("\nāœ… Identity (did:keri) initialized successfully!");
394                    println!(
395                        "   Repository:         {:?}",
396                        repo_path
397                            .canonicalize()
398                            .unwrap_or_else(|_| repo_path.clone())
399                    );
400                    println!("   Controller DID:     {}", controller_did_keri);
401                    println!(
402                        "   Local Key Alias:  {} (Use this for local signing/rotations)",
403                        alias
404                    );
405                    let did_prefix = controller_did_keri
406                        .as_str()
407                        .strip_prefix("did:keri:")
408                        .unwrap_or("");
409                    if !did_prefix.is_empty() {
410                        println!(
411                            "   KEL Ref Used:       '{}'",
412                            layout::keri_kel_ref(&Prefix::new_unchecked(did_prefix.to_string()))
413                        );
414                    }
415                    println!("   Identity Ref Used:  '{}'", config.identity_ref);
416                    println!(
417                        "   Identity Blob Used: '{}'",
418                        layout::identity_blob_name(&config)
419                    );
420                    println!("   Metadata stored from: {:?}", metadata_file_path);
421                    println!("šŸ”‘ Keep your passphrase secure!");
422                    Ok(())
423                }
424                Err(e) => Err(e).context("Failed to initialize KERI identity"),
425            }
426        }
427
428        IdSubcommand::Show => {
429            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
430
431            let identity = identity_storage
432                .load_identity()
433                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
434
435            if is_json_mode() {
436                let response = JsonResponse::success(
437                    "id show",
438                    IdShowResponse {
439                        controller_did: identity.controller_did.to_string(),
440                        storage_id: identity.storage_id.clone(),
441                        metadata: identity.metadata.clone(),
442                    },
443                );
444                response.print()?;
445            } else {
446                println!("Showing identity details...");
447                println!("   Using Repository:    {:?}", repo_path);
448                println!("   Using Identity Ref:  '{}'", config.identity_ref);
449                println!("   Using Identity Blob: '{}'", config.identity_blob_name);
450
451                println!("Controller DID: {}", identity.controller_did);
452                println!("Storage ID (RID): {}", identity.storage_id);
453                println!("Metadata (raw JSON, interpretation depends on convention):");
454                if let Some(meta) = &identity.metadata {
455                    println!(
456                        "{}",
457                        serde_json::to_string_pretty(meta)
458                            .unwrap_or_else(|_| "  <Error serializing metadata>".to_string())
459                    );
460                } else {
461                    println!("  (None)");
462                }
463
464                println!("\nUse 'auths device list' to see authorized devices");
465            }
466            Ok(())
467        }
468
469        IdSubcommand::Rotate {
470            alias,
471            current_key_alias,
472            next_key_alias,
473            add_witness,
474            remove_witness,
475            witness_threshold,
476            dry_run,
477        } => {
478            let identity_key_alias = alias.or(current_key_alias);
479
480            if dry_run {
481                return display_dry_run_rotate(
482                    &repo_path,
483                    identity_key_alias.as_deref(),
484                    next_key_alias.as_deref(),
485                );
486            }
487
488            println!("šŸ”„ Rotating KERI identity keys...");
489            println!("   Using Repository: {:?}", repo_path);
490            if let Some(ref a) = identity_key_alias {
491                println!("   Current Key Alias: {}", a);
492            }
493            if let Some(ref a) = next_key_alias {
494                println!("   New Key Alias: {}", a);
495            }
496            if !add_witness.is_empty() {
497                println!("   Witnesses to Add: {:?}", add_witness);
498            }
499            if !remove_witness.is_empty() {
500                println!("   Witnesses to Remove: {:?}", remove_witness);
501            }
502            if let Some(thresh) = witness_threshold {
503                println!("   New Witness Threshold: {}", thresh);
504            }
505
506            let rotation_config = auths_sdk::types::IdentityRotationConfig {
507                repo_path: repo_path.clone(),
508                identity_key_alias: identity_key_alias.map(KeyAlias::new_unchecked),
509                next_key_alias: next_key_alias.map(KeyAlias::new_unchecked),
510            };
511            let rotation_ctx = {
512                use auths_core::storage::keychain::get_platform_keychain_with_config;
513                use auths_id::attestation::export::AttestationSink;
514                use auths_id::storage::attestation::AttestationSource;
515                use auths_id::storage::identity::IdentityStorage;
516                use auths_sdk::context::AuthsContext;
517                use auths_storage::git::{
518                    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig,
519                    RegistryIdentityStorage,
520                };
521                let backend: Arc<dyn auths_id::ports::registry::RegistryBackend + Send + Sync> =
522                    Arc::new(GitRegistryBackend::from_config_unchecked(
523                        RegistryConfig::single_tenant(&repo_path),
524                    ));
525                let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
526                    Arc::new(RegistryIdentityStorage::new(repo_path.clone()));
527                let attestation_store = Arc::new(RegistryAttestationStorage::new(&repo_path));
528                let attestation_sink: Arc<dyn AttestationSink + Send + Sync> =
529                    Arc::clone(&attestation_store) as Arc<dyn AttestationSink + Send + Sync>;
530                let attestation_source: Arc<dyn AttestationSource + Send + Sync> =
531                    attestation_store as Arc<dyn AttestationSource + Send + Sync>;
532                let key_storage: Arc<dyn auths_core::storage::keychain::KeyStorage + Send + Sync> =
533                    Arc::from(
534                        get_platform_keychain_with_config(env_config)
535                            .context("Failed to access keychain")?,
536                    );
537                AuthsContext::builder()
538                    .registry(backend)
539                    .key_storage(key_storage)
540                    .clock(Arc::new(auths_core::ports::clock::SystemClock))
541                    .identity_storage(identity_storage)
542                    .attestation_sink(attestation_sink)
543                    .attestation_source(attestation_source)
544                    .passphrase_provider(Arc::clone(&passphrase_provider))
545                    .build()
546            };
547            let result = auths_sdk::workflows::rotation::rotate_identity(
548                rotation_config,
549                &rotation_ctx,
550                &auths_core::ports::clock::SystemClock,
551            )
552            .with_context(|| "Failed to rotate KERI identity keys")?;
553
554            println!("\nāœ… KERI identity keys rotated successfully!");
555            println!("   Identity DID: {}", result.controller_did);
556            println!(
557                "   Old key fingerprint: {}...",
558                result.previous_key_fingerprint
559            );
560            println!("   New key fingerprint: {}...", result.new_key_fingerprint);
561            println!(
562                "āš ļø The previous key alias is no longer the active signing key for this identity."
563            );
564
565            log::info!(
566                "Key rotation completed: old_key={}, new_key={}",
567                result.previous_key_fingerprint,
568                result.new_key_fingerprint,
569            );
570
571            Ok(())
572        }
573
574        IdSubcommand::ExportBundle {
575            alias,
576            output_file,
577            max_age_secs,
578        } => {
579            println!("šŸ“¦ Exporting identity bundle...");
580            println!("   Using Repository:  {:?}", repo_path);
581            println!("   Key Alias:         {}", alias);
582            println!("   Output File:       {:?}", output_file);
583
584            // Load identity
585            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
586            let identity = identity_storage
587                .load_identity()
588                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
589
590            println!("   Identity DID:      {}", identity.controller_did);
591
592            // Load attestations
593            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
594            let attestations = attestation_storage
595                .load_all_attestations()
596                .unwrap_or_default();
597
598            // Load the public key from keychain
599            let keychain = get_platform_keychain()?;
600            let (_, _role, encrypted_key) = keychain
601                .load_key(&KeyAlias::new_unchecked(&alias))
602                .with_context(|| format!("Key '{}' not found in keychain", alias))?;
603
604            // Decrypt to get public key
605            let pass = passphrase_provider
606                .get_passphrase(&format!("Enter passphrase for key '{}':", alias))?;
607            let pkcs8_bytes = auths_core::crypto::signer::decrypt_keypair(&encrypted_key, &pass)
608                .context("Failed to decrypt key")?;
609            let keypair = auths_id::identity::helpers::load_keypair_from_der_or_seed(&pkcs8_bytes)?;
610            let public_key_hex = hex::encode(keypair.public_key().as_ref());
611
612            // Create the bundle
613            let bundle = IdentityBundle {
614                identity_did: identity.controller_did.to_string(),
615                public_key_hex,
616                attestation_chain: attestations,
617                bundle_timestamp: Utc::now(),
618                max_valid_for_secs: max_age_secs,
619            };
620
621            // Write to output file
622            let json = serde_json::to_string_pretty(&bundle)
623                .context("Failed to serialize identity bundle")?;
624            fs::write(&output_file, &json)
625                .with_context(|| format!("Failed to write bundle to {:?}", output_file))?;
626
627            println!("\nāœ… Identity bundle exported successfully!");
628            println!("   Output:            {:?}", output_file);
629            println!("   Attestations:      {}", bundle.attestation_chain.len());
630            println!("\nUsage in CI:");
631            println!(
632                "   auths verify-commit --identity-bundle {:?} HEAD",
633                output_file
634            );
635
636            Ok(())
637        }
638
639        IdSubcommand::Register { registry } => {
640            super::register::handle_register(&repo_path, &registry)
641        }
642
643        IdSubcommand::Claim(claim_cmd) => {
644            super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, env_config)
645        }
646
647        IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd),
648    }
649}