Skip to main content

auths_cli/commands/id/
migrate.rs

1//! Migration commands for importing existing keys into Auths.
2//!
3//! Supports migrating from:
4//! - GPG keys (`auths migrate from-gpg`)
5//! - SSH keys (`auths migrate from-ssh`)
6
7use 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/// Migrate existing keys to Auths identities.
16#[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    /// Import an existing GPG key.
26    #[command(name = "from-gpg")]
27    FromGpg(FromGpgCommand),
28
29    /// Import an existing SSH key.
30    #[command(name = "from-ssh")]
31    FromSsh(FromSshCommand),
32
33    /// Show migration status for a repository.
34    #[command(name = "status")]
35    Status(MigrateStatusCommand),
36}
37
38/// Import a GPG key into Auths.
39#[derive(Parser, Debug, Clone)]
40pub struct FromGpgCommand {
41    /// Specific GPG key ID to import (e.g., 0xABCD1234).
42    #[arg(long, value_name = "KEY_ID")]
43    pub key_id: Option<String>,
44
45    /// List available GPG keys without importing.
46    #[arg(long)]
47    pub list: bool,
48
49    /// Preview the migration without making changes.
50    #[arg(long)]
51    pub dry_run: bool,
52
53    /// Path to the Auths repository (defaults to ~/.auths or current repo).
54    #[arg(long)]
55    pub repo: Option<PathBuf>,
56
57    /// Key alias for storing the new Auths key (defaults to gpg-<keyid>).
58    #[arg(long)]
59    pub key_alias: Option<String>,
60}
61
62/// Import an SSH key into Auths.
63#[derive(Parser, Debug, Clone)]
64pub struct FromSshCommand {
65    /// Path to the SSH private key file (e.g., ~/.ssh/id_ed25519).
66    #[arg(long, short = 'k', value_name = "PATH")]
67    pub key: Option<PathBuf>,
68
69    /// List available SSH keys without importing.
70    #[arg(long)]
71    pub list: bool,
72
73    /// Preview the migration without making changes.
74    #[arg(long)]
75    pub dry_run: bool,
76
77    /// Path to the Auths repository (defaults to ~/.auths or current repo).
78    #[arg(long)]
79    pub repo: Option<PathBuf>,
80
81    /// Key alias for storing the new Auths key (defaults to ssh-<filename>).
82    #[arg(long)]
83    pub key_alias: Option<String>,
84
85    /// Update allowed_signers file if it exists.
86    #[arg(long)]
87    pub update_allowed_signers: bool,
88}
89
90/// Show migration status for a repository.
91#[derive(Parser, Debug, Clone)]
92pub struct MigrateStatusCommand {
93    /// Path to the Git repository to analyze (defaults to current directory).
94    #[arg(long)]
95    pub repo: Option<PathBuf>,
96
97    /// Number of commits to analyze (default: 100).
98    #[arg(long, short = 'n', default_value = "100")]
99    pub count: usize,
100
101    /// Show per-author breakdown.
102    #[arg(long)]
103    pub by_author: bool,
104}
105
106/// Information about a GPG key.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct GpgKeyInfo {
109    /// Key ID (short form).
110    pub key_id: String,
111    /// Key fingerprint (full).
112    pub fingerprint: String,
113    /// User ID (name <email>).
114    pub user_id: String,
115    /// Key algorithm (e.g., rsa4096, ed25519).
116    pub algorithm: String,
117    /// Creation date.
118    pub created: String,
119    /// Expiry date (if any).
120    pub expires: Option<String>,
121}
122
123/// Information about an SSH key.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct SshKeyInfo {
126    /// Path to the private key file.
127    pub path: PathBuf,
128    /// Key algorithm (ed25519, rsa, ecdsa).
129    pub algorithm: String,
130    /// Key size in bits (for RSA).
131    pub bits: Option<u32>,
132    /// Public key fingerprint.
133    pub fingerprint: String,
134    /// Comment from the public key file.
135    pub comment: Option<String>,
136}
137
138/// Handle the migrate command.
139pub 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
147/// Handle the from-gpg subcommand.
148fn handle_from_gpg(cmd: FromGpgCommand) -> Result<()> {
149    let out = Output::new();
150
151    // Check if GPG is installed
152    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    // List GPG keys
159    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 --list flag, just show keys and exit
168    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    // Find the key to migrate
189    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 the actual migration
234    perform_gpg_migration(&key, &cmd, &out)
235}
236
237/// Check if GPG is available.
238fn is_gpg_available() -> bool {
239    Command::new("gpg").arg("--version").output().is_ok()
240}
241
242/// List GPG secret keys.
243fn list_gpg_secret_keys() -> Result<Vec<GpgKeyInfo>> {
244    // Use gpg with colon-separated output for reliable parsing
245    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
264/// Parse GPG colon-separated output.
265fn 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                // Secret key line: sec:u:4096:1:KEYID:created:expires::::algo:
278                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                // Fingerprint line
323                if let Some(ref mut key) = current_key {
324                    key.fingerprint = fields.get(9).unwrap_or(&"").to_string();
325                }
326            }
327            "uid" => {
328                // User ID line
329                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
346/// Perform the actual GPG key migration.
347fn 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    // Get keychain
358    let keychain = get_platform_keychain().context("Failed to access platform keychain")?;
359
360    // Determine key alias
361    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    // Determine repo path
376    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    // Ensure repo directory exists
388    if !repo_path.exists() {
389        fs::create_dir_all(&repo_path)
390            .with_context(|| format!("Failed to create directory: {:?}", repo_path))?;
391    }
392
393    // Initialize Git repo if needed
394    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    // Create metadata linking to GPG key
403    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    // Create a simple passphrase provider that prompts if needed
412    struct MigrationPassphraseProvider;
413    impl auths_core::signing::PassphraseProvider for MigrationPassphraseProvider {
414        fn get_passphrase(&self, prompt: &str) -> Result<Zeroizing<String>, AgentError> {
415            // For migration, we create unencrypted keys by default
416            // Return empty passphrase
417            let _ = prompt;
418            Ok(Zeroizing::new(String::new()))
419        }
420    }
421    let passphrase_provider = MigrationPassphraseProvider;
422
423    // Initialize the identity
424    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            // Create cross-reference attestation
439            out.print_info("Creating cross-reference attestation...");
440
441            let attestation = create_gpg_cross_reference_attestation(key, &controller_did)?;
442
443            // Save the attestation
444            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
488/// Create a cross-reference attestation linking GPG key to Auths identity.
489fn 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
513// ============================================================================
514// SSH Migration
515// ============================================================================
516
517/// Handle the from-ssh subcommand.
518fn handle_from_ssh(cmd: FromSshCommand) -> Result<()> {
519    let out = Output::new();
520
521    // Scan for SSH keys
522    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 --list flag, just show keys and exit
531    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    // Find the key to migrate
556    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 the actual migration
607    perform_ssh_migration(&key, &cmd, &out)
608}
609
610/// List SSH keys in ~/.ssh/
611fn 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    // Look for common SSH key filenames
623    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    // Also scan for any other .pub files
645    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                    // Skip if we already found this key
652                    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
666/// Parse an SSH public key file to extract key info.
667fn 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    // SSH public key format: <algorithm> <base64-key> [comment]
672    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    // Determine algorithm type and bits
679    let (algo_name, bits) = match algorithm.as_str() {
680        "ssh-ed25519" => ("ed25519".to_string(), None),
681        "ssh-rsa" => {
682            // For RSA, we need to check the key size
683            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    // Compute fingerprint (SHA256 of the base64-decoded key data)
695    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
706/// Compute SSH key fingerprint (SHA256).
707fn 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    // Format as SHA256:base64
720    let fingerprint = base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash);
721    Ok(format!("SHA256:{}", fingerprint))
722}
723
724/// Get SSH key bits using ssh-keygen.
725fn 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    // Output format: "4096 SHA256:... comment (RSA)"
738    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
744/// Perform the actual SSH key migration.
745fn 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    // Get keychain
755    let keychain = get_platform_keychain().context("Failed to access platform keychain")?;
756
757    // Determine key alias
758    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    // Determine repo path
768    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    // Ensure repo directory exists
780    if !repo_path.exists() {
781        fs::create_dir_all(&repo_path)
782            .with_context(|| format!("Failed to create directory: {:?}", repo_path))?;
783    }
784
785    // Initialize Git repo if needed
786    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    // Create metadata linking to SSH key
795    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    // Create a simple passphrase provider
805    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    // Initialize the identity
815    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            // Create cross-reference attestation
830            out.print_info("Creating cross-reference attestation...");
831
832            let attestation = create_ssh_cross_reference_attestation(key, &controller_did)?;
833
834            // Save the attestation
835            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            // Update allowed_signers if requested
845            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
892/// Create a cross-reference attestation linking SSH key to Auths identity.
893fn 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
916/// Update the allowed_signers file with the new Auths identity.
917fn 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    // Read existing content or start fresh
923    let mut content = if allowed_signers_path.exists() {
924        fs::read_to_string(&allowed_signers_path)?
925    } else {
926        String::new()
927    };
928
929    // Add a comment and the new entry
930    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// ============================================================================
944// Migration Status
945// ============================================================================
946
947/// Signing method detected for a commit.
948#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
949#[serde(rename_all = "lowercase")]
950pub enum SigningMethod {
951    /// Signed with Auths identity.
952    Auths,
953    /// Signed with GPG.
954    Gpg,
955    /// Signed with SSH.
956    Ssh,
957    /// No signature.
958    Unsigned,
959    /// Unknown signature type.
960    Unknown,
961}
962
963/// Statistics for migration status.
964#[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/// Per-author migration status.
975#[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/// Full migration status output.
988#[derive(Debug, Serialize, Deserialize)]
989pub struct MigrationStatusOutput {
990    pub stats: MigrationStats,
991    pub authors: Vec<AuthorStatus>,
992}
993
994/// Handle the migrate status subcommand.
995fn handle_migrate_status(cmd: MigrateStatusCommand) -> Result<()> {
996    let out = Output::new();
997
998    // Determine repo path
999    let repo_path = cmd
1000        .repo
1001        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1002
1003    // Check if it's a git repo
1004    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    // Analyze commits
1009    let (stats, authors) = analyze_commit_signatures(&repo_path, cmd.count)?;
1010
1011    // Output
1012    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    // Text output
1022    out.print_heading("Migration Status");
1023    out.newline();
1024
1025    // Overall stats
1026    out.println(&format!("  Last {} commits:", stats.total));
1027    out.newline();
1028
1029    // Calculate percentages
1030    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    // Progress bar helper
1052    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    // Per-author breakdown
1095    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    // Migration suggestion
1131    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
1145/// Analyze commit signatures in a repository.
1146fn analyze_commit_signatures(
1147    repo_path: &PathBuf,
1148    count: usize,
1149) -> Result<(MigrationStats, Vec<AuthorStatus>)> {
1150    use std::collections::HashMap;
1151
1152    // Use git log to get commit info with signatures
1153    // %GS = signer identity (SSH keys show "ssh-ed25519 ...", GPG shows key ID/email)
1154    // %GK = signing key fingerprint
1155    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]; // G=good, B=bad, U=untrusted, X=expired, Y=expired key, R=revoked, E=missing key, N=none
1189        let sig_key = parts[4];
1190        let sig_signer = if parts.len() > 5 { parts[5] } else { "" };
1191
1192        // Determine signing method by inspecting the signer identity and key format.
1193        // SSH signatures: %GS starts with an SSH key type (e.g., "ssh-ed25519", "ssh-rsa")
1194        //                 %GK is an SSH fingerprint like "SHA256:..."
1195        // GPG signatures: %GS is a name/email, %GK is a hex key ID
1196        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        // Update stats
1213        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        // Update author stats
1223        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    // Determine primary method for each author
1248    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    // Sort authors by commit count
1262    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        // Test with a known key data
1324        let fingerprint = compute_ssh_fingerprint("AAAAC3NzaC1lZDI1NTE5").unwrap();
1325        assert!(fingerprint.starts_with("SHA256:"));
1326    }
1327
1328    #[test]
1329    fn test_ssh_algorithm_mapping() {
1330        // Test that we correctly map SSH algorithm strings
1331        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}