diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-22 19:12:29 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-22 19:12:29 +0300 |
| commit | 1c6397b1cddb288099ed212a5979732698bd72c2 (patch) | |
| tree | 0574b428903ea1ce106bc7ba36e213c0418aa9f4 /src/platform | |
| parent | 7237703dc59102cf3097e833eb896b314007b548 (diff) | |
fix: reuse ssh authentication and add machine recovery commands (#4490)
* fix: reuse ssh authentication and add in-app mfa recovery
refs #3763
* refactor: move ssh recovery to machine cli
refs #3763
Diffstat (limited to 'src/platform')
| -rw-r--r-- | src/platform/mod.rs | 15 | ||||
| -rw-r--r-- | src/platform/unix_common.rs | 136 |
2 files changed, 150 insertions, 1 deletions
diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 864516da..88f433bf 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -81,7 +81,9 @@ impl ChildExitReason { } #[cfg(unix)] -pub(crate) use unix_common::{classify_child_exit, poll_fd_readable, read_fd}; +pub(crate) use unix_common::{ + classify_child_exit, poll_fd_readable, read_fd, shared_ssh_control_path, +}; #[cfg(not(any(unix, windows)))] pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildExitReason { @@ -712,3 +714,14 @@ mod tests { ); } } + +#[cfg(not(unix))] +pub(crate) fn shared_ssh_control_path( + _namespace: &std::path::Path, + _target: &str, +) -> std::io::Result<std::path::PathBuf> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "interactive SSH recovery requires Unix OpenSSH multiplexing", + )) +} diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index 6063edd9..8b76212c 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -498,3 +498,139 @@ mod tests { assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } } + +/// Shared OpenSSH sockets outlive individual helpers. Never adopt a directory +/// belonging to another uid, a symlink, or a directory accessible by others. +pub(crate) fn shared_ssh_control_path(namespace: &Path, target: &str) -> std::io::Result<PathBuf> { + use sha2::{Digest, Sha256}; + use std::os::unix::{ + ffi::OsStrExt, + fs::{DirBuilderExt, MetadataExt}, + }; + + // Validate the resolved system temp directory, but retain the short /tmp + // spelling for sockets. On macOS /tmp resolves to /private/tmp; those extra + // bytes would consume the space OpenSSH needs for its staging suffix. + let base = Path::new("/tmp"); + let resolved_base = std::fs::canonicalize(base)?; + let metadata = std::fs::symlink_metadata(&resolved_base)?; + if !metadata.is_dir() || metadata.uid() != 0 || metadata.mode() & 0o1000 == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "unsafe SSH control directory parent", + )); + } + let dir = base.join(format!("hssh-{}", unsafe { libc::geteuid() })); + match std::fs::DirBuilder::new().mode(0o700).create(&dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + validate_shared_ssh_dir(&dir)?; + let namespace = if namespace.is_absolute() { + namespace.to_owned() + } else { + std::env::current_dir()?.join(namespace) + }; + let mut hash = Sha256::new(); + hash.update(namespace.as_os_str().as_bytes()); + hash.update([0]); + hash.update(target.as_bytes()); + // %C additionally scopes the socket to OpenSSH's resolved destination, + // port and jump host, rather than merely the spelling of an alias. + // Keep 96 bits of namespace/target hash plus OpenSSH's 160-bit %C. + let hash = format!("{:x}", hash.finalize()); + let path = dir.join(format!("{}-%C", &hash[..24])); + // OpenSSH first binds ControlPath + '.' + 16 random characters, then + // renames it. Reserve those 17 bytes, not just the final socket's length. + let expanded = path.to_string_lossy().replace("%C", &"0".repeat(40)); + let staging = PathBuf::from(format!("{expanded}.{}", "0".repeat(16))); + if !fits_unix_socket_path(&staging) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "SSH control socket staging path exceeds the Unix socket length limit", + )); + } + Ok(path) +} + +fn validate_shared_ssh_dir(dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::MetadataExt; + let metadata = std::fs::symlink_metadata(dir)?; + if !metadata.is_dir() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o7777 != 0o700 + { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "SSH control directory must be owned by the current user, mode 0700, and not a symlink", + )); + } + Ok(()) +} + +#[cfg(test)] +mod shared_ssh_tests { + use super::*; + + #[test] + fn shared_ssh_control_path_is_stable_scoped_and_bounded() { + let path = shared_ssh_control_path(Path::new("/config/one"), "user@host").unwrap(); + assert_eq!( + path, + shared_ssh_control_path(Path::new("/config/one"), "user@host").unwrap() + ); + assert_ne!( + path, + shared_ssh_control_path(Path::new("/config/two"), "user@host").unwrap() + ); + assert_ne!( + path, + shared_ssh_control_path(Path::new("/config/one"), "other@host").unwrap() + ); + let expanded = path.to_string_lossy().replace("%C", &"f".repeat(40)); + assert!(fits_unix_socket_path(&PathBuf::from(&expanded))); + // OpenSSH binds this temporary socket before renaming it to ControlPath. + assert!(fits_unix_socket_path(&PathBuf::from(format!( + "{expanded}.QuuYe7ZFE2HYeAE4" + )))); + validate_shared_ssh_dir(path.parent().unwrap()).unwrap(); + } + + #[test] + fn shared_ssh_staging_path_fits_with_maximum_uid_width() { + let path = shared_ssh_control_path(Path::new("/config/one"), "user@host").unwrap(); + let directory = path.parent().unwrap(); + let name = directory.file_name().unwrap().to_string_lossy(); + let prefix = name.trim_end_matches(|ch: char| ch.is_ascii_digit()); + let maximum_uid_directory = directory + .parent() + .unwrap() + .join(format!("{prefix}{}", u32::MAX)); + let expanded = maximum_uid_directory + .join(path.file_name().unwrap()) + .to_string_lossy() + .replace("%C", &"f".repeat(40)); + assert!(fits_unix_socket_path(&PathBuf::from(format!( + "{expanded}.QuuYe7ZFE2HYeAE4" + )))); + } + + #[test] + fn shared_ssh_directory_rejects_symlinks_and_public_modes() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let dir = create_remote_ssh_config_dir("ctl").unwrap(); + let link = dir.join("link"); + symlink(&dir, &link).unwrap(); + assert_eq!( + validate_shared_ssh_dir(&link).unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + validate_shared_ssh_dir(&dir).unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + std::fs::remove_dir_all(dir).unwrap(); + } +} |
