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#[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#[derive(Debug, Clone, Copy, ValueEnum, Default)]
46pub enum LayoutPreset {
47 #[default]
49 Default,
50 Radicle,
52 Gitoxide,
54}
55
56impl LayoutPreset {
57 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 #[command(name = "create")]
90 Create {
91 #[arg(
93 long,
94 value_parser,
95 help = "Path to JSON file with arbitrary identity metadata."
96 )]
97 metadata_file: PathBuf,
98
99 #[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 #[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,
120
121 Rotate {
123 #[arg(long, help = "Alias of the identity key to rotate.")]
125 alias: Option<String>,
126
127 #[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 #[arg(long, help = "Alias to store the NEWLY generated private key under.")]
137 next_key_alias: Option<String>,
138
139 #[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 #[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 #[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 #[arg(long)]
164 dry_run: bool,
165 },
166
167 ExportBundle {
173 #[arg(long, help = "Key alias to include in bundle")]
175 alias: String,
176
177 #[arg(long = "output", short = 'o')]
179 output_file: PathBuf,
180
181 #[arg(
183 long,
184 help = "Maximum bundle age in seconds before it is considered stale"
185 )]
186 max_age_secs: u64,
187 },
188
189 Register {
191 #[arg(long, default_value = "https://auths-registry.fly.dev")]
193 registry: String,
194 },
195
196 Claim(super::claim::ClaimCommand),
198
199 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#[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 let repo_path = layout::resolve_repo_path(repo_opt)?;
264
265 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 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 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 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 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 println!(" Initializing using did:keri method (default)...");
377
378 let _metadata_value = metadata_value; 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 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 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
594 let attestations = attestation_storage
595 .load_all_attestations()
596 .unwrap_or_default();
597
598 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 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 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 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, ®istry)
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}