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