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 crate::commands::registry_overrides::RegistryOverrides;
18use crate::ux::format::{JsonResponse, is_json_mode};
19
20#[derive(Debug, Serialize)]
22struct IdShowResponse {
23 controller_did: String,
24 storage_id: String,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 metadata: Option<serde_json::Value>,
27}
28
29use auths_sdk::storage::{
30 GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
31};
32use auths_sdk::{
33 identity::initialize_registry_identity,
34 ports::{AttestationSource, IdentityStorage, RegistryBackend},
35 storage_layout::{StorageLayoutConfig, layout},
36};
37
38#[derive(Debug, Clone, Copy, ValueEnum, Default)]
40pub enum LayoutPreset {
41 #[default]
43 Default,
44 Radicle,
46 Gitoxide,
48}
49
50impl LayoutPreset {
51 pub fn to_config(self) -> StorageLayoutConfig {
53 match self {
54 LayoutPreset::Default => StorageLayoutConfig::default(),
55 LayoutPreset::Radicle => StorageLayoutConfig::radicle(),
56 LayoutPreset::Gitoxide => StorageLayoutConfig::gitoxide(),
57 }
58 }
59}
60
61#[derive(Parser, Debug, Clone)]
62#[command(
63 about = "Manage your signing identity.",
64 after_help = "Examples:
65 auths id show # Show current identity details
66 auths id list # List identities (same as show)
67 auths id create # Create a new identity
68 auths id export-bundle # Export identity bundle for verification
69
70Related:
71 auths init — Initialize identity with setup wizard
72 auths device — Manage linked devices
73 auths key — Manage cryptographic keys"
74)]
75pub struct IdCommand {
76 #[clap(subcommand)]
77 pub subcommand: IdSubcommand,
78
79 #[command(flatten)]
80 pub overrides: RegistryOverrides,
81}
82
83#[derive(Subcommand, Debug, Clone)]
84pub enum IdSubcommand {
85 #[command(name = "create")]
87 Create {
88 #[arg(
90 long,
91 value_parser,
92 help = "Path to JSON file with arbitrary identity metadata."
93 )]
94 metadata_file: PathBuf,
95
96 #[arg(long, help = "Name for the new signing key in secure storage.")]
98 local_key_alias: String,
99
100 #[arg(
104 long,
105 value_enum,
106 default_value = "default",
107 help = "Storage layout preset (default, radicle, gitoxide)"
108 )]
109 preset: LayoutPreset,
110 },
111
112 Show,
114
115 List,
117
118 Rotate {
120 #[arg(long, help = "Name of the key to rotate.")]
122 alias: Option<String>,
123
124 #[arg(
126 long,
127 help = "Current signing key name (alternative to --alias).",
128 conflicts_with = "alias"
129 )]
130 current_key_alias: Option<String>,
131
132 #[arg(long, help = "Name for the new signing key after rotation.")]
134 next_key_alias: Option<String>,
135
136 #[arg(
138 long,
139 action = ArgAction::Append,
140 help = "Add a witness server address (repeatable)."
141 )]
142 add_witness: Vec<String>,
143
144 #[arg(
146 long,
147 action = ArgAction::Append,
148 help = "Remove a witness server address (repeatable)."
149 )]
150 remove_witness: Vec<String>,
151
152 #[arg(
154 long,
155 help = "Number of witnesses required to accept this rotation (e.g., 1)."
156 )]
157 witness_threshold: Option<u64>,
158
159 #[arg(long)]
161 dry_run: bool,
162
163 #[arg(long, action = ArgAction::Append, value_name = "CURVE")]
166 add_device: Vec<String>,
167
168 #[arg(long, action = ArgAction::Append, value_name = "INDEX")]
171 remove_device: Vec<u32>,
172
173 #[arg(long)]
176 signing_threshold: Option<String>,
177
178 #[arg(long)]
181 rotation_threshold: Option<String>,
182
183 #[cfg(feature = "lan-pairing")]
189 #[arg(long, conflicts_with_all = ["alias", "current_key_alias", "next_key_alias", "add_witness", "remove_witness", "witness_threshold", "dry_run"])]
190 from_device: bool,
191
192 #[cfg(feature = "lan-pairing")]
194 #[arg(long = "no-qr", requires = "from_device", hide_short_help = true)]
195 no_qr: bool,
196
197 #[cfg(feature = "lan-pairing")]
200 #[arg(long = "print-uri", requires = "from_device", hide_short_help = true)]
201 print_uri: bool,
202
203 #[cfg(feature = "lan-pairing")]
205 #[arg(long = "no-mdns", requires = "from_device", hide_short_help = true)]
206 no_mdns: bool,
207
208 #[cfg(feature = "lan-pairing")]
210 #[arg(
211 long = "timeout",
212 requires = "from_device",
213 default_value = "300",
214 hide_short_help = true
215 )]
216 timeout: u64,
217 },
218
219 ExportBundle {
225 #[arg(long, help = "Key alias to include in bundle")]
227 alias: String,
228
229 #[arg(long = "output", short = 'o')]
231 output_file: PathBuf,
232
233 #[arg(
235 long,
236 help = "Maximum bundle age in seconds before it is considered stale"
237 )]
238 max_age_secs: u64,
239 },
240
241 AllowedSigners {
247 #[arg(long, help = "Key alias to export the signing key for")]
249 alias: String,
250
251 #[arg(
254 long,
255 help = "Principal for the allowed_signers line (default: identity DID)"
256 )]
257 principal: Option<String>,
258
259 #[arg(long = "output", short = 'o', help = "Output file (default: stdout)")]
261 output_file: Option<PathBuf>,
262 },
263
264 Register {
266 #[arg(long, env = "AUTHS_REGISTRY_URL")]
268 registry: Option<String>,
269 },
270
271 Claim(super::claim::ClaimCommand),
273
274 Migrate(super::migrate::MigrateCommand),
276
277 BindIdp(super::bind_idp::BindIdpStubCommand),
282
283 #[command(name = "update-scope")]
289 UpdateScope {
290 #[arg(help = "Platform name (currently supports 'github')")]
292 platform: String,
293 },
294
295 Agent(super::agent::AgentCommand),
297}
298
299#[cfg(feature = "lan-pairing")]
308fn handle_rotate_from_device(
309 repo_path: &std::path::Path,
310 no_qr: bool,
311 print_uri: bool,
312 no_mdns: bool,
313 timeout: u64,
314 now: chrono::DateTime<chrono::Utc>,
315 env_config: &EnvironmentConfig,
316) -> Result<()> {
317 use auths_sdk::domains::identity::shared_rot::apply_shared_kel_rot;
318
319 let rt = tokio::runtime::Runtime::new()?;
320 let envelope = rt.block_on(crate::commands::device::pair::handle_receive_shared_rot(
321 now, repo_path, no_qr, print_uri, no_mdns, timeout,
322 ))?;
323
324 let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
325 let controller_did =
326 auths_sdk::pairing::load_controller_did(&identity_storage).map_err(anyhow::Error::from)?;
327 let prefix_str = controller_did
328 .strip_prefix("did:keri:")
329 .ok_or_else(|| anyhow!("invalid DID format, expected 'did:keri:': {controller_did}"))?;
330 let prefix = auths_keri::Prefix::new_unchecked(prefix_str.to_string());
331
332 let ctx = crate::factories::storage::build_auths_context(repo_path, env_config, None)?;
333 let applied = apply_shared_kel_rot(&envelope, &prefix, ctx.registry.as_ref())
334 .with_context(|| "Failed to apply the device-authored rotation")?;
335
336 if is_json_mode() {
337 JsonResponse::success(
338 "id rotate",
339 &serde_json::json!({
340 "from_device": true,
341 "controller_did": controller_did,
342 "sequence": applied.sequence.to_string(),
343 "event_said": applied.said.as_str(),
344 "controller_count": applied.controller_count,
345 }),
346 )
347 .print()
348 .map_err(anyhow::Error::from)?;
349 } else {
350 println!("\n✅ Device-authored rotation applied.");
351 println!(" Identity: {}", controller_did);
352 println!(" KEL advanced to seq {}", applied.sequence);
353 println!(" Controllers in force: {}", applied.controller_count);
354 }
355
356 if let Err(e) = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(&ctx, repo_path) {
359 println!("⚠️ Could not refresh commit trailers ({e}); run `auths doctor`.");
360 }
361 Ok(())
362}
363
364fn display_dry_run_rotate(
365 repo_path: &std::path::Path,
366 current_alias: Option<&str>,
367 next_alias: Option<&str>,
368) -> Result<()> {
369 if is_json_mode() {
370 JsonResponse::success(
371 "id rotate",
372 &serde_json::json!({
373 "dry_run": true,
374 "repo_path": repo_path.display().to_string(),
375 "current_key_alias": current_alias,
376 "next_key_alias": next_alias,
377 "actions": [
378 "Generate new signing key",
379 "Record rotation in identity log",
380 "Update key name mappings",
381 "All devices will need to re-authorize"
382 ]
383 }),
384 )
385 .print()
386 .map_err(anyhow::Error::from)
387 } else {
388 let out = crate::ux::format::Output::new();
389 out.print_info("Dry run mode — no changes will be made");
390 out.newline();
391 out.println(&format!(" Repository: {:?}", repo_path));
392 if let Some(alias) = current_alias {
393 out.println(&format!(" Current key name: {}", alias));
394 }
395 if let Some(alias) = next_alias {
396 out.println(&format!(" New key name: {}", alias));
397 }
398 out.newline();
399 out.println("Would perform the following actions:");
400 out.println(" 1. Generate new signing key");
401 out.println(" 2. Record rotation in identity log");
402 out.println(" 3. Update key name mappings");
403 out.println(" 4. All devices will need to re-authorize");
404 Ok(())
405 }
406}
407
408#[allow(clippy::too_many_arguments)]
413pub fn handle_id(
414 cmd: IdCommand,
415 repo_opt: Option<PathBuf>,
416 identity_ref_override: Option<String>,
417 identity_blob_name_override: Option<String>,
418 attestation_prefix_override: Option<String>,
419 attestation_blob_name_override: Option<String>,
420 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
421 env_config: &EnvironmentConfig,
422 now: chrono::DateTime<chrono::Utc>,
423) -> Result<()> {
424 let repo_path = layout::resolve_repo_path(repo_opt)?;
426
427 let mut config = StorageLayoutConfig::default();
430 if let Some(ref identity_ref) = identity_ref_override {
431 config.identity_ref = identity_ref.clone().into();
432 }
433 if let Some(ref blob_name) = identity_blob_name_override {
434 config.identity_blob_name = blob_name.clone().into();
435 }
436 if let Some(ref prefix) = attestation_prefix_override {
437 config.device_attestation_prefix = prefix.clone().into();
438 }
439 if let Some(ref blob_name) = attestation_blob_name_override {
440 config.attestation_blob_name = blob_name.clone().into();
441 }
442
443 match cmd.subcommand {
444 IdSubcommand::Create {
445 metadata_file,
446 local_key_alias,
447 preset,
448 } => {
449 let mut config = preset.to_config();
451 if let Some(ref identity_ref) = identity_ref_override {
452 config.identity_ref = identity_ref.clone().into();
453 }
454 if let Some(ref blob_name) = identity_blob_name_override {
455 config.identity_blob_name = blob_name.clone().into();
456 }
457 if let Some(ref prefix) = attestation_prefix_override {
458 config.device_attestation_prefix = prefix.clone().into();
459 }
460 if let Some(ref blob_name) = attestation_blob_name_override {
461 config.attestation_blob_name = blob_name.clone().into();
462 }
463 let metadata_file_path = metadata_file;
464
465 println!("🔐 Creating identity...");
467 println!(" Repository path: {:?}", repo_path);
468 println!(" Key name: {}", local_key_alias);
469 println!(" Metadata File: {:?}", metadata_file_path);
470
471 use crate::factories::storage::{ensure_git_repo, open_git_repo};
473
474 let identity_storage_check = RegistryIdentityStorage::new(repo_path.clone());
475 if repo_path.exists() {
476 match open_git_repo(&repo_path) {
477 Ok(_repo) => {
478 println!(" Git repository found at {:?}.", repo_path);
479 if identity_storage_check.load_identity().is_ok() {
480 eprintln!(
481 "⚠️ Primary identity already initialized and loadable at {:?} using ref '{}'. Aborting.",
482 repo_path,
483 identity_storage_check.get_identity_ref()?
484 );
485 return Err(anyhow!("Identity already exists in this repository"));
486 } else {
487 println!(
488 " Repository exists, but primary identity ref/data is missing or invalid. Proceeding..."
489 );
490 }
491 }
492 Err(_) => {
493 println!(
494 " Path {:?} exists but is not a Git repository. Initializing...",
495 repo_path
496 );
497 ensure_git_repo(&repo_path).map_err(|e| {
498 anyhow!(
499 "Path {:?} exists but failed to initialize as Git repository: {}",
500 repo_path,
501 e
502 )
503 })?;
504 println!(" Successfully initialized Git repository.");
505 }
506 }
507 } else {
508 println!(" Initializing Git repository at {:?}...", repo_path);
509 ensure_git_repo(&repo_path).map_err(|e| {
510 anyhow!(
511 "Failed to initialize Git repository at {:?}: {}",
512 repo_path,
513 e
514 )
515 })?;
516 println!(" Successfully initialized Git repository.");
517 }
518
519 if !metadata_file_path.exists() {
521 return Err(anyhow!("Metadata file not found: {:?}", metadata_file_path));
522 }
523 let metadata_content = fs::read_to_string(&metadata_file_path).with_context(|| {
524 format!("Failed to read metadata file: {:?}", metadata_file_path)
525 })?;
526 let metadata_value: serde_json::Value = serde_json::from_str(&metadata_content)
527 .with_context(|| {
528 format!(
529 "Failed to parse JSON from metadata file: {:?}",
530 metadata_file_path
531 )
532 })?;
533 println!(" Metadata loaded successfully from file.");
534
535 let _metadata_value = metadata_value; let backend: Arc<dyn RegistryBackend + Send + Sync> =
540 Arc::new(GitRegistryBackend::from_config_unchecked(
541 RegistryConfig::single_tenant(&repo_path),
542 ));
543 let local_key_alias = KeyAlias::new_unchecked(local_key_alias);
544 match initialize_registry_identity(
545 backend,
546 &local_key_alias,
547 passphrase_provider.as_ref(),
548 &get_platform_keychain()?,
549 auths_sdk::witness::WitnessParams::Disabled,
550 auths_crypto::CurveType::default(),
551 chrono::Utc::now(),
552 ) {
553 Ok((controller_did_keri, alias)) => {
554 println!("\n✅ Identity created.");
555 println!(
556 " Repository: {:?}",
557 repo_path
558 .canonicalize()
559 .unwrap_or_else(|_| repo_path.clone())
560 );
561 println!(" Identity: {}", controller_did_keri);
562 println!(
563 " Key name: {} (use this for signing and rotations)",
564 alias
565 );
566 println!(" Metadata stored from: {:?}", metadata_file_path);
567 println!("🔑 Keep your passphrase secure!");
568 Ok(())
569 }
570 Err(e) => Err(e).context("Failed to create identity"),
571 }
572 }
573
574 IdSubcommand::Show | IdSubcommand::List => {
575 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
576
577 let identity = identity_storage
578 .load_identity()
579 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
580
581 let cmd_name = match cmd.subcommand {
582 IdSubcommand::List => "id list",
583 _ => "id show",
584 };
585
586 if is_json_mode() {
587 let response = JsonResponse::success(
588 cmd_name,
589 IdShowResponse {
590 controller_did: identity.controller_did.to_string(),
591 storage_id: identity.storage_id.clone(),
592 metadata: identity.metadata.clone(),
593 },
594 );
595 response.print()?;
596 } else {
597 println!("Identity: {}", identity.controller_did);
598 println!("Storage ID: {}", identity.storage_id);
599 println!("Metadata:");
600 if let Some(meta) = &identity.metadata {
601 println!(
602 "{}",
603 serde_json::to_string_pretty(meta)
604 .unwrap_or_else(|_| " <Error serializing metadata>".to_string())
605 );
606 } else {
607 println!(" (None)");
608 }
609
610 println!("\nUse 'auths device list' to see authorized devices");
611 }
612 Ok(())
613 }
614
615 IdSubcommand::Rotate {
616 alias,
617 current_key_alias,
618 next_key_alias,
619 add_witness,
620 remove_witness,
621 witness_threshold,
622 dry_run,
623 add_device,
624 remove_device,
625 signing_threshold,
626 rotation_threshold,
627 #[cfg(feature = "lan-pairing")]
628 from_device,
629 #[cfg(feature = "lan-pairing")]
630 no_qr,
631 #[cfg(feature = "lan-pairing")]
632 print_uri,
633 #[cfg(feature = "lan-pairing")]
634 no_mdns,
635 #[cfg(feature = "lan-pairing")]
636 timeout,
637 } => {
638 #[cfg(feature = "lan-pairing")]
639 if from_device {
640 return handle_rotate_from_device(
641 &repo_path, no_qr, print_uri, no_mdns, timeout, now, env_config,
642 );
643 }
644 if !add_device.is_empty()
645 || !remove_device.is_empty()
646 || signing_threshold.is_some()
647 || rotation_threshold.is_some()
648 {
649 return Err(anyhow!(
650 "multi-device rotation (--add-device / --remove-device / --signing-threshold / \
651 --rotation-threshold) is not wired through `auths id rotate`. Add a device \
652 with `auths device pair` (each device is a delegated identity under your \
653 root), or omit these flags for a standard rotation."
654 ));
655 }
656 let identity_key_alias = alias.or(current_key_alias);
657
658 if dry_run {
659 return display_dry_run_rotate(
660 &repo_path,
661 identity_key_alias.as_deref(),
662 next_key_alias.as_deref(),
663 );
664 }
665
666 println!("🔄 Rotating keys...");
667 println!(" Using Repository: {:?}", repo_path);
668 if let Some(ref a) = identity_key_alias {
669 println!(" Current key name: {}", a);
670 }
671 if let Some(ref a) = next_key_alias {
672 println!(" New key name: {}", a);
673 }
674 if !add_witness.is_empty() {
675 println!(" Adding witnesses: {:?}", add_witness);
676 }
677 if !remove_witness.is_empty() {
678 println!(" Removing witnesses: {:?}", remove_witness);
679 }
680 if let Some(thresh) = witness_threshold {
681 println!(" Witnesses required: {}", thresh);
682 }
683
684 let rotation_config_next_alias = next_key_alias.clone();
685 let rotation_config = auths_sdk::types::IdentityRotationConfig {
686 repo_path: repo_path.clone(),
687 identity_key_alias: identity_key_alias.map(KeyAlias::new_unchecked),
688 next_key_alias: next_key_alias.map(KeyAlias::new_unchecked),
689 };
690 let rotation_ctx = {
691 use auths_sdk::attestation::AttestationSink;
692 use auths_sdk::context::AuthsContext;
693 use auths_sdk::keychain::get_platform_keychain_with_config;
694 use auths_sdk::ports::IdentityStorage;
695 use auths_sdk::storage::{
696 GitRegistryBackend, RegistryAttestationStorage, RegistryConfig,
697 RegistryIdentityStorage,
698 };
699 let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> =
700 Arc::new(GitRegistryBackend::from_config_unchecked(
701 RegistryConfig::single_tenant(&repo_path),
702 ));
703 let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
704 Arc::new(RegistryIdentityStorage::new(repo_path.clone()));
705 let attestation_store = Arc::new(RegistryAttestationStorage::new(&repo_path));
706 let attestation_sink: Arc<dyn AttestationSink + Send + Sync> =
707 Arc::clone(&attestation_store) as Arc<dyn AttestationSink + Send + Sync>;
708 let attestation_source: Arc<dyn AttestationSource + Send + Sync> =
709 attestation_store as Arc<dyn AttestationSource + Send + Sync>;
710 let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
711 get_platform_keychain_with_config(env_config)
712 .context("Failed to access keychain")?,
713 );
714 AuthsContext::builder()
715 .registry(backend)
716 .key_storage(key_storage)
717 .clock(Arc::new(auths_sdk::ports::SystemClock))
718 .identity_storage(identity_storage)
719 .attestation_sink(attestation_sink)
720 .attestation_source(attestation_source)
721 .passphrase_provider(Arc::clone(&passphrase_provider))
722 .build()
723 };
724 let result = auths_sdk::workflows::rotation::rotate_identity(
725 rotation_config,
726 &rotation_ctx,
727 &auths_sdk::ports::SystemClock,
728 )
729 .with_context(|| "Failed to rotate keys")?;
730
731 println!("\n✅ Keys rotated.");
732 println!(" Identity: {}", result.controller_did);
733 println!(
734 " Old key fingerprint: {}...",
735 result.previous_key_fingerprint
736 );
737 println!(" New key fingerprint: {}...", result.new_key_fingerprint);
738 println!(" Key name: {} (unchanged)", result.new_key_alias);
739
740 if let Err(e) = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(
743 &rotation_ctx,
744 &repo_path,
745 ) {
746 println!("⚠️ Could not refresh commit trailers ({e}); run `auths doctor`.");
747 }
748
749 if let Some(ref explicit) = rotation_config_next_alias {
752 let new_signing_key = format!("auths:{explicit}");
753 let updated = crate::subprocess::git_command(&[
754 "config",
755 "--global",
756 "user.signingKey",
757 &new_signing_key,
758 ])
759 .output()
760 .map(|o| o.status.success())
761 .unwrap_or(false);
762 if updated {
763 println!(" Git signing key updated to {new_signing_key}");
764 } else {
765 println!(
766 "⚠️ Could not update git user.signingKey — set it manually: \
767 git config --global user.signingKey {new_signing_key}"
768 );
769 }
770 }
771
772 log::info!(
773 "Key rotation completed: old_key={}, new_key={}, alias={}",
774 result.previous_key_fingerprint,
775 result.new_key_fingerprint,
776 result.new_key_alias,
777 );
778
779 Ok(())
780 }
781
782 IdSubcommand::ExportBundle {
783 alias,
784 output_file,
785 max_age_secs,
786 } => {
787 println!("📦 Exporting identity bundle...");
788 println!(" Using Repository: {:?}", repo_path);
789 println!(" Key name: {}", alias);
790 println!(" Output File: {:?}", output_file);
791
792 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
794 let identity = identity_storage
795 .load_identity()
796 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
797
798 println!(" Identity DID: {}", identity.controller_did);
799
800 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
802 let attestations = attestation_storage
803 .load_all_enriched()
804 .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
805 .unwrap_or_default();
806
807 let keychain = get_platform_keychain()?;
809 let alias_typed = KeyAlias::new_unchecked(&alias);
810 let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
811 keychain.as_ref(),
812 &alias_typed,
813 passphrase_provider.as_ref(),
814 )
815 .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
816 #[allow(clippy::disallowed_methods)]
817 let public_key_hex =
819 auths_verifier::PublicKeyHex::new_unchecked(hex::encode(&public_key_bytes));
820
821 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
825 auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
826 );
827 let prefix = auths_sdk::keri::parse_did_keri(identity.controller_did.as_str())
828 .context("identity DID is not did:keri")?;
829 let mut events = Vec::new();
834 let _ = registry.visit_events(&prefix, 0, &mut |e| {
835 events.push(e.clone());
836 std::ops::ControlFlow::Continue(())
837 });
838 if events.is_empty() {
839 return Err(anyhow::anyhow!(
840 "no KEL events found for {} — cannot export a verifiable bundle",
841 identity.controller_did
842 ));
843 }
844 let mut kel: Vec<auths_keri::Event> = Vec::with_capacity(events.len());
845 let mut kel_attachments: Vec<String> = Vec::with_capacity(events.len());
846 for e in &events {
847 let seq = e.sequence().value();
848 let att = registry
849 .get_attachment(&prefix, seq)
850 .map_err(|err| anyhow::anyhow!("failed to read KEL signature: {err}"))?
851 .ok_or_else(|| {
852 anyhow::anyhow!(
853 "KEL event at seq {seq} has no stored signature attachment; \
854 cannot export a verifiable bundle (re-initialize this identity)"
855 )
856 })?;
857 kel.push(e.clone());
858 kel_attachments.push(hex::encode(att));
859 }
860
861 let root_prefix_str = prefix.as_str().to_string();
866 let mut device_prefixes: Vec<String> = Vec::new();
867 let _ = registry.visit_identities(&mut |candidate| {
868 if candidate != root_prefix_str {
869 device_prefixes.push(candidate.to_string());
870 }
871 std::ops::ControlFlow::Continue(())
872 });
873 let mut device_kels = Vec::new();
874 for device in device_prefixes {
875 let device_prefix = auths_keri::Prefix::new_unchecked(device.clone());
876 let mut device_events: Vec<auths_keri::Event> = Vec::new();
877 let _ = registry.visit_events(&device_prefix, 0, &mut |e| {
878 device_events.push(e.clone());
879 std::ops::ControlFlow::Continue(())
880 });
881 let delegated_by_this_root = device_events
882 .first()
883 .and_then(|e| e.delegator())
884 .is_some_and(|d| d.as_str() == root_prefix_str);
885 if !delegated_by_this_root {
886 continue;
887 }
888 let mut device_attachments = Vec::with_capacity(device_events.len());
889 for e in &device_events {
890 let seq = e.sequence().value();
891 let att = registry
892 .get_attachment(&device_prefix, seq)
893 .map_err(|err| anyhow::anyhow!("failed to read device KEL signature: {err}"))?
894 .ok_or_else(|| {
895 anyhow::anyhow!(
896 "device {device} KEL event at seq {seq} has no stored signature attachment; cannot export a verifiable bundle"
897 )
898 })?;
899 device_attachments.push(hex::encode(att));
900 }
901 device_kels.push(auths_verifier::BundleDeviceKel {
902 did: format!("did:keri:{device}"),
903 kel: device_events,
904 kel_attachments: device_attachments,
905 });
906 }
907
908 let bundle = IdentityBundle {
911 identity_did: identity.controller_did.clone(),
912 public_key_hex,
913 curve,
914 attestation_chain: attestations,
915 kel,
916 kel_attachments,
917 device_kels,
918 bundle_timestamp: now,
919 max_valid_for_secs: max_age_secs,
920 };
921
922 let json = serde_json::to_string_pretty(&bundle)
924 .context("Failed to serialize identity bundle")?;
925 fs::write(&output_file, &json)
926 .with_context(|| format!("Failed to write bundle to {:?}", output_file))?;
927
928 println!("\n✅ Identity bundle exported successfully!");
929 println!(" Output: {:?}", output_file);
930 println!(" Attestations: {}", bundle.attestation_chain.len());
931 println!("\nUsage in CI:");
932 println!(" auths verify --identity-bundle {:?} HEAD", output_file);
933
934 Ok(())
935 }
936
937 IdSubcommand::AllowedSigners {
938 alias,
939 principal,
940 output_file,
941 } => {
942 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
943 let identity = identity_storage
944 .load_identity()
945 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
946 let principal =
947 principal.unwrap_or_else(|| identity.controller_did.as_str().to_string());
948
949 let keychain = get_platform_keychain()?;
950 let alias_typed = KeyAlias::new_unchecked(&alias);
951 let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
952 keychain.as_ref(),
953 &alias_typed,
954 passphrase_provider.as_ref(),
955 )
956 .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
957
958 let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &public_key_bytes)
959 .with_context(|| format!("'{}' is not a valid signing key", alias))?;
960 let openssh = auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
961 .context("Failed to encode signing key as OpenSSH")?;
962 let key_field = openssh
966 .split_whitespace()
967 .take(2)
968 .collect::<Vec<_>>()
969 .join(" ");
970 let line = format!("{principal} {key_field}\n");
971
972 match &output_file {
973 Some(path) => {
974 fs::write(path, &line).with_context(|| {
975 format!("Failed to write allowed_signers to {:?}", path)
976 })?;
977 println!("✅ Wrote allowed_signers for {} to {:?}", principal, path);
978 println!(" git config gpg.ssh.allowedSignersFile {:?}", path);
979 }
980 None => print!("{line}"),
981 }
982 Ok(())
983 }
984
985 IdSubcommand::Register { registry } => super::register::handle_register(
986 &repo_path,
987 &crate::commands::verify_helpers::require_registry(registry.clone())?,
988 ),
989
990 IdSubcommand::Claim(claim_cmd) => {
991 super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, env_config, now)
992 }
993
994 IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd, now),
995
996 IdSubcommand::BindIdp(bind_cmd) => super::bind_idp::handle_bind_idp(bind_cmd),
997
998 IdSubcommand::UpdateScope { platform } => {
999 if platform.to_lowercase() != "github" {
1000 return Err(anyhow!(
1001 "Platform '{}' is not supported yet. Currently only 'github' is available.",
1002 platform
1003 ));
1004 }
1005
1006 use crate::constants::GITHUB_SSH_UPLOAD_SCOPES;
1007 use auths_infra_http::{HttpGitHubOAuthProvider, HttpGitHubSshKeyUploader};
1008 use auths_sdk::keychain::extract_public_key_bytes;
1009 use auths_sdk::ports::platform::OAuthDeviceFlowProvider;
1010 use std::time::Duration;
1011
1012 const GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
1013 #[allow(clippy::disallowed_methods)]
1014 let client_id = std::env::var("AUTHS_GITHUB_CLIENT_ID")
1015 .unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string());
1016
1017 let home =
1019 dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
1020 let auths_dir = home.join(".auths");
1021 let ctx = crate::factories::storage::build_auths_context(
1022 &auths_dir,
1023 env_config,
1024 Some(passphrase_provider.clone()),
1025 )?;
1026
1027 let oauth = HttpGitHubOAuthProvider::new();
1028 let ssh_uploader = HttpGitHubSshKeyUploader::new();
1029
1030 let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
1031
1032 let out = crate::ux::format::Output::new();
1033 out.print_info(&format!("Re-authorizing with {}", platform));
1034
1035 let device_code = rt
1036 .block_on(oauth.request_device_code(&client_id, GITHUB_SSH_UPLOAD_SCOPES))
1037 .map_err(anyhow::Error::from)?;
1038
1039 out.println(&format!(
1040 " Enter this code: {}",
1041 out.bold(&device_code.user_code)
1042 ));
1043 out.println(&format!(
1044 " At: {}",
1045 out.info(&device_code.verification_uri)
1046 ));
1047 if let Err(e) = open::that(&device_code.verification_uri) {
1048 out.print_warn(&format!("Could not open browser automatically: {e}"));
1049 out.println(" Please open the URL above manually.");
1050 } else {
1051 out.println(" Browser opened — waiting for authorization...");
1052 }
1053
1054 let expires_in = Duration::from_secs(device_code.expires_in);
1055 let interval = Duration::from_secs(device_code.interval);
1056
1057 let access_token = rt
1058 .block_on(oauth.poll_for_token(
1059 &client_id,
1060 &device_code.device_code,
1061 interval,
1062 expires_in,
1063 ))
1064 .map_err(anyhow::Error::from)?;
1065
1066 let profile = rt
1067 .block_on(oauth.fetch_user_profile(&access_token))
1068 .map_err(anyhow::Error::from)?;
1069
1070 out.print_success(&format!("Re-authenticated as @{}", profile.login));
1071
1072 let controller_did =
1074 auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref())
1075 .map_err(anyhow::Error::from)?;
1076
1077 let identity_did = IdentityDID::parse(&controller_did)
1078 .context("controller DID is not a valid did:keri identifier")?;
1079 let aliases = ctx
1080 .key_storage
1081 .list_aliases_for_identity(&identity_did)
1082 .context("failed to list key aliases")?;
1083 let key_alias = aliases
1084 .into_iter()
1085 .find(|a| !a.contains("--next-"))
1086 .ok_or_else(|| anyhow::anyhow!("no signing key found for {controller_did}"))?;
1087
1088 let device_key_result = extract_public_key_bytes(
1090 ctx.key_storage.as_ref(),
1091 &key_alias,
1092 passphrase_provider.as_ref(),
1093 );
1094
1095 if let Ok((pk_bytes, curve)) = device_key_result {
1096 let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
1097 .map_err(|e| anyhow::anyhow!("Invalid device key: {e}"))?;
1098 let public_key =
1099 auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
1100 .map_err(|e| anyhow::anyhow!("Failed to encode SSH key: {e}"))?;
1101
1102 out.println(" Uploading SSH signing key...");
1103 #[allow(clippy::disallowed_methods)]
1104 let now = chrono::Utc::now();
1105 #[allow(clippy::disallowed_methods)]
1106 let hostname = gethostname::gethostname();
1107 let hostname_str = hostname.to_string_lossy().to_string();
1108 let result = rt.block_on(
1109 auths_sdk::workflows::platform::upload_github_ssh_signing_key(
1110 &ssh_uploader,
1111 &access_token,
1112 &public_key,
1113 &key_alias,
1114 &hostname_str,
1115 ctx.identity_storage.as_ref(),
1116 now,
1117 ),
1118 );
1119
1120 match result {
1121 Ok(()) => {
1122 out.print_success("SSH signing key uploaded to GitHub");
1123 out.println(" View at: https://github.com/settings/keys");
1124 }
1125 Err(e) => {
1126 out.print_warn(&format!("SSH key upload failed: {e}"));
1127 out.println(
1128 " You can upload manually at https://github.com/settings/keys",
1129 );
1130 }
1131 }
1132 } else {
1133 out.print_warn("Could not extract device public key for SSH upload");
1134 }
1135
1136 out.print_success("Scope update complete");
1137 Ok(())
1138 }
1139
1140 IdSubcommand::Agent(agent_cmd) => {
1141 super::agent::handle_agent(agent_cmd, repo_path, env_config, passphrase_provider)
1142 }
1143 }
1144}
1145
1146#[cfg(all(test, feature = "lan-pairing"))]
1147mod tests {
1148 use super::*;
1149 use clap::Parser;
1150
1151 #[derive(Parser)]
1152 struct Harness {
1153 #[command(subcommand)]
1154 sub: IdSubcommand,
1155 }
1156
1157 #[test]
1158 fn rotate_from_device_parses() {
1159 let h = Harness::parse_from(["id", "rotate", "--from-device"]);
1160 match h.sub {
1161 IdSubcommand::Rotate { from_device, .. } => assert!(from_device),
1162 other => panic!("expected Rotate, got {other:?}"),
1163 }
1164 }
1165
1166 #[test]
1167 fn rotate_from_device_conflicts_with_local_authoring_flags() {
1168 assert!(
1171 Harness::try_parse_from(["id", "rotate", "--from-device", "--alias", "main"]).is_err()
1172 );
1173 assert!(Harness::try_parse_from(["id", "rotate", "--from-device", "--dry-run"]).is_err());
1174 }
1175
1176 #[test]
1177 fn rotate_session_toggles_require_from_device() {
1178 assert!(Harness::try_parse_from(["id", "rotate", "--no-qr"]).is_err());
1179 assert!(Harness::try_parse_from(["id", "rotate", "--print-uri"]).is_err());
1180 assert!(
1181 Harness::try_parse_from(["id", "rotate", "--from-device", "--no-qr", "--print-uri"])
1182 .is_ok()
1183 );
1184 }
1185}