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 id migrate from-gpg`)
5//! - SSH keys (`auths id migrate from-ssh`)
6
7use 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/// Migrate existing keys to Auths identities.
17#[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    /// Import an existing GPG key.
27    #[command(name = "from-gpg")]
28    FromGpg(FromGpgCommand),
29
30    /// Import an existing SSH key.
31    #[command(name = "from-ssh")]
32    FromSsh(FromSshCommand),
33
34    /// Show migration status for a repository.
35    #[command(name = "status")]
36    Status(MigrateStatusCommand),
37}
38
39/// Import a GPG key into Auths.
40#[derive(Parser, Debug, Clone)]
41pub struct FromGpgCommand {
42    /// Specific GPG key ID to import (e.g., 0xABCD1234).
43    #[arg(long, value_name = "KEY_ID")]
44    pub key_id: Option<String>,
45
46    /// List available GPG keys without importing.
47    #[arg(long)]
48    pub list: bool,
49
50    /// Preview the migration without making changes.
51    #[arg(long)]
52    pub dry_run: bool,
53
54    /// Path to the Auths repository (defaults to ~/.auths or current repo).
55    #[arg(long)]
56    pub repo: Option<PathBuf>,
57
58    /// Key alias for storing the new Auths key (defaults to gpg-<keyid>).
59    #[arg(long)]
60    pub key_alias: Option<String>,
61}
62
63/// Import an SSH key into Auths.
64#[derive(Parser, Debug, Clone)]
65pub struct FromSshCommand {
66    /// Path to the SSH private key file (e.g., ~/.ssh/id_ed25519).
67    #[arg(long, short = 'k', value_name = "PATH")]
68    pub key: Option<PathBuf>,
69
70    /// List available SSH keys without importing.
71    #[arg(long)]
72    pub list: bool,
73
74    /// Preview the migration without making changes.
75    #[arg(long)]
76    pub dry_run: bool,
77
78    /// Path to the Auths repository (defaults to ~/.auths or current repo).
79    #[arg(long)]
80    pub repo: Option<PathBuf>,
81
82    /// Key alias for storing the new Auths key (defaults to ssh-<filename>).
83    #[arg(long)]
84    pub key_alias: Option<String>,
85
86    /// Update allowed_signers file if it exists.
87    #[arg(long)]
88    pub update_allowed_signers: bool,
89}
90
91/// Show migration status for a repository.
92#[derive(Parser, Debug, Clone)]
93pub struct MigrateStatusCommand {
94    /// Path to the Git repository to analyze (defaults to current directory).
95    #[arg(long)]
96    pub repo: Option<PathBuf>,
97
98    /// Number of commits to analyze (default: 100).
99    #[arg(long, short = 'n', default_value = "100")]
100    pub count: usize,
101
102    /// Show per-author breakdown.
103    #[arg(long)]
104    pub by_author: bool,
105}
106
107/// Information about a GPG key.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct GpgKeyInfo {
110    /// Key ID (short form).
111    pub key_id: String,
112    /// Key fingerprint (full).
113    pub fingerprint: String,
114    /// User ID (name <email>).
115    pub user_id: String,
116    /// Key algorithm (e.g., rsa4096, ed25519).
117    pub algorithm: String,
118    /// Creation date.
119    pub created: String,
120    /// Expiry date (if any).
121    pub expires: Option<String>,
122}
123
124/// Information about an SSH key.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct SshKeyInfo {
127    /// Path to the private key file.
128    pub path: PathBuf,
129    /// Key algorithm (ed25519, rsa, ecdsa).
130    pub algorithm: String,
131    /// Key size in bits (for RSA).
132    pub bits: Option<u32>,
133    /// Public key fingerprint.
134    pub fingerprint: String,
135    /// Comment from the public key file.
136    pub comment: Option<String>,
137}
138
139/// Handle the migrate command.
140pub 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
148/// Handle the from-gpg subcommand.
149fn handle_from_gpg(cmd: FromGpgCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
150    let out = Output::new();
151
152    // Check if GPG is installed
153    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    // List GPG keys
160    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 --list flag, just show keys and exit
169    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    // Find the key to migrate
190    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 the actual migration
235    perform_gpg_migration(&key, &cmd, &out, now)
236}
237
238/// Check if GPG is available.
239fn is_gpg_available() -> bool {
240    Command::new("gpg").arg("--version").output().is_ok()
241}
242
243/// List GPG secret keys.
244fn list_gpg_secret_keys() -> Result<Vec<GpgKeyInfo>> {
245    // Use gpg with colon-separated output for reliable parsing
246    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
265/// Parse GPG colon-separated output.
266fn 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                // Secret key line: sec:u:4096:1:KEYID:created:expires::::algo:
279                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                // Fingerprint line
324                if let Some(ref mut key) = current_key {
325                    key.fingerprint = fields.get(9).unwrap_or(&"").to_string();
326                }
327            }
328            "uid" => {
329                // User ID line
330                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
347/// Perform the actual GPG key migration.
348fn 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    // Get keychain
364    let keychain = get_platform_keychain().context("Failed to access platform keychain")?;
365
366    // Determine key alias
367    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    // Determine repo path
382    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    // Ensure repo directory exists
394    if !repo_path.exists() {
395        fs::create_dir_all(&repo_path)
396            .with_context(|| format!("Failed to create directory: {:?}", repo_path))?;
397    }
398
399    // Initialize Git repo if needed
400    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    // Create metadata linking to GPG key
408    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    // Create a simple passphrase provider that prompts if needed
417    struct MigrationPassphraseProvider;
418    impl auths_sdk::signing::PassphraseProvider for MigrationPassphraseProvider {
419        fn get_passphrase(&self, prompt: &str) -> Result<Zeroizing<String>, AgentError> {
420            // For migration, we create unencrypted keys by default
421            // Return empty passphrase
422            let _ = prompt;
423            Ok(Zeroizing::new(String::new()))
424        }
425    }
426    let passphrase_provider = MigrationPassphraseProvider;
427
428    // Initialize the identity
429    let backend: Arc<dyn RegistryBackend + Send + Sync> = Arc::new(
430        GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&repo_path)),
431    );
432    let key_alias = KeyAlias::new_unchecked(key_alias);
433    match initialize_registry_identity(
434        backend,
435        &key_alias,
436        &passphrase_provider,
437        keychain.as_ref(),
438        auths_sdk::witness::WitnessParams::Disabled,
439        auths_crypto::CurveType::default(),
440        chrono::Utc::now(),
441    ) {
442        Ok((controller_did, alias)) => {
443            out.print_success(&format!("Created Auths identity: {}", controller_did));
444
445            // Create cross-reference attestation
446            out.print_info("Creating cross-reference attestation...");
447
448            let attestation = create_gpg_cross_reference_attestation(key, &controller_did, now)?;
449
450            // Save the attestation
451            let attestation_path = repo_path.join("gpg-migration.json");
452            fs::write(
453                &attestation_path,
454                serde_json::to_string_pretty(&attestation)?,
455            )
456            .context("Failed to write attestation file")?;
457
458            out.print_success("Cross-reference attestation created");
459            out.newline();
460
461            out.print_heading("Migration Complete");
462            out.println(&format!("  GPG Key:          {}", out.dim(&key.key_id)));
463            out.println(&format!("  GPG User:         {}", key.user_id));
464            out.println(&format!(
465                "  Auths Identity:   {}",
466                out.info(&controller_did)
467            ));
468            out.println(&format!("  Key Alias:        {}", out.info(&alias)));
469            out.println(&format!(
470                "  Repository:       {}",
471                out.info(&repo_path.display().to_string())
472            ));
473            out.println(&format!(
474                "  Attestation:      {}",
475                out.dim(&attestation_path.display().to_string())
476            ));
477            out.newline();
478
479            out.print_heading("Next Steps");
480            out.println("  1. Sign the attestation with your GPG key:");
481            out.println(&format!(
482                "     gpg --armor --detach-sign {}",
483                attestation_path.display()
484            ));
485            out.println("  2. Start using Auths for new commits:");
486            out.println("     auths agent start");
487            out.println("  3. Existing GPG-signed commits remain verifiable");
488
489            Ok(())
490        }
491        Err(e) => Err(e).context("Failed to initialize identity"),
492    }
493}
494
495/// Create a cross-reference attestation linking GPG key to Auths identity.
496fn create_gpg_cross_reference_attestation(
497    gpg_key: &GpgKeyInfo,
498    auths_did: &str,
499    now: chrono::DateTime<chrono::Utc>,
500) -> Result<serde_json::Value> {
501    let attestation = serde_json::json!({
502        "version": 1,
503        "type": "gpg-migration",
504        "gpg": {
505            "key_id": gpg_key.key_id,
506            "fingerprint": gpg_key.fingerprint,
507            "user_id": gpg_key.user_id,
508            "algorithm": gpg_key.algorithm
509        },
510        "auths": {
511            "did": auths_did
512        },
513        "statement": "This attestation links the GPG key to the Auths identity. Both keys belong to the same entity.",
514        "created_at": now.to_rfc3339(),
515        "instructions": "To complete the cross-reference: 1) Sign this file with your GPG key using 'gpg --armor --detach-sign', 2) The Auths signature will be added automatically."
516    });
517
518    Ok(attestation)
519}
520
521// ============================================================================
522// SSH Migration
523// ============================================================================
524
525/// Handle the from-ssh subcommand.
526fn handle_from_ssh(cmd: FromSshCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
527    let out = Output::new();
528
529    // Scan for SSH keys
530    let keys = list_ssh_keys()?;
531
532    if keys.is_empty() {
533        out.print_warn("No SSH keys found in ~/.ssh/");
534        out.println("  To create an SSH key: ssh-keygen -t ed25519");
535        return Ok(());
536    }
537
538    // If --list flag, just show keys and exit
539    if cmd.list {
540        out.print_heading("Available SSH Keys");
541        out.newline();
542        for (i, key) in keys.iter().enumerate() {
543            let bits_str = key
544                .bits
545                .map(|b| format!(" ({} bits)", b))
546                .unwrap_or_default();
547            out.println(&format!(
548                "  {}. {} {}{}",
549                i + 1,
550                out.bold(&key.path.display().to_string()),
551                out.dim(&key.algorithm),
552                bits_str
553            ));
554            out.println(&format!("     Fingerprint: {}", out.dim(&key.fingerprint)));
555            if let Some(comment) = &key.comment {
556                out.println(&format!("     Comment: {}", comment));
557            }
558            out.newline();
559        }
560        return Ok(());
561    }
562
563    // Find the key to migrate
564    let key = if let Some(key_path) = &cmd.key {
565        keys.iter()
566            .find(|k| k.path == *key_path || k.path.file_name() == key_path.file_name())
567            .ok_or_else(|| anyhow!("SSH key not found: {}", key_path.display()))?
568            .clone()
569    } else if keys.len() == 1 {
570        keys[0].clone()
571    } else {
572        out.print_heading("Multiple SSH keys found. Please specify one:");
573        out.newline();
574        for key in &keys {
575            out.println(&format!(
576                "  {} ({})",
577                out.bold(&key.path.display().to_string()),
578                key.algorithm
579            ));
580        }
581        out.newline();
582        out.println("Use: auths id migrate from-ssh --key <PATH>");
583        return Ok(());
584    };
585
586    out.print_heading("SSH Key Migration");
587    out.newline();
588    out.println(&format!(
589        "  {} Found SSH key: {}",
590        out.success("✓"),
591        key.path.display()
592    ));
593    out.println(&format!("  Algorithm: {}", out.info(&key.algorithm)));
594    out.println(&format!("  Fingerprint: {}", out.dim(&key.fingerprint)));
595    if let Some(comment) = &key.comment {
596        out.println(&format!("  Comment: {}", comment));
597    }
598    out.newline();
599
600    if cmd.dry_run {
601        out.print_info("Dry run mode - no changes will be made");
602        out.newline();
603        out.println("Would perform the following actions:");
604        out.println("  1. Create new Auths Ed25519 identity");
605        out.println("  2. Create cross-reference attestation linking SSH key to Auths identity");
606        if cmd.update_allowed_signers {
607            out.println("  3. Update allowed_signers file with new Auths key");
608        }
609        out.newline();
610        out.print_info("Re-run without --dry-run to execute migration");
611        return Ok(());
612    }
613
614    // Perform the actual migration
615    perform_ssh_migration(&key, &cmd, &out, now)
616}
617
618/// List SSH keys in ~/.ssh/
619fn list_ssh_keys() -> Result<Vec<SshKeyInfo>> {
620    let ssh_dir = dirs::home_dir()
621        .map(|h| h.join(".ssh"))
622        .ok_or_else(|| anyhow!("Could not determine home directory"))?;
623
624    if !ssh_dir.exists() {
625        return Ok(Vec::new());
626    }
627
628    let mut keys = Vec::new();
629
630    // Look for common SSH key filenames
631    let key_patterns = [
632        "id_ed25519",
633        "id_rsa",
634        "id_ecdsa",
635        "id_ecdsa_sk",
636        "id_ed25519_sk",
637        "id_dsa",
638    ];
639
640    for pattern in &key_patterns {
641        let private_key_path = ssh_dir.join(pattern);
642        let public_key_path = ssh_dir.join(format!("{}.pub", pattern));
643
644        if private_key_path.exists()
645            && public_key_path.exists()
646            && let Ok(key_info) = parse_ssh_public_key(&private_key_path, &public_key_path)
647        {
648            keys.push(key_info);
649        }
650    }
651
652    // Also scan for any other .pub files
653    if let Ok(entries) = fs::read_dir(&ssh_dir) {
654        for entry in entries.flatten() {
655            let path = entry.path();
656            if path.extension().map(|e| e == "pub").unwrap_or(false) {
657                let private_key_path = path.with_extension("");
658                if private_key_path.exists() {
659                    // Skip if we already found this key
660                    if keys.iter().any(|k| k.path == private_key_path) {
661                        continue;
662                    }
663                    if let Ok(key_info) = parse_ssh_public_key(&private_key_path, &path) {
664                        keys.push(key_info);
665                    }
666                }
667            }
668        }
669    }
670
671    Ok(keys)
672}
673
674/// Parse an SSH public key file to extract key info.
675fn parse_ssh_public_key(private_path: &Path, public_path: &Path) -> Result<SshKeyInfo> {
676    let public_key_content = fs::read_to_string(public_path)
677        .with_context(|| format!("Failed to read {}", public_path.display()))?;
678
679    // SSH public key format: <algorithm> <base64-key> [comment]
680    let parts: Vec<&str> = public_key_content.trim().splitn(3, ' ').collect();
681
682    let algorithm = parts.first().unwrap_or(&"unknown").to_string();
683    let key_data = parts.get(1).unwrap_or(&"");
684    let comment = parts.get(2).map(|s| s.to_string());
685
686    // Determine algorithm type and bits
687    let (algo_name, bits) = match algorithm.as_str() {
688        "ssh-ed25519" => ("ed25519".to_string(), None),
689        "ssh-rsa" => {
690            // For RSA, we need to check the key size
691            let bits = get_ssh_key_bits(public_path).ok();
692            ("rsa".to_string(), bits)
693        }
694        "ecdsa-sha2-nistp256" => ("ecdsa-p256".to_string(), Some(256)),
695        "ecdsa-sha2-nistp384" => ("ecdsa-p384".to_string(), Some(384)),
696        "ecdsa-sha2-nistp521" => ("ecdsa-p521".to_string(), Some(521)),
697        "sk-ssh-ed25519@openssh.com" => ("ed25519-sk".to_string(), None),
698        "sk-ecdsa-sha2-nistp256@openssh.com" => ("ecdsa-sk".to_string(), Some(256)),
699        _ => (algorithm.clone(), None),
700    };
701
702    // Compute fingerprint (SHA256 of the base64-decoded key data)
703    let fingerprint = compute_ssh_fingerprint(key_data)?;
704
705    Ok(SshKeyInfo {
706        path: private_path.to_path_buf(),
707        algorithm: algo_name,
708        bits,
709        fingerprint,
710        comment,
711    })
712}
713
714/// Compute SSH key fingerprint (SHA256).
715fn compute_ssh_fingerprint(key_data: &str) -> Result<String> {
716    use base64::{Engine, engine::general_purpose::STANDARD};
717    use sha2::{Digest, Sha256};
718
719    let decoded = STANDARD
720        .decode(key_data)
721        .unwrap_or_else(|_| key_data.as_bytes().to_vec());
722
723    let mut hasher = Sha256::new();
724    hasher.update(&decoded);
725    let hash = hasher.finalize();
726
727    // Format as SHA256:base64
728    let fingerprint = base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash);
729    Ok(format!("SHA256:{}", fingerprint))
730}
731
732/// Get SSH key bits using ssh-keygen.
733fn get_ssh_key_bits(public_path: &Path) -> Result<u32> {
734    let output = Command::new("ssh-keygen")
735        .args(["-l", "-f"])
736        .arg(public_path)
737        .output()
738        .context("Failed to run ssh-keygen")?;
739
740    if !output.status.success() {
741        return Err(anyhow!("ssh-keygen failed"));
742    }
743
744    let stdout = String::from_utf8_lossy(&output.stdout);
745    // Output format: "4096 SHA256:... comment (RSA)"
746    let bits_str = stdout.split_whitespace().next().unwrap_or("0");
747    bits_str
748        .parse()
749        .map_err(|_| anyhow!("Failed to parse key bits"))
750}
751
752/// Perform the actual SSH key migration.
753fn perform_ssh_migration(
754    key: &SshKeyInfo,
755    cmd: &FromSshCommand,
756    out: &Output,
757    now: chrono::DateTime<chrono::Utc>,
758) -> Result<()> {
759    use auths_sdk::error::AgentError;
760    use auths_sdk::identity::initialize_registry_identity;
761    use auths_sdk::keychain::{KeyAlias, get_platform_keychain};
762    use auths_sdk::ports::RegistryBackend;
763    use auths_sdk::storage::{GitRegistryBackend, RegistryConfig};
764    use std::sync::Arc;
765    use zeroize::Zeroizing;
766
767    // Get keychain
768    let keychain = get_platform_keychain().context("Failed to access platform keychain")?;
769
770    // Determine key alias
771    let key_alias = cmd.key_alias.clone().unwrap_or_else(|| {
772        let filename = key
773            .path
774            .file_name()
775            .and_then(|n| n.to_str())
776            .unwrap_or("unknown");
777        format!("ssh-{}", filename)
778    });
779
780    // Determine repo path
781    let repo_path = cmd.repo.clone().unwrap_or_else(|| {
782        dirs::home_dir()
783            .map(|h| h.join(".auths"))
784            .unwrap_or_else(|| PathBuf::from(".auths"))
785    });
786
787    out.print_info(&format!(
788        "Creating Auths identity with key alias: {}",
789        key_alias
790    ));
791
792    // Ensure repo directory exists
793    if !repo_path.exists() {
794        fs::create_dir_all(&repo_path)
795            .with_context(|| format!("Failed to create directory: {:?}", repo_path))?;
796    }
797
798    // Initialize Git repo if needed
799    if !repo_path.join(".git").exists() {
800        git_command(&["init"])
801            .current_dir(&repo_path)
802            .output()
803            .context("Failed to initialize Git repository")?;
804    }
805
806    // Create metadata linking to SSH key
807    let _metadata = serde_json::json!({
808        "migrated_from": "ssh",
809        "ssh_key_path": key.path.display().to_string(),
810        "ssh_algorithm": key.algorithm,
811        "ssh_fingerprint": key.fingerprint,
812        "ssh_comment": key.comment,
813        "created_at": now.to_rfc3339()
814    });
815
816    // Create a simple passphrase provider
817    struct MigrationPassphraseProvider;
818    impl auths_sdk::signing::PassphraseProvider for MigrationPassphraseProvider {
819        fn get_passphrase(&self, prompt: &str) -> Result<Zeroizing<String>, AgentError> {
820            let _ = prompt;
821            Ok(Zeroizing::new(String::new()))
822        }
823    }
824    let passphrase_provider = MigrationPassphraseProvider;
825
826    // Initialize the identity
827    let backend: Arc<dyn RegistryBackend + Send + Sync> = Arc::new(
828        GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&repo_path)),
829    );
830    let key_alias = KeyAlias::new_unchecked(key_alias);
831    match initialize_registry_identity(
832        backend,
833        &key_alias,
834        &passphrase_provider,
835        keychain.as_ref(),
836        auths_sdk::witness::WitnessParams::Disabled,
837        auths_crypto::CurveType::default(),
838        chrono::Utc::now(),
839    ) {
840        Ok((controller_did, alias)) => {
841            out.print_success(&format!("Created Auths identity: {}", controller_did));
842
843            // Create cross-reference attestation
844            out.print_info("Creating cross-reference attestation...");
845
846            let attestation = create_ssh_cross_reference_attestation(key, &controller_did, now)?;
847
848            // Save the attestation
849            let attestation_path = repo_path.join("ssh-migration.json");
850            fs::write(
851                &attestation_path,
852                serde_json::to_string_pretty(&attestation)?,
853            )
854            .context("Failed to write attestation file")?;
855
856            out.print_success("Cross-reference attestation created");
857
858            // Update allowed_signers if requested
859            if cmd.update_allowed_signers {
860                if let Err(e) = update_allowed_signers(&controller_did, &key.comment) {
861                    out.print_warn(&format!("Could not update allowed_signers: {}", e));
862                } else {
863                    out.print_success("Updated allowed_signers file");
864                }
865            }
866
867            out.newline();
868
869            out.print_heading("Migration Complete");
870            out.println(&format!(
871                "  SSH Key:          {}",
872                out.dim(&key.path.display().to_string())
873            ));
874            out.println(&format!("  Algorithm:        {}", key.algorithm));
875            out.println(&format!(
876                "  Fingerprint:      {}",
877                out.dim(&key.fingerprint)
878            ));
879            out.println(&format!(
880                "  Auths Identity:   {}",
881                out.info(&controller_did)
882            ));
883            out.println(&format!("  Key Alias:        {}", out.info(&alias)));
884            out.println(&format!(
885                "  Repository:       {}",
886                out.info(&repo_path.display().to_string())
887            ));
888            out.println(&format!(
889                "  Attestation:      {}",
890                out.dim(&attestation_path.display().to_string())
891            ));
892            out.newline();
893
894            out.print_heading("Next Steps");
895            out.println("  1. Start using Auths for new commits:");
896            out.println("     auths agent start");
897            out.println("  2. Existing SSH-signed commits remain verifiable");
898            out.println(
899                "  3. Run 'auths verify HEAD' after your next commit to confirm it verifies",
900            );
901
902            Ok(())
903        }
904        Err(e) => Err(e).context("Failed to initialize identity"),
905    }
906}
907
908/// Create a cross-reference attestation linking SSH key to Auths identity.
909fn create_ssh_cross_reference_attestation(
910    ssh_key: &SshKeyInfo,
911    auths_did: &str,
912    now: chrono::DateTime<chrono::Utc>,
913) -> Result<serde_json::Value> {
914    let attestation = serde_json::json!({
915        "version": 1,
916        "type": "ssh-migration",
917        "ssh": {
918            "path": ssh_key.path.display().to_string(),
919            "algorithm": ssh_key.algorithm,
920            "fingerprint": ssh_key.fingerprint,
921            "comment": ssh_key.comment
922        },
923        "auths": {
924            "did": auths_did
925        },
926        "statement": "This attestation links the SSH key to the Auths identity. Both keys belong to the same entity.",
927        "created_at": now.to_rfc3339()
928    });
929
930    Ok(attestation)
931}
932
933/// Update the allowed_signers file with the new Auths identity.
934fn update_allowed_signers(auths_did: &str, email: &Option<String>) -> Result<()> {
935    let allowed_signers_path = dirs::home_dir()
936        .map(|h| h.join(".ssh").join("allowed_signers"))
937        .ok_or_else(|| anyhow!("Could not determine home directory"))?;
938
939    // Read existing content or start fresh
940    let mut content = if allowed_signers_path.exists() {
941        fs::read_to_string(&allowed_signers_path)?
942    } else {
943        String::new()
944    };
945
946    // Add a comment and the new entry
947    let email_str = email.as_deref().unwrap_or("*");
948    let entry = format!(
949        "\n# Auths identity: {}\n{} namespaces=\"git\" {}\n",
950        auths_did, email_str, auths_did
951    );
952
953    content.push_str(&entry);
954
955    fs::write(&allowed_signers_path, content)?;
956
957    Ok(())
958}
959
960// ============================================================================
961// Migration Status
962// ============================================================================
963
964/// Signing method detected for a commit.
965#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
966#[serde(rename_all = "lowercase")]
967pub enum SigningMethod {
968    /// Signed with Auths identity.
969    Auths,
970    /// Signed with GPG.
971    Gpg,
972    /// Signed with SSH.
973    Ssh,
974    /// No signature.
975    Unsigned,
976    /// Unknown signature type.
977    Unknown,
978}
979
980/// Statistics for migration status.
981#[derive(Debug, Default, Clone, Serialize, Deserialize)]
982pub struct MigrationStats {
983    pub total: usize,
984    pub auths_signed: usize,
985    pub gpg_signed: usize,
986    pub ssh_signed: usize,
987    pub unsigned: usize,
988    pub unknown: usize,
989}
990
991/// Per-author migration status.
992#[derive(Debug, Clone, Serialize, Deserialize)]
993pub struct AuthorStatus {
994    pub name: String,
995    pub email: String,
996    pub total_commits: usize,
997    pub auths_signed: usize,
998    pub gpg_signed: usize,
999    pub ssh_signed: usize,
1000    pub unsigned: usize,
1001    pub primary_method: SigningMethod,
1002}
1003
1004/// Full migration status output.
1005#[derive(Debug, Serialize, Deserialize)]
1006pub struct MigrationStatusOutput {
1007    pub stats: MigrationStats,
1008    pub authors: Vec<AuthorStatus>,
1009}
1010
1011/// Handle the migrate status subcommand.
1012fn handle_migrate_status(cmd: MigrateStatusCommand) -> Result<()> {
1013    let out = Output::new();
1014
1015    // Determine repo path
1016    let repo_path = cmd
1017        .repo
1018        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1019
1020    // Check if it's a git repo
1021    if !repo_path.join(".git").exists() && !repo_path.ends_with(".git") {
1022        return Err(anyhow!("Not a Git repository: {}", repo_path.display()));
1023    }
1024
1025    // Analyze commits
1026    let (stats, authors) = analyze_commit_signatures(&repo_path, cmd.count)?;
1027
1028    // Output
1029    if is_json_mode() {
1030        let output = MigrationStatusOutput {
1031            stats: stats.clone(),
1032            authors: authors.clone(),
1033        };
1034        println!("{}", serde_json::to_string_pretty(&output)?);
1035        return Ok(());
1036    }
1037
1038    // Text output
1039    out.print_heading("Migration Status");
1040    out.newline();
1041
1042    // Overall stats
1043    out.println(&format!("  Last {} commits:", stats.total));
1044    out.newline();
1045
1046    // Calculate percentages
1047    let auths_pct = if stats.total > 0 {
1048        (stats.auths_signed * 100) / stats.total
1049    } else {
1050        0
1051    };
1052    let gpg_pct = if stats.total > 0 {
1053        (stats.gpg_signed * 100) / stats.total
1054    } else {
1055        0
1056    };
1057    let ssh_pct = if stats.total > 0 {
1058        (stats.ssh_signed * 100) / stats.total
1059    } else {
1060        0
1061    };
1062    let unsigned_pct = if stats.total > 0 {
1063        (stats.unsigned * 100) / stats.total
1064    } else {
1065        0
1066    };
1067
1068    // Progress bar helper
1069    let progress_bar = |count: usize, total: usize, width: usize| -> String {
1070        let filled = if total > 0 {
1071            (count * width) / total
1072        } else {
1073            0
1074        };
1075        let empty = width.saturating_sub(filled);
1076        format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
1077    };
1078
1079    out.println(&format!(
1080        "    {} Auths-signed: {:>4} ({:>3}%) {}",
1081        out.success("✓"),
1082        stats.auths_signed,
1083        auths_pct,
1084        out.success(&progress_bar(stats.auths_signed, stats.total, 20))
1085    ));
1086
1087    out.println(&format!(
1088        "    {} GPG-signed:   {:>4} ({:>3}%) {}",
1089        out.info("●"),
1090        stats.gpg_signed,
1091        gpg_pct,
1092        out.info(&progress_bar(stats.gpg_signed, stats.total, 20))
1093    ));
1094
1095    out.println(&format!(
1096        "    {} SSH-signed:   {:>4} ({:>3}%) {}",
1097        out.info("●"),
1098        stats.ssh_signed,
1099        ssh_pct,
1100        out.info(&progress_bar(stats.ssh_signed, stats.total, 20))
1101    ));
1102
1103    out.println(&format!(
1104        "    {} Unsigned:     {:>4} ({:>3}%) {}",
1105        out.warn("○"),
1106        stats.unsigned,
1107        unsigned_pct,
1108        out.dim(&progress_bar(stats.unsigned, stats.total, 20))
1109    ));
1110
1111    // Per-author breakdown
1112    if cmd.by_author && !authors.is_empty() {
1113        out.newline();
1114        out.print_heading("  Per-Author Status");
1115        out.newline();
1116
1117        for author in &authors {
1118            let status_icon = match author.primary_method {
1119                SigningMethod::Auths => out.success("✅"),
1120                SigningMethod::Gpg => out.info("🔄"),
1121                SigningMethod::Ssh => out.info("🔄"),
1122                SigningMethod::Unsigned => out.warn("⚠️"),
1123                SigningMethod::Unknown => out.dim("?"),
1124            };
1125
1126            let method_str = match author.primary_method {
1127                SigningMethod::Auths => "Auths",
1128                SigningMethod::Gpg => "GPG (pending)",
1129                SigningMethod::Ssh => "SSH (pending)",
1130                SigningMethod::Unsigned => "Unsigned",
1131                SigningMethod::Unknown => "Unknown",
1132            };
1133
1134            out.println(&format!(
1135                "    {} {} <{}> - {} ({} commits)",
1136                status_icon,
1137                out.bold(&author.name),
1138                out.dim(&author.email),
1139                method_str,
1140                author.total_commits
1141            ));
1142        }
1143    }
1144
1145    out.newline();
1146
1147    // Migration suggestion
1148    if stats.gpg_signed > 0 || stats.ssh_signed > 0 {
1149        out.print_heading("  Next Steps");
1150        out.newline();
1151        if stats.gpg_signed > 0 {
1152            out.println("    For GPG users: auths id migrate from-gpg");
1153        }
1154        if stats.ssh_signed > 0 {
1155            out.println("    For SSH users: auths id migrate from-ssh");
1156        }
1157    }
1158
1159    Ok(())
1160}
1161
1162/// Analyze commit signatures in a repository.
1163fn analyze_commit_signatures(
1164    repo_path: &PathBuf,
1165    count: usize,
1166) -> Result<(MigrationStats, Vec<AuthorStatus>)> {
1167    use std::collections::HashMap;
1168
1169    // Use git log to get commit info with signatures
1170    // %GS = signer identity (SSH keys show "ssh-ed25519 ...", GPG shows key ID/email)
1171    // %GK = signing key fingerprint
1172    let output = git_command(&[
1173        "log",
1174        &format!("-{}", count),
1175        "--pretty=format:%H|%an|%ae|%G?|%GK|%GS",
1176    ])
1177    .current_dir(repo_path)
1178    .output()
1179    .context("Failed to run git log")?;
1180
1181    if !output.status.success() {
1182        let stderr = String::from_utf8_lossy(&output.stderr);
1183        return Err(anyhow!("git log failed: {}", stderr));
1184    }
1185
1186    let stdout = String::from_utf8_lossy(&output.stdout);
1187
1188    let mut stats = MigrationStats::default();
1189    let mut author_map: HashMap<String, AuthorStatus> = HashMap::new();
1190
1191    for line in stdout.lines() {
1192        if line.trim().is_empty() {
1193            continue;
1194        }
1195
1196        let parts: Vec<&str> = line.split('|').collect();
1197        if parts.len() < 5 {
1198            continue;
1199        }
1200
1201        let _commit_hash = parts[0];
1202        let author_name = parts[1];
1203        let author_email = parts[2];
1204        let sig_status = parts[3]; // G=good, B=bad, U=untrusted, X=expired, Y=expired key, R=revoked, E=missing key, N=none
1205        let sig_key = parts[4];
1206        let sig_signer = if parts.len() > 5 { parts[5] } else { "" };
1207
1208        // Determine signing method by inspecting the signer identity and key format.
1209        // SSH signatures: %GS starts with an SSH key type (e.g., "ssh-ed25519", "ssh-rsa")
1210        //                 %GK is an SSH fingerprint like "SHA256:..."
1211        // GPG signatures: %GS is a name/email, %GK is a hex key ID
1212        let method = match sig_status {
1213            "G" | "U" | "X" | "Y" | "R" | "E" => {
1214                if sig_signer.starts_with("ssh-")
1215                    || sig_signer.starts_with("ecdsa-")
1216                    || sig_signer.starts_with("sk-ssh-")
1217                    || sig_key.starts_with("SHA256:")
1218                {
1219                    SigningMethod::Ssh
1220                } else {
1221                    SigningMethod::Gpg
1222                }
1223            }
1224            "N" | "" => SigningMethod::Unsigned,
1225            _ => SigningMethod::Unknown,
1226        };
1227
1228        // Update stats
1229        stats.total += 1;
1230        match method {
1231            SigningMethod::Auths => stats.auths_signed += 1,
1232            SigningMethod::Gpg => stats.gpg_signed += 1,
1233            SigningMethod::Ssh => stats.ssh_signed += 1,
1234            SigningMethod::Unsigned => stats.unsigned += 1,
1235            SigningMethod::Unknown => stats.unknown += 1,
1236        }
1237
1238        // Update author stats
1239        let author_key = format!("{} <{}>", author_name, author_email);
1240        let author = author_map
1241            .entry(author_key)
1242            .or_insert_with(|| AuthorStatus {
1243                name: author_name.to_string(),
1244                email: author_email.to_string(),
1245                total_commits: 0,
1246                auths_signed: 0,
1247                gpg_signed: 0,
1248                ssh_signed: 0,
1249                unsigned: 0,
1250                primary_method: SigningMethod::Unsigned,
1251            });
1252
1253        author.total_commits += 1;
1254        match method {
1255            SigningMethod::Auths => author.auths_signed += 1,
1256            SigningMethod::Gpg => author.gpg_signed += 1,
1257            SigningMethod::Ssh => author.ssh_signed += 1,
1258            SigningMethod::Unsigned => author.unsigned += 1,
1259            SigningMethod::Unknown => {}
1260        }
1261    }
1262
1263    // Determine primary method for each author
1264    let mut authors: Vec<AuthorStatus> = author_map.into_values().collect();
1265    for author in &mut authors {
1266        author.primary_method = if author.auths_signed > 0 {
1267            SigningMethod::Auths
1268        } else if author.gpg_signed > author.ssh_signed && author.gpg_signed > author.unsigned {
1269            SigningMethod::Gpg
1270        } else if author.ssh_signed > author.unsigned {
1271            SigningMethod::Ssh
1272        } else {
1273            SigningMethod::Unsigned
1274        };
1275    }
1276
1277    // Sort authors by commit count
1278    authors.sort_by(|a, b| b.total_commits.cmp(&a.total_commits));
1279
1280    Ok((stats, authors))
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use super::*;
1286
1287    #[test]
1288    fn test_parse_gpg_colon_output() {
1289        let output = r#"sec:u:4096:1:ABCD1234EFGH5678:1609459200:1704067200::::scESC::::::23::0:
1290fpr:::::::::ABCD1234EFGH5678IJKL9012MNOP3456QRST7890:
1291uid:u::::1609459200::ABCD1234::Test User <test@example.com>::::::::::0:
1292"#;
1293
1294        let keys = parse_gpg_colon_output(output).unwrap();
1295        assert_eq!(keys.len(), 1);
1296        assert!(keys[0].user_id.contains("Test User"));
1297        assert!(keys[0].fingerprint.contains("ABCD1234"));
1298    }
1299
1300    #[test]
1301    fn test_parse_empty_output() {
1302        let keys = parse_gpg_colon_output("").unwrap();
1303        assert!(keys.is_empty());
1304    }
1305
1306    #[test]
1307    fn test_gpg_key_info_serialization() {
1308        let key = GpgKeyInfo {
1309            key_id: "ABCD1234".to_string(),
1310            fingerprint: "ABCD1234EFGH5678".to_string(),
1311            user_id: "Test <test@example.com>".to_string(),
1312            algorithm: "rsa4096".to_string(),
1313            created: "1609459200".to_string(),
1314            expires: None,
1315        };
1316
1317        let json = serde_json::to_string(&key).unwrap();
1318        assert!(json.contains("ABCD1234"));
1319        assert!(json.contains("rsa4096"));
1320    }
1321
1322    #[test]
1323    fn test_ssh_key_info_serialization() {
1324        let key = SshKeyInfo {
1325            path: PathBuf::from("/home/user/.ssh/id_ed25519"),
1326            algorithm: "ed25519".to_string(),
1327            bits: None,
1328            fingerprint: "SHA256:abcdefg".to_string(),
1329            comment: Some("user@example.com".to_string()),
1330        };
1331
1332        let json = serde_json::to_string(&key).unwrap();
1333        assert!(json.contains("ed25519"));
1334        assert!(json.contains("SHA256:abcdefg"));
1335    }
1336
1337    #[test]
1338    fn test_compute_ssh_fingerprint() {
1339        // Test with a known key data
1340        let fingerprint = compute_ssh_fingerprint("AAAAC3NzaC1lZDI1NTE5").unwrap();
1341        assert!(fingerprint.starts_with("SHA256:"));
1342    }
1343
1344    #[test]
1345    fn test_ssh_algorithm_mapping() {
1346        // Test that we correctly map SSH algorithm strings
1347        let test_cases = [
1348            ("ssh-ed25519", "ed25519"),
1349            ("ssh-rsa", "rsa"),
1350            ("ecdsa-sha2-nistp256", "ecdsa-p256"),
1351            ("sk-ssh-ed25519@openssh.com", "ed25519-sk"),
1352        ];
1353
1354        for (input, expected) in test_cases {
1355            let algo = match input {
1356                "ssh-ed25519" => "ed25519",
1357                "ssh-rsa" => "rsa",
1358                "ecdsa-sha2-nistp256" => "ecdsa-p256",
1359                "ecdsa-sha2-nistp384" => "ecdsa-p384",
1360                "ecdsa-sha2-nistp521" => "ecdsa-p521",
1361                "sk-ssh-ed25519@openssh.com" => "ed25519-sk",
1362                "sk-ecdsa-sha2-nistp256@openssh.com" => "ecdsa-sk",
1363                _ => input,
1364            };
1365            assert_eq!(algo, expected);
1366        }
1367    }
1368}