cat / goq
clone

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

fix/architecture-review-findings
goq / crates / sigil-host / src / secure_state.rs
secure_state.rs 13.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};910pub fn ensure_private_directory(path: &Path) -> Result<()> {11 match fs::symlink_metadata(path) {12 Ok(_) => validate_private_directory(path),13 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {14 let mut builder = fs::DirBuilder::new();15 #[cfg(unix)]16 builder.mode(0o700);17 builder18 .create(path)19 .with_context(|| format!("creating private state directory {}", path.display()))?;20 validate_private_directory(path)21 }22 Err(error) => Err(error)23 .with_context(|| format!("inspecting private state directory {}", path.display())),24 }25}2627pub fn validate_private_directory(path: &Path) -> Result<()> {28 let metadata = validate_owned_directory(path)?;29 #[cfg(unix)]30 {31 ensure!(32 metadata.mode() & 0o077 == 0,33 "private state directory is accessible by group or others"34 );35 }36 Ok(())37}3839pub fn validate_integrity_protected_directory(path: &Path) -> Result<()> {40 let metadata = validate_owned_directory(path)?;41 #[cfg(unix)]42 {43 ensure!(44 metadata.mode() & 0o300 == 0o300,45 "protected directory must be writable and searchable by its owner"46 );47 ensure!(48 metadata.mode() & 0o022 == 0,49 "protected directory is writable by group or others"50 );51 }52 Ok(())53}5455fn validate_owned_directory(path: &Path) -> Result<fs::Metadata> {56 let metadata = fs::symlink_metadata(path)57 .with_context(|| format!("inspecting owned directory {}", path.display()))?;58 ensure!(59 !metadata.file_type().is_symlink(),60 "owned directory must not be a symlink"61 );62 ensure!(metadata.is_dir(), "owned path must be a directory");63 #[cfg(unix)]64 {65 ensure!(66 metadata.uid() == unsafe { libc::geteuid() },67 "owned directory has the wrong owner"68 );69 }70 Ok(metadata)71}7273#[derive(Debug)]74pub enum LockAcquireError {75 Busy,76 Unsafe(anyhow::Error),77}7879impl std::fmt::Display for LockAcquireError {80 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {81 match self {82 Self::Busy => formatter.write_str(83 "another Sigil daemon or capture probe already owns this lifecycle scope",84 ),85 Self::Unsafe(error) => write!(formatter, "unsafe lock storage: {error}"),86 }87 }88}8990impl std::error::Error for LockAcquireError {91 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {92 match self {93 Self::Busy => None,94 Self::Unsafe(error) => Some(error.as_ref()),95 }96 }97}9899pub fn try_open_lifetime_lock(100 directory: &Path,101 file_name: &str,102) -> std::result::Result<File, LockAcquireError> {103 validate_private_directory(directory).map_err(LockAcquireError::Unsafe)?;104 let path = directory.join(file_name);105 let mut options = OpenOptions::new();106 options.read(true).write(true).create(true);107 #[cfg(unix)]108 options109 .mode(0o600)110 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);111 let file = options112 .open(&path)113 .with_context(|| format!("opening lifecycle lock {}", path.display()))114 .map_err(LockAcquireError::Unsafe)?;115 validate_private_file(&file, &path).map_err(LockAcquireError::Unsafe)?;116117 #[cfg(unix)]118 {119 use std::os::fd::AsRawFd;120121 let status = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };122 if status != 0 {123 let error = std::io::Error::last_os_error();124 if error.kind() == std::io::ErrorKind::WouldBlock {125 return Err(LockAcquireError::Busy);126 }127 return Err(LockAcquireError::Unsafe(error.into()));128 }129 }130131 Ok(file)132}133134pub fn read_bounded(135 directory: &Path,136 file_name: &str,137 maximum_bytes: u64,138) -> Result<Option<Vec<u8>>> {139 validate_private_directory(directory)?;140 let path = directory.join(file_name);141 let mut options = OpenOptions::new();142 options.read(true);143 #[cfg(unix)]144 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);145 let mut file = match options.open(&path) {146 Ok(file) => file,147 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),148 Err(error) => {149 return Err(error).with_context(|| format!("opening private state {}", path.display()));150 }151 };152 validate_private_file(&file, &path)?;153 let length = file.metadata()?.len();154 ensure!(155 length <= maximum_bytes,156 "private state file exceeds its fixed size bound"157 );158 let mut bytes = Vec::with_capacity(length as usize);159 Read::by_ref(&mut file)160 .take(maximum_bytes.saturating_add(1))161 .read_to_end(&mut bytes)?;162 ensure!(163 bytes.len() as u64 <= maximum_bytes,164 "private state file exceeds its fixed size bound"165 );166 ensure!(!bytes.is_empty(), "private state file is empty");167 Ok(Some(bytes))168}169170pub fn atomic_write(171 directory: &Path,172 file_name: &str,173 bytes: &[u8],174 maximum_bytes: u64,175) -> Result<()> {176 let mut terminated = Vec::with_capacity(bytes.len().saturating_add(1));177 terminated.extend_from_slice(bytes);178 terminated.push(b'\n');179 atomic_write_exact(directory, file_name, &terminated, maximum_bytes)180}181182pub fn atomic_write_exact(183 directory: &Path,184 file_name: &str,185 bytes: &[u8],186 maximum_bytes: u64,187) -> Result<()> {188 validate_private_directory(directory)?;189 atomic_write_exact_in_validated_directory(directory, file_name, bytes, maximum_bytes)190}191192pub fn atomic_write_exact_in_integrity_protected_directory(193 directory: &Path,194 file_name: &str,195 bytes: &[u8],196 maximum_bytes: u64,197) -> Result<()> {198 validate_integrity_protected_directory(directory)?;199 atomic_write_exact_in_validated_directory(directory, file_name, bytes, maximum_bytes)200}201202fn atomic_write_exact_in_validated_directory(203 directory: &Path,204 file_name: &str,205 bytes: &[u8],206 maximum_bytes: u64,207) -> Result<()> {208 ensure!(209 bytes.len() as u64 <= maximum_bytes,210 "private state file exceeds its fixed size bound"211 );212 let mut random = [0_u8; 8];213 getrandom::fill(&mut random).context("generating private state temporary-file name")?;214 let temporary = directory.join(format!(215 ".{file_name}.{:016x}.tmp",216 u64::from_be_bytes(random)217 ));218 let destination = directory.join(file_name);219 let mut options = OpenOptions::new();220 options.write(true).create_new(true);221 #[cfg(unix)]222 options223 .mode(0o600)224 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);225 let mut file = options.open(&temporary).with_context(|| {226 format!(227 "creating private state temporary file {}",228 temporary.display()229 )230 })?;231 let result = (|| -> Result<()> {232 file.write_all(bytes)?;233 file.sync_all()?;234 fs::rename(&temporary, &destination)?;235 File::open(directory)?.sync_all()?;236 Ok(())237 })();238 if result.is_err() {239 let _ = fs::remove_file(&temporary);240 }241 result242}243244pub fn remove_file_if_exists(directory: &Path, file_name: &str) -> Result<bool> {245 validate_private_directory(directory)?;246 let path = directory.join(file_name);247 match fs::symlink_metadata(&path) {248 Ok(metadata) => {249 ensure!(250 !metadata.file_type().is_symlink() && metadata.is_file(),251 "private state cleanup target is not a regular file"252 );253 #[cfg(unix)]254 {255 ensure!(256 metadata.mode() & 0o077 == 0,257 "private state cleanup target is accessible by group or others"258 );259 ensure!(260 metadata.uid() == unsafe { libc::geteuid() },261 "private state cleanup target has the wrong owner"262 );263 }264 fs::remove_file(&path)265 .with_context(|| format!("removing private state {}", path.display()))?;266 File::open(directory)?.sync_all()?;267 Ok(true)268 }269 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),270 Err(error) => {271 Err(error).with_context(|| format!("inspecting private state {}", path.display()))272 }273 }274}275276fn validate_private_file(file: &File, path: &Path) -> Result<()> {277 let metadata = file.metadata()?;278 ensure!(279 metadata.is_file(),280 "{} is not a regular file",281 path.display()282 );283 #[cfg(unix)]284 {285 ensure!(286 metadata.mode() & 0o077 == 0,287 "{} is accessible by group or others",288 path.display()289 );290 ensure!(291 metadata.uid() == unsafe { libc::geteuid() },292 "{} has the wrong owner",293 path.display()294 );295 }296 Ok(())297}298299#[cfg(test)]300mod tests {301 use super::*;302303 #[cfg(unix)]304 use std::os::unix::fs::{PermissionsExt, symlink};305306 fn private_directory() -> tempfile::TempDir {307 let directory = tempfile::tempdir().unwrap();308 #[cfg(unix)]309 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();310 directory311 }312313 #[test]314 fn lifetime_lock_is_exclusive_and_reacquirable() {315 let directory = private_directory();316 let first = try_open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();317 assert!(matches!(318 try_open_lifetime_lock(directory.path(), "daemon-v1.lock"),319 Err(LockAcquireError::Busy)320 ));321 drop(first);322 try_open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();323 }324325 #[test]326 fn atomic_state_round_trips_with_a_fixed_bound() {327 let directory = private_directory();328 atomic_write(directory.path(), "status.json", b"{\"ok\":true}", 64).unwrap();329 assert_eq!(330 read_bounded(directory.path(), "status.json", 64)331 .unwrap()332 .unwrap(),333 b"{\"ok\":true}\n"334 );335 assert!(read_bounded(directory.path(), "status.json", 4).is_err());336 #[cfg(unix)]337 assert_eq!(338 fs::metadata(directory.path().join("status.json"))339 .unwrap()340 .permissions()341 .mode()342 & 0o777,343 0o600344 );345 }346347 #[test]348 fn exact_atomic_state_preserves_bytes_and_cleanup_is_durable() {349 let directory = private_directory();350 atomic_write_exact(directory.path(), "config.toml", b"x = 1\n\n", 64).unwrap();351 assert_eq!(352 read_bounded(directory.path(), "config.toml", 64)353 .unwrap()354 .unwrap(),355 b"x = 1\n\n"356 );357 assert!(remove_file_if_exists(directory.path(), "config.toml").unwrap());358 assert!(!remove_file_if_exists(directory.path(), "config.toml").unwrap());359 }360361 #[cfg(unix)]362 #[test]363 fn integrity_protected_atomic_write_accepts_readable_but_not_writable_directory() {364 let directory = private_directory();365 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();366 assert!(367 atomic_write_exact(directory.path(), "private.toml", b"framerate = 60\n", 64,).is_err()368 );369 atomic_write_exact_in_integrity_protected_directory(370 directory.path(),371 "host.toml",372 b"framerate = 60\n",373 64,374 )375 .unwrap();376 assert_eq!(377 fs::read(directory.path().join("host.toml")).unwrap(),378 b"framerate = 60\n"379 );380 assert_eq!(381 fs::metadata(directory.path().join("host.toml"))382 .unwrap()383 .permissions()384 .mode()385 & 0o777,386 0o600387 );388389 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o555)).unwrap();390 assert!(391 atomic_write_exact_in_integrity_protected_directory(392 directory.path(),393 "host.toml",394 b"framerate = 72\n",395 64,396 )397 .is_err()398 );399 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o775)).unwrap();400 assert!(401 atomic_write_exact_in_integrity_protected_directory(402 directory.path(),403 "host.toml",404 b"framerate = 72\n",405 64,406 )407 .is_err()408 );409 assert_eq!(410 fs::read(directory.path().join("host.toml")).unwrap(),411 b"framerate = 60\n"412 );413 }414415 #[cfg(unix)]416 #[test]417 fn rejects_symlink_and_permissive_state() {418 let directory = private_directory();419 let target = directory.path().join("target");420 fs::write(&target, b"state").unwrap();421 fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();422 symlink(&target, directory.path().join("status.json")).unwrap();423 assert!(read_bounded(directory.path(), "status.json", 64).is_err());424425 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();426 assert!(matches!(427 try_open_lifetime_lock(directory.path(), "daemon-v1.lock"),428 Err(LockAcquireError::Unsafe(_))429 ));430 }431}