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 super::register::DEFAULT_REGISTRY_URL;
18use crate::commands::registry_overrides::RegistryOverrides;
19use crate::ux::format::{JsonResponse, is_json_mode};
20
21#[derive(Debug, Serialize)]
23struct IdShowResponse {
24 controller_did: String,
25 storage_id: String,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 metadata: Option<serde_json::Value>,
28}
29
30use auths_sdk::storage::{
31 GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
32};
33use auths_sdk::{
34 identity::initialize_registry_identity,
35 ports::{AttestationSource, IdentityStorage, RegistryBackend},
36 storage_layout::{StorageLayoutConfig, layout},
37};
38
39#[derive(Debug, Clone, Copy, ValueEnum, Default)]
41pub enum LayoutPreset {
42 #[default]
44 Default,
45 Radicle,
47 Gitoxide,
49}
50
51impl LayoutPreset {
52 pub fn to_config(self) -> StorageLayoutConfig {
54 match self {
55 LayoutPreset::Default => StorageLayoutConfig::default(),
56 LayoutPreset::Radicle => StorageLayoutConfig::radicle(),
57 LayoutPreset::Gitoxide => StorageLayoutConfig::gitoxide(),
58 }
59 }
60}
61
62#[derive(Parser, Debug, Clone)]
63#[command(
64 about = "Manage your signing identity.",
65 after_help = "Examples:
66 auths id show # Show current identity details
67 auths id list # List identities (same as show)
68 auths id create # Create a new identity
69 auths id export-bundle # Export identity bundle for verification
70
71Related:
72 auths init — Initialize identity with setup wizard
73 auths device — Manage linked devices
74 auths key — Manage cryptographic keys"
75)]
76pub struct IdCommand {
77 #[clap(subcommand)]
78 pub subcommand: IdSubcommand,
79
80 #[command(flatten)]
81 pub overrides: RegistryOverrides,
82}
83
84#[derive(Subcommand, Debug, Clone)]
85pub enum IdSubcommand {
86 #[command(name = "create")]
88 Create {
89 #[arg(
91 long,
92 value_parser,
93 help = "Path to JSON file with arbitrary identity metadata."
94 )]
95 metadata_file: PathBuf,
96
97 #[arg(long, help = "Name for the new signing key in secure storage.")]
99 local_key_alias: String,
100
101 #[arg(
105 long,
106 value_enum,
107 default_value = "default",
108 help = "Storage layout preset (default, radicle, gitoxide)"
109 )]
110 preset: LayoutPreset,
111 },
112
113 Show,
115
116 List,
118
119 Rotate {
121 #[arg(long, help = "Name of the key to rotate.")]
123 alias: Option<String>,
124
125 #[arg(
127 long,
128 help = "Current signing key name (alternative to --alias).",
129 conflicts_with = "alias"
130 )]
131 current_key_alias: Option<String>,
132
133 #[arg(long, help = "Name for the new signing key after rotation.")]
135 next_key_alias: Option<String>,
136
137 #[arg(
139 long,
140 action = ArgAction::Append,
141 help = "Add a witness server address (repeatable)."
142 )]
143 add_witness: Vec<String>,
144
145 #[arg(
147 long,
148 action = ArgAction::Append,
149 help = "Remove a witness server address (repeatable)."
150 )]
151 remove_witness: Vec<String>,
152
153 #[arg(
155 long,
156 help = "Number of witnesses required to accept this rotation (e.g., 1)."
157 )]
158 witness_threshold: Option<u64>,
159
160 #[arg(long)]
162 dry_run: bool,
163
164 #[arg(long, action = ArgAction::Append, value_name = "CURVE")]
167 add_device: Vec<String>,
168
169 #[arg(long, action = ArgAction::Append, value_name = "INDEX")]
172 remove_device: Vec<u32>,
173
174 #[arg(long)]
177 signing_threshold: Option<String>,
178
179 #[arg(long)]
182 rotation_threshold: Option<String>,
183 },
184
185 Expand {
187 #[arg(long, action = ArgAction::Append, value_name = "CURVE")]
189 add_device: Vec<String>,
190
191 #[arg(long)]
193 signing_threshold: String,
194
195 #[arg(long)]
197 rotation_threshold: String,
198
199 #[arg(long, default_value = "main")]
201 alias: String,
202
203 #[arg(long, default_value = "main")]
205 next_alias: String,
206 },
207
208 ExportBundle {
214 #[arg(long, help = "Key alias to include in bundle")]
216 alias: String,
217
218 #[arg(long = "output", short = 'o')]
220 output_file: PathBuf,
221
222 #[arg(
224 long,
225 help = "Maximum bundle age in seconds before it is considered stale"
226 )]
227 max_age_secs: u64,
228 },
229
230 Register {
232 #[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
234 registry: String,
235 },
236
237 Claim(super::claim::ClaimCommand),
239
240 Migrate(super::migrate::MigrateCommand),
242
243 BindIdp(super::bind_idp::BindIdpStubCommand),
248
249 #[command(name = "update-scope")]
255 UpdateScope {
256 #[arg(help = "Platform name (currently supports 'github')")]
258 platform: String,
259 },
260
261 Agent(super::agent::AgentCommand),
263}
264
265fn display_dry_run_rotate(
266 repo_path: &std::path::Path,
267 current_alias: Option<&str>,
268 next_alias: Option<&str>,
269) -> Result<()> {
270 if is_json_mode() {
271 JsonResponse::success(
272 "id rotate",
273 &serde_json::json!({
274 "dry_run": true,
275 "repo_path": repo_path.display().to_string(),
276 "current_key_alias": current_alias,
277 "next_key_alias": next_alias,
278 "actions": [
279 "Generate new signing key",
280 "Record rotation in identity log",
281 "Update key name mappings",
282 "All devices will need to re-authorize"
283 ]
284 }),
285 )
286 .print()
287 .map_err(anyhow::Error::from)
288 } else {
289 let out = crate::ux::format::Output::new();
290 out.print_info("Dry run mode — no changes will be made");
291 out.newline();
292 out.println(&format!(" Repository: {:?}", repo_path));
293 if let Some(alias) = current_alias {
294 out.println(&format!(" Current key name: {}", alias));
295 }
296 if let Some(alias) = next_alias {
297 out.println(&format!(" New key name: {}", alias));
298 }
299 out.newline();
300 out.println("Would perform the following actions:");
301 out.println(" 1. Generate new signing key");
302 out.println(" 2. Record rotation in identity log");
303 out.println(" 3. Update key name mappings");
304 out.println(" 4. All devices will need to re-authorize");
305 Ok(())
306 }
307}
308
309#[allow(clippy::too_many_arguments)]
314pub fn handle_id(
315 cmd: IdCommand,
316 repo_opt: Option<PathBuf>,
317 identity_ref_override: Option<String>,
318 identity_blob_name_override: Option<String>,
319 attestation_prefix_override: Option<String>,
320 attestation_blob_name_override: Option<String>,
321 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
322 env_config: &EnvironmentConfig,
323 now: chrono::DateTime<chrono::Utc>,
324) -> Result<()> {
325 let repo_path = layout::resolve_repo_path(repo_opt)?;
327
328 let mut config = StorageLayoutConfig::default();
331 if let Some(ref identity_ref) = identity_ref_override {
332 config.identity_ref = identity_ref.clone().into();
333 }
334 if let Some(ref blob_name) = identity_blob_name_override {
335 config.identity_blob_name = blob_name.clone().into();
336 }
337 if let Some(ref prefix) = attestation_prefix_override {
338 config.device_attestation_prefix = prefix.clone().into();
339 }
340 if let Some(ref blob_name) = attestation_blob_name_override {
341 config.attestation_blob_name = blob_name.clone().into();
342 }
343
344 match cmd.subcommand {
345 IdSubcommand::Create {
346 metadata_file,
347 local_key_alias,
348 preset,
349 } => {
350 let mut config = preset.to_config();
352 if let Some(ref identity_ref) = identity_ref_override {
353 config.identity_ref = identity_ref.clone().into();
354 }
355 if let Some(ref blob_name) = identity_blob_name_override {
356 config.identity_blob_name = blob_name.clone().into();
357 }
358 if let Some(ref prefix) = attestation_prefix_override {
359 config.device_attestation_prefix = prefix.clone().into();
360 }
361 if let Some(ref blob_name) = attestation_blob_name_override {
362 config.attestation_blob_name = blob_name.clone().into();
363 }
364 let metadata_file_path = metadata_file;
365
366 println!("🔐 Creating identity...");
368 println!(" Repository path: {:?}", repo_path);
369 println!(" Key name: {}", local_key_alias);
370 println!(" Metadata File: {:?}", metadata_file_path);
371
372 use crate::factories::storage::{ensure_git_repo, open_git_repo};
374
375 let identity_storage_check = RegistryIdentityStorage::new(repo_path.clone());
376 if repo_path.exists() {
377 match open_git_repo(&repo_path) {
378 Ok(_repo) => {
379 println!(" Git repository found at {:?}.", repo_path);
380 if identity_storage_check.load_identity().is_ok() {
381 eprintln!(
382 "⚠️ Primary identity already initialized and loadable at {:?} using ref '{}'. Aborting.",
383 repo_path,
384 identity_storage_check.get_identity_ref()?
385 );
386 return Err(anyhow!("Identity already exists in this repository"));
387 } else {
388 println!(
389 " Repository exists, but primary identity ref/data is missing or invalid. Proceeding..."
390 );
391 }
392 }
393 Err(_) => {
394 println!(
395 " Path {:?} exists but is not a Git repository. Initializing...",
396 repo_path
397 );
398 ensure_git_repo(&repo_path).map_err(|e| {
399 anyhow!(
400 "Path {:?} exists but failed to initialize as Git repository: {}",
401 repo_path,
402 e
403 )
404 })?;
405 println!(" Successfully initialized Git repository.");
406 }
407 }
408 } else {
409 println!(" Initializing Git repository at {:?}...", repo_path);
410 ensure_git_repo(&repo_path).map_err(|e| {
411 anyhow!(
412 "Failed to initialize Git repository at {:?}: {}",
413 repo_path,
414 e
415 )
416 })?;
417 println!(" Successfully initialized Git repository.");
418 }
419
420 if !metadata_file_path.exists() {
422 return Err(anyhow!("Metadata file not found: {:?}", metadata_file_path));
423 }
424 let metadata_content = fs::read_to_string(&metadata_file_path).with_context(|| {
425 format!("Failed to read metadata file: {:?}", metadata_file_path)
426 })?;
427 let metadata_value: serde_json::Value = serde_json::from_str(&metadata_content)
428 .with_context(|| {
429 format!(
430 "Failed to parse JSON from metadata file: {:?}",
431 metadata_file_path
432 )
433 })?;
434 println!(" Metadata loaded successfully from file.");
435
436 let _metadata_value = metadata_value; let backend: Arc<dyn RegistryBackend + Send + Sync> =
441 Arc::new(GitRegistryBackend::from_config_unchecked(
442 RegistryConfig::single_tenant(&repo_path),
443 ));
444 let local_key_alias = KeyAlias::new_unchecked(local_key_alias);
445 match initialize_registry_identity(
446 backend,
447 &local_key_alias,
448 passphrase_provider.as_ref(),
449 &get_platform_keychain()?,
450 None,
451 auths_crypto::CurveType::default(),
452 ) {
453 Ok((controller_did_keri, alias)) => {
454 println!("\n✅ Identity created.");
455 println!(
456 " Repository: {:?}",
457 repo_path
458 .canonicalize()
459 .unwrap_or_else(|_| repo_path.clone())
460 );
461 println!(" Identity: {}", controller_did_keri);
462 println!(
463 " Key name: {} (use this for signing and rotations)",
464 alias
465 );
466 println!(" Metadata stored from: {:?}", metadata_file_path);
467 println!("🔑 Keep your passphrase secure!");
468 Ok(())
469 }
470 Err(e) => Err(e).context("Failed to create identity"),
471 }
472 }
473
474 IdSubcommand::Show | IdSubcommand::List => {
475 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
476
477 let identity = identity_storage
478 .load_identity()
479 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
480
481 let cmd_name = match cmd.subcommand {
482 IdSubcommand::List => "id list",
483 _ => "id show",
484 };
485
486 if is_json_mode() {
487 let response = JsonResponse::success(
488 cmd_name,
489 IdShowResponse {
490 controller_did: identity.controller_did.to_string(),
491 storage_id: identity.storage_id.clone(),
492 metadata: identity.metadata.clone(),
493 },
494 );
495 response.print()?;
496 } else {
497 println!("Identity: {}", identity.controller_did);
498 println!("Storage ID: {}", identity.storage_id);
499 println!("Metadata:");
500 if let Some(meta) = &identity.metadata {
501 println!(
502 "{}",
503 serde_json::to_string_pretty(meta)
504 .unwrap_or_else(|_| " <Error serializing metadata>".to_string())
505 );
506 } else {
507 println!(" (None)");
508 }
509
510 println!("\nUse 'auths device list' to see authorized devices");
511 }
512 Ok(())
513 }
514
515 IdSubcommand::Rotate {
516 alias,
517 current_key_alias,
518 next_key_alias,
519 add_witness,
520 remove_witness,
521 witness_threshold,
522 dry_run,
523 add_device,
524 remove_device,
525 signing_threshold,
526 rotation_threshold,
527 } => {
528 if !add_device.is_empty()
529 || !remove_device.is_empty()
530 || signing_threshold.is_some()
531 || rotation_threshold.is_some()
532 {
533 return Err(anyhow!(
534 "multi-device rotation (--add-device / --remove-device / --signing-threshold / \
535 --rotation-threshold) is not yet wired through `auths id rotate`. Use \
536 `auths id expand` to add devices, or omit these flags for a standard rotation."
537 ));
538 }
539 let identity_key_alias = alias.or(current_key_alias);
540
541 if dry_run {
542 return display_dry_run_rotate(
543 &repo_path,
544 identity_key_alias.as_deref(),
545 next_key_alias.as_deref(),
546 );
547 }
548
549 println!("🔄 Rotating keys...");
550 println!(" Using Repository: {:?}", repo_path);
551 if let Some(ref a) = identity_key_alias {
552 println!(" Current key name: {}", a);
553 }
554 if let Some(ref a) = next_key_alias {
555 println!(" New key name: {}", a);
556 }
557 if !add_witness.is_empty() {
558 println!(" Adding witnesses: {:?}", add_witness);
559 }
560 if !remove_witness.is_empty() {
561 println!(" Removing witnesses: {:?}", remove_witness);
562 }
563 if let Some(thresh) = witness_threshold {
564 println!(" Witnesses required: {}", thresh);
565 }
566
567 let rotation_config_next_alias = next_key_alias.clone();
568 let rotation_config = auths_sdk::types::IdentityRotationConfig {
569 repo_path: repo_path.clone(),
570 identity_key_alias: identity_key_alias.map(KeyAlias::new_unchecked),
571 next_key_alias: next_key_alias.map(KeyAlias::new_unchecked),
572 };
573 let rotation_ctx = {
574 use auths_sdk::attestation::AttestationSink;
575 use auths_sdk::context::AuthsContext;
576 use auths_sdk::keychain::get_platform_keychain_with_config;
577 use auths_sdk::ports::IdentityStorage;
578 use auths_sdk::storage::{
579 GitRegistryBackend, RegistryAttestationStorage, RegistryConfig,
580 RegistryIdentityStorage,
581 };
582 let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> =
583 Arc::new(GitRegistryBackend::from_config_unchecked(
584 RegistryConfig::single_tenant(&repo_path),
585 ));
586 let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
587 Arc::new(RegistryIdentityStorage::new(repo_path.clone()));
588 let attestation_store = Arc::new(RegistryAttestationStorage::new(&repo_path));
589 let attestation_sink: Arc<dyn AttestationSink + Send + Sync> =
590 Arc::clone(&attestation_store) as Arc<dyn AttestationSink + Send + Sync>;
591 let attestation_source: Arc<dyn AttestationSource + Send + Sync> =
592 attestation_store as Arc<dyn AttestationSource + Send + Sync>;
593 let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
594 get_platform_keychain_with_config(env_config)
595 .context("Failed to access keychain")?,
596 );
597 AuthsContext::builder()
598 .registry(backend)
599 .key_storage(key_storage)
600 .clock(Arc::new(auths_sdk::ports::SystemClock))
601 .identity_storage(identity_storage)
602 .attestation_sink(attestation_sink)
603 .attestation_source(attestation_source)
604 .passphrase_provider(Arc::clone(&passphrase_provider))
605 .build()
606 };
607 let result = auths_sdk::workflows::rotation::rotate_identity(
608 rotation_config,
609 &rotation_ctx,
610 &auths_sdk::ports::SystemClock,
611 )
612 .with_context(|| "Failed to rotate keys")?;
613
614 println!("\n✅ Keys rotated.");
615 println!(" Identity: {}", result.controller_did);
616 println!(
617 " Old key fingerprint: {}...",
618 result.previous_key_fingerprint
619 );
620 println!(" New key fingerprint: {}...", result.new_key_fingerprint);
621 println!(" Key name: {} (unchanged)", result.new_key_alias);
622
623 if let Err(e) = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(
626 &rotation_ctx,
627 &repo_path,
628 ) {
629 println!("⚠️ Could not refresh commit trailers ({e}); run `auths doctor`.");
630 }
631
632 if let Some(ref explicit) = rotation_config_next_alias {
635 let new_signing_key = format!("auths:{explicit}");
636 let updated = crate::subprocess::git_command(&[
637 "config",
638 "--global",
639 "user.signingKey",
640 &new_signing_key,
641 ])
642 .output()
643 .map(|o| o.status.success())
644 .unwrap_or(false);
645 if updated {
646 println!(" Git signing key updated to {new_signing_key}");
647 } else {
648 println!(
649 "⚠️ Could not update git user.signingKey — set it manually: \
650 git config --global user.signingKey {new_signing_key}"
651 );
652 }
653 }
654
655 log::info!(
656 "Key rotation completed: old_key={}, new_key={}, alias={}",
657 result.previous_key_fingerprint,
658 result.new_key_fingerprint,
659 result.new_key_alias,
660 );
661
662 Ok(())
663 }
664
665 IdSubcommand::Expand {
666 add_device,
667 signing_threshold,
668 rotation_threshold,
669 alias,
670 next_alias,
671 } => {
672 if add_device.is_empty() {
673 return Err(anyhow!(
674 "`auths id expand` requires at least one --add-device; use `auths id rotate` for key-set-preserving rotations"
675 ));
676 }
677
678 let curves: Result<Vec<auths_crypto::CurveType>, _> = add_device
680 .iter()
681 .map(|s| match s.to_ascii_lowercase().as_str() {
682 "p256" | "p-256" => Ok(auths_crypto::CurveType::P256),
683 "ed25519" => Ok(auths_crypto::CurveType::Ed25519),
684 other => Err(anyhow!(
685 "unknown curve {:?}: expected P256 or Ed25519",
686 other
687 )),
688 })
689 .collect();
690 let curves = curves?;
691
692 let estimated_count = 1 + curves.len();
697 let new_kt =
698 crate::commands::init::parse_threshold_cli(&signing_threshold, estimated_count)?;
699 let new_nt =
700 crate::commands::init::parse_threshold_cli(&rotation_threshold, estimated_count)?;
701
702 let base_alias = KeyAlias::new_unchecked(alias.clone());
703
704 let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
707 auths_sdk::keychain::get_platform_keychain_with_config(env_config)
708 .context("Failed to access keychain")?,
709 );
710 let _migrated =
711 auths_sdk::keychain::migrate_legacy_alias(key_storage.as_ref(), &base_alias)
712 .context("Failed to migrate legacy key alias before expansion")?;
713
714 let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> = Arc::new(
716 auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
717 auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
718 ),
719 );
720
721 let shape = auths_sdk::identity::RotationShape {
722 add_devices: curves,
723 remove_indices: vec![],
724 new_kt: Some(new_kt),
725 new_nt: Some(new_nt),
726 };
727 let layout = auths_sdk::storage_layout::StorageLayoutConfig::default();
728 let next_alias_obj = KeyAlias::new_unchecked(next_alias.clone());
729 let result = auths_sdk::identity::rotate_registry_identity_multi(
730 backend,
731 &base_alias,
732 &next_alias_obj,
733 passphrase_provider.as_ref(),
734 &layout,
735 key_storage.as_ref(),
736 None,
737 shape,
738 )
739 .context("Failed to expand identity")?;
740
741 println!(
742 "[OK] Identity expanded — rotation at sequence {} with {} new device(s)",
743 result.sequence,
744 add_device.len()
745 );
746 println!(
747 " Base alias: {} → slots {}..{}",
748 alias, 0, estimated_count
749 );
750 Ok(())
751 }
752
753 IdSubcommand::ExportBundle {
754 alias,
755 output_file,
756 max_age_secs,
757 } => {
758 println!("📦 Exporting identity bundle...");
759 println!(" Using Repository: {:?}", repo_path);
760 println!(" Key name: {}", alias);
761 println!(" Output File: {:?}", output_file);
762
763 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
765 let identity = identity_storage
766 .load_identity()
767 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
768
769 println!(" Identity DID: {}", identity.controller_did);
770
771 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
773 let attestations = attestation_storage
774 .load_all_enriched()
775 .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
776 .unwrap_or_default();
777
778 let keychain = get_platform_keychain()?;
780 let alias_typed = KeyAlias::new_unchecked(&alias);
781 let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
782 keychain.as_ref(),
783 &alias_typed,
784 passphrase_provider.as_ref(),
785 )
786 .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
787 #[allow(clippy::disallowed_methods)]
788 let public_key_hex =
790 auths_verifier::PublicKeyHex::new_unchecked(hex::encode(&public_key_bytes));
791
792 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
796 auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
797 );
798 let prefix = auths_sdk::keri::parse_did_keri(identity.controller_did.as_str())
799 .context("identity DID is not did:keri")?;
800 let mut kel: Vec<serde_json::Value> = Vec::new();
801 let mut kel_err: Option<String> = None;
802 let _ = registry.visit_events(&prefix, 0, &mut |e| {
803 match serde_json::to_value(e) {
804 Ok(v) => kel.push(v),
805 Err(err) => kel_err = Some(err.to_string()),
806 }
807 std::ops::ControlFlow::Continue(())
808 });
809 if let Some(err) = kel_err {
810 return Err(anyhow::anyhow!("failed to serialize KEL event: {err}"));
811 }
812 if kel.is_empty() {
813 return Err(anyhow::anyhow!(
814 "no KEL events found for {} — cannot export a verifiable bundle",
815 identity.controller_did
816 ));
817 }
818
819 let bundle = IdentityBundle {
822 #[allow(clippy::disallowed_methods)] identity_did: IdentityDID::new_unchecked(identity.controller_did.to_string()),
824 public_key_hex,
825 curve,
826 attestation_chain: attestations,
827 kel,
828 bundle_timestamp: now,
829 max_valid_for_secs: max_age_secs,
830 };
831
832 let json = serde_json::to_string_pretty(&bundle)
834 .context("Failed to serialize identity bundle")?;
835 fs::write(&output_file, &json)
836 .with_context(|| format!("Failed to write bundle to {:?}", output_file))?;
837
838 println!("\n✅ Identity bundle exported successfully!");
839 println!(" Output: {:?}", output_file);
840 println!(" Attestations: {}", bundle.attestation_chain.len());
841 println!("\nUsage in CI:");
842 println!(" auths verify --identity-bundle {:?} HEAD", output_file);
843
844 Ok(())
845 }
846
847 IdSubcommand::Register { registry } => {
848 super::register::handle_register(&repo_path, ®istry)
849 }
850
851 IdSubcommand::Claim(claim_cmd) => {
852 super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, env_config, now)
853 }
854
855 IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd, now),
856
857 IdSubcommand::BindIdp(bind_cmd) => super::bind_idp::handle_bind_idp(bind_cmd),
858
859 IdSubcommand::UpdateScope { platform } => {
860 if platform.to_lowercase() != "github" {
861 return Err(anyhow!(
862 "Platform '{}' is not supported yet. Currently only 'github' is available.",
863 platform
864 ));
865 }
866
867 use crate::constants::GITHUB_SSH_UPLOAD_SCOPES;
868 use auths_infra_http::{HttpGitHubOAuthProvider, HttpGitHubSshKeyUploader};
869 use auths_sdk::keychain::extract_public_key_bytes;
870 use auths_sdk::ports::platform::OAuthDeviceFlowProvider;
871 use std::time::Duration;
872
873 const GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
874 #[allow(clippy::disallowed_methods)]
875 let client_id = std::env::var("AUTHS_GITHUB_CLIENT_ID")
876 .unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string());
877
878 let home =
880 dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
881 let auths_dir = home.join(".auths");
882 let ctx = crate::factories::storage::build_auths_context(
883 &auths_dir,
884 env_config,
885 Some(passphrase_provider.clone()),
886 )?;
887
888 let oauth = HttpGitHubOAuthProvider::new();
889 let ssh_uploader = HttpGitHubSshKeyUploader::new();
890
891 let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
892
893 let out = crate::ux::format::Output::new();
894 out.print_info(&format!("Re-authorizing with {}", platform));
895
896 let device_code = rt
897 .block_on(oauth.request_device_code(&client_id, GITHUB_SSH_UPLOAD_SCOPES))
898 .map_err(anyhow::Error::from)?;
899
900 out.println(&format!(
901 " Enter this code: {}",
902 out.bold(&device_code.user_code)
903 ));
904 out.println(&format!(
905 " At: {}",
906 out.info(&device_code.verification_uri)
907 ));
908 if let Err(e) = open::that(&device_code.verification_uri) {
909 out.print_warn(&format!("Could not open browser automatically: {e}"));
910 out.println(" Please open the URL above manually.");
911 } else {
912 out.println(" Browser opened — waiting for authorization...");
913 }
914
915 let expires_in = Duration::from_secs(device_code.expires_in);
916 let interval = Duration::from_secs(device_code.interval);
917
918 let access_token = rt
919 .block_on(oauth.poll_for_token(
920 &client_id,
921 &device_code.device_code,
922 interval,
923 expires_in,
924 ))
925 .map_err(anyhow::Error::from)?;
926
927 let profile = rt
928 .block_on(oauth.fetch_user_profile(&access_token))
929 .map_err(anyhow::Error::from)?;
930
931 out.print_success(&format!("Re-authenticated as @{}", profile.login));
932
933 let controller_did =
935 auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref())
936 .map_err(anyhow::Error::from)?;
937
938 #[allow(clippy::disallowed_methods)]
939 let identity_did = IdentityDID::new_unchecked(controller_did.clone());
940 let aliases = ctx
941 .key_storage
942 .list_aliases_for_identity(&identity_did)
943 .context("failed to list key aliases")?;
944 let key_alias = aliases
945 .into_iter()
946 .find(|a| !a.contains("--next-"))
947 .ok_or_else(|| anyhow::anyhow!("no signing key found for {controller_did}"))?;
948
949 let device_key_result = extract_public_key_bytes(
951 ctx.key_storage.as_ref(),
952 &key_alias,
953 passphrase_provider.as_ref(),
954 );
955
956 if let Ok((pk_bytes, curve)) = device_key_result {
957 let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
958 .map_err(|e| anyhow::anyhow!("Invalid device key: {e}"))?;
959 let public_key =
960 auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
961 .map_err(|e| anyhow::anyhow!("Failed to encode SSH key: {e}"))?;
962
963 out.println(" Uploading SSH signing key...");
964 #[allow(clippy::disallowed_methods)]
965 let now = chrono::Utc::now();
966 #[allow(clippy::disallowed_methods)]
967 let hostname = gethostname::gethostname();
968 let hostname_str = hostname.to_string_lossy().to_string();
969 let result = rt.block_on(
970 auths_sdk::workflows::platform::upload_github_ssh_signing_key(
971 &ssh_uploader,
972 &access_token,
973 &public_key,
974 &key_alias,
975 &hostname_str,
976 ctx.identity_storage.as_ref(),
977 now,
978 ),
979 );
980
981 match result {
982 Ok(()) => {
983 out.print_success("SSH signing key uploaded to GitHub");
984 out.println(" View at: https://github.com/settings/keys");
985 }
986 Err(e) => {
987 out.print_warn(&format!("SSH key upload failed: {e}"));
988 out.println(
989 " You can upload manually at https://github.com/settings/keys",
990 );
991 }
992 }
993 } else {
994 out.print_warn("Could not extract device public key for SSH upload");
995 }
996
997 out.print_success("Scope update complete");
998 Ok(())
999 }
1000
1001 IdSubcommand::Agent(agent_cmd) => {
1002 super::agent::handle_agent(agent_cmd, repo_path, env_config, passphrase_provider)
1003 }
1004 }
1005}