1use crate::subprocess::git_command;
8use crate::ux::format::{Output, is_json_mode};
9use anyhow::{Context, Result, anyhow};
10use clap::{Parser, Subcommand};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16#[derive(Parser, Debug, Clone)]
18#[command(name = "migrate", about = "Import existing GPG or SSH keys")]
19pub struct MigrateCommand {
20 #[command(subcommand)]
21 pub command: MigrateSubcommand,
22}
23
24#[derive(Subcommand, Debug, Clone)]
25pub enum MigrateSubcommand {
26 #[command(name = "from-gpg")]
28 FromGpg(FromGpgCommand),
29
30 #[command(name = "from-ssh")]
32 FromSsh(FromSshCommand),
33
34 #[command(name = "status")]
36 Status(MigrateStatusCommand),
37}
38
39#[derive(Parser, Debug, Clone)]
41pub struct FromGpgCommand {
42 #[arg(long, value_name = "KEY_ID")]
44 pub key_id: Option<String>,
45
46 #[arg(long)]
48 pub list: bool,
49
50 #[arg(long)]
52 pub dry_run: bool,
53
54 #[arg(long)]
56 pub repo: Option<PathBuf>,
57
58 #[arg(long)]
60 pub key_alias: Option<String>,
61}
62
63#[derive(Parser, Debug, Clone)]
65pub struct FromSshCommand {
66 #[arg(long, short = 'k', value_name = "PATH")]
68 pub key: Option<PathBuf>,
69
70 #[arg(long)]
72 pub list: bool,
73
74 #[arg(long)]
76 pub dry_run: bool,
77
78 #[arg(long)]
80 pub repo: Option<PathBuf>,
81
82 #[arg(long)]
84 pub key_alias: Option<String>,
85
86 #[arg(long)]
88 pub update_allowed_signers: bool,
89}
90
91#[derive(Parser, Debug, Clone)]
93pub struct MigrateStatusCommand {
94 #[arg(long)]
96 pub repo: Option<PathBuf>,
97
98 #[arg(long, short = 'n', default_value = "100")]
100 pub count: usize,
101
102 #[arg(long)]
104 pub by_author: bool,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct GpgKeyInfo {
110 pub key_id: String,
112 pub fingerprint: String,
114 pub user_id: String,
116 pub algorithm: String,
118 pub created: String,
120 pub expires: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct SshKeyInfo {
127 pub path: PathBuf,
129 pub algorithm: String,
131 pub bits: Option<u32>,
133 pub fingerprint: String,
135 pub comment: Option<String>,
137}
138
139pub fn handle_migrate(cmd: MigrateCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
141 match cmd.command {
142 MigrateSubcommand::FromGpg(gpg_cmd) => handle_from_gpg(gpg_cmd, now),
143 MigrateSubcommand::FromSsh(ssh_cmd) => handle_from_ssh(ssh_cmd, now),
144 MigrateSubcommand::Status(status_cmd) => handle_migrate_status(status_cmd),
145 }
146}
147
148fn handle_from_gpg(cmd: FromGpgCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
150 let out = Output::new();
151
152 if !is_gpg_available() {
154 return Err(anyhow!(
155 "GPG is not installed or not in PATH. Please install GPG first."
156 ));
157 }
158
159 let keys = list_gpg_secret_keys()?;
161
162 if keys.is_empty() {
163 out.print_warn("No GPG secret keys found in ~/.gnupg/");
164 out.println(" To create a GPG key: gpg --gen-key");
165 return Ok(());
166 }
167
168 if cmd.list {
170 out.print_heading("Available GPG Keys");
171 out.newline();
172 for (i, key) in keys.iter().enumerate() {
173 out.println(&format!(
174 " {}. {} {}",
175 i + 1,
176 out.bold(&key.key_id),
177 out.dim(&key.algorithm)
178 ));
179 out.println(&format!(" {}", key.user_id));
180 out.println(&format!(" Fingerprint: {}", out.dim(&key.fingerprint)));
181 if let Some(expires) = &key.expires {
182 out.println(&format!(" Expires: {}", expires));
183 }
184 out.newline();
185 }
186 return Ok(());
187 }
188
189 let key = if let Some(key_id) = &cmd.key_id {
191 keys.iter()
192 .find(|k| {
193 k.key_id.ends_with(key_id.trim_start_matches("0x"))
194 || k.fingerprint.ends_with(key_id.trim_start_matches("0x"))
195 })
196 .ok_or_else(|| anyhow!("GPG key not found: {}", key_id))?
197 .clone()
198 } else if keys.len() == 1 {
199 keys[0].clone()
200 } else {
201 out.print_heading("Multiple GPG keys found. Please specify one:");
202 out.newline();
203 for key in &keys {
204 out.println(&format!(" {} - {}", out.bold(&key.key_id), key.user_id));
205 }
206 out.newline();
207 out.println("Use: auths id migrate from-gpg --key-id <KEY_ID>");
208 return Ok(());
209 };
210
211 out.print_heading("GPG Key Migration");
212 out.newline();
213 out.println(&format!(
214 " {} Found GPG key: {}",
215 out.success("✓"),
216 key.user_id
217 ));
218 out.println(&format!(" Key ID: {}", out.info(&key.key_id)));
219 out.println(&format!(" Fingerprint: {}", out.dim(&key.fingerprint)));
220 out.newline();
221
222 if cmd.dry_run {
223 out.print_info("Dry run mode - no changes will be made");
224 out.newline();
225 out.println("Would perform the following actions:");
226 out.println(" 1. Create new Auths Ed25519 identity");
227 out.println(" 2. Create cross-reference attestation linking GPG key to Auths identity");
228 out.println(" 3. Sign attestation with both GPG key and new Auths key");
229 out.newline();
230 out.print_info("Re-run without --dry-run to execute migration");
231 return Ok(());
232 }
233
234 perform_gpg_migration(&key, &cmd, &out, now)
236}
237
238fn is_gpg_available() -> bool {
240 Command::new("gpg").arg("--version").output().is_ok()
241}
242
243fn list_gpg_secret_keys() -> Result<Vec<GpgKeyInfo>> {
245 let output = Command::new("gpg")
247 .args([
248 "--list-secret-keys",
249 "--with-colons",
250 "--keyid-format",
251 "long",
252 ])
253 .output()
254 .context("Failed to run gpg --list-secret-keys")?;
255
256 if !output.status.success() {
257 let stderr = String::from_utf8_lossy(&output.stderr);
258 return Err(anyhow!("GPG command failed: {}", stderr));
259 }
260
261 let stdout = String::from_utf8_lossy(&output.stdout);
262 parse_gpg_colon_output(&stdout)
263}
264
265fn parse_gpg_colon_output(output: &str) -> Result<Vec<GpgKeyInfo>> {
267 let mut keys = Vec::new();
268 let mut current_key: Option<GpgKeyInfo> = None;
269
270 for line in output.lines() {
271 let fields: Vec<&str> = line.split(':').collect();
272 if fields.is_empty() {
273 continue;
274 }
275
276 match fields[0] {
277 "sec" => {
278 if let Some(key) = current_key.take() {
280 keys.push(key);
281 }
282
283 let key_id = fields.get(4).unwrap_or(&"").to_string();
284 let algo_code = *fields.get(3).unwrap_or(&"1");
285 let algorithm = match algo_code {
286 "1" => "rsa".to_string(),
287 "17" => "dsa".to_string(),
288 "18" => "ecdh".to_string(),
289 "19" => "ecdsa".to_string(),
290 "22" => "ed25519".to_string(),
291 other => format!("algo{}", other),
292 };
293 let key_bits = *fields.get(2).unwrap_or(&"");
294 let algorithm = if !key_bits.is_empty() && algorithm.starts_with("rsa") {
295 format!("{}{}", algorithm, key_bits)
296 } else {
297 algorithm
298 };
299
300 let created = fields.get(5).unwrap_or(&"").to_string();
301 let expires = fields
302 .get(6)
303 .filter(|s| !s.is_empty())
304 .map(|s| s.to_string());
305
306 current_key = Some(GpgKeyInfo {
307 key_id: key_id
308 .chars()
309 .rev()
310 .take(16)
311 .collect::<String>()
312 .chars()
313 .rev()
314 .collect(),
315 fingerprint: String::new(),
316 user_id: String::new(),
317 algorithm,
318 created,
319 expires,
320 });
321 }
322 "fpr" => {
323 if let Some(ref mut key) = current_key {
325 key.fingerprint = fields.get(9).unwrap_or(&"").to_string();
326 }
327 }
328 "uid" => {
329 if let Some(ref mut key) = current_key
331 && key.user_id.is_empty()
332 {
333 key.user_id = fields.get(9).unwrap_or(&"").to_string();
334 }
335 }
336 _ => {}
337 }
338 }
339
340 if let Some(key) = current_key {
341 keys.push(key);
342 }
343
344 Ok(keys)
345}
346
347fn perform_gpg_migration(
349 key: &GpgKeyInfo,
350 cmd: &FromGpgCommand,
351 out: &Output,
352 now: chrono::DateTime<chrono::Utc>,
353) -> Result<()> {
354 use auths_sdk::error::AgentError;
355 use auths_sdk::identity::initialize_registry_identity;
356 use auths_sdk::keychain::{KeyAlias, get_platform_keychain};
357 use auths_sdk::ports::RegistryBackend;
358 use auths_sdk::storage::{GitRegistryBackend, RegistryConfig};
359 use std::fs;
360 use std::sync::Arc;
361 use zeroize::Zeroizing;
362
363 let keychain = get_platform_keychain().context("Failed to access platform keychain")?;
365
366 let key_alias = cmd.key_alias.clone().unwrap_or_else(|| {
368 format!(
369 "gpg-{}",
370 key.key_id
371 .chars()
372 .rev()
373 .take(8)
374 .collect::<String>()
375 .chars()
376 .rev()
377 .collect::<String>()
378 )
379 });
380
381 let repo_path = cmd.repo.clone().unwrap_or_else(|| {
383 dirs::home_dir()
384 .map(|h| h.join(".auths"))
385 .unwrap_or_else(|| PathBuf::from(".auths"))
386 });
387
388 out.print_info(&format!(
389 "Creating Auths identity with key alias: {}",
390 key_alias
391 ));
392
393 if !repo_path.exists() {
395 fs::create_dir_all(&repo_path)
396 .with_context(|| format!("Failed to create directory: {:?}", repo_path))?;
397 }
398
399 if !repo_path.join(".git").exists() {
401 git_command(&["init"])
402 .current_dir(&repo_path)
403 .output()
404 .context("Failed to initialize Git repository")?;
405 }
406
407 let _metadata = serde_json::json!({
409 "migrated_from": "gpg",
410 "gpg_key_id": key.key_id,
411 "gpg_fingerprint": key.fingerprint,
412 "gpg_user_id": key.user_id,
413 "created_at": now.to_rfc3339()
414 });
415
416 struct MigrationPassphraseProvider;
418 impl auths_sdk::signing::PassphraseProvider for MigrationPassphraseProvider {
419 fn get_passphrase(&self, prompt: &str) -> Result<Zeroizing<String>, AgentError> {
420 let _ = prompt;
423 Ok(Zeroizing::new(String::new()))
424 }
425 }
426 let passphrase_provider = MigrationPassphraseProvider;
427
428 let backend: Arc<dyn RegistryBackend + Send + Sync> = Arc::new(
430 GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&repo_path)),
431 );
432 let key_alias = KeyAlias::new_unchecked(key_alias);
433 match initialize_registry_identity(
434 backend,
435 &key_alias,
436 &passphrase_provider,
437 keychain.as_ref(),
438 auths_sdk::witness::WitnessParams::Disabled,
439 auths_crypto::CurveType::default(),
440 chrono::Utc::now(),
441 ) {
442 Ok((controller_did, alias)) => {
443 out.print_success(&format!("Created Auths identity: {}", controller_did));
444
445 out.print_info("Creating cross-reference attestation...");
447
448 let attestation = create_gpg_cross_reference_attestation(key, &controller_did, now)?;
449
450 let attestation_path = repo_path.join("gpg-migration.json");
452 fs::write(
453 &attestation_path,
454 serde_json::to_string_pretty(&attestation)?,
455 )
456 .context("Failed to write attestation file")?;
457
458 out.print_success("Cross-reference attestation created");
459 out.newline();
460
461 out.print_heading("Migration Complete");
462 out.println(&format!(" GPG Key: {}", out.dim(&key.key_id)));
463 out.println(&format!(" GPG User: {}", key.user_id));
464 out.println(&format!(
465 " Auths Identity: {}",
466 out.info(&controller_did)
467 ));
468 out.println(&format!(" Key Alias: {}", out.info(&alias)));
469 out.println(&format!(
470 " Repository: {}",
471 out.info(&repo_path.display().to_string())
472 ));
473 out.println(&format!(
474 " Attestation: {}",
475 out.dim(&attestation_path.display().to_string())
476 ));
477 out.newline();
478
479 out.print_heading("Next Steps");
480 out.println(" 1. Sign the attestation with your GPG key:");
481 out.println(&format!(
482 " gpg --armor --detach-sign {}",
483 attestation_path.display()
484 ));
485 out.println(" 2. Start using Auths for new commits:");
486 out.println(" auths agent start");
487 out.println(" 3. Existing GPG-signed commits remain verifiable");
488
489 Ok(())
490 }
491 Err(e) => Err(e).context("Failed to initialize identity"),
492 }
493}
494
495fn create_gpg_cross_reference_attestation(
497 gpg_key: &GpgKeyInfo,
498 auths_did: &str,
499 now: chrono::DateTime<chrono::Utc>,
500) -> Result<serde_json::Value> {
501 let attestation = serde_json::json!({
502 "version": 1,
503 "type": "gpg-migration",
504 "gpg": {
505 "key_id": gpg_key.key_id,
506 "fingerprint": gpg_key.fingerprint,
507 "user_id": gpg_key.user_id,
508 "algorithm": gpg_key.algorithm
509 },
510 "auths": {
511 "did": auths_did
512 },
513 "statement": "This attestation links the GPG key to the Auths identity. Both keys belong to the same entity.",
514 "created_at": now.to_rfc3339(),
515 "instructions": "To complete the cross-reference: 1) Sign this file with your GPG key using 'gpg --armor --detach-sign', 2) The Auths signature will be added automatically."
516 });
517
518 Ok(attestation)
519}
520
521fn handle_from_ssh(cmd: FromSshCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
527 let out = Output::new();
528
529 let keys = list_ssh_keys()?;
531
532 if keys.is_empty() {
533 out.print_warn("No SSH keys found in ~/.ssh/");
534 out.println(" To create an SSH key: ssh-keygen -t ed25519");
535 return Ok(());
536 }
537
538 if cmd.list {
540 out.print_heading("Available SSH Keys");
541 out.newline();
542 for (i, key) in keys.iter().enumerate() {
543 let bits_str = key
544 .bits
545 .map(|b| format!(" ({} bits)", b))
546 .unwrap_or_default();
547 out.println(&format!(
548 " {}. {} {}{}",
549 i + 1,
550 out.bold(&key.path.display().to_string()),
551 out.dim(&key.algorithm),
552 bits_str
553 ));
554 out.println(&format!(" Fingerprint: {}", out.dim(&key.fingerprint)));
555 if let Some(comment) = &key.comment {
556 out.println(&format!(" Comment: {}", comment));
557 }
558 out.newline();
559 }
560 return Ok(());
561 }
562
563 let key = if let Some(key_path) = &cmd.key {
565 keys.iter()
566 .find(|k| k.path == *key_path || k.path.file_name() == key_path.file_name())
567 .ok_or_else(|| anyhow!("SSH key not found: {}", key_path.display()))?
568 .clone()
569 } else if keys.len() == 1 {
570 keys[0].clone()
571 } else {
572 out.print_heading("Multiple SSH keys found. Please specify one:");
573 out.newline();
574 for key in &keys {
575 out.println(&format!(
576 " {} ({})",
577 out.bold(&key.path.display().to_string()),
578 key.algorithm
579 ));
580 }
581 out.newline();
582 out.println("Use: auths id migrate from-ssh --key <PATH>");
583 return Ok(());
584 };
585
586 out.print_heading("SSH Key Migration");
587 out.newline();
588 out.println(&format!(
589 " {} Found SSH key: {}",
590 out.success("✓"),
591 key.path.display()
592 ));
593 out.println(&format!(" Algorithm: {}", out.info(&key.algorithm)));
594 out.println(&format!(" Fingerprint: {}", out.dim(&key.fingerprint)));
595 if let Some(comment) = &key.comment {
596 out.println(&format!(" Comment: {}", comment));
597 }
598 out.newline();
599
600 if cmd.dry_run {
601 out.print_info("Dry run mode - no changes will be made");
602 out.newline();
603 out.println("Would perform the following actions:");
604 out.println(" 1. Create new Auths Ed25519 identity");
605 out.println(" 2. Create cross-reference attestation linking SSH key to Auths identity");
606 if cmd.update_allowed_signers {
607 out.println(" 3. Update allowed_signers file with new Auths key");
608 }
609 out.newline();
610 out.print_info("Re-run without --dry-run to execute migration");
611 return Ok(());
612 }
613
614 perform_ssh_migration(&key, &cmd, &out, now)
616}
617
618fn list_ssh_keys() -> Result<Vec<SshKeyInfo>> {
620 let ssh_dir = dirs::home_dir()
621 .map(|h| h.join(".ssh"))
622 .ok_or_else(|| anyhow!("Could not determine home directory"))?;
623
624 if !ssh_dir.exists() {
625 return Ok(Vec::new());
626 }
627
628 let mut keys = Vec::new();
629
630 let key_patterns = [
632 "id_ed25519",
633 "id_rsa",
634 "id_ecdsa",
635 "id_ecdsa_sk",
636 "id_ed25519_sk",
637 "id_dsa",
638 ];
639
640 for pattern in &key_patterns {
641 let private_key_path = ssh_dir.join(pattern);
642 let public_key_path = ssh_dir.join(format!("{}.pub", pattern));
643
644 if private_key_path.exists()
645 && public_key_path.exists()
646 && let Ok(key_info) = parse_ssh_public_key(&private_key_path, &public_key_path)
647 {
648 keys.push(key_info);
649 }
650 }
651
652 if let Ok(entries) = fs::read_dir(&ssh_dir) {
654 for entry in entries.flatten() {
655 let path = entry.path();
656 if path.extension().map(|e| e == "pub").unwrap_or(false) {
657 let private_key_path = path.with_extension("");
658 if private_key_path.exists() {
659 if keys.iter().any(|k| k.path == private_key_path) {
661 continue;
662 }
663 if let Ok(key_info) = parse_ssh_public_key(&private_key_path, &path) {
664 keys.push(key_info);
665 }
666 }
667 }
668 }
669 }
670
671 Ok(keys)
672}
673
674fn parse_ssh_public_key(private_path: &Path, public_path: &Path) -> Result<SshKeyInfo> {
676 let public_key_content = fs::read_to_string(public_path)
677 .with_context(|| format!("Failed to read {}", public_path.display()))?;
678
679 let parts: Vec<&str> = public_key_content.trim().splitn(3, ' ').collect();
681
682 let algorithm = parts.first().unwrap_or(&"unknown").to_string();
683 let key_data = parts.get(1).unwrap_or(&"");
684 let comment = parts.get(2).map(|s| s.to_string());
685
686 let (algo_name, bits) = match algorithm.as_str() {
688 "ssh-ed25519" => ("ed25519".to_string(), None),
689 "ssh-rsa" => {
690 let bits = get_ssh_key_bits(public_path).ok();
692 ("rsa".to_string(), bits)
693 }
694 "ecdsa-sha2-nistp256" => ("ecdsa-p256".to_string(), Some(256)),
695 "ecdsa-sha2-nistp384" => ("ecdsa-p384".to_string(), Some(384)),
696 "ecdsa-sha2-nistp521" => ("ecdsa-p521".to_string(), Some(521)),
697 "sk-ssh-ed25519@openssh.com" => ("ed25519-sk".to_string(), None),
698 "sk-ecdsa-sha2-nistp256@openssh.com" => ("ecdsa-sk".to_string(), Some(256)),
699 _ => (algorithm.clone(), None),
700 };
701
702 let fingerprint = compute_ssh_fingerprint(key_data)?;
704
705 Ok(SshKeyInfo {
706 path: private_path.to_path_buf(),
707 algorithm: algo_name,
708 bits,
709 fingerprint,
710 comment,
711 })
712}
713
714fn compute_ssh_fingerprint(key_data: &str) -> Result<String> {
716 use base64::{Engine, engine::general_purpose::STANDARD};
717 use sha2::{Digest, Sha256};
718
719 let decoded = STANDARD
720 .decode(key_data)
721 .unwrap_or_else(|_| key_data.as_bytes().to_vec());
722
723 let mut hasher = Sha256::new();
724 hasher.update(&decoded);
725 let hash = hasher.finalize();
726
727 let fingerprint = base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash);
729 Ok(format!("SHA256:{}", fingerprint))
730}
731
732fn get_ssh_key_bits(public_path: &Path) -> Result<u32> {
734 let output = Command::new("ssh-keygen")
735 .args(["-l", "-f"])
736 .arg(public_path)
737 .output()
738 .context("Failed to run ssh-keygen")?;
739
740 if !output.status.success() {
741 return Err(anyhow!("ssh-keygen failed"));
742 }
743
744 let stdout = String::from_utf8_lossy(&output.stdout);
745 let bits_str = stdout.split_whitespace().next().unwrap_or("0");
747 bits_str
748 .parse()
749 .map_err(|_| anyhow!("Failed to parse key bits"))
750}
751
752fn perform_ssh_migration(
754 key: &SshKeyInfo,
755 cmd: &FromSshCommand,
756 out: &Output,
757 now: chrono::DateTime<chrono::Utc>,
758) -> Result<()> {
759 use auths_sdk::error::AgentError;
760 use auths_sdk::identity::initialize_registry_identity;
761 use auths_sdk::keychain::{KeyAlias, get_platform_keychain};
762 use auths_sdk::ports::RegistryBackend;
763 use auths_sdk::storage::{GitRegistryBackend, RegistryConfig};
764 use std::sync::Arc;
765 use zeroize::Zeroizing;
766
767 let keychain = get_platform_keychain().context("Failed to access platform keychain")?;
769
770 let key_alias = cmd.key_alias.clone().unwrap_or_else(|| {
772 let filename = key
773 .path
774 .file_name()
775 .and_then(|n| n.to_str())
776 .unwrap_or("unknown");
777 format!("ssh-{}", filename)
778 });
779
780 let repo_path = cmd.repo.clone().unwrap_or_else(|| {
782 dirs::home_dir()
783 .map(|h| h.join(".auths"))
784 .unwrap_or_else(|| PathBuf::from(".auths"))
785 });
786
787 out.print_info(&format!(
788 "Creating Auths identity with key alias: {}",
789 key_alias
790 ));
791
792 if !repo_path.exists() {
794 fs::create_dir_all(&repo_path)
795 .with_context(|| format!("Failed to create directory: {:?}", repo_path))?;
796 }
797
798 if !repo_path.join(".git").exists() {
800 git_command(&["init"])
801 .current_dir(&repo_path)
802 .output()
803 .context("Failed to initialize Git repository")?;
804 }
805
806 let _metadata = serde_json::json!({
808 "migrated_from": "ssh",
809 "ssh_key_path": key.path.display().to_string(),
810 "ssh_algorithm": key.algorithm,
811 "ssh_fingerprint": key.fingerprint,
812 "ssh_comment": key.comment,
813 "created_at": now.to_rfc3339()
814 });
815
816 struct MigrationPassphraseProvider;
818 impl auths_sdk::signing::PassphraseProvider for MigrationPassphraseProvider {
819 fn get_passphrase(&self, prompt: &str) -> Result<Zeroizing<String>, AgentError> {
820 let _ = prompt;
821 Ok(Zeroizing::new(String::new()))
822 }
823 }
824 let passphrase_provider = MigrationPassphraseProvider;
825
826 let backend: Arc<dyn RegistryBackend + Send + Sync> = Arc::new(
828 GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&repo_path)),
829 );
830 let key_alias = KeyAlias::new_unchecked(key_alias);
831 match initialize_registry_identity(
832 backend,
833 &key_alias,
834 &passphrase_provider,
835 keychain.as_ref(),
836 auths_sdk::witness::WitnessParams::Disabled,
837 auths_crypto::CurveType::default(),
838 chrono::Utc::now(),
839 ) {
840 Ok((controller_did, alias)) => {
841 out.print_success(&format!("Created Auths identity: {}", controller_did));
842
843 out.print_info("Creating cross-reference attestation...");
845
846 let attestation = create_ssh_cross_reference_attestation(key, &controller_did, now)?;
847
848 let attestation_path = repo_path.join("ssh-migration.json");
850 fs::write(
851 &attestation_path,
852 serde_json::to_string_pretty(&attestation)?,
853 )
854 .context("Failed to write attestation file")?;
855
856 out.print_success("Cross-reference attestation created");
857
858 if cmd.update_allowed_signers {
860 if let Err(e) = update_allowed_signers(&controller_did, &key.comment) {
861 out.print_warn(&format!("Could not update allowed_signers: {}", e));
862 } else {
863 out.print_success("Updated allowed_signers file");
864 }
865 }
866
867 out.newline();
868
869 out.print_heading("Migration Complete");
870 out.println(&format!(
871 " SSH Key: {}",
872 out.dim(&key.path.display().to_string())
873 ));
874 out.println(&format!(" Algorithm: {}", key.algorithm));
875 out.println(&format!(
876 " Fingerprint: {}",
877 out.dim(&key.fingerprint)
878 ));
879 out.println(&format!(
880 " Auths Identity: {}",
881 out.info(&controller_did)
882 ));
883 out.println(&format!(" Key Alias: {}", out.info(&alias)));
884 out.println(&format!(
885 " Repository: {}",
886 out.info(&repo_path.display().to_string())
887 ));
888 out.println(&format!(
889 " Attestation: {}",
890 out.dim(&attestation_path.display().to_string())
891 ));
892 out.newline();
893
894 out.print_heading("Next Steps");
895 out.println(" 1. Start using Auths for new commits:");
896 out.println(" auths agent start");
897 out.println(" 2. Existing SSH-signed commits remain verifiable");
898 out.println(
899 " 3. Run 'auths verify HEAD' after your next commit to confirm it verifies",
900 );
901
902 Ok(())
903 }
904 Err(e) => Err(e).context("Failed to initialize identity"),
905 }
906}
907
908fn create_ssh_cross_reference_attestation(
910 ssh_key: &SshKeyInfo,
911 auths_did: &str,
912 now: chrono::DateTime<chrono::Utc>,
913) -> Result<serde_json::Value> {
914 let attestation = serde_json::json!({
915 "version": 1,
916 "type": "ssh-migration",
917 "ssh": {
918 "path": ssh_key.path.display().to_string(),
919 "algorithm": ssh_key.algorithm,
920 "fingerprint": ssh_key.fingerprint,
921 "comment": ssh_key.comment
922 },
923 "auths": {
924 "did": auths_did
925 },
926 "statement": "This attestation links the SSH key to the Auths identity. Both keys belong to the same entity.",
927 "created_at": now.to_rfc3339()
928 });
929
930 Ok(attestation)
931}
932
933fn update_allowed_signers(auths_did: &str, email: &Option<String>) -> Result<()> {
935 let allowed_signers_path = dirs::home_dir()
936 .map(|h| h.join(".ssh").join("allowed_signers"))
937 .ok_or_else(|| anyhow!("Could not determine home directory"))?;
938
939 let mut content = if allowed_signers_path.exists() {
941 fs::read_to_string(&allowed_signers_path)?
942 } else {
943 String::new()
944 };
945
946 let email_str = email.as_deref().unwrap_or("*");
948 let entry = format!(
949 "\n# Auths identity: {}\n{} namespaces=\"git\" {}\n",
950 auths_did, email_str, auths_did
951 );
952
953 content.push_str(&entry);
954
955 fs::write(&allowed_signers_path, content)?;
956
957 Ok(())
958}
959
960#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
966#[serde(rename_all = "lowercase")]
967pub enum SigningMethod {
968 Auths,
970 Gpg,
972 Ssh,
974 Unsigned,
976 Unknown,
978}
979
980#[derive(Debug, Default, Clone, Serialize, Deserialize)]
982pub struct MigrationStats {
983 pub total: usize,
984 pub auths_signed: usize,
985 pub gpg_signed: usize,
986 pub ssh_signed: usize,
987 pub unsigned: usize,
988 pub unknown: usize,
989}
990
991#[derive(Debug, Clone, Serialize, Deserialize)]
993pub struct AuthorStatus {
994 pub name: String,
995 pub email: String,
996 pub total_commits: usize,
997 pub auths_signed: usize,
998 pub gpg_signed: usize,
999 pub ssh_signed: usize,
1000 pub unsigned: usize,
1001 pub primary_method: SigningMethod,
1002}
1003
1004#[derive(Debug, Serialize, Deserialize)]
1006pub struct MigrationStatusOutput {
1007 pub stats: MigrationStats,
1008 pub authors: Vec<AuthorStatus>,
1009}
1010
1011fn handle_migrate_status(cmd: MigrateStatusCommand) -> Result<()> {
1013 let out = Output::new();
1014
1015 let repo_path = cmd
1017 .repo
1018 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1019
1020 if !repo_path.join(".git").exists() && !repo_path.ends_with(".git") {
1022 return Err(anyhow!("Not a Git repository: {}", repo_path.display()));
1023 }
1024
1025 let (stats, authors) = analyze_commit_signatures(&repo_path, cmd.count)?;
1027
1028 if is_json_mode() {
1030 let output = MigrationStatusOutput {
1031 stats: stats.clone(),
1032 authors: authors.clone(),
1033 };
1034 println!("{}", serde_json::to_string_pretty(&output)?);
1035 return Ok(());
1036 }
1037
1038 out.print_heading("Migration Status");
1040 out.newline();
1041
1042 out.println(&format!(" Last {} commits:", stats.total));
1044 out.newline();
1045
1046 let auths_pct = if stats.total > 0 {
1048 (stats.auths_signed * 100) / stats.total
1049 } else {
1050 0
1051 };
1052 let gpg_pct = if stats.total > 0 {
1053 (stats.gpg_signed * 100) / stats.total
1054 } else {
1055 0
1056 };
1057 let ssh_pct = if stats.total > 0 {
1058 (stats.ssh_signed * 100) / stats.total
1059 } else {
1060 0
1061 };
1062 let unsigned_pct = if stats.total > 0 {
1063 (stats.unsigned * 100) / stats.total
1064 } else {
1065 0
1066 };
1067
1068 let progress_bar = |count: usize, total: usize, width: usize| -> String {
1070 let filled = if total > 0 {
1071 (count * width) / total
1072 } else {
1073 0
1074 };
1075 let empty = width.saturating_sub(filled);
1076 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
1077 };
1078
1079 out.println(&format!(
1080 " {} Auths-signed: {:>4} ({:>3}%) {}",
1081 out.success("✓"),
1082 stats.auths_signed,
1083 auths_pct,
1084 out.success(&progress_bar(stats.auths_signed, stats.total, 20))
1085 ));
1086
1087 out.println(&format!(
1088 " {} GPG-signed: {:>4} ({:>3}%) {}",
1089 out.info("●"),
1090 stats.gpg_signed,
1091 gpg_pct,
1092 out.info(&progress_bar(stats.gpg_signed, stats.total, 20))
1093 ));
1094
1095 out.println(&format!(
1096 " {} SSH-signed: {:>4} ({:>3}%) {}",
1097 out.info("●"),
1098 stats.ssh_signed,
1099 ssh_pct,
1100 out.info(&progress_bar(stats.ssh_signed, stats.total, 20))
1101 ));
1102
1103 out.println(&format!(
1104 " {} Unsigned: {:>4} ({:>3}%) {}",
1105 out.warn("○"),
1106 stats.unsigned,
1107 unsigned_pct,
1108 out.dim(&progress_bar(stats.unsigned, stats.total, 20))
1109 ));
1110
1111 if cmd.by_author && !authors.is_empty() {
1113 out.newline();
1114 out.print_heading(" Per-Author Status");
1115 out.newline();
1116
1117 for author in &authors {
1118 let status_icon = match author.primary_method {
1119 SigningMethod::Auths => out.success("✅"),
1120 SigningMethod::Gpg => out.info("🔄"),
1121 SigningMethod::Ssh => out.info("🔄"),
1122 SigningMethod::Unsigned => out.warn("⚠️"),
1123 SigningMethod::Unknown => out.dim("?"),
1124 };
1125
1126 let method_str = match author.primary_method {
1127 SigningMethod::Auths => "Auths",
1128 SigningMethod::Gpg => "GPG (pending)",
1129 SigningMethod::Ssh => "SSH (pending)",
1130 SigningMethod::Unsigned => "Unsigned",
1131 SigningMethod::Unknown => "Unknown",
1132 };
1133
1134 out.println(&format!(
1135 " {} {} <{}> - {} ({} commits)",
1136 status_icon,
1137 out.bold(&author.name),
1138 out.dim(&author.email),
1139 method_str,
1140 author.total_commits
1141 ));
1142 }
1143 }
1144
1145 out.newline();
1146
1147 if stats.gpg_signed > 0 || stats.ssh_signed > 0 {
1149 out.print_heading(" Next Steps");
1150 out.newline();
1151 if stats.gpg_signed > 0 {
1152 out.println(" For GPG users: auths id migrate from-gpg");
1153 }
1154 if stats.ssh_signed > 0 {
1155 out.println(" For SSH users: auths id migrate from-ssh");
1156 }
1157 }
1158
1159 Ok(())
1160}
1161
1162fn analyze_commit_signatures(
1164 repo_path: &PathBuf,
1165 count: usize,
1166) -> Result<(MigrationStats, Vec<AuthorStatus>)> {
1167 use std::collections::HashMap;
1168
1169 let output = git_command(&[
1173 "log",
1174 &format!("-{}", count),
1175 "--pretty=format:%H|%an|%ae|%G?|%GK|%GS",
1176 ])
1177 .current_dir(repo_path)
1178 .output()
1179 .context("Failed to run git log")?;
1180
1181 if !output.status.success() {
1182 let stderr = String::from_utf8_lossy(&output.stderr);
1183 return Err(anyhow!("git log failed: {}", stderr));
1184 }
1185
1186 let stdout = String::from_utf8_lossy(&output.stdout);
1187
1188 let mut stats = MigrationStats::default();
1189 let mut author_map: HashMap<String, AuthorStatus> = HashMap::new();
1190
1191 for line in stdout.lines() {
1192 if line.trim().is_empty() {
1193 continue;
1194 }
1195
1196 let parts: Vec<&str> = line.split('|').collect();
1197 if parts.len() < 5 {
1198 continue;
1199 }
1200
1201 let _commit_hash = parts[0];
1202 let author_name = parts[1];
1203 let author_email = parts[2];
1204 let sig_status = parts[3]; let sig_key = parts[4];
1206 let sig_signer = if parts.len() > 5 { parts[5] } else { "" };
1207
1208 let method = match sig_status {
1213 "G" | "U" | "X" | "Y" | "R" | "E" => {
1214 if sig_signer.starts_with("ssh-")
1215 || sig_signer.starts_with("ecdsa-")
1216 || sig_signer.starts_with("sk-ssh-")
1217 || sig_key.starts_with("SHA256:")
1218 {
1219 SigningMethod::Ssh
1220 } else {
1221 SigningMethod::Gpg
1222 }
1223 }
1224 "N" | "" => SigningMethod::Unsigned,
1225 _ => SigningMethod::Unknown,
1226 };
1227
1228 stats.total += 1;
1230 match method {
1231 SigningMethod::Auths => stats.auths_signed += 1,
1232 SigningMethod::Gpg => stats.gpg_signed += 1,
1233 SigningMethod::Ssh => stats.ssh_signed += 1,
1234 SigningMethod::Unsigned => stats.unsigned += 1,
1235 SigningMethod::Unknown => stats.unknown += 1,
1236 }
1237
1238 let author_key = format!("{} <{}>", author_name, author_email);
1240 let author = author_map
1241 .entry(author_key)
1242 .or_insert_with(|| AuthorStatus {
1243 name: author_name.to_string(),
1244 email: author_email.to_string(),
1245 total_commits: 0,
1246 auths_signed: 0,
1247 gpg_signed: 0,
1248 ssh_signed: 0,
1249 unsigned: 0,
1250 primary_method: SigningMethod::Unsigned,
1251 });
1252
1253 author.total_commits += 1;
1254 match method {
1255 SigningMethod::Auths => author.auths_signed += 1,
1256 SigningMethod::Gpg => author.gpg_signed += 1,
1257 SigningMethod::Ssh => author.ssh_signed += 1,
1258 SigningMethod::Unsigned => author.unsigned += 1,
1259 SigningMethod::Unknown => {}
1260 }
1261 }
1262
1263 let mut authors: Vec<AuthorStatus> = author_map.into_values().collect();
1265 for author in &mut authors {
1266 author.primary_method = if author.auths_signed > 0 {
1267 SigningMethod::Auths
1268 } else if author.gpg_signed > author.ssh_signed && author.gpg_signed > author.unsigned {
1269 SigningMethod::Gpg
1270 } else if author.ssh_signed > author.unsigned {
1271 SigningMethod::Ssh
1272 } else {
1273 SigningMethod::Unsigned
1274 };
1275 }
1276
1277 authors.sort_by(|a, b| b.total_commits.cmp(&a.total_commits));
1279
1280 Ok((stats, authors))
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use super::*;
1286
1287 #[test]
1288 fn test_parse_gpg_colon_output() {
1289 let output = r#"sec:u:4096:1:ABCD1234EFGH5678:1609459200:1704067200::::scESC::::::23::0:
1290fpr:::::::::ABCD1234EFGH5678IJKL9012MNOP3456QRST7890:
1291uid:u::::1609459200::ABCD1234::Test User <test@example.com>::::::::::0:
1292"#;
1293
1294 let keys = parse_gpg_colon_output(output).unwrap();
1295 assert_eq!(keys.len(), 1);
1296 assert!(keys[0].user_id.contains("Test User"));
1297 assert!(keys[0].fingerprint.contains("ABCD1234"));
1298 }
1299
1300 #[test]
1301 fn test_parse_empty_output() {
1302 let keys = parse_gpg_colon_output("").unwrap();
1303 assert!(keys.is_empty());
1304 }
1305
1306 #[test]
1307 fn test_gpg_key_info_serialization() {
1308 let key = GpgKeyInfo {
1309 key_id: "ABCD1234".to_string(),
1310 fingerprint: "ABCD1234EFGH5678".to_string(),
1311 user_id: "Test <test@example.com>".to_string(),
1312 algorithm: "rsa4096".to_string(),
1313 created: "1609459200".to_string(),
1314 expires: None,
1315 };
1316
1317 let json = serde_json::to_string(&key).unwrap();
1318 assert!(json.contains("ABCD1234"));
1319 assert!(json.contains("rsa4096"));
1320 }
1321
1322 #[test]
1323 fn test_ssh_key_info_serialization() {
1324 let key = SshKeyInfo {
1325 path: PathBuf::from("/home/user/.ssh/id_ed25519"),
1326 algorithm: "ed25519".to_string(),
1327 bits: None,
1328 fingerprint: "SHA256:abcdefg".to_string(),
1329 comment: Some("user@example.com".to_string()),
1330 };
1331
1332 let json = serde_json::to_string(&key).unwrap();
1333 assert!(json.contains("ed25519"));
1334 assert!(json.contains("SHA256:abcdefg"));
1335 }
1336
1337 #[test]
1338 fn test_compute_ssh_fingerprint() {
1339 let fingerprint = compute_ssh_fingerprint("AAAAC3NzaC1lZDI1NTE5").unwrap();
1341 assert!(fingerprint.starts_with("SHA256:"));
1342 }
1343
1344 #[test]
1345 fn test_ssh_algorithm_mapping() {
1346 let test_cases = [
1348 ("ssh-ed25519", "ed25519"),
1349 ("ssh-rsa", "rsa"),
1350 ("ecdsa-sha2-nistp256", "ecdsa-p256"),
1351 ("sk-ssh-ed25519@openssh.com", "ed25519-sk"),
1352 ];
1353
1354 for (input, expected) in test_cases {
1355 let algo = match input {
1356 "ssh-ed25519" => "ed25519",
1357 "ssh-rsa" => "rsa",
1358 "ecdsa-sha2-nistp256" => "ecdsa-p256",
1359 "ecdsa-sha2-nistp384" => "ecdsa-p384",
1360 "ecdsa-sha2-nistp521" => "ecdsa-p521",
1361 "sk-ssh-ed25519@openssh.com" => "ed25519-sk",
1362 "sk-ecdsa-sha2-nistp256@openssh.com" => "ecdsa-sk",
1363 _ => input,
1364 };
1365 assert_eq!(algo, expected);
1366 }
1367 }
1368}