Skip to main content

omnyssh_core/ssh/
key_setup.rs

1//! Auto SSH Key Setup
2//!
3//! Provides automated SSH key generation and server configuration for transitioning
4//! from password-based to key-based authentication.
5//!
6//! ## Safety Invariants
7//! - Never disable password authentication without verified key auth
8//! - Append to authorized_keys, never overwrite
9//! - Always backup sshd_config before modification
10//! - Show warning about alternative access before disabling password
11//! - Log all operations to key_setup.log
12//! - Private key 600, .ssh directory 700
13//! - Never transmit private key over network
14
15use anyhow::{anyhow, Context, Result};
16use std::path::PathBuf;
17use std::time::Duration;
18use tokio::time;
19use tracing::{error, info, warn};
20
21use crate::ssh::client::Host;
22use crate::ssh::session::SshSession;
23
24// ---------------------------------------------------------------------------
25// Constants
26// ---------------------------------------------------------------------------
27
28/// Maximum length for sanitized hostname in key filename.
29const MAX_HOSTNAME_LENGTH: usize = 64;
30
31/// Total timeout for the entire key setup process.
32const TOTAL_TIMEOUT: Duration = Duration::from_secs(60);
33
34/// Timeout for individual SSH operations during key setup.
35const STEP_TIMEOUT: Duration = Duration::from_secs(15);
36
37// ---------------------------------------------------------------------------
38// Key Type
39// ---------------------------------------------------------------------------
40
41/// Supported SSH key types for generation.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum KeyType {
44    /// Ed25519 (recommended, modern, fast).
45    Ed25519,
46}
47
48impl KeyType {
49    /// Returns the file extension for this key type.
50    pub fn extension(&self) -> &'static str {
51        match self {
52            KeyType::Ed25519 => "ed25519",
53        }
54    }
55}
56
57// ---------------------------------------------------------------------------
58// Key Setup Steps
59// ---------------------------------------------------------------------------
60
61/// Individual steps in the key setup process.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
63pub enum KeySetupStep {
64    GenerateKey = 1,
65    CopyPublicKey = 2,
66    VerifyKeyAuth = 3,
67    DisablePassword = 4,
68    ReloadSshd = 5,
69    FinalCheck = 6,
70}
71
72impl KeySetupStep {
73    /// Returns all steps in order.
74    pub fn all_steps() -> Vec<Self> {
75        vec![
76            Self::GenerateKey,
77            Self::CopyPublicKey,
78            Self::VerifyKeyAuth,
79            Self::DisablePassword,
80            Self::ReloadSshd,
81            Self::FinalCheck,
82        ]
83    }
84
85    /// Human-readable description for UI display.
86    pub fn description(&self) -> &'static str {
87        match self {
88            Self::GenerateKey => "Generating Ed25519 key pair",
89            Self::CopyPublicKey => "Copying public key to server",
90            Self::VerifyKeyAuth => "Verifying key authentication",
91            Self::DisablePassword => "Disabling password authentication",
92            Self::ReloadSshd => "Reloading SSH service",
93            Self::FinalCheck => "Final verification",
94        }
95    }
96}
97
98// ---------------------------------------------------------------------------
99// Key Setup State
100// ---------------------------------------------------------------------------
101
102/// Result of the key setup process.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum KeySetupState {
105    /// Setup not yet started.
106    NotStarted,
107    /// Setup is in progress.
108    InProgress,
109    /// Setup completed successfully (password auth disabled).
110    Success,
111    /// Setup partially succeeded (key works, but no sudo to disable password).
112    PartialSuccess,
113    /// Setup failed safely (password auth NOT disabled).
114    FailedSafe,
115    /// Setup failed after disabling password — needs rollback.
116    NeedsRollback,
117    /// Rollback completed.
118    RolledBack,
119}
120
121/// State machine for tracking key setup progress.
122#[derive(Debug)]
123pub struct KeySetupMachine {
124    state: KeySetupState,
125    current_step: Option<KeySetupStep>,
126    has_sudo: bool,
127    password_disabled: bool,
128}
129
130impl KeySetupMachine {
131    /// Creates a new state machine in the NotStarted state.
132    pub fn new() -> Self {
133        Self {
134            state: KeySetupState::NotStarted,
135            current_step: None,
136            has_sudo: true,
137            password_disabled: false,
138        }
139    }
140
141    /// Returns the current state.
142    pub fn state(&self) -> &KeySetupState {
143        &self.state
144    }
145
146    /// Sets the sudo availability flag.
147    pub fn set_has_sudo(&mut self, has_sudo: bool) {
148        self.has_sudo = has_sudo;
149    }
150
151    /// Marks a step as complete with the given result.
152    ///
153    /// Updates the state machine based on which step completed and whether it succeeded.
154    /// Implements the safety invariants from tech-2.md B.3.3.
155    pub fn step_result(&mut self, step: KeySetupStep, result: Result<()>) {
156        self.current_step = Some(step);
157
158        match (step, result) {
159            // Step 1-2: Safe to fail, no changes to server yet.
160            (KeySetupStep::GenerateKey | KeySetupStep::CopyPublicKey, Err(_)) => {
161                self.state = KeySetupState::FailedSafe;
162            }
163
164            // Step 3 (VerifyKeyAuth): CRITICAL — if this fails, STOP.
165            // Never disable password without verified key.
166            (KeySetupStep::VerifyKeyAuth, Err(_)) => {
167                self.state = KeySetupState::FailedSafe;
168            }
169            (KeySetupStep::VerifyKeyAuth, Ok(())) if !self.has_sudo => {
170                // Key works, but no sudo — partial success.
171                self.state = KeySetupState::PartialSuccess;
172            }
173
174            // Step 4 (DisablePassword): Point of no return.
175            (KeySetupStep::DisablePassword, Ok(())) => {
176                self.password_disabled = true;
177            }
178            (KeySetupStep::DisablePassword, Err(_)) => {
179                // Failed to disable password — safe, stop here.
180                self.state = KeySetupState::FailedSafe;
181            }
182
183            // Step 5 (ReloadSshd): Mostly safe (reload doesn't kill existing connections).
184            (KeySetupStep::ReloadSshd, Err(_)) => {
185                // Reload failed, but password is already disabled.
186                // This might be okay if the daemon auto-reloaded, but risky.
187                self.state = KeySetupState::NeedsRollback;
188            }
189
190            // Step 6 (FinalCheck): Verify key still works after reload.
191            // If this fails, password is disabled but key doesn't work — emergency rollback!
192            (KeySetupStep::FinalCheck, Err(_)) => {
193                self.state = KeySetupState::NeedsRollback;
194            }
195            (KeySetupStep::FinalCheck, Ok(())) => {
196                self.state = KeySetupState::Success;
197            }
198
199            // All other OK results → continue.
200            (_, Ok(())) => {
201                self.state = KeySetupState::InProgress;
202            }
203        }
204    }
205
206    /// Marks the rollback as complete.
207    pub fn rollback_complete(&mut self) {
208        self.state = KeySetupState::RolledBack;
209        self.password_disabled = false;
210    }
211}
212
213impl Default for KeySetupMachine {
214    fn default() -> Self {
215        Self::new()
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Key Generation
221// ---------------------------------------------------------------------------
222
223/// Generates an Ed25519 SSH key pair and writes it to disk.
224///
225/// Returns the paths to the private and public key files.
226///
227/// # Errors
228/// Returns an error if key generation or file I/O fails.
229///
230/// # Safety
231/// - Private key is written with mode 0600.
232/// - .ssh directory is created with mode 0700 if it doesn't exist.
233pub async fn generate_key_pair(host_name: &str, key_type: KeyType) -> Result<(PathBuf, PathBuf)> {
234    let sanitized = sanitize_hostname(host_name);
235    let key_filename = format!("omnyssh_{}_{}", sanitized, key_type.extension());
236
237    let ssh_dir = dirs::home_dir()
238        .ok_or_else(|| anyhow!("Cannot determine home directory"))?
239        .join(".ssh");
240
241    // Create .ssh directory if it doesn't exist.
242    tokio::fs::create_dir_all(&ssh_dir)
243        .await
244        .with_context(|| format!("Failed to create {}", ssh_dir.display()))?;
245
246    #[cfg(unix)]
247    {
248        use std::os::unix::fs::PermissionsExt;
249        let perms = std::fs::Permissions::from_mode(0o700);
250        tokio::fs::set_permissions(&ssh_dir, perms)
251            .await
252            .with_context(|| format!("Failed to set permissions on {}", ssh_dir.display()))?;
253    }
254
255    let private_key_path = ssh_dir.join(&key_filename);
256    let public_key_path = ssh_dir.join(format!("{}.pub", key_filename));
257
258    // Check if key already exists - if so, reuse it instead of failing.
259    if private_key_path.exists() && public_key_path.exists() {
260        info!(
261            "Key already exists for {}, reusing: {}",
262            host_name,
263            private_key_path.display()
264        );
265        return Ok((private_key_path, public_key_path));
266    }
267
268    // Generate the key pair using ssh-keygen directly to ensure macOS OpenSSH compatibility.
269    // Using ssh-keygen ensures the key is in the correct format (OpenSSH) from the start.
270    info!(
271        "Generating {} key pair for {}",
272        key_type.extension(),
273        host_name
274    );
275
276    let key_type_arg = match key_type {
277        KeyType::Ed25519 => "ed25519",
278    };
279
280    let mut keygen_cmd = tokio::process::Command::new("ssh-keygen");
281    keygen_cmd
282        .arg("-t")
283        .arg(key_type_arg)
284        .arg("-f")
285        .arg(&private_key_path)
286        .arg("-N")
287        .arg("") // No passphrase
288        .arg("-C")
289        .arg(format!("omnyssh-{}", host_name)); // Comment
290
291    let keygen_output = keygen_cmd
292        .output()
293        .await
294        .context("Failed to run ssh-keygen for key generation")?;
295
296    if !keygen_output.status.success() {
297        let stderr = String::from_utf8_lossy(&keygen_output.stderr);
298        return Err(anyhow!("ssh-keygen failed to generate key: {}", stderr));
299    }
300
301    // Ensure private key has correct permissions (ssh-keygen should set this, but be explicit)
302    #[cfg(unix)]
303    {
304        use std::os::unix::fs::PermissionsExt;
305        let perms = std::fs::Permissions::from_mode(0o600);
306        tokio::fs::set_permissions(&private_key_path, perms)
307            .await
308            .with_context(|| {
309                format!(
310                    "Failed to set permissions on private key {}",
311                    private_key_path.display()
312                )
313            })?;
314    }
315
316    info!(
317        "Generated key pair:\n  Private: {}\n  Public: {}",
318        private_key_path.display(),
319        public_key_path.display()
320    );
321
322    Ok((private_key_path, public_key_path))
323}
324
325/// Sanitizes a hostname for use in a key filename.
326///
327/// - Replaces non-alphanumeric characters (except `-`, `_`, `.`) with `_`
328/// - Truncates to MAX_HOSTNAME_LENGTH
329/// - Returns "unnamed_host" for empty input
330///
331/// # Examples
332/// ```
333/// use omnyssh_core::ssh::key_setup::sanitize_hostname;
334///
335/// assert_eq!(sanitize_hostname("web-prod-1"), "web-prod-1");
336/// assert_eq!(sanitize_hostname("my server (prod)"), "my_server__prod_");
337/// assert_eq!(sanitize_hostname("../../etc/passwd"), "______etc_passwd");
338/// assert_eq!(sanitize_hostname(""), "unnamed_host");
339/// ```
340pub fn sanitize_hostname(hostname: &str) -> String {
341    if hostname.is_empty() {
342        return "unnamed_host".to_string();
343    }
344
345    let sanitized: String = hostname
346        .chars()
347        .map(|c| {
348            // Only allow alphanumerics, hyphens, and underscores.
349            // Dots are replaced to prevent path traversal attacks (../../etc/passwd).
350            if c.is_alphanumeric() || c == '-' || c == '_' {
351                c
352            } else {
353                '_'
354            }
355        })
356        .take(MAX_HOSTNAME_LENGTH)
357        .collect();
358
359    if sanitized.is_empty() {
360        "unnamed_host".to_string()
361    } else {
362        sanitized
363    }
364}
365
366// ---------------------------------------------------------------------------
367// SSH Command Builders
368// ---------------------------------------------------------------------------
369
370/// Builds the command to append a public key to authorized_keys.
371///
372/// The command creates ~/.ssh if it doesn't exist, appends the key (never overwrites),
373/// and sets correct permissions.
374pub fn build_authorized_keys_command(public_key: &str) -> String {
375    // Validate public key is a single line matching expected SSH format.
376    let public_key = public_key.trim();
377    if public_key.contains('\n') || public_key.contains('\r') || public_key.contains('\0') {
378        panic!("Public key file contains invalid characters");
379    }
380    if !public_key.starts_with("ssh-ed25519 ")
381        && !public_key.starts_with("ssh-rsa ")
382        && !public_key.starts_with("ecdsa-sha2-")
383    {
384        panic!("Public key file has unrecognized key type");
385    }
386
387    // Escape single quotes in the public key.
388    let escaped_key = public_key.replace('\'', "'\\''");
389
390    format!(
391        r#"mkdir -p ~/.ssh && chmod 700 ~/.ssh && \
392           echo '{escaped_key}' >> ~/.ssh/authorized_keys && \
393           chmod 600 ~/.ssh/authorized_keys"#
394    )
395}
396
397/// Builds the command to disable password authentication in sshd_config.
398///
399/// The command:
400/// - Checks for sudo access
401/// - Creates a timestamped backup of sshd_config
402/// - Comments out Include directives to prevent overrides from sshd_config.d/*
403/// - Disables password authentication (PasswordAuthentication, ChallengeResponseAuthentication, KbdInteractiveAuthentication)
404/// - Disables UsePAM to prevent PAM from bypassing password auth restrictions
405/// - Validates the config with `sshd -t`
406///
407/// ## Security Note
408/// Even with `PasswordAuthentication no`, PAM (Pluggable Authentication Modules) can provide
409/// alternative authentication methods (keyboard-interactive) that accept passwords.
410/// Setting `UsePAM no` ensures password authentication is completely disabled and cannot be
411/// bypassed with flags like `ssh -o PubkeyAuthentication=no`.
412///
413/// ## Include Directive Handling
414/// Many cloud providers (AWS, DigitalOcean, etc.) use `/etc/ssh/sshd_config.d/*.conf` files
415/// (e.g., `50-cloud-init.conf`) that override the main config. We comment out the Include
416/// directive to prevent these files from re-enabling password authentication.
417///
418/// ## Missing Directives
419/// `sed` only rewrites lines that already exist. A config that simply omits a
420/// directive would otherwise keep sshd's compiled-in default (e.g. `UsePAM yes`),
421/// so each directive is also prepended when the file contains no occurrence.
422/// Prepending keeps it the first — and therefore effective — global value.
423///
424/// Returns the command string or an error if sudo is required but unavailable.
425pub fn build_disable_password_command() -> String {
426    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
427    let password = force_sshd_directive("PasswordAuthentication", "no");
428    let challenge = force_sshd_directive("ChallengeResponseAuthentication", "no");
429    let kbd = force_sshd_directive("KbdInteractiveAuthentication", "no");
430    let pam = force_sshd_directive("UsePAM", "no");
431
432    format!(
433        r#"sudo -n true 2>/dev/null || {{ echo "OMNYSSH_NO_SUDO"; exit 1; }}; \
434           sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.omnyssh_backup.{timestamp} && \
435           sudo sed -i.bak 's|^Include /etc/ssh/sshd_config.d/|#Include /etc/ssh/sshd_config.d/|' /etc/ssh/sshd_config && \
436           {password} && \
437           {challenge} && \
438           {kbd} && \
439           {pam} && \
440           sudo sshd -t || {{ echo "OMNYSSH_CONFIG_ERROR"; sudo cp /etc/ssh/sshd_config.omnyssh_backup.{timestamp} /etc/ssh/sshd_config; exit 1; }}"#
441    )
442}
443
444/// Builds the shell fragment that forces `sshd_config` to set `directive value`.
445///
446/// First rewrites every existing column-0 occurrence (commented or not) to the
447/// desired value; then, if the file had none, prepends the directive so it
448/// becomes the first global occurrence sshd reads.
449fn force_sshd_directive(directive: &str, value: &str) -> String {
450    assert!(
451        directive
452            .chars()
453            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
454        "directive contains unsafe characters: {directive}"
455    );
456    assert!(
457        value
458            .chars()
459            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == ' '),
460        "value contains unsafe characters: {value}"
461    );
462    format!(
463        r#"sudo sed -i.bak 's/^#\?{directive}.*/{directive} {value}/' /etc/ssh/sshd_config && \
464           {{ sudo grep -qE '^{directive}[[:space:]]' /etc/ssh/sshd_config || \
465              sudo sed -i '1i {directive} {value}' /etc/ssh/sshd_config; }}"#
466    )
467}
468
469/// Builds the command to reload the SSH daemon.
470///
471/// Uses `reload` instead of `restart` to avoid killing existing connections.
472pub fn build_reload_sshd_command() -> String {
473    r#"if command -v systemctl &>/dev/null; then \
474           sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh 2>/dev/null; \
475       elif command -v service &>/dev/null; then \
476           sudo service sshd reload 2>/dev/null || sudo service ssh reload 2>/dev/null; \
477       else \
478           echo "OMNYSSH_NO_INIT_SYSTEM"; exit 1; \
479       fi"#
480    .to_string()
481}
482
483/// Builds the emergency rollback command.
484///
485/// Restores the most recent OmnySSH backup of sshd_config and reloads the daemon.
486pub fn build_rollback_command() -> String {
487    r#"BACKUP=$(find /etc/ssh -maxdepth 1 -name 'sshd_config.omnyssh_backup.*' 2>/dev/null | sort | tail -1); \
488       if [ -n "$BACKUP" ]; then \
489           sudo cp "$BACKUP" /etc/ssh/sshd_config && \
490           (sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh 2>/dev/null || sudo service sshd reload 2>/dev/null || sudo service ssh reload); \
491       else \
492           echo "OMNYSSH_NO_BACKUP"; exit 1; \
493       fi"#
494    .to_string()
495}
496
497// ---------------------------------------------------------------------------
498// High-Level Key Setup Orchestrator
499// ---------------------------------------------------------------------------
500
501/// Executes the complete key setup process for a host.
502///
503/// This is the main entry point for Auto SSH Key Setup. It orchestrates all 6 steps
504/// with proper error handling, timeouts, and rollback on failure.
505///
506/// # Errors
507/// Returns an error if any critical step fails. Password authentication is never
508/// disabled unless key authentication has been verified.
509pub async fn setup_key_for_host(
510    host: &Host,
511    password_session: &SshSession,
512    key_type: KeyType,
513    progress_tx: Option<tokio::sync::mpsc::Sender<KeySetupStep>>,
514) -> Result<KeySetupResult> {
515    let mut machine = KeySetupMachine::new();
516    let mut result = KeySetupResult {
517        key_path: PathBuf::new(),
518        state: KeySetupState::NotStarted,
519        error_message: None,
520    };
521
522    // Wrap the entire process in a timeout.
523    match time::timeout(
524        TOTAL_TIMEOUT,
525        setup_key_internal(host, password_session, key_type, &mut machine, progress_tx),
526    )
527    .await
528    {
529        Ok(Ok(key_path)) => {
530            result.key_path = key_path;
531            result.state = machine.state().clone();
532            Ok(result)
533        }
534        Ok(Err(e)) => {
535            error!("Key setup failed for {}: {}", host.name, e);
536            result.state = machine.state().clone();
537            result.error_message = Some(format!("{:#}", e));
538
539            // Attempt rollback if needed.
540            if matches!(machine.state(), KeySetupState::NeedsRollback) {
541                if let Err(rollback_err) = emergency_rollback(password_session).await {
542                    error!("Rollback failed: {}", rollback_err);
543                    result.error_message = Some(format!(
544                        "Setup failed AND rollback failed: {}\nRollback error: {}",
545                        e, rollback_err
546                    ));
547                } else {
548                    machine.rollback_complete();
549                    result.state = KeySetupState::RolledBack;
550                }
551            }
552
553            Err(e)
554        }
555        Err(_) => {
556            let err = anyhow!(
557                "Key setup timed out after {} seconds",
558                TOTAL_TIMEOUT.as_secs()
559            );
560            result.error_message = Some(err.to_string());
561            result.state = KeySetupState::FailedSafe;
562            Err(err)
563        }
564    }
565}
566
567/// Internal implementation of the key setup process.
568async fn setup_key_internal(
569    host: &Host,
570    password_session: &SshSession,
571    key_type: KeyType,
572    machine: &mut KeySetupMachine,
573    progress_tx: Option<tokio::sync::mpsc::Sender<KeySetupStep>>,
574) -> Result<PathBuf> {
575    // Step 1: Generate key pair.
576    info!("Step 1/6: Generating key pair for {}", host.name);
577    if let Some(ref tx) = progress_tx {
578        let _ = tx.send(KeySetupStep::GenerateKey).await;
579    }
580    let (private_key_path, public_key_path) = match generate_key_pair(&host.name, key_type).await {
581        Ok(paths) => {
582            machine.step_result(KeySetupStep::GenerateKey, Ok(()));
583            paths
584        }
585        Err(e) => {
586            machine.step_result(KeySetupStep::GenerateKey, Err(anyhow!("Generation failed")));
587            return Err(e);
588        }
589    };
590
591    // Step 2: Copy public key to server.
592    info!("Step 2/6: Copying public key to server");
593    if let Some(ref tx) = progress_tx {
594        let _ = tx.send(KeySetupStep::CopyPublicKey).await;
595    }
596    let public_key_content = tokio::fs::read_to_string(&public_key_path)
597        .await
598        .context("Failed to read public key file")?;
599
600    // Validate public key format before embedding in shell command.
601    let public_key_trimmed = public_key_content.trim();
602    if public_key_trimmed.contains('\n')
603        || public_key_trimmed.contains('\r')
604        || public_key_trimmed.contains('\0')
605    {
606        anyhow::bail!("Public key file contains invalid characters");
607    }
608    if !public_key_trimmed.starts_with("ssh-ed25519 ")
609        && !public_key_trimmed.starts_with("ssh-rsa ")
610        && !public_key_trimmed.starts_with("ecdsa-sha2-")
611    {
612        anyhow::bail!("Public key file has unrecognized key type");
613    }
614
615    let copy_cmd = build_authorized_keys_command(&public_key_content);
616    match time::timeout(STEP_TIMEOUT, password_session.run_command(&copy_cmd)).await {
617        Ok(Ok(_)) => {
618            machine.step_result(KeySetupStep::CopyPublicKey, Ok(()));
619        }
620        Ok(Err(e)) => {
621            machine.step_result(KeySetupStep::CopyPublicKey, Err(anyhow!("Copy failed")));
622            return Err(anyhow!("Failed to copy public key to server: {}", e));
623        }
624        Err(_) => {
625            machine.step_result(KeySetupStep::CopyPublicKey, Err(anyhow!("Copy failed")));
626            return Err(anyhow!("Failed to copy public key to server: timeout"));
627        }
628    }
629
630    // Step 3: Verify key authentication (CRITICAL).
631    info!("Step 3/6: Verifying key authentication");
632    if let Some(ref tx) = progress_tx {
633        let _ = tx.send(KeySetupStep::VerifyKeyAuth).await;
634    }
635    let mut test_host = host.clone();
636    test_host.identity_file = Some(private_key_path.to_string_lossy().to_string());
637    test_host.password = None; // Force key-only auth.
638
639    match time::timeout(STEP_TIMEOUT, SshSession::connect(&test_host)).await {
640        Ok(Ok(test_session)) => {
641            info!("Key authentication verified successfully!");
642            test_session.disconnect().await;
643            machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(()));
644        }
645        Ok(Err(e)) => {
646            machine.step_result(KeySetupStep::VerifyKeyAuth, Err(anyhow!("Verify failed")));
647            return Err(anyhow!(
648                "Key authentication verification failed: {}. Password NOT disabled.",
649                e
650            ));
651        }
652        Err(_) => {
653            machine.step_result(KeySetupStep::VerifyKeyAuth, Err(anyhow!("Verify failed")));
654            return Err(anyhow!(
655                "Key authentication verification timed out. Password NOT disabled."
656            ));
657        }
658    }
659
660    // Check sudo availability. `run_command_checked` is required here — the
661    // probe's exit status is the answer, and plain `run_command` ignores it.
662    info!("Checking sudo availability");
663    match password_session
664        .run_command_checked("sudo -n true 2>/dev/null")
665        .await
666    {
667        Ok(_) => {
668            info!("Sudo access confirmed");
669        }
670        Err(_) => {
671            warn!("No sudo access — password authentication will NOT be disabled");
672            machine.set_has_sudo(false);
673            machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(())); // Trigger PartialSuccess.
674            return Ok(private_key_path);
675        }
676    }
677
678    // Step 4: Disable password authentication.
679    info!("Step 4/6: Disabling password authentication");
680    if let Some(ref tx) = progress_tx {
681        let _ = tx.send(KeySetupStep::DisablePassword).await;
682    }
683    let disable_cmd = build_disable_password_command();
684    match time::timeout(STEP_TIMEOUT, password_session.run_command(&disable_cmd)).await {
685        Ok(Ok(output)) if output.contains("OMNYSSH_NO_SUDO") => {
686            machine.set_has_sudo(false);
687            machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(()));
688            return Ok(private_key_path);
689        }
690        Ok(Ok(output)) if output.contains("OMNYSSH_CONFIG_ERROR") => {
691            machine.step_result(KeySetupStep::DisablePassword, Err(anyhow!("Config error")));
692            return Err(anyhow!("sshd config validation failed. Backup restored."));
693        }
694        Ok(Ok(_)) => {
695            machine.step_result(KeySetupStep::DisablePassword, Ok(()));
696        }
697        Ok(Err(e)) => {
698            machine.step_result(
699                KeySetupStep::DisablePassword,
700                Err(anyhow!("Disable failed")),
701            );
702            return Err(anyhow!("Failed to disable password authentication: {}", e));
703        }
704        Err(_) => {
705            machine.step_result(
706                KeySetupStep::DisablePassword,
707                Err(anyhow!("Disable failed")),
708            );
709            return Err(anyhow!(
710                "Failed to disable password authentication: timeout"
711            ));
712        }
713    }
714
715    // Step 5: Reload sshd.
716    info!("Step 5/6: Reloading SSH daemon");
717    if let Some(ref tx) = progress_tx {
718        let _ = tx.send(KeySetupStep::ReloadSshd).await;
719    }
720    let reload_cmd = build_reload_sshd_command();
721    match time::timeout(STEP_TIMEOUT, password_session.run_command(&reload_cmd)).await {
722        Ok(Ok(_)) => {
723            machine.step_result(KeySetupStep::ReloadSshd, Ok(()));
724        }
725        Ok(Err(e)) => {
726            machine.step_result(KeySetupStep::ReloadSshd, Err(anyhow!("Reload failed")));
727            return Err(anyhow!("Failed to reload SSH daemon: {}", e));
728        }
729        Err(_) => {
730            machine.step_result(KeySetupStep::ReloadSshd, Err(anyhow!("Reload failed")));
731            return Err(anyhow!("Failed to reload SSH daemon: timeout"));
732        }
733    }
734
735    // Step 6: Final check — verify key still works after reload.
736    info!("Step 6/6: Final verification");
737    if let Some(ref tx) = progress_tx {
738        let _ = tx.send(KeySetupStep::FinalCheck).await;
739    }
740    match time::timeout(STEP_TIMEOUT, SshSession::connect(&test_host)).await {
741        Ok(Ok(final_session)) => {
742            info!("Final verification passed! Key setup complete.");
743            final_session.disconnect().await;
744            machine.step_result(KeySetupStep::FinalCheck, Ok(()));
745            Ok(private_key_path)
746        }
747        Ok(Err(e)) => {
748            error!(
749                "Final check failed: {}. Password is disabled but key doesn't work!",
750                e
751            );
752            machine.step_result(KeySetupStep::FinalCheck, Err(anyhow!("Final check failed")));
753            Err(anyhow!(
754                "Final verification failed after disabling password. Attempting rollback."
755            ))
756        }
757        Err(_) => {
758            error!("Final check timed out. Password is disabled but key doesn't work!");
759            machine.step_result(KeySetupStep::FinalCheck, Err(anyhow!("Final check failed")));
760            Err(anyhow!(
761                "Final verification timed out after disabling password. Attempting rollback."
762            ))
763        }
764    }
765}
766
767/// Attempts to rollback sshd_config to the most recent OmnySSH backup.
768async fn emergency_rollback(session: &SshSession) -> Result<()> {
769    warn!("Attempting emergency rollback of sshd_config");
770    let rollback_cmd = build_rollback_command();
771
772    match time::timeout(STEP_TIMEOUT, session.run_command(&rollback_cmd)).await {
773        Ok(Ok(output)) if output.contains("OMNYSSH_NO_BACKUP") => {
774            Err(anyhow!("No backup file found for rollback"))
775        }
776        Ok(Ok(_)) => {
777            info!("Rollback successful — password authentication restored");
778            Ok(())
779        }
780        Ok(Err(e)) => Err(anyhow!("Rollback failed: {}", e)),
781        Err(_) => Err(anyhow!("Rollback failed: timeout")),
782    }
783}
784
785// ---------------------------------------------------------------------------
786// Result Type
787// ---------------------------------------------------------------------------
788
789/// Result of the key setup process.
790#[derive(Debug, Clone)]
791pub struct KeySetupResult {
792    /// Path to the generated private key file.
793    pub key_path: PathBuf,
794    /// Final state of the setup process.
795    pub state: KeySetupState,
796    /// Error message if the setup failed.
797    pub error_message: Option<String>,
798}
799
800// ---------------------------------------------------------------------------
801// Tests
802// ---------------------------------------------------------------------------
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807
808    #[test]
809    fn test_sanitize_hostname() {
810        assert_eq!(sanitize_hostname("web-prod-1"), "web-prod-1");
811        assert_eq!(sanitize_hostname("my server (prod)"), "my_server__prod_");
812        assert_eq!(sanitize_hostname("../../etc/passwd"), "______etc_passwd");
813        assert_eq!(sanitize_hostname(""), "unnamed_host");
814        assert_eq!(sanitize_hostname("a/b\\c:d*e?f"), "a_b_c_d_e_f");
815
816        // Test truncation.
817        let long_name = "a".repeat(100);
818        assert_eq!(sanitize_hostname(&long_name).len(), MAX_HOSTNAME_LENGTH);
819    }
820
821    #[tokio::test]
822    #[ignore] // Run manually with: cargo test test_key_generation_and_conversion -- --ignored
823    async fn test_key_generation_and_conversion() {
824        // Generate a test key pair
825        let test_host = "test_conversion_host";
826        let result = generate_key_pair(test_host, KeyType::Ed25519).await;
827
828        assert!(result.is_ok(), "Key generation should succeed");
829        let (private_key_path, public_key_path) = result.unwrap();
830
831        // Check that files exist
832        assert!(private_key_path.exists(), "Private key file should exist");
833        assert!(public_key_path.exists(), "Public key file should exist");
834
835        // Read the private key and check format
836        let private_key_content = tokio::fs::read_to_string(&private_key_path).await.unwrap();
837        let first_line = private_key_content.lines().next().unwrap();
838
839        // After conversion, it should be OpenSSH format, not PKCS#8
840        assert!(
841            first_line.contains("BEGIN OPENSSH PRIVATE KEY")
842                || first_line.contains("BEGIN PRIVATE KEY"),
843            "Key should be in OpenSSH or PKCS#8 format, got: {}",
844            first_line
845        );
846
847        // Try to extract public key using ssh-keygen (validates the key is readable by OpenSSH)
848        let output = tokio::process::Command::new("ssh-keygen")
849            .args(["-y", "-f"])
850            .arg(&private_key_path)
851            .output()
852            .await
853            .unwrap();
854
855        assert!(
856            output.status.success(),
857            "ssh-keygen should be able to read the generated key. stderr: {}",
858            String::from_utf8_lossy(&output.stderr)
859        );
860
861        // Cleanup
862        let _ = tokio::fs::remove_file(&private_key_path).await;
863        let _ = tokio::fs::remove_file(&public_key_path).await;
864    }
865
866    #[test]
867    fn test_key_setup_step_ordering() {
868        let steps = KeySetupStep::all_steps();
869        assert_eq!(steps[0], KeySetupStep::GenerateKey);
870        assert_eq!(steps[5], KeySetupStep::FinalCheck);
871        assert_eq!(steps.len(), 6);
872    }
873
874    #[test]
875    fn test_key_setup_machine_verify_failure_stops_process() {
876        let mut machine = KeySetupMachine::new();
877        machine.step_result(KeySetupStep::GenerateKey, Ok(()));
878        machine.step_result(KeySetupStep::CopyPublicKey, Ok(()));
879        machine.step_result(
880            KeySetupStep::VerifyKeyAuth,
881            Err(anyhow!("connection refused")),
882        );
883
884        // Password must NOT be disabled.
885        assert_eq!(machine.state(), &KeySetupState::FailedSafe);
886        assert!(!machine.password_disabled);
887    }
888
889    #[test]
890    fn test_key_setup_machine_no_sudo_partial_success() {
891        let mut machine = KeySetupMachine::new();
892        machine.set_has_sudo(false);
893
894        machine.step_result(KeySetupStep::GenerateKey, Ok(()));
895        machine.step_result(KeySetupStep::CopyPublicKey, Ok(()));
896        machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(()));
897
898        // Key works but no sudo → PartialSuccess.
899        assert_eq!(machine.state(), &KeySetupState::PartialSuccess);
900        assert!(!machine.password_disabled);
901    }
902
903    #[test]
904    fn test_key_setup_machine_final_check_failure_triggers_rollback() {
905        let mut machine = KeySetupMachine::new();
906        machine.step_result(KeySetupStep::GenerateKey, Ok(()));
907        machine.step_result(KeySetupStep::CopyPublicKey, Ok(()));
908        machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(()));
909        machine.step_result(KeySetupStep::DisablePassword, Ok(()));
910        machine.step_result(KeySetupStep::ReloadSshd, Ok(()));
911        machine.step_result(KeySetupStep::FinalCheck, Err(anyhow!("timeout")));
912
913        // Password is disabled but key doesn't work → rollback needed.
914        assert_eq!(machine.state(), &KeySetupState::NeedsRollback);
915        assert!(machine.password_disabled);
916    }
917
918    #[test]
919    fn test_authorized_keys_command_escapes_quotes() {
920        let pubkey = "ssh-ed25519 AAAA... user's key";
921        let cmd = build_authorized_keys_command(pubkey);
922
923        // Should escape single quotes.
924        assert!(cmd.contains("user'\\''s"));
925        // Should use >> (append, not overwrite).
926        assert!(cmd.contains(">> ~/.ssh/authorized_keys"));
927        // Should NOT use > (overwrite).
928        assert!(!cmd.contains(" > ~/.ssh/authorized_keys"));
929    }
930
931    #[test]
932    fn test_disable_password_command_creates_backup() {
933        let cmd = build_disable_password_command();
934
935        // Should create timestamped backup.
936        assert!(cmd.contains("omnyssh_backup."));
937        // Should run sshd -t for validation.
938        assert!(cmd.contains("sshd -t"));
939        // Should comment out cloud-init Include directive to prevent overrides.
940        assert!(
941            cmd.contains("'s|^Include /etc/ssh/sshd_config.d/|#Include /etc/ssh/sshd_config.d/|'")
942        );
943        // Should disable all password auth methods.
944        assert!(cmd.contains("PasswordAuthentication no"));
945        assert!(cmd.contains("ChallengeResponseAuthentication no"));
946        assert!(cmd.contains("KbdInteractiveAuthentication no"));
947        // Should disable PAM to prevent bypassing password auth.
948        assert!(cmd.contains("UsePAM no"));
949    }
950
951    #[test]
952    fn test_disable_password_command_adds_missing_directives() {
953        let cmd = build_disable_password_command();
954
955        // Each directive is prepended when the config contains no occurrence.
956        assert!(cmd.contains("grep -qE '^PasswordAuthentication[[:space:]]'"));
957        assert!(cmd.contains("sed -i '1i PasswordAuthentication no'"));
958        assert!(cmd.contains("grep -qE '^UsePAM[[:space:]]'"));
959        assert!(cmd.contains("sed -i '1i UsePAM no'"));
960    }
961
962    #[test]
963    fn test_reload_sshd_uses_reload_not_restart() {
964        let cmd = build_reload_sshd_command();
965
966        // Should use reload, not restart.
967        assert!(cmd.contains("reload"));
968        assert!(!cmd.contains("restart"));
969    }
970}