cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

main
goq / crates / sigil-host / src / secure_state.rs
secure_state.rs 15.5K
1use std::fs::{self, File, OpenOptions};2use std::io::{Read, Write};3use std::path::Path;45use anyhow::{Context, Result, ensure};67#[cfg(unix)]8use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};9#[cfg(unix)]10use std::os::unix::fs::{FileTypeExt, PermissionsExt};1112pub fn ensure_private_directory(path: &Path) -> Result<()> {13 match fs::symlink_metadata(path) {14 Ok(_) => validate_private_directory(path),15 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {16 let mut builder = fs::DirBuilder::new();17 #[cfg(unix)]18 builder.mode(0o700);19 builder20 .create(path)21 .with_context(|| format!("creating private state directory {}", path.display()))?;22 validate_private_directory(path)23 }24 Err(error) => Err(error)25 .with_context(|| format!("inspecting private state directory {}", path.display())),26 }27}2829pub fn validate_private_directory(path: &Path) -> Result<()> {30 let metadata = validate_owned_directory(path)?;31 #[cfg(unix)]32 {33 ensure!(34 metadata.mode() & 0o077 == 0,35 "private state directory is accessible by group or others"36 );37 }38 Ok(())39}4041pub fn validate_integrity_protected_directory(path: &Path) -> Result<()> {42 let metadata = validate_owned_directory(path)?;43 #[cfg(unix)]44 {45 ensure!(46 metadata.mode() & 0o300 == 0o300,47 "protected directory must be writable and searchable by its owner"48 );49 ensure!(50 metadata.mode() & 0o022 == 0,51 "protected directory is writable by group or others"52 );53 }54 Ok(())55}5657fn validate_owned_directory(path: &Path) -> Result<fs::Metadata> {58 let metadata = fs::symlink_metadata(path)59 .with_context(|| format!("inspecting owned directory {}", path.display()))?;60 ensure!(61 !metadata.file_type().is_symlink(),62 "owned directory must not be a symlink"63 );64 ensure!(metadata.is_dir(), "owned path must be a directory");65 #[cfg(unix)]66 {67 ensure!(68 metadata.uid() == unsafe { libc::geteuid() },69 "owned directory has the wrong owner"70 );71 }72 Ok(metadata)73}7475#[derive(Debug)]76pub enum LockAcquireError {77 Busy,78 Unsafe(anyhow::Error),79}8081impl std::fmt::Display for LockAcquireError {82 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {83 match self {84 Self::Busy => formatter.write_str(85 "another Sigil daemon or capture probe already owns this lifecycle scope",86 ),87 Self::Unsafe(error) => write!(formatter, "unsafe lock storage: {error}"),88 }89 }90}9192impl std::error::Error for LockAcquireError {93 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {94 match self {95 Self::Busy => None,96 Self::Unsafe(error) => Some(error.as_ref()),97 }98 }99}100101pub fn try_open_lifetime_lock(102 directory: &Path,103 file_name: &str,104) -> std::result::Result<File, LockAcquireError> {105 validate_private_directory(directory).map_err(LockAcquireError::Unsafe)?;106 let path = directory.join(file_name);107 let mut options = OpenOptions::new();108 options.read(true).write(true).create(true);109 #[cfg(unix)]110 options111 .mode(0o600)112 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);113 let file = options114 .open(&path)115 .with_context(|| format!("opening lifecycle lock {}", path.display()))116 .map_err(LockAcquireError::Unsafe)?;117 validate_private_file(&file, &path).map_err(LockAcquireError::Unsafe)?;118119 #[cfg(unix)]120 {121 use std::os::fd::AsRawFd;122123 let status = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };124 if status != 0 {125 let error = std::io::Error::last_os_error();126 if error.kind() == std::io::ErrorKind::WouldBlock {127 return Err(LockAcquireError::Busy);128 }129 return Err(LockAcquireError::Unsafe(error.into()));130 }131 }132133 Ok(file)134}135136pub fn read_bounded(137 directory: &Path,138 file_name: &str,139 maximum_bytes: u64,140) -> Result<Option<Vec<u8>>> {141 validate_private_directory(directory)?;142 let path = directory.join(file_name);143 let mut options = OpenOptions::new();144 options.read(true);145 #[cfg(unix)]146 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);147 let mut file = match options.open(&path) {148 Ok(file) => file,149 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),150 Err(error) => {151 return Err(error).with_context(|| format!("opening private state {}", path.display()));152 }153 };154 validate_private_file(&file, &path)?;155 let length = file.metadata()?.len();156 ensure!(157 length <= maximum_bytes,158 "private state file exceeds its fixed size bound"159 );160 let mut bytes = Vec::with_capacity(length as usize);161 Read::by_ref(&mut file)162 .take(maximum_bytes.saturating_add(1))163 .read_to_end(&mut bytes)?;164 ensure!(165 bytes.len() as u64 <= maximum_bytes,166 "private state file exceeds its fixed size bound"167 );168 ensure!(!bytes.is_empty(), "private state file is empty");169 Ok(Some(bytes))170}171172pub fn atomic_write(173 directory: &Path,174 file_name: &str,175 bytes: &[u8],176 maximum_bytes: u64,177) -> Result<()> {178 let mut terminated = Vec::with_capacity(bytes.len().saturating_add(1));179 terminated.extend_from_slice(bytes);180 terminated.push(b'\n');181 atomic_write_exact(directory, file_name, &terminated, maximum_bytes)182}183184pub fn atomic_write_exact(185 directory: &Path,186 file_name: &str,187 bytes: &[u8],188 maximum_bytes: u64,189) -> Result<()> {190 validate_private_directory(directory)?;191 atomic_write_exact_in_validated_directory(directory, file_name, bytes, maximum_bytes)192}193194pub fn atomic_write_exact_in_integrity_protected_directory(195 directory: &Path,196 file_name: &str,197 bytes: &[u8],198 maximum_bytes: u64,199) -> Result<()> {200 validate_integrity_protected_directory(directory)?;201 atomic_write_exact_in_validated_directory(directory, file_name, bytes, maximum_bytes)202}203204fn atomic_write_exact_in_validated_directory(205 directory: &Path,206 file_name: &str,207 bytes: &[u8],208 maximum_bytes: u64,209) -> Result<()> {210 ensure!(211 bytes.len() as u64 <= maximum_bytes,212 "private state file exceeds its fixed size bound"213 );214 let mut random = [0_u8; 8];215 getrandom::fill(&mut random).context("generating private state temporary-file name")?;216 let temporary = directory.join(format!(217 ".{file_name}.{:016x}.tmp",218 u64::from_be_bytes(random)219 ));220 let destination = directory.join(file_name);221 let mut options = OpenOptions::new();222 options.write(true).create_new(true);223 #[cfg(unix)]224 options225 .mode(0o600)226 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);227 let mut file = options.open(&temporary).with_context(|| {228 format!(229 "creating private state temporary file {}",230 temporary.display()231 )232 })?;233 let result = (|| -> Result<()> {234 file.write_all(bytes)?;235 file.sync_all()?;236 fs::rename(&temporary, &destination)?;237 File::open(directory)?.sync_all()?;238 Ok(())239 })();240 if result.is_err() {241 let _ = fs::remove_file(&temporary);242 }243 result244}245246pub fn remove_file_if_exists(directory: &Path, file_name: &str) -> Result<bool> {247 validate_private_directory(directory)?;248 let path = directory.join(file_name);249 match fs::symlink_metadata(&path) {250 Ok(metadata) => {251 ensure!(252 !metadata.file_type().is_symlink() && metadata.is_file(),253 "private state cleanup target is not a regular file"254 );255 #[cfg(unix)]256 {257 ensure!(258 metadata.mode() & 0o077 == 0,259 "private state cleanup target is accessible by group or others"260 );261 ensure!(262 metadata.uid() == unsafe { libc::geteuid() },263 "private state cleanup target has the wrong owner"264 );265 }266 fs::remove_file(&path)267 .with_context(|| format!("removing private state {}", path.display()))?;268 File::open(directory)?.sync_all()?;269 Ok(true)270 }271 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),272 Err(error) => {273 Err(error).with_context(|| format!("inspecting private state {}", path.display()))274 }275 }276}277278#[cfg(unix)]279pub fn prepare_private_unix_socket(280 directory: &Path,281 file_name: &str,282) -> Result<std::path::PathBuf> {283 validate_private_directory(directory)?;284 let path = directory.join(file_name);285 match fs::symlink_metadata(&path) {286 Ok(metadata) => {287 ensure!(288 metadata.file_type().is_socket() && !metadata.file_type().is_symlink(),289 "private administration socket path is not a Unix socket"290 );291 ensure!(292 metadata.uid() == unsafe { libc::geteuid() },293 "private administration socket has the wrong owner"294 );295 ensure!(296 metadata.mode() & 0o077 == 0,297 "private administration socket is accessible by group or others"298 );299 fs::remove_file(&path).with_context(|| {300 format!("removing stale administration socket {}", path.display())301 })?;302 }303 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}304 Err(error) => {305 return Err(error)306 .with_context(|| format!("inspecting administration socket {}", path.display()));307 }308 }309 Ok(path)310}311312#[cfg(unix)]313pub fn secure_private_unix_socket(path: &Path) -> Result<()> {314 fs::set_permissions(path, fs::Permissions::from_mode(0o600))315 .with_context(|| format!("securing administration socket {}", path.display()))?;316 let metadata = fs::symlink_metadata(path)317 .with_context(|| format!("inspecting administration socket {}", path.display()))?;318 ensure!(319 metadata.file_type().is_socket() && !metadata.file_type().is_symlink(),320 "private administration socket path is not a Unix socket"321 );322 ensure!(323 metadata.uid() == unsafe { libc::geteuid() },324 "private administration socket has the wrong owner"325 );326 ensure!(327 metadata.mode() & 0o777 == 0o600,328 "private administration socket must have mode 0600"329 );330 Ok(())331}332333fn validate_private_file(file: &File, path: &Path) -> Result<()> {334 let metadata = file.metadata()?;335 ensure!(336 metadata.is_file(),337 "{} is not a regular file",338 path.display()339 );340 #[cfg(unix)]341 {342 ensure!(343 metadata.mode() & 0o077 == 0,344 "{} is accessible by group or others",345 path.display()346 );347 ensure!(348 metadata.uid() == unsafe { libc::geteuid() },349 "{} has the wrong owner",350 path.display()351 );352 }353 Ok(())354}355356#[cfg(test)]357mod tests {358 use super::*;359360 #[cfg(unix)]361 use std::os::unix::fs::{PermissionsExt, symlink};362363 fn private_directory() -> tempfile::TempDir {364 let directory = tempfile::tempdir().unwrap();365 #[cfg(unix)]366 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();367 directory368 }369370 #[test]371 fn lifetime_lock_is_exclusive_and_reacquirable() {372 let directory = private_directory();373 let first = try_open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();374 assert!(matches!(375 try_open_lifetime_lock(directory.path(), "daemon-v1.lock"),376 Err(LockAcquireError::Busy)377 ));378 drop(first);379 try_open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();380 }381382 #[test]383 fn atomic_state_round_trips_with_a_fixed_bound() {384 let directory = private_directory();385 atomic_write(directory.path(), "status.json", b"{\"ok\":true}", 64).unwrap();386 assert_eq!(387 read_bounded(directory.path(), "status.json", 64)388 .unwrap()389 .unwrap(),390 b"{\"ok\":true}\n"391 );392 assert!(read_bounded(directory.path(), "status.json", 4).is_err());393 #[cfg(unix)]394 assert_eq!(395 fs::metadata(directory.path().join("status.json"))396 .unwrap()397 .permissions()398 .mode()399 & 0o777,400 0o600401 );402 }403404 #[test]405 fn exact_atomic_state_preserves_bytes_and_cleanup_is_durable() {406 let directory = private_directory();407 atomic_write_exact(directory.path(), "config.toml", b"x = 1\n\n", 64).unwrap();408 assert_eq!(409 read_bounded(directory.path(), "config.toml", 64)410 .unwrap()411 .unwrap(),412 b"x = 1\n\n"413 );414 assert!(remove_file_if_exists(directory.path(), "config.toml").unwrap());415 assert!(!remove_file_if_exists(directory.path(), "config.toml").unwrap());416 }417418 #[cfg(unix)]419 #[test]420 fn integrity_protected_atomic_write_accepts_readable_but_not_writable_directory() {421 let directory = private_directory();422 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();423 assert!(424 atomic_write_exact(directory.path(), "private.toml", b"framerate = 60\n", 64,).is_err()425 );426 atomic_write_exact_in_integrity_protected_directory(427 directory.path(),428 "host.toml",429 b"framerate = 60\n",430 64,431 )432 .unwrap();433 assert_eq!(434 fs::read(directory.path().join("host.toml")).unwrap(),435 b"framerate = 60\n"436 );437 assert_eq!(438 fs::metadata(directory.path().join("host.toml"))439 .unwrap()440 .permissions()441 .mode()442 & 0o777,443 0o600444 );445446 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o555)).unwrap();447 assert!(448 atomic_write_exact_in_integrity_protected_directory(449 directory.path(),450 "host.toml",451 b"framerate = 72\n",452 64,453 )454 .is_err()455 );456 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o775)).unwrap();457 assert!(458 atomic_write_exact_in_integrity_protected_directory(459 directory.path(),460 "host.toml",461 b"framerate = 72\n",462 64,463 )464 .is_err()465 );466 assert_eq!(467 fs::read(directory.path().join("host.toml")).unwrap(),468 b"framerate = 60\n"469 );470 }471472 #[cfg(unix)]473 #[test]474 fn rejects_symlink_and_permissive_state() {475 let directory = private_directory();476 let target = directory.path().join("target");477 fs::write(&target, b"state").unwrap();478 fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();479 symlink(&target, directory.path().join("status.json")).unwrap();480 assert!(read_bounded(directory.path(), "status.json", 64).is_err());481482 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();483 assert!(matches!(484 try_open_lifetime_lock(directory.path(), "daemon-v1.lock"),485 Err(LockAcquireError::Unsafe(_))486 ));487 }488}