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 None,
550 auths_crypto::CurveType::default(),
551 ) {
552 Ok((controller_did_keri, alias)) => {
553 println!("\n✅ Identity created.");
554 println!(
555 " Repository: {:?}",
556 repo_path
557 .canonicalize()
558 .unwrap_or_else(|_| repo_path.clone())
559 );
560 println!(" Identity: {}", controller_did_keri);
561 println!(
562 " Key name: {} (use this for signing and rotations)",
563 alias
564 );
565 println!(" Metadata stored from: {:?}", metadata_file_path);
566 println!("🔑 Keep your passphrase secure!");
567 Ok(())
568 }
569 Err(e) => Err(e).context("Failed to create identity"),
570 }
571 }
572
573 IdSubcommand::Show | IdSubcommand::List => {
574 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
575
576 let identity = identity_storage
577 .load_identity()
578 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
579
580 let cmd_name = match cmd.subcommand {
581 IdSubcommand::List => "id list",
582 _ => "id show",
583 };
584
585 if is_json_mode() {
586 let response = JsonResponse::success(
587 cmd_name,
588 IdShowResponse {
589 controller_did: identity.controller_did.to_string(),
590 storage_id: identity.storage_id.clone(),
591 metadata: identity.metadata.clone(),
592 },
593 );
594 response.print()?;
595 } else {
596 println!("Identity: {}", identity.controller_did);
597 println!("Storage ID: {}", identity.storage_id);
598 println!("Metadata:");
599 if let Some(meta) = &identity.metadata {
600 println!(
601 "{}",
602 serde_json::to_string_pretty(meta)
603 .unwrap_or_else(|_| " <Error serializing metadata>".to_string())
604 );
605 } else {
606 println!(" (None)");
607 }
608
609 println!("\nUse 'auths device list' to see authorized devices");
610 }
611 Ok(())
612 }
613
614 IdSubcommand::Rotate {
615 alias,
616 current_key_alias,
617 next_key_alias,
618 add_witness,
619 remove_witness,
620 witness_threshold,
621 dry_run,
622 add_device,
623 remove_device,
624 signing_threshold,
625 rotation_threshold,
626 #[cfg(feature = "lan-pairing")]
627 from_device,
628 #[cfg(feature = "lan-pairing")]
629 no_qr,
630 #[cfg(feature = "lan-pairing")]
631 print_uri,
632 #[cfg(feature = "lan-pairing")]
633 no_mdns,
634 #[cfg(feature = "lan-pairing")]
635 timeout,
636 } => {
637 #[cfg(feature = "lan-pairing")]
638 if from_device {
639 return handle_rotate_from_device(
640 &repo_path, no_qr, print_uri, no_mdns, timeout, now, env_config,
641 );
642 }
643 if !add_device.is_empty()
644 || !remove_device.is_empty()
645 || signing_threshold.is_some()
646 || rotation_threshold.is_some()
647 {
648 return Err(anyhow!(
649 "multi-device rotation (--add-device / --remove-device / --signing-threshold / \
650 --rotation-threshold) is not wired through `auths id rotate`. Add a device \
651 with `auths device pair` (each device is a delegated identity under your \
652 root), or omit these flags for a standard rotation."
653 ));
654 }
655 let identity_key_alias = alias.or(current_key_alias);
656
657 if dry_run {
658 return display_dry_run_rotate(
659 &repo_path,
660 identity_key_alias.as_deref(),
661 next_key_alias.as_deref(),
662 );
663 }
664
665 println!("🔄 Rotating keys...");
666 println!(" Using Repository: {:?}", repo_path);
667 if let Some(ref a) = identity_key_alias {
668 println!(" Current key name: {}", a);
669 }
670 if let Some(ref a) = next_key_alias {
671 println!(" New key name: {}", a);
672 }
673 if !add_witness.is_empty() {
674 println!(" Adding witnesses: {:?}", add_witness);
675 }
676 if !remove_witness.is_empty() {
677 println!(" Removing witnesses: {:?}", remove_witness);
678 }
679 if let Some(thresh) = witness_threshold {
680 println!(" Witnesses required: {}", thresh);
681 }
682
683 let rotation_config_next_alias = next_key_alias.clone();
684 let rotation_config = auths_sdk::types::IdentityRotationConfig {
685 repo_path: repo_path.clone(),
686 identity_key_alias: identity_key_alias.map(KeyAlias::new_unchecked),
687 next_key_alias: next_key_alias.map(KeyAlias::new_unchecked),
688 };
689 let rotation_ctx = {
690 use auths_sdk::attestation::AttestationSink;
691 use auths_sdk::context::AuthsContext;
692 use auths_sdk::keychain::get_platform_keychain_with_config;
693 use auths_sdk::ports::IdentityStorage;
694 use auths_sdk::storage::{
695 GitRegistryBackend, RegistryAttestationStorage, RegistryConfig,
696 RegistryIdentityStorage,
697 };
698 let backend: Arc<dyn auths_sdk::ports::RegistryBackend + Send + Sync> =
699 Arc::new(GitRegistryBackend::from_config_unchecked(
700 RegistryConfig::single_tenant(&repo_path),
701 ));
702 let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
703 Arc::new(RegistryIdentityStorage::new(repo_path.clone()));
704 let attestation_store = Arc::new(RegistryAttestationStorage::new(&repo_path));
705 let attestation_sink: Arc<dyn AttestationSink + Send + Sync> =
706 Arc::clone(&attestation_store) as Arc<dyn AttestationSink + Send + Sync>;
707 let attestation_source: Arc<dyn AttestationSource + Send + Sync> =
708 attestation_store as Arc<dyn AttestationSource + Send + Sync>;
709 let key_storage: Arc<dyn auths_sdk::keychain::KeyStorage + Send + Sync> = Arc::from(
710 get_platform_keychain_with_config(env_config)
711 .context("Failed to access keychain")?,
712 );
713 AuthsContext::builder()
714 .registry(backend)
715 .key_storage(key_storage)
716 .clock(Arc::new(auths_sdk::ports::SystemClock))
717 .identity_storage(identity_storage)
718 .attestation_sink(attestation_sink)
719 .attestation_source(attestation_source)
720 .passphrase_provider(Arc::clone(&passphrase_provider))
721 .build()
722 };
723 let result = auths_sdk::workflows::rotation::rotate_identity(
724 rotation_config,
725 &rotation_ctx,
726 &auths_sdk::ports::SystemClock,
727 )
728 .with_context(|| "Failed to rotate keys")?;
729
730 println!("\n✅ Keys rotated.");
731 println!(" Identity: {}", result.controller_did);
732 println!(
733 " Old key fingerprint: {}...",
734 result.previous_key_fingerprint
735 );
736 println!(" New key fingerprint: {}...", result.new_key_fingerprint);
737 println!(" Key name: {} (unchanged)", result.new_key_alias);
738
739 if let Err(e) = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(
742 &rotation_ctx,
743 &repo_path,
744 ) {
745 println!("⚠️ Could not refresh commit trailers ({e}); run `auths doctor`.");
746 }
747
748 if let Some(ref explicit) = rotation_config_next_alias {
751 let new_signing_key = format!("auths:{explicit}");
752 let updated = crate::subprocess::git_command(&[
753 "config",
754 "--global",
755 "user.signingKey",
756 &new_signing_key,
757 ])
758 .output()
759 .map(|o| o.status.success())
760 .unwrap_or(false);
761 if updated {
762 println!(" Git signing key updated to {new_signing_key}");
763 } else {
764 println!(
765 "⚠️ Could not update git user.signingKey — set it manually: \
766 git config --global user.signingKey {new_signing_key}"
767 );
768 }
769 }
770
771 log::info!(
772 "Key rotation completed: old_key={}, new_key={}, alias={}",
773 result.previous_key_fingerprint,
774 result.new_key_fingerprint,
775 result.new_key_alias,
776 );
777
778 Ok(())
779 }
780
781 IdSubcommand::ExportBundle {
782 alias,
783 output_file,
784 max_age_secs,
785 } => {
786 println!("📦 Exporting identity bundle...");
787 println!(" Using Repository: {:?}", repo_path);
788 println!(" Key name: {}", alias);
789 println!(" Output File: {:?}", output_file);
790
791 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
793 let identity = identity_storage
794 .load_identity()
795 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
796
797 println!(" Identity DID: {}", identity.controller_did);
798
799 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
801 let attestations = attestation_storage
802 .load_all_enriched()
803 .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
804 .unwrap_or_default();
805
806 let keychain = get_platform_keychain()?;
808 let alias_typed = KeyAlias::new_unchecked(&alias);
809 let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
810 keychain.as_ref(),
811 &alias_typed,
812 passphrase_provider.as_ref(),
813 )
814 .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
815 #[allow(clippy::disallowed_methods)]
816 let public_key_hex =
818 auths_verifier::PublicKeyHex::new_unchecked(hex::encode(&public_key_bytes));
819
820 let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
824 auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
825 );
826 let prefix = auths_sdk::keri::parse_did_keri(identity.controller_did.as_str())
827 .context("identity DID is not did:keri")?;
828 let mut events = Vec::new();
833 let _ = registry.visit_events(&prefix, 0, &mut |e| {
834 events.push(e.clone());
835 std::ops::ControlFlow::Continue(())
836 });
837 if events.is_empty() {
838 return Err(anyhow::anyhow!(
839 "no KEL events found for {} — cannot export a verifiable bundle",
840 identity.controller_did
841 ));
842 }
843 let mut kel: Vec<auths_keri::Event> = Vec::with_capacity(events.len());
844 let mut kel_attachments: Vec<String> = Vec::with_capacity(events.len());
845 for e in &events {
846 let seq = e.sequence().value();
847 let att = registry
848 .get_attachment(&prefix, seq)
849 .map_err(|err| anyhow::anyhow!("failed to read KEL signature: {err}"))?
850 .ok_or_else(|| {
851 anyhow::anyhow!(
852 "KEL event at seq {seq} has no stored signature attachment; \
853 cannot export a verifiable bundle (re-initialize this identity)"
854 )
855 })?;
856 kel.push(e.clone());
857 kel_attachments.push(hex::encode(att));
858 }
859
860 let root_prefix_str = prefix.as_str().to_string();
865 let mut device_prefixes: Vec<String> = Vec::new();
866 let _ = registry.visit_identities(&mut |candidate| {
867 if candidate != root_prefix_str {
868 device_prefixes.push(candidate.to_string());
869 }
870 std::ops::ControlFlow::Continue(())
871 });
872 let mut device_kels = Vec::new();
873 for device in device_prefixes {
874 let device_prefix = auths_keri::Prefix::new_unchecked(device.clone());
875 let mut device_events: Vec<auths_keri::Event> = Vec::new();
876 let _ = registry.visit_events(&device_prefix, 0, &mut |e| {
877 device_events.push(e.clone());
878 std::ops::ControlFlow::Continue(())
879 });
880 let delegated_by_this_root = device_events
881 .first()
882 .and_then(|e| e.delegator())
883 .is_some_and(|d| d.as_str() == root_prefix_str);
884 if !delegated_by_this_root {
885 continue;
886 }
887 let mut device_attachments = Vec::with_capacity(device_events.len());
888 for e in &device_events {
889 let seq = e.sequence().value();
890 let att = registry
891 .get_attachment(&device_prefix, seq)
892 .map_err(|err| anyhow::anyhow!("failed to read device KEL signature: {err}"))?
893 .ok_or_else(|| {
894 anyhow::anyhow!(
895 "device {device} KEL event at seq {seq} has no stored signature attachment; cannot export a verifiable bundle"
896 )
897 })?;
898 device_attachments.push(hex::encode(att));
899 }
900 device_kels.push(auths_verifier::BundleDeviceKel {
901 did: format!("did:keri:{device}"),
902 kel: device_events,
903 kel_attachments: device_attachments,
904 });
905 }
906
907 let bundle = IdentityBundle {
910 identity_did: identity.controller_did.clone(),
911 public_key_hex,
912 curve,
913 attestation_chain: attestations,
914 kel,
915 kel_attachments,
916 device_kels,
917 bundle_timestamp: now,
918 max_valid_for_secs: max_age_secs,
919 };
920
921 let json = serde_json::to_string_pretty(&bundle)
923 .context("Failed to serialize identity bundle")?;
924 fs::write(&output_file, &json)
925 .with_context(|| format!("Failed to write bundle to {:?}", output_file))?;
926
927 println!("\n✅ Identity bundle exported successfully!");
928 println!(" Output: {:?}", output_file);
929 println!(" Attestations: {}", bundle.attestation_chain.len());
930 println!("\nUsage in CI:");
931 println!(" auths verify --identity-bundle {:?} HEAD", output_file);
932
933 Ok(())
934 }
935
936 IdSubcommand::AllowedSigners {
937 alias,
938 principal,
939 output_file,
940 } => {
941 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
942 let identity = identity_storage
943 .load_identity()
944 .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
945 let principal =
946 principal.unwrap_or_else(|| identity.controller_did.as_str().to_string());
947
948 let keychain = get_platform_keychain()?;
949 let alias_typed = KeyAlias::new_unchecked(&alias);
950 let (public_key_bytes, curve) = auths_sdk::keychain::extract_public_key_bytes(
951 keychain.as_ref(),
952 &alias_typed,
953 passphrase_provider.as_ref(),
954 )
955 .with_context(|| format!("Failed to extract public key for '{}'", alias))?;
956
957 let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &public_key_bytes)
958 .with_context(|| format!("'{}' is not a valid signing key", alias))?;
959 let openssh = auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
960 .context("Failed to encode signing key as OpenSSH")?;
961 let key_field = openssh
965 .split_whitespace()
966 .take(2)
967 .collect::<Vec<_>>()
968 .join(" ");
969 let line = format!("{principal} {key_field}\n");
970
971 match &output_file {
972 Some(path) => {
973 fs::write(path, &line).with_context(|| {
974 format!("Failed to write allowed_signers to {:?}", path)
975 })?;
976 println!("✅ Wrote allowed_signers for {} to {:?}", principal, path);
977 println!(" git config gpg.ssh.allowedSignersFile {:?}", path);
978 }
979 None => print!("{line}"),
980 }
981 Ok(())
982 }
983
984 IdSubcommand::Register { registry } => super::register::handle_register(
985 &repo_path,
986 &crate::commands::verify_helpers::require_registry(registry.clone())?,
987 ),
988
989 IdSubcommand::Claim(claim_cmd) => {
990 super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, env_config, now)
991 }
992
993 IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd, now),
994
995 IdSubcommand::BindIdp(bind_cmd) => super::bind_idp::handle_bind_idp(bind_cmd),
996
997 IdSubcommand::UpdateScope { platform } => {
998 if platform.to_lowercase() != "github" {
999 return Err(anyhow!(
1000 "Platform '{}' is not supported yet. Currently only 'github' is available.",
1001 platform
1002 ));
1003 }
1004
1005 use crate::constants::GITHUB_SSH_UPLOAD_SCOPES;
1006 use auths_infra_http::{HttpGitHubOAuthProvider, HttpGitHubSshKeyUploader};
1007 use auths_sdk::keychain::extract_public_key_bytes;
1008 use auths_sdk::ports::platform::OAuthDeviceFlowProvider;
1009 use std::time::Duration;
1010
1011 const GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
1012 #[allow(clippy::disallowed_methods)]
1013 let client_id = std::env::var("AUTHS_GITHUB_CLIENT_ID")
1014 .unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string());
1015
1016 let home =
1018 dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
1019 let auths_dir = home.join(".auths");
1020 let ctx = crate::factories::storage::build_auths_context(
1021 &auths_dir,
1022 env_config,
1023 Some(passphrase_provider.clone()),
1024 )?;
1025
1026 let oauth = HttpGitHubOAuthProvider::new();
1027 let ssh_uploader = HttpGitHubSshKeyUploader::new();
1028
1029 let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
1030
1031 let out = crate::ux::format::Output::new();
1032 out.print_info(&format!("Re-authorizing with {}", platform));
1033
1034 let device_code = rt
1035 .block_on(oauth.request_device_code(&client_id, GITHUB_SSH_UPLOAD_SCOPES))
1036 .map_err(anyhow::Error::from)?;
1037
1038 out.println(&format!(
1039 " Enter this code: {}",
1040 out.bold(&device_code.user_code)
1041 ));
1042 out.println(&format!(
1043 " At: {}",
1044 out.info(&device_code.verification_uri)
1045 ));
1046 if let Err(e) = open::that(&device_code.verification_uri) {
1047 out.print_warn(&format!("Could not open browser automatically: {e}"));
1048 out.println(" Please open the URL above manually.");
1049 } else {
1050 out.println(" Browser opened — waiting for authorization...");
1051 }
1052
1053 let expires_in = Duration::from_secs(device_code.expires_in);
1054 let interval = Duration::from_secs(device_code.interval);
1055
1056 let access_token = rt
1057 .block_on(oauth.poll_for_token(
1058 &client_id,
1059 &device_code.device_code,
1060 interval,
1061 expires_in,
1062 ))
1063 .map_err(anyhow::Error::from)?;
1064
1065 let profile = rt
1066 .block_on(oauth.fetch_user_profile(&access_token))
1067 .map_err(anyhow::Error::from)?;
1068
1069 out.print_success(&format!("Re-authenticated as @{}", profile.login));
1070
1071 let controller_did =
1073 auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref())
1074 .map_err(anyhow::Error::from)?;
1075
1076 let identity_did = IdentityDID::parse(&controller_did)
1077 .context("controller DID is not a valid did:keri identifier")?;
1078 let aliases = ctx
1079 .key_storage
1080 .list_aliases_for_identity(&identity_did)
1081 .context("failed to list key aliases")?;
1082 let key_alias = aliases
1083 .into_iter()
1084 .find(|a| !a.contains("--next-"))
1085 .ok_or_else(|| anyhow::anyhow!("no signing key found for {controller_did}"))?;
1086
1087 let device_key_result = extract_public_key_bytes(
1089 ctx.key_storage.as_ref(),
1090 &key_alias,
1091 passphrase_provider.as_ref(),
1092 );
1093
1094 if let Ok((pk_bytes, curve)) = device_key_result {
1095 let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
1096 .map_err(|e| anyhow::anyhow!("Invalid device key: {e}"))?;
1097 let public_key =
1098 auths_sdk::workflows::git_integration::public_key_to_ssh(&device_pk)
1099 .map_err(|e| anyhow::anyhow!("Failed to encode SSH key: {e}"))?;
1100
1101 out.println(" Uploading SSH signing key...");
1102 #[allow(clippy::disallowed_methods)]
1103 let now = chrono::Utc::now();
1104 #[allow(clippy::disallowed_methods)]
1105 let hostname = gethostname::gethostname();
1106 let hostname_str = hostname.to_string_lossy().to_string();
1107 let result = rt.block_on(
1108 auths_sdk::workflows::platform::upload_github_ssh_signing_key(
1109 &ssh_uploader,
1110 &access_token,
1111 &public_key,
1112 &key_alias,
1113 &hostname_str,
1114 ctx.identity_storage.as_ref(),
1115 now,
1116 ),
1117 );
1118
1119 match result {
1120 Ok(()) => {
1121 out.print_success("SSH signing key uploaded to GitHub");
1122 out.println(" View at: https://github.com/settings/keys");
1123 }
1124 Err(e) => {
1125 out.print_warn(&format!("SSH key upload failed: {e}"));
1126 out.println(
1127 " You can upload manually at https://github.com/settings/keys",
1128 );
1129 }
1130 }
1131 } else {
1132 out.print_warn("Could not extract device public key for SSH upload");
1133 }
1134
1135 out.print_success("Scope update complete");
1136 Ok(())
1137 }
1138
1139 IdSubcommand::Agent(agent_cmd) => {
1140 super::agent::handle_agent(agent_cmd, repo_path, env_config, passphrase_provider)
1141 }
1142 }
1143}
1144
1145#[cfg(all(test, feature = "lan-pairing"))]
1146mod tests {
1147 use super::*;
1148 use clap::Parser;
1149
1150 #[derive(Parser)]
1151 struct Harness {
1152 #[command(subcommand)]
1153 sub: IdSubcommand,
1154 }
1155
1156 #[test]
1157 fn rotate_from_device_parses() {
1158 let h = Harness::parse_from(["id", "rotate", "--from-device"]);
1159 match h.sub {
1160 IdSubcommand::Rotate { from_device, .. } => assert!(from_device),
1161 other => panic!("expected Rotate, got {other:?}"),
1162 }
1163 }
1164
1165 #[test]
1166 fn rotate_from_device_conflicts_with_local_authoring_flags() {
1167 assert!(
1170 Harness::try_parse_from(["id", "rotate", "--from-device", "--alias", "main"]).is_err()
1171 );
1172 assert!(Harness::try_parse_from(["id", "rotate", "--from-device", "--dry-run"]).is_err());
1173 }
1174
1175 #[test]
1176 fn rotate_session_toggles_require_from_device() {
1177 assert!(Harness::try_parse_from(["id", "rotate", "--no-qr"]).is_err());
1178 assert!(Harness::try_parse_from(["id", "rotate", "--print-uri"]).is_err());
1179 assert!(
1180 Harness::try_parse_from(["id", "rotate", "--from-device", "--no-qr", "--print-uri"])
1181 .is_ok()
1182 );
1183 }
1184}