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 = auths_sdk::types::IdentityRotationConfig {
568 repo_path: repo_path.clone(),
569 identity_key_alias: identity_key_alias.map(KeyAlias::new_unchecked),
570 next_key_alias: next_key_alias.map(KeyAlias::new_unchecked),
571 };
572 let rotation_ctx = {
573 use auths_sdk::attestation::AttestationSink;
574 use auths_sdk::context::AuthsContext;
575 use auths_sdk::keychain::get_platform_keychain_with_config;
576 use auths_sdk::ports::IdentityStorage;
577 use auths_sdk::storage::{
578 GitRegistryBackend, RegistryAttestationStorage, RegistryConfig,
579 RegistryIdentityStorage,
580 };
581 let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> =
582 Arc::new(GitRegistryBackend::from_config_unchecked(
583 RegistryConfig::single_tenant(&repo_path),
584 ));
585 let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
586 Arc::new(RegistryIdentityStorage::new(repo_path.clone()));
587 let attestation_store = Arc::new(RegistryAttestationStorage::new(&repo_path));
588 let attestation_sink: Arc<dyn AttestationSink + Send + Sync> =
589 Arc::clone(&attestation_store) as Arc<dyn AttestationSink + Send + Sync>;
590 let attestation_source: Arc<dyn AttestationSource + Send + Sync> =
591 attestation_store as Arc<dyn AttestationSource + Send + Sync>;
592 let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
593 get_platform_keychain_with_config(env_config)
594 .context("Failed to access keychain")?,
595 );
596 AuthsContext::builder()
597 .registry(backend)
598 .key_storage(key_storage)
599 .clock(Arc::new(auths_sdk::ports::SystemClock))
600 .identity_storage(identity_storage)
601 .attestation_sink(attestation_sink)
602 .attestation_source(attestation_source)
603 .passphrase_provider(Arc::clone(&passphrase_provider))
604 .build()
605 };
606 let result = auths_sdk::workflows::rotation::rotate_identity(
607 rotation_config,
608 &rotation_ctx,
609 &auths_sdk::ports::SystemClock,
610 )
611 .with_context(|| "Failed to rotate keys")?;
612
613 println!("\n✅ Keys rotated.");
614 println!(" Identity: {}", result.controller_did);
615 println!(
616 " Old key fingerprint: {}...",
617 result.previous_key_fingerprint
618 );
619 println!(" New key fingerprint: {}...", result.new_key_fingerprint);
620 println!(
621 "⚠️ Your old key name is no longer active. Update any scripts that reference it."
622 );
623
624 log::info!(
625 "Key rotation completed: old_key={}, new_key={}",
626 result.previous_key_fingerprint,
627 result.new_key_fingerprint,
628 );
629
630 Ok(())
631 }
632
633 IdSubcommand::Expand {
634 add_device,
635 signing_threshold,
636 rotation_threshold,
637 alias,
638 next_alias,
639 } => {
640 if add_device.is_empty() {
641 return Err(anyhow!(
642 "`auths id expand` requires at least one --add-device; use `auths id rotate` for key-set-preserving rotations"
643 ));
644 }
645
646 let curves: Result<Vec<auths_crypto::CurveType>, _> = add_device
648 .iter()
649 .map(|s| match s.to_ascii_lowercase().as_str() {
650 "p256" | "p-256" => Ok(auths_crypto::CurveType::P256),
651 "ed25519" => Ok(auths_crypto::CurveType::Ed25519),
652 other => Err(anyhow!(
653 "unknown curve {:?}: expected P256 or Ed25519",
654 other
655 )),
656 })
657 .collect();
658 let curves = curves?;
659
660 let estimated_count = 1 + curves.len();
665 let new_kt =
666 crate::commands::init::parse_threshold_cli(&signing_threshold, estimated_count)?;
667 let new_nt =
668 crate::commands::init::parse_threshold_cli(&rotation_threshold, estimated_count)?;
669
670 let base_alias = KeyAlias::new_unchecked(alias.clone());
671
672 let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
675 auths_sdk::keychain::get_platform_keychain_with_config(env_config)
676 .context("Failed to access keychain")?,
677 );
678 let _migrated =
679 auths_sdk::keychain::migrate_legacy_alias(key_storage.as_ref(), &base_alias)
680 .context("Failed to migrate legacy key alias before expansion")?;
681
682 let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> = Arc::new(
684 auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
685 auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
686 ),
687 );
688
689 let shape = auths_sdk::identity::RotationShape {
690 add_devices: curves,
691 remove_indices: vec![],
692 new_kt: Some(new_kt),
693 new_nt: Some(new_nt),
694 };
695 let layout = auths_sdk::storage_layout::StorageLayoutConfig::default();
696 let next_alias_obj = KeyAlias::new_unchecked(next_alias.clone());
697 let result = auths_sdk::identity::rotate_registry_identity_multi(
698 backend,
699 &base_alias,
700 &next_alias_obj,
701 passphrase_provider.as_ref(),
702 &layout,
703 key_storage.as_ref(),
704 None,
705 shape,
706 )
707 .context("Failed to expand identity")?;
708
709 println!(
710 "[OK] Identity expanded — rotation at sequence {} with {} new device(s)",
711 result.sequence,
712 add_device.len()
713 );
714 println!(
715 " Base alias: {} → slots {}..{}",
716 alias, 0, estimated_count
717 );
718 Ok(())
719 }
720
721 IdSubcommand::ExportBundle {
722 alias,
723 output_file,
724 max_age_secs,
725 } => {
726 println!("📦 Exporting identity bundle...");
727 println!(" Using Repository: {:?}", repo_path);
728 println!(" Key name: {}", alias);
729 println!(" Output File: {:?}", output_file);
730
731 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
733 let identity = identity_storage
734 .load_identity()
735 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
736
737 println!(" Identity DID: {}", identity.controller_did);
738
739 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
741 let attestations = attestation_storage
742 .load_all_enriched()
743 .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
744 .unwrap_or_default();
745
746 let keychain = get_platform_keychain()?;
748 let alias_typed = KeyAlias::new_unchecked(&alias);
749 let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
750 keychain.as_ref(),
751 &alias_typed,
752 passphrase_provider.as_ref(),
753 )
754 .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
755 #[allow(clippy::disallowed_methods)]
756 let public_key_hex =
758 auths_verifier::PublicKeyHex::new_unchecked(hex::encode(&public_key_bytes));
759
760 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
764 auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
765 );
766 let prefix = auths_sdk::keri::parse_did_keri(identity.controller_did.as_str())
767 .context("identity DID is not did:keri")?;
768 let mut kel: Vec<serde_json::Value> = Vec::new();
769 let mut kel_err: Option<String> = None;
770 let _ = registry.visit_events(&prefix, 0, &mut |e| {
771 match serde_json::to_value(e) {
772 Ok(v) => kel.push(v),
773 Err(err) => kel_err = Some(err.to_string()),
774 }
775 std::ops::ControlFlow::Continue(())
776 });
777 if let Some(err) = kel_err {
778 return Err(anyhow::anyhow!("failed to serialize KEL event: {err}"));
779 }
780 if kel.is_empty() {
781 return Err(anyhow::anyhow!(
782 "no KEL events found for {} — cannot export a verifiable bundle",
783 identity.controller_did
784 ));
785 }
786
787 let bundle = IdentityBundle {
790 #[allow(clippy::disallowed_methods)] identity_did: IdentityDID::new_unchecked(identity.controller_did.to_string()),
792 public_key_hex,
793 curve,
794 attestation_chain: attestations,
795 kel,
796 bundle_timestamp: now,
797 max_valid_for_secs: max_age_secs,
798 };
799
800 let json = serde_json::to_string_pretty(&bundle)
802 .context("Failed to serialize identity bundle")?;
803 fs::write(&output_file, &json)
804 .with_context(|| format!("Failed to write bundle to {:?}", output_file))?;
805
806 println!("\n✅ Identity bundle exported successfully!");
807 println!(" Output: {:?}", output_file);
808 println!(" Attestations: {}", bundle.attestation_chain.len());
809 println!("\nUsage in CI:");
810 println!(" auths verify --identity-bundle {:?} HEAD", output_file);
811
812 Ok(())
813 }
814
815 IdSubcommand::Register { registry } => {
816 super::register::handle_register(&repo_path, ®istry)
817 }
818
819 IdSubcommand::Claim(claim_cmd) => {
820 super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, env_config, now)
821 }
822
823 IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd, now),
824
825 IdSubcommand::BindIdp(bind_cmd) => super::bind_idp::handle_bind_idp(bind_cmd),
826
827 IdSubcommand::UpdateScope { platform } => {
828 if platform.to_lowercase() != "github" {
829 return Err(anyhow!(
830 "Platform '{}' is not supported yet. Currently only 'github' is available.",
831 platform
832 ));
833 }
834
835 use crate::constants::GITHUB_SSH_UPLOAD_SCOPES;
836 use auths_infra_http::{HttpGitHubOAuthProvider, HttpGitHubSshKeyUploader};
837 use auths_sdk::keychain::extract_public_key_bytes;
838 use auths_sdk::ports::platform::OAuthDeviceFlowProvider;
839 use std::time::Duration;
840
841 const GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
842 #[allow(clippy::disallowed_methods)]
843 let client_id = std::env::var("AUTHS_GITHUB_CLIENT_ID")
844 .unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string());
845
846 let home =
848 dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
849 let auths_dir = home.join(".auths");
850 let ctx = crate::factories::storage::build_auths_context(
851 &auths_dir,
852 env_config,
853 Some(passphrase_provider.clone()),
854 )?;
855
856 let oauth = HttpGitHubOAuthProvider::new();
857 let ssh_uploader = HttpGitHubSshKeyUploader::new();
858
859 let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
860
861 let out = crate::ux::format::Output::new();
862 out.print_info(&format!("Re-authorizing with {}", platform));
863
864 let device_code = rt
865 .block_on(oauth.request_device_code(&client_id, GITHUB_SSH_UPLOAD_SCOPES))
866 .map_err(anyhow::Error::from)?;
867
868 out.println(&format!(
869 " Enter this code: {}",
870 out.bold(&device_code.user_code)
871 ));
872 out.println(&format!(
873 " At: {}",
874 out.info(&device_code.verification_uri)
875 ));
876 if let Err(e) = open::that(&device_code.verification_uri) {
877 out.print_warn(&format!("Could not open browser automatically: {e}"));
878 out.println(" Please open the URL above manually.");
879 } else {
880 out.println(" Browser opened — waiting for authorization...");
881 }
882
883 let expires_in = Duration::from_secs(device_code.expires_in);
884 let interval = Duration::from_secs(device_code.interval);
885
886 let access_token = rt
887 .block_on(oauth.poll_for_token(
888 &client_id,
889 &device_code.device_code,
890 interval,
891 expires_in,
892 ))
893 .map_err(anyhow::Error::from)?;
894
895 let profile = rt
896 .block_on(oauth.fetch_user_profile(&access_token))
897 .map_err(anyhow::Error::from)?;
898
899 out.print_success(&format!("Re-authenticated as @{}", profile.login));
900
901 let controller_did =
903 auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref())
904 .map_err(anyhow::Error::from)?;
905
906 #[allow(clippy::disallowed_methods)]
907 let identity_did = IdentityDID::new_unchecked(controller_did.clone());
908 let aliases = ctx
909 .key_storage
910 .list_aliases_for_identity(&identity_did)
911 .context("failed to list key aliases")?;
912 let key_alias = aliases
913 .into_iter()
914 .find(|a| !a.contains("--next-"))
915 .ok_or_else(|| anyhow::anyhow!("no signing key found for {controller_did}"))?;
916
917 let device_key_result = extract_public_key_bytes(
919 ctx.key_storage.as_ref(),
920 &key_alias,
921 passphrase_provider.as_ref(),
922 );
923
924 if let Ok((pk_bytes, curve)) = device_key_result {
925 let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
926 .map_err(|e| anyhow::anyhow!("Invalid device key: {e}"))?;
927 let public_key =
928 auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
929 .map_err(|e| anyhow::anyhow!("Failed to encode SSH key: {e}"))?;
930
931 out.println(" Uploading SSH signing key...");
932 #[allow(clippy::disallowed_methods)]
933 let now = chrono::Utc::now();
934 #[allow(clippy::disallowed_methods)]
935 let hostname = gethostname::gethostname();
936 let hostname_str = hostname.to_string_lossy().to_string();
937 let result = rt.block_on(
938 auths_sdk::workflows::platform::upload_github_ssh_signing_key(
939 &ssh_uploader,
940 &access_token,
941 &public_key,
942 &key_alias,
943 &hostname_str,
944 ctx.identity_storage.as_ref(),
945 now,
946 ),
947 );
948
949 match result {
950 Ok(()) => {
951 out.print_success("SSH signing key uploaded to GitHub");
952 out.println(" View at: https://github.com/settings/keys");
953 }
954 Err(e) => {
955 out.print_warn(&format!("SSH key upload failed: {e}"));
956 out.println(
957 " You can upload manually at https://github.com/settings/keys",
958 );
959 }
960 }
961 } else {
962 out.print_warn("Could not extract device public key for SSH upload");
963 }
964
965 out.print_success("Scope update complete");
966 Ok(())
967 }
968
969 IdSubcommand::Agent(agent_cmd) => {
970 super::agent::handle_agent(agent_cmd, repo_path, env_config, passphrase_provider)
971 }
972 }
973}