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
163    /// Export an identity bundle for stateless CI/CD verification.
164    ///
165    /// Creates a portable JSON bundle containing the identity ID, public key,
166    /// and authorization chain. This bundle can be used in CI pipelines to verify
167    /// commit signatures without requiring access to the identity repository.
168    ExportBundle {
169        /// Key alias to include in the bundle.
170        #[arg(long, help = "Key alias to include in bundle")]
171        alias: String,
172
173        /// Output file path for the JSON bundle.
174        #[arg(long, short, help = "Output file path")]
175        output: PathBuf,
176
177        /// TTL in seconds. The bundle will fail verification after this many seconds.
178        #[arg(
179            long,
180            help = "Maximum bundle age in seconds before it is considered stale"
181        )]
182        max_age_secs: u64,
183    },
184
185    /// Publish this identity to a public registry for discovery.
186    Register {
187        /// Registry URL to publish to.
188        #[arg(long, default_value = "https://auths-registry.fly.dev")]
189        registry: String,
190    },
191
192    /// Add a platform claim to an already-registered identity.
193    Claim(super::claim::ClaimCommand),
194
195    /// Import existing GPG or SSH keys into Auths.
196    Migrate(super::migrate::MigrateCommand),
197}
198
199// --- Command Handler ---
200
201/// Handles the `id` subcommand, accepting the specific subcommand details
202/// and the global configuration overrides passed from `main`.
203#[allow(clippy::too_many_arguments)]
204pub fn handle_id(
205    cmd: IdCommand,
206    repo_opt: Option<PathBuf>,
207    identity_ref_override: Option<String>,
208    identity_blob_name_override: Option<String>,
209    attestation_prefix_override: Option<String>,
210    attestation_blob_name_override: Option<String>,
211    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
212    http_client: &reqwest::Client,
213    env_config: &EnvironmentConfig,
214) -> Result<()> {
215    // Determine repo path using the passed Option
216    let repo_path = layout::resolve_repo_path(repo_opt)?;
217
218    // Build StorageLayoutConfig from defaults and function arguments
219    // Used by non-Init subcommands
220    let mut config = StorageLayoutConfig::default();
221    if let Some(ref identity_ref) = identity_ref_override {
222        config.identity_ref = identity_ref.clone().into();
223    }
224    if let Some(ref blob_name) = identity_blob_name_override {
225        config.identity_blob_name = blob_name.clone().into();
226    }
227    if let Some(ref prefix) = attestation_prefix_override {
228        config.device_attestation_prefix = prefix.clone().into();
229    }
230    if let Some(ref blob_name) = attestation_blob_name_override {
231        config.attestation_blob_name = blob_name.clone().into();
232    }
233
234    match cmd.subcommand {
235        IdSubcommand::Create {
236            metadata_file,
237            local_key_alias,
238            preset,
239        } => {
240            // Apply preset first, then override with explicit flags
241            let mut config = preset.to_config();
242            if let Some(ref identity_ref) = identity_ref_override {
243                config.identity_ref = identity_ref.clone().into();
244            }
245            if let Some(ref blob_name) = identity_blob_name_override {
246                config.identity_blob_name = blob_name.clone().into();
247            }
248            if let Some(ref prefix) = attestation_prefix_override {
249                config.device_attestation_prefix = prefix.clone().into();
250            }
251            if let Some(ref blob_name) = attestation_blob_name_override {
252                config.attestation_blob_name = blob_name.clone().into();
253            }
254            let metadata_file_path = metadata_file;
255
256            // --- Common Setup: Repo Init Check & Metadata Loading ---
257            println!("šŸ” Creating new cryptographic identity...");
258            println!("   Repository path:   {:?}", repo_path);
259            println!("   Local Key Alias:   {}", local_key_alias);
260            println!("   Metadata File:     {:?}", metadata_file_path);
261            println!("   Using Identity Ref: '{}'", config.identity_ref);
262            println!("   Using Identity Blob: '{}'", config.identity_blob_name);
263
264            // Ensure Git Repository Exists and is Initialized
265            use crate::factories::storage::{ensure_git_repo, open_git_repo};
266
267            let identity_storage_check = RegistryIdentityStorage::new(repo_path.clone());
268            if repo_path.exists() {
269                match open_git_repo(&repo_path) {
270                    Ok(_repo) => {
271                        println!("   Git repository found at {:?}.", repo_path);
272                        if identity_storage_check.load_identity().is_ok() {
273                            eprintln!(
274                                "āš ļø Primary identity already initialized and loadable at {:?} using ref '{}'. Aborting.",
275                                repo_path,
276                                identity_storage_check.get_identity_ref()?
277                            );
278                            return Err(anyhow!("Identity already exists in this repository"));
279                        } else {
280                            println!(
281                                "   Repository exists, but primary identity ref/data is missing or invalid. Proceeding..."
282                            );
283                        }
284                    }
285                    Err(_) => {
286                        println!(
287                            "   Path {:?} exists but is not a Git repository. Initializing...",
288                            repo_path
289                        );
290                        ensure_git_repo(&repo_path).map_err(|e| {
291                            anyhow!(
292                                "Path {:?} exists but failed to initialize as Git repository: {}",
293                                repo_path,
294                                e
295                            )
296                        })?;
297                        println!("   Successfully initialized Git repository.");
298                    }
299                }
300            } else {
301                println!("   Initializing Git repository at {:?}...", repo_path);
302                ensure_git_repo(&repo_path).map_err(|e| {
303                    anyhow!(
304                        "Failed to initialize Git repository at {:?}: {}",
305                        repo_path,
306                        e
307                    )
308                })?;
309                println!("   Successfully initialized Git repository.");
310            }
311
312            // Load and Parse Metadata File (common logic)
313            if !metadata_file_path.exists() {
314                return Err(anyhow!("Metadata file not found: {:?}", metadata_file_path));
315            }
316            let metadata_content = fs::read_to_string(&metadata_file_path).with_context(|| {
317                format!("Failed to read metadata file: {:?}", metadata_file_path)
318            })?;
319            let metadata_value: serde_json::Value = serde_json::from_str(&metadata_content)
320                .with_context(|| {
321                    format!(
322                        "Failed to parse JSON from metadata file: {:?}",
323                        metadata_file_path
324                    )
325                })?;
326            println!("   Metadata loaded successfully from file.");
327
328            // --- Always Use KERI Initialization Logic ---
329            println!("   Initializing using did:keri method (default)...");
330
331            // Call the initialize_registry_identity function from auths_id
332            let _metadata_value = metadata_value; // metadata stored separately if needed
333            let backend: Arc<dyn RegistryBackend + Send + Sync> =
334                Arc::new(GitRegistryBackend::from_config_unchecked(
335                    RegistryConfig::single_tenant(&repo_path),
336                ));
337            let local_key_alias = KeyAlias::new_unchecked(local_key_alias);
338            match initialize_registry_identity(
339                backend,
340                &local_key_alias,
341                passphrase_provider.as_ref(),
342                &get_platform_keychain()?,
343                None,
344            ) {
345                Ok((controller_did_keri, alias)) => {
346                    println!("\nāœ… Identity (did:keri) initialized successfully!");
347                    println!(
348                        "   Repository:         {:?}",
349                        repo_path
350                            .canonicalize()
351                            .unwrap_or_else(|_| repo_path.clone())
352                    );
353                    println!("   Controller DID:     {}", controller_did_keri);
354                    println!(
355                        "   Local Key Alias:  {} (Use this for local signing/rotations)",
356                        alias
357                    );
358                    let did_prefix = controller_did_keri
359                        .as_str()
360                        .strip_prefix("did:keri:")
361                        .unwrap_or("");
362                    if !did_prefix.is_empty() {
363                        println!(
364                            "   KEL Ref Used:       '{}'",
365                            layout::keri_kel_ref(&Prefix::new_unchecked(did_prefix.to_string()))
366                        );
367                    }
368                    println!("   Identity Ref Used:  '{}'", config.identity_ref);
369                    println!(
370                        "   Identity Blob Used: '{}'",
371                        layout::identity_blob_name(&config)
372                    );
373                    println!("   Metadata stored from: {:?}", metadata_file_path);
374                    println!("šŸ”‘ Keep your passphrase secure!");
375                    Ok(())
376                }
377                Err(e) => Err(e).context("Failed to initialize KERI identity"),
378            }
379        }
380
381        IdSubcommand::Show => {
382            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
383
384            let identity = identity_storage
385                .load_identity()
386                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
387
388            if is_json_mode() {
389                let response = JsonResponse::success(
390                    "id show",
391                    IdShowResponse {
392                        controller_did: identity.controller_did.to_string(),
393                        storage_id: identity.storage_id.clone(),
394                        metadata: identity.metadata.clone(),
395                    },
396                );
397                response.print()?;
398            } else {
399                println!("Showing identity details...");
400                println!("   Using Repository:    {:?}", repo_path);
401                println!("   Using Identity Ref:  '{}'", config.identity_ref);
402                println!("   Using Identity Blob: '{}'", config.identity_blob_name);
403
404                println!("Controller DID: {}", identity.controller_did);
405                println!("Storage ID (RID): {}", identity.storage_id);
406                println!("Metadata (raw JSON, interpretation depends on convention):");
407                if let Some(meta) = &identity.metadata {
408                    println!(
409                        "{}",
410                        serde_json::to_string_pretty(meta)
411                            .unwrap_or_else(|_| "  <Error serializing metadata>".to_string())
412                    );
413                } else {
414                    println!("  (None)");
415                }
416
417                println!("\nUse 'auths device list' to see authorized devices");
418            }
419            Ok(())
420        }
421
422        IdSubcommand::Rotate {
423            alias,
424            current_key_alias,
425            next_key_alias,
426            add_witness,
427            remove_witness,
428            witness_threshold,
429        } => {
430            let identity_key_alias = alias.or(current_key_alias);
431
432            println!("šŸ”„ Rotating KERI identity keys...");
433            println!("   Using Repository: {:?}", repo_path);
434            if let Some(ref a) = identity_key_alias {
435                println!("   Current Key Alias: {}", a);
436            }
437            if let Some(ref a) = next_key_alias {
438                println!("   New Key Alias: {}", a);
439            }
440            if !add_witness.is_empty() {
441                println!("   Witnesses to Add: {:?}", add_witness);
442            }
443            if !remove_witness.is_empty() {
444                println!("   Witnesses to Remove: {:?}", remove_witness);
445            }
446            if let Some(thresh) = witness_threshold {
447                println!("   New Witness Threshold: {}", thresh);
448            }
449
450            let rotation_config = auths_sdk::types::RotationConfig {
451                repo_path: repo_path.clone(),
452                identity_key_alias: identity_key_alias.map(KeyAlias::new_unchecked),
453                next_key_alias: next_key_alias.map(KeyAlias::new_unchecked),
454            };
455            let rotation_ctx = {
456                use auths_core::storage::keychain::get_platform_keychain_with_config;
457                use auths_id::attestation::export::AttestationSink;
458                use auths_id::storage::attestation::AttestationSource;
459                use auths_id::storage::identity::IdentityStorage;
460                use auths_sdk::context::AuthsContext;
461                use auths_storage::git::{
462                    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig,
463                    RegistryIdentityStorage,
464                };
465                let backend: Arc<dyn auths_id::ports::registry::RegistryBackend + Send + Sync> =
466                    Arc::new(GitRegistryBackend::from_config_unchecked(
467                        RegistryConfig::single_tenant(&repo_path),
468                    ));
469                let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
470                    Arc::new(RegistryIdentityStorage::new(repo_path.clone()));
471                let attestation_store = Arc::new(RegistryAttestationStorage::new(&repo_path));
472                let attestation_sink: Arc<dyn AttestationSink + Send + Sync> =
473                    Arc::clone(&attestation_store) as Arc<dyn AttestationSink + Send + Sync>;
474                let attestation_source: Arc<dyn AttestationSource + Send + Sync> =
475                    attestation_store as Arc<dyn AttestationSource + Send + Sync>;
476                let key_storage: Arc<dyn auths_core::storage::keychain::KeyStorage + Send + Sync> =
477                    Arc::from(
478                        get_platform_keychain_with_config(env_config)
479                            .context("Failed to access keychain")?,
480                    );
481                AuthsContext::builder()
482                    .registry(backend)
483                    .key_storage(key_storage)
484                    .clock(Arc::new(auths_core::ports::clock::SystemClock))
485                    .identity_storage(identity_storage)
486                    .attestation_sink(attestation_sink)
487                    .attestation_source(attestation_source)
488                    .passphrase_provider(Arc::clone(&passphrase_provider))
489                    .build()?
490            };
491            let result = auths_sdk::workflows::rotation::rotate_identity(
492                rotation_config,
493                &rotation_ctx,
494                &auths_core::ports::clock::SystemClock,
495            )
496            .with_context(|| "Failed to rotate KERI identity keys")?;
497
498            println!("\nāœ… KERI identity keys rotated successfully!");
499            println!("   Identity DID: {}", result.controller_did);
500            println!(
501                "   Old key fingerprint: {}...",
502                result.previous_key_fingerprint
503            );
504            println!("   New key fingerprint: {}...", result.new_key_fingerprint);
505            println!(
506                "āš ļø The previous key alias is no longer the active signing key for this identity."
507            );
508
509            log::info!(
510                "Key rotation completed: old_key={}, new_key={}",
511                result.previous_key_fingerprint,
512                result.new_key_fingerprint,
513            );
514
515            Ok(())
516        }
517
518        IdSubcommand::ExportBundle {
519            alias,
520            output,
521            max_age_secs,
522        } => {
523            println!("šŸ“¦ Exporting identity bundle...");
524            println!("   Using Repository:  {:?}", repo_path);
525            println!("   Key Alias:         {}", alias);
526            println!("   Output File:       {:?}", output);
527
528            // Load identity
529            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
530            let identity = identity_storage
531                .load_identity()
532                .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
533
534            println!("   Identity DID:      {}", identity.controller_did);
535
536            // Load attestations
537            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
538            let attestations = attestation_storage
539                .load_all_attestations()
540                .unwrap_or_default();
541
542            // Load the public key from keychain
543            let keychain = get_platform_keychain()?;
544            let (_, encrypted_key) = keychain
545                .load_key(&KeyAlias::new_unchecked(&alias))
546                .with_context(|| format!("Key '{}' not found in keychain", alias))?;
547
548            // Decrypt to get public key
549            let pass = passphrase_provider
550                .get_passphrase(&format!("Enter passphrase for key '{}':", alias))?;
551            let pkcs8_bytes = auths_core::crypto::signer::decrypt_keypair(&encrypted_key, &pass)
552                .context("Failed to decrypt key")?;
553            let keypair = auths_id::identity::helpers::load_keypair_from_der_or_seed(&pkcs8_bytes)?;
554            let public_key_hex = hex::encode(keypair.public_key().as_ref());
555
556            // Create the bundle
557            let bundle = IdentityBundle {
558                identity_did: identity.controller_did.to_string(),
559                public_key_hex,
560                attestation_chain: attestations,
561                bundle_timestamp: Utc::now(),
562                max_valid_for_secs: max_age_secs,
563            };
564
565            // Write to output file
566            let json = serde_json::to_string_pretty(&bundle)
567                .context("Failed to serialize identity bundle")?;
568            fs::write(&output, &json)
569                .with_context(|| format!("Failed to write bundle to {:?}", output))?;
570
571            println!("\nāœ… Identity bundle exported successfully!");
572            println!("   Output:            {:?}", output);
573            println!("   Attestations:      {}", bundle.attestation_chain.len());
574            println!("\nUsage in CI:");
575            println!("   auths verify-commit --identity-bundle {:?} HEAD", output);
576
577            Ok(())
578        }
579
580        IdSubcommand::Register { registry } => {
581            super::register::handle_register(&repo_path, &registry)
582        }
583
584        IdSubcommand::Claim(claim_cmd) => {
585            super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, http_client)
586        }
587
588        IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd),
589    }
590}