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