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 None,
439 auths_crypto::CurveType::default(),
440 ) {
441 Ok((controller_did, alias)) => {
442 out.print_success(&format!("Created Auths identity: {}", controller_did));
443
444 out.print_info("Creating cross-reference attestation...");
446
447 let attestation = create_gpg_cross_reference_attestation(key, &controller_did, now)?;
448
449 let attestation_path = repo_path.join("gpg-migration.json");
451 fs::write(
452 &attestation_path,
453 serde_json::to_string_pretty(&attestation)?,
454 )
455 .context("Failed to write attestation file")?;
456
457 out.print_success("Cross-reference attestation created");
458 out.newline();
459
460 out.print_heading("Migration Complete");
461 out.println(&format!(" GPG Key: {}", out.dim(&key.key_id)));
462 out.println(&format!(" GPG User: {}", key.user_id));
463 out.println(&format!(
464 " Auths Identity: {}",
465 out.info(&controller_did)
466 ));
467 out.println(&format!(" Key Alias: {}", out.info(&alias)));
468 out.println(&format!(
469 " Repository: {}",
470 out.info(&repo_path.display().to_string())
471 ));
472 out.println(&format!(
473 " Attestation: {}",
474 out.dim(&attestation_path.display().to_string())
475 ));
476 out.newline();
477
478 out.print_heading("Next Steps");
479 out.println(" 1. Sign the attestation with your GPG key:");
480 out.println(&format!(
481 " gpg --armor --detach-sign {}",
482 attestation_path.display()
483 ));
484 out.println(" 2. Start using Auths for new commits:");
485 out.println(" auths agent start");
486 out.println(" 3. Existing GPG-signed commits remain verifiable");
487
488 Ok(())
489 }
490 Err(e) => Err(e).context("Failed to initialize identity"),
491 }
492}
493
494fn create_gpg_cross_reference_attestation(
496 gpg_key: &GpgKeyInfo,
497 auths_did: &str,
498 now: chrono::DateTime<chrono::Utc>,
499) -> Result<serde_json::Value> {
500 let attestation = serde_json::json!({
501 "version": 1,
502 "type": "gpg-migration",
503 "gpg": {
504 "key_id": gpg_key.key_id,
505 "fingerprint": gpg_key.fingerprint,
506 "user_id": gpg_key.user_id,
507 "algorithm": gpg_key.algorithm
508 },
509 "auths": {
510 "did": auths_did
511 },
512 "statement": "This attestation links the GPG key to the Auths identity. Both keys belong to the same entity.",
513 "created_at": now.to_rfc3339(),
514 "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."
515 });
516
517 Ok(attestation)
518}
519
520fn handle_from_ssh(cmd: FromSshCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
526 let out = Output::new();
527
528 let keys = list_ssh_keys()?;
530
531 if keys.is_empty() {
532 out.print_warn("No SSH keys found in ~/.ssh/");
533 out.println(" To create an SSH key: ssh-keygen -t ed25519");
534 return Ok(());
535 }
536
537 if cmd.list {
539 out.print_heading("Available SSH Keys");
540 out.newline();
541 for (i, key) in keys.iter().enumerate() {
542 let bits_str = key
543 .bits
544 .map(|b| format!(" ({} bits)", b))
545 .unwrap_or_default();
546 out.println(&format!(
547 " {}. {} {}{}",
548 i + 1,
549 out.bold(&key.path.display().to_string()),
550 out.dim(&key.algorithm),
551 bits_str
552 ));
553 out.println(&format!(" Fingerprint: {}", out.dim(&key.fingerprint)));
554 if let Some(comment) = &key.comment {
555 out.println(&format!(" Comment: {}", comment));
556 }
557 out.newline();
558 }
559 return Ok(());
560 }
561
562 let key = if let Some(key_path) = &cmd.key {
564 keys.iter()
565 .find(|k| k.path == *key_path || k.path.file_name() == key_path.file_name())
566 .ok_or_else(|| anyhow!("SSH key not found: {}", key_path.display()))?
567 .clone()
568 } else if keys.len() == 1 {
569 keys[0].clone()
570 } else {
571 out.print_heading("Multiple SSH keys found. Please specify one:");
572 out.newline();
573 for key in &keys {
574 out.println(&format!(
575 " {} ({})",
576 out.bold(&key.path.display().to_string()),
577 key.algorithm
578 ));
579 }
580 out.newline();
581 out.println("Use: auths id migrate from-ssh --key <PATH>");
582 return Ok(());
583 };
584
585 out.print_heading("SSH Key Migration");
586 out.newline();
587 out.println(&format!(
588 " {} Found SSH key: {}",
589 out.success("✓"),
590 key.path.display()
591 ));
592 out.println(&format!(" Algorithm: {}", out.info(&key.algorithm)));
593 out.println(&format!(" Fingerprint: {}", out.dim(&key.fingerprint)));
594 if let Some(comment) = &key.comment {
595 out.println(&format!(" Comment: {}", comment));
596 }
597 out.newline();
598
599 if cmd.dry_run {
600 out.print_info("Dry run mode - no changes will be made");
601 out.newline();
602 out.println("Would perform the following actions:");
603 out.println(" 1. Create new Auths Ed25519 identity");
604 out.println(" 2. Create cross-reference attestation linking SSH key to Auths identity");
605 if cmd.update_allowed_signers {
606 out.println(" 3. Update allowed_signers file with new Auths key");
607 }
608 out.newline();
609 out.print_info("Re-run without --dry-run to execute migration");
610 return Ok(());
611 }
612
613 perform_ssh_migration(&key, &cmd, &out, now)
615}
616
617fn list_ssh_keys() -> Result<Vec<SshKeyInfo>> {
619 let ssh_dir = dirs::home_dir()
620 .map(|h| h.join(".ssh"))
621 .ok_or_else(|| anyhow!("Could not determine home directory"))?;
622
623 if !ssh_dir.exists() {
624 return Ok(Vec::new());
625 }
626
627 let mut keys = Vec::new();
628
629 let key_patterns = [
631 "id_ed25519",
632 "id_rsa",
633 "id_ecdsa",
634 "id_ecdsa_sk",
635 "id_ed25519_sk",
636 "id_dsa",
637 ];
638
639 for pattern in &key_patterns {
640 let private_key_path = ssh_dir.join(pattern);
641 let public_key_path = ssh_dir.join(format!("{}.pub", pattern));
642
643 if private_key_path.exists()
644 && public_key_path.exists()
645 && let Ok(key_info) = parse_ssh_public_key(&private_key_path, &public_key_path)
646 {
647 keys.push(key_info);
648 }
649 }
650
651 if let Ok(entries) = fs::read_dir(&ssh_dir) {
653 for entry in entries.flatten() {
654 let path = entry.path();
655 if path.extension().map(|e| e == "pub").unwrap_or(false) {
656 let private_key_path = path.with_extension("");
657 if private_key_path.exists() {
658 if keys.iter().any(|k| k.path == private_key_path) {
660 continue;
661 }
662 if let Ok(key_info) = parse_ssh_public_key(&private_key_path, &path) {
663 keys.push(key_info);
664 }
665 }
666 }
667 }
668 }
669
670 Ok(keys)
671}
672
673fn parse_ssh_public_key(private_path: &Path, public_path: &Path) -> Result<SshKeyInfo> {
675 let public_key_content = fs::read_to_string(public_path)
676 .with_context(|| format!("Failed to read {}", public_path.display()))?;
677
678 let parts: Vec<&str> = public_key_content.trim().splitn(3, ' ').collect();
680
681 let algorithm = parts.first().unwrap_or(&"unknown").to_string();
682 let key_data = parts.get(1).unwrap_or(&"");
683 let comment = parts.get(2).map(|s| s.to_string());
684
685 let (algo_name, bits) = match algorithm.as_str() {
687 "ssh-ed25519" => ("ed25519".to_string(), None),
688 "ssh-rsa" => {
689 let bits = get_ssh_key_bits(public_path).ok();
691 ("rsa".to_string(), bits)
692 }
693 "ecdsa-sha2-nistp256" => ("ecdsa-p256".to_string(), Some(256)),
694 "ecdsa-sha2-nistp384" => ("ecdsa-p384".to_string(), Some(384)),
695 "ecdsa-sha2-nistp521" => ("ecdsa-p521".to_string(), Some(521)),
696 "sk-ssh-ed25519@openssh.com" => ("ed25519-sk".to_string(), None),
697 "sk-ecdsa-sha2-nistp256@openssh.com" => ("ecdsa-sk".to_string(), Some(256)),
698 _ => (algorithm.clone(), None),
699 };
700
701 let fingerprint = compute_ssh_fingerprint(key_data)?;
703
704 Ok(SshKeyInfo {
705 path: private_path.to_path_buf(),
706 algorithm: algo_name,
707 bits,
708 fingerprint,
709 comment,
710 })
711}
712
713fn compute_ssh_fingerprint(key_data: &str) -> Result<String> {
715 use base64::{Engine, engine::general_purpose::STANDARD};
716 use sha2::{Digest, Sha256};
717
718 let decoded = STANDARD
719 .decode(key_data)
720 .unwrap_or_else(|_| key_data.as_bytes().to_vec());
721
722 let mut hasher = Sha256::new();
723 hasher.update(&decoded);
724 let hash = hasher.finalize();
725
726 let fingerprint = base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash);
728 Ok(format!("SHA256:{}", fingerprint))
729}
730
731fn get_ssh_key_bits(public_path: &Path) -> Result<u32> {
733 let output = Command::new("ssh-keygen")
734 .args(["-l", "-f"])
735 .arg(public_path)
736 .output()
737 .context("Failed to run ssh-keygen")?;
738
739 if !output.status.success() {
740 return Err(anyhow!("ssh-keygen failed"));
741 }
742
743 let stdout = String::from_utf8_lossy(&output.stdout);
744 let bits_str = stdout.split_whitespace().next().unwrap_or("0");
746 bits_str
747 .parse()
748 .map_err(|_| anyhow!("Failed to parse key bits"))
749}
750
751fn perform_ssh_migration(
753 key: &SshKeyInfo,
754 cmd: &FromSshCommand,
755 out: &Output,
756 now: chrono::DateTime<chrono::Utc>,
757) -> Result<()> {
758 use auths_sdk::error::AgentError;
759 use auths_sdk::identity::initialize_registry_identity;
760 use auths_sdk::keychain::{KeyAlias, get_platform_keychain};
761 use auths_sdk::ports::RegistryBackend;
762 use auths_sdk::storage::{GitRegistryBackend, RegistryConfig};
763 use std::sync::Arc;
764 use zeroize::Zeroizing;
765
766 let keychain = get_platform_keychain().context("Failed to access platform keychain")?;
768
769 let key_alias = cmd.key_alias.clone().unwrap_or_else(|| {
771 let filename = key
772 .path
773 .file_name()
774 .and_then(|n| n.to_str())
775 .unwrap_or("unknown");
776 format!("ssh-{}", filename)
777 });
778
779 let repo_path = cmd.repo.clone().unwrap_or_else(|| {
781 dirs::home_dir()
782 .map(|h| h.join(".auths"))
783 .unwrap_or_else(|| PathBuf::from(".auths"))
784 });
785
786 out.print_info(&format!(
787 "Creating Auths identity with key alias: {}",
788 key_alias
789 ));
790
791 if !repo_path.exists() {
793 fs::create_dir_all(&repo_path)
794 .with_context(|| format!("Failed to create directory: {:?}", repo_path))?;
795 }
796
797 if !repo_path.join(".git").exists() {
799 git_command(&["init"])
800 .current_dir(&repo_path)
801 .output()
802 .context("Failed to initialize Git repository")?;
803 }
804
805 let _metadata = serde_json::json!({
807 "migrated_from": "ssh",
808 "ssh_key_path": key.path.display().to_string(),
809 "ssh_algorithm": key.algorithm,
810 "ssh_fingerprint": key.fingerprint,
811 "ssh_comment": key.comment,
812 "created_at": now.to_rfc3339()
813 });
814
815 struct MigrationPassphraseProvider;
817 impl auths_sdk::signing::PassphraseProvider for MigrationPassphraseProvider {
818 fn get_passphrase(&self, prompt: &str) -> Result<Zeroizing<String>, AgentError> {
819 let _ = prompt;
820 Ok(Zeroizing::new(String::new()))
821 }
822 }
823 let passphrase_provider = MigrationPassphraseProvider;
824
825 let backend: Arc<dyn RegistryBackend + Send + Sync> = Arc::new(
827 GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&repo_path)),
828 );
829 let key_alias = KeyAlias::new_unchecked(key_alias);
830 match initialize_registry_identity(
831 backend,
832 &key_alias,
833 &passphrase_provider,
834 keychain.as_ref(),
835 None,
836 auths_crypto::CurveType::default(),
837 ) {
838 Ok((controller_did, alias)) => {
839 out.print_success(&format!("Created Auths identity: {}", controller_did));
840
841 out.print_info("Creating cross-reference attestation...");
843
844 let attestation = create_ssh_cross_reference_attestation(key, &controller_did, now)?;
845
846 let attestation_path = repo_path.join("ssh-migration.json");
848 fs::write(
849 &attestation_path,
850 serde_json::to_string_pretty(&attestation)?,
851 )
852 .context("Failed to write attestation file")?;
853
854 out.print_success("Cross-reference attestation created");
855
856 if cmd.update_allowed_signers {
858 if let Err(e) = update_allowed_signers(&controller_did, &key.comment) {
859 out.print_warn(&format!("Could not update allowed_signers: {}", e));
860 } else {
861 out.print_success("Updated allowed_signers file");
862 }
863 }
864
865 out.newline();
866
867 out.print_heading("Migration Complete");
868 out.println(&format!(
869 " SSH Key: {}",
870 out.dim(&key.path.display().to_string())
871 ));
872 out.println(&format!(" Algorithm: {}", key.algorithm));
873 out.println(&format!(
874 " Fingerprint: {}",
875 out.dim(&key.fingerprint)
876 ));
877 out.println(&format!(
878 " Auths Identity: {}",
879 out.info(&controller_did)
880 ));
881 out.println(&format!(" Key Alias: {}", out.info(&alias)));
882 out.println(&format!(
883 " Repository: {}",
884 out.info(&repo_path.display().to_string())
885 ));
886 out.println(&format!(
887 " Attestation: {}",
888 out.dim(&attestation_path.display().to_string())
889 ));
890 out.newline();
891
892 out.print_heading("Next Steps");
893 out.println(" 1. Start using Auths for new commits:");
894 out.println(" auths agent start");
895 out.println(" 2. Existing SSH-signed commits remain verifiable");
896 out.println(
897 " 3. Run 'auths verify HEAD' after your next commit to confirm it verifies",
898 );
899
900 Ok(())
901 }
902 Err(e) => Err(e).context("Failed to initialize identity"),
903 }
904}
905
906fn create_ssh_cross_reference_attestation(
908 ssh_key: &SshKeyInfo,
909 auths_did: &str,
910 now: chrono::DateTime<chrono::Utc>,
911) -> Result<serde_json::Value> {
912 let attestation = serde_json::json!({
913 "version": 1,
914 "type": "ssh-migration",
915 "ssh": {
916 "path": ssh_key.path.display().to_string(),
917 "algorithm": ssh_key.algorithm,
918 "fingerprint": ssh_key.fingerprint,
919 "comment": ssh_key.comment
920 },
921 "auths": {
922 "did": auths_did
923 },
924 "statement": "This attestation links the SSH key to the Auths identity. Both keys belong to the same entity.",
925 "created_at": now.to_rfc3339()
926 });
927
928 Ok(attestation)
929}
930
931fn update_allowed_signers(auths_did: &str, email: &Option<String>) -> Result<()> {
933 let allowed_signers_path = dirs::home_dir()
934 .map(|h| h.join(".ssh").join("allowed_signers"))
935 .ok_or_else(|| anyhow!("Could not determine home directory"))?;
936
937 let mut content = if allowed_signers_path.exists() {
939 fs::read_to_string(&allowed_signers_path)?
940 } else {
941 String::new()
942 };
943
944 let email_str = email.as_deref().unwrap_or("*");
946 let entry = format!(
947 "\n# Auths identity: {}\n{} namespaces=\"git\" {}\n",
948 auths_did, email_str, auths_did
949 );
950
951 content.push_str(&entry);
952
953 fs::write(&allowed_signers_path, content)?;
954
955 Ok(())
956}
957
958#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
964#[serde(rename_all = "lowercase")]
965pub enum SigningMethod {
966 Auths,
968 Gpg,
970 Ssh,
972 Unsigned,
974 Unknown,
976}
977
978#[derive(Debug, Default, Clone, Serialize, Deserialize)]
980pub struct MigrationStats {
981 pub total: usize,
982 pub auths_signed: usize,
983 pub gpg_signed: usize,
984 pub ssh_signed: usize,
985 pub unsigned: usize,
986 pub unknown: usize,
987}
988
989#[derive(Debug, Clone, Serialize, Deserialize)]
991pub struct AuthorStatus {
992 pub name: String,
993 pub email: String,
994 pub total_commits: usize,
995 pub auths_signed: usize,
996 pub gpg_signed: usize,
997 pub ssh_signed: usize,
998 pub unsigned: usize,
999 pub primary_method: SigningMethod,
1000}
1001
1002#[derive(Debug, Serialize, Deserialize)]
1004pub struct MigrationStatusOutput {
1005 pub stats: MigrationStats,
1006 pub authors: Vec<AuthorStatus>,
1007}
1008
1009fn handle_migrate_status(cmd: MigrateStatusCommand) -> Result<()> {
1011 let out = Output::new();
1012
1013 let repo_path = cmd
1015 .repo
1016 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1017
1018 if !repo_path.join(".git").exists() && !repo_path.ends_with(".git") {
1020 return Err(anyhow!("Not a Git repository: {}", repo_path.display()));
1021 }
1022
1023 let (stats, authors) = analyze_commit_signatures(&repo_path, cmd.count)?;
1025
1026 if is_json_mode() {
1028 let output = MigrationStatusOutput {
1029 stats: stats.clone(),
1030 authors: authors.clone(),
1031 };
1032 println!("{}", serde_json::to_string_pretty(&output)?);
1033 return Ok(());
1034 }
1035
1036 out.print_heading("Migration Status");
1038 out.newline();
1039
1040 out.println(&format!(" Last {} commits:", stats.total));
1042 out.newline();
1043
1044 let auths_pct = if stats.total > 0 {
1046 (stats.auths_signed * 100) / stats.total
1047 } else {
1048 0
1049 };
1050 let gpg_pct = if stats.total > 0 {
1051 (stats.gpg_signed * 100) / stats.total
1052 } else {
1053 0
1054 };
1055 let ssh_pct = if stats.total > 0 {
1056 (stats.ssh_signed * 100) / stats.total
1057 } else {
1058 0
1059 };
1060 let unsigned_pct = if stats.total > 0 {
1061 (stats.unsigned * 100) / stats.total
1062 } else {
1063 0
1064 };
1065
1066 let progress_bar = |count: usize, total: usize, width: usize| -> String {
1068 let filled = if total > 0 {
1069 (count * width) / total
1070 } else {
1071 0
1072 };
1073 let empty = width.saturating_sub(filled);
1074 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
1075 };
1076
1077 out.println(&format!(
1078 " {} Auths-signed: {:>4} ({:>3}%) {}",
1079 out.success("✓"),
1080 stats.auths_signed,
1081 auths_pct,
1082 out.success(&progress_bar(stats.auths_signed, stats.total, 20))
1083 ));
1084
1085 out.println(&format!(
1086 " {} GPG-signed: {:>4} ({:>3}%) {}",
1087 out.info("●"),
1088 stats.gpg_signed,
1089 gpg_pct,
1090 out.info(&progress_bar(stats.gpg_signed, stats.total, 20))
1091 ));
1092
1093 out.println(&format!(
1094 " {} SSH-signed: {:>4} ({:>3}%) {}",
1095 out.info("●"),
1096 stats.ssh_signed,
1097 ssh_pct,
1098 out.info(&progress_bar(stats.ssh_signed, stats.total, 20))
1099 ));
1100
1101 out.println(&format!(
1102 " {} Unsigned: {:>4} ({:>3}%) {}",
1103 out.warn("○"),
1104 stats.unsigned,
1105 unsigned_pct,
1106 out.dim(&progress_bar(stats.unsigned, stats.total, 20))
1107 ));
1108
1109 if cmd.by_author && !authors.is_empty() {
1111 out.newline();
1112 out.print_heading(" Per-Author Status");
1113 out.newline();
1114
1115 for author in &authors {
1116 let status_icon = match author.primary_method {
1117 SigningMethod::Auths => out.success("✅"),
1118 SigningMethod::Gpg => out.info("🔄"),
1119 SigningMethod::Ssh => out.info("🔄"),
1120 SigningMethod::Unsigned => out.warn("⚠️"),
1121 SigningMethod::Unknown => out.dim("?"),
1122 };
1123
1124 let method_str = match author.primary_method {
1125 SigningMethod::Auths => "Auths",
1126 SigningMethod::Gpg => "GPG (pending)",
1127 SigningMethod::Ssh => "SSH (pending)",
1128 SigningMethod::Unsigned => "Unsigned",
1129 SigningMethod::Unknown => "Unknown",
1130 };
1131
1132 out.println(&format!(
1133 " {} {} <{}> - {} ({} commits)",
1134 status_icon,
1135 out.bold(&author.name),
1136 out.dim(&author.email),
1137 method_str,
1138 author.total_commits
1139 ));
1140 }
1141 }
1142
1143 out.newline();
1144
1145 if stats.gpg_signed > 0 || stats.ssh_signed > 0 {
1147 out.print_heading(" Next Steps");
1148 out.newline();
1149 if stats.gpg_signed > 0 {
1150 out.println(" For GPG users: auths id migrate from-gpg");
1151 }
1152 if stats.ssh_signed > 0 {
1153 out.println(" For SSH users: auths id migrate from-ssh");
1154 }
1155 }
1156
1157 Ok(())
1158}
1159
1160fn analyze_commit_signatures(
1162 repo_path: &PathBuf,
1163 count: usize,
1164) -> Result<(MigrationStats, Vec<AuthorStatus>)> {
1165 use std::collections::HashMap;
1166
1167 let output = git_command(&[
1171 "log",
1172 &format!("-{}", count),
1173 "--pretty=format:%H|%an|%ae|%G?|%GK|%GS",
1174 ])
1175 .current_dir(repo_path)
1176 .output()
1177 .context("Failed to run git log")?;
1178
1179 if !output.status.success() {
1180 let stderr = String::from_utf8_lossy(&output.stderr);
1181 return Err(anyhow!("git log failed: {}", stderr));
1182 }
1183
1184 let stdout = String::from_utf8_lossy(&output.stdout);
1185
1186 let mut stats = MigrationStats::default();
1187 let mut author_map: HashMap<String, AuthorStatus> = HashMap::new();
1188
1189 for line in stdout.lines() {
1190 if line.trim().is_empty() {
1191 continue;
1192 }
1193
1194 let parts: Vec<&str> = line.split('|').collect();
1195 if parts.len() < 5 {
1196 continue;
1197 }
1198
1199 let _commit_hash = parts[0];
1200 let author_name = parts[1];
1201 let author_email = parts[2];
1202 let sig_status = parts[3]; let sig_key = parts[4];
1204 let sig_signer = if parts.len() > 5 { parts[5] } else { "" };
1205
1206 let method = match sig_status {
1211 "G" | "U" | "X" | "Y" | "R" | "E" => {
1212 if sig_signer.starts_with("ssh-")
1213 || sig_signer.starts_with("ecdsa-")
1214 || sig_signer.starts_with("sk-ssh-")
1215 || sig_key.starts_with("SHA256:")
1216 {
1217 SigningMethod::Ssh
1218 } else {
1219 SigningMethod::Gpg
1220 }
1221 }
1222 "N" | "" => SigningMethod::Unsigned,
1223 _ => SigningMethod::Unknown,
1224 };
1225
1226 stats.total += 1;
1228 match method {
1229 SigningMethod::Auths => stats.auths_signed += 1,
1230 SigningMethod::Gpg => stats.gpg_signed += 1,
1231 SigningMethod::Ssh => stats.ssh_signed += 1,
1232 SigningMethod::Unsigned => stats.unsigned += 1,
1233 SigningMethod::Unknown => stats.unknown += 1,
1234 }
1235
1236 let author_key = format!("{} <{}>", author_name, author_email);
1238 let author = author_map
1239 .entry(author_key)
1240 .or_insert_with(|| AuthorStatus {
1241 name: author_name.to_string(),
1242 email: author_email.to_string(),
1243 total_commits: 0,
1244 auths_signed: 0,
1245 gpg_signed: 0,
1246 ssh_signed: 0,
1247 unsigned: 0,
1248 primary_method: SigningMethod::Unsigned,
1249 });
1250
1251 author.total_commits += 1;
1252 match method {
1253 SigningMethod::Auths => author.auths_signed += 1,
1254 SigningMethod::Gpg => author.gpg_signed += 1,
1255 SigningMethod::Ssh => author.ssh_signed += 1,
1256 SigningMethod::Unsigned => author.unsigned += 1,
1257 SigningMethod::Unknown => {}
1258 }
1259 }
1260
1261 let mut authors: Vec<AuthorStatus> = author_map.into_values().collect();
1263 for author in &mut authors {
1264 author.primary_method = if author.auths_signed > 0 {
1265 SigningMethod::Auths
1266 } else if author.gpg_signed > author.ssh_signed && author.gpg_signed > author.unsigned {
1267 SigningMethod::Gpg
1268 } else if author.ssh_signed > author.unsigned {
1269 SigningMethod::Ssh
1270 } else {
1271 SigningMethod::Unsigned
1272 };
1273 }
1274
1275 authors.sort_by(|a, b| b.total_commits.cmp(&a.total_commits));
1277
1278 Ok((stats, authors))
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283 use super::*;
1284
1285 #[test]
1286 fn test_parse_gpg_colon_output() {
1287 let output = r#"sec:u:4096:1:ABCD1234EFGH5678:1609459200:1704067200::::scESC::::::23::0:
1288fpr:::::::::ABCD1234EFGH5678IJKL9012MNOP3456QRST7890:
1289uid:u::::1609459200::ABCD1234::Test User <test@example.com>::::::::::0:
1290"#;
1291
1292 let keys = parse_gpg_colon_output(output).unwrap();
1293 assert_eq!(keys.len(), 1);
1294 assert!(keys[0].user_id.contains("Test User"));
1295 assert!(keys[0].fingerprint.contains("ABCD1234"));
1296 }
1297
1298 #[test]
1299 fn test_parse_empty_output() {
1300 let keys = parse_gpg_colon_output("").unwrap();
1301 assert!(keys.is_empty());
1302 }
1303
1304 #[test]
1305 fn test_gpg_key_info_serialization() {
1306 let key = GpgKeyInfo {
1307 key_id: "ABCD1234".to_string(),
1308 fingerprint: "ABCD1234EFGH5678".to_string(),
1309 user_id: "Test <test@example.com>".to_string(),
1310 algorithm: "rsa4096".to_string(),
1311 created: "1609459200".to_string(),
1312 expires: None,
1313 };
1314
1315 let json = serde_json::to_string(&key).unwrap();
1316 assert!(json.contains("ABCD1234"));
1317 assert!(json.contains("rsa4096"));
1318 }
1319
1320 #[test]
1321 fn test_ssh_key_info_serialization() {
1322 let key = SshKeyInfo {
1323 path: PathBuf::from("/home/user/.ssh/id_ed25519"),
1324 algorithm: "ed25519".to_string(),
1325 bits: None,
1326 fingerprint: "SHA256:abcdefg".to_string(),
1327 comment: Some("user@example.com".to_string()),
1328 };
1329
1330 let json = serde_json::to_string(&key).unwrap();
1331 assert!(json.contains("ed25519"));
1332 assert!(json.contains("SHA256:abcdefg"));
1333 }
1334
1335 #[test]
1336 fn test_compute_ssh_fingerprint() {
1337 let fingerprint = compute_ssh_fingerprint("AAAAC3NzaC1lZDI1NTE5").unwrap();
1339 assert!(fingerprint.starts_with("SHA256:"));
1340 }
1341
1342 #[test]
1343 fn test_ssh_algorithm_mapping() {
1344 let test_cases = [
1346 ("ssh-ed25519", "ed25519"),
1347 ("ssh-rsa", "rsa"),
1348 ("ecdsa-sha2-nistp256", "ecdsa-p256"),
1349 ("sk-ssh-ed25519@openssh.com", "ed25519-sk"),
1350 ];
1351
1352 for (input, expected) in test_cases {
1353 let algo = match input {
1354 "ssh-ed25519" => "ed25519",
1355 "ssh-rsa" => "rsa",
1356 "ecdsa-sha2-nistp256" => "ecdsa-p256",
1357 "ecdsa-sha2-nistp384" => "ecdsa-p384",
1358 "ecdsa-sha2-nistp521" => "ecdsa-p521",
1359 "sk-ssh-ed25519@openssh.com" => "ed25519-sk",
1360 "sk-ecdsa-sha2-nistp256@openssh.com" => "ecdsa-sk",
1361 _ => input,
1362 };
1363 assert_eq!(algo, expected);
1364 }
1365 }
1366}