//! Host config-dir + owner-private file helpers — a leaf crate so the subsystem crates //! (`pf-media`, `pf-vdisplay`) and the orchestrator can all reach them WITHOUT depending on the //! `gamestream` module they used to live in (plan §2.4 / §W6: the secret helpers were shared //! vocabulary parked above their consumers in the junk drawer). Pure std + `tracing`; no I/O stack. //! //! - [`config_dir`] resolves the per-host config directory (XDG / `%ProgramData%`, `PUNKTFUNK_CONFIG_DIR` override). //! - [`create_private_dir`] makes it owner-private (0700 / restrictive DACL). //! - [`write_secret_file`] writes an owner-only secret (0600 / SYSTEM+Admins DACL). #![forbid(unsafe_code)] use std::path::PathBuf; /// The shared path of the file where the gamescope backend relays the nested session's /// `LIBEI_SOCKET` (gamescope's EIS server) for the input injector: `$XDG_RUNTIME_DIR/ /// punktfunk-gamescope-ei` (per-user 0700), or `/tmp/…` when the runtime dir is unset. It is a /// **contract shared** by the gamescope producer (`pf-vdisplay`, which writes it under the session /// env lock) and the libei consumer (`pf-inject`, which reads it after the session env is applied) — /// a leaf so neither subsystem crate has to reach into the other (plan §W6). Linux-only. #[cfg(target_os = "linux")] pub fn gamescope_ei_socket_file() -> PathBuf { match std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()) { Some(rt) => PathBuf::from(rt).join("punktfunk-gamescope-ei"), None => PathBuf::from("/tmp/punktfunk-gamescope-ei"), } } /// The host config dir (host identity, pairing state, mgmt token, library) — created on demand. /// Linux: `$XDG_CONFIG_HOME/punktfunk` or `~/.config/punktfunk`. Windows: `%ProgramData%\punktfunk` /// (machine-wide — the SYSTEM service and the interactive user share ONE dir that survives logout). /// `PUNKTFUNK_CONFIG_DIR` overrides on both platforms (used by the Windows service config / tests). pub fn config_dir() -> PathBuf { if let Some(dir) = std::env::var_os("PUNKTFUNK_CONFIG_DIR").filter(|s| !s.is_empty()) { return PathBuf::from(dir); } // Windows: %ProgramData% (e.g. C:\ProgramData\punktfunk) — machine-wide, SYSTEM-readable, // persists across user logout, correct for a SYSTEM service. Falls back to %APPDATA% then CWD. #[cfg(target_os = "windows")] let base = std::env::var_os("ProgramData") .or_else(|| std::env::var_os("APPDATA")) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")); #[cfg(not(target_os = "windows"))] let base = std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) .unwrap_or_else(|| PathBuf::from(".")); base.join("punktfunk") } /// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable /// by other local users via a traversable config path). On Windows, applies a restrictive DACL /// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in /// the config tree (the default `%ProgramData%` ACL grants Users *create*; security-review /// 2026-06-28 #3/#11). Tightens (and re-owns) an already-existing dir too. pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> { #[cfg(unix)] { use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; let r = std::fs::DirBuilder::new() .recursive(true) .mode(0o700) .create(dir); // `recursive` doesn't re-chmod an existing dir — tighten it so an old 0755 dir gets locked. if dir.exists() { let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); } r } #[cfg(not(unix))] { let r = std::fs::create_dir_all(dir); #[cfg(windows)] restrict_dir_to_system_admins(dir, first_hardening_of(dir)); r } } /// Whether this is the first hardening pass of `dir` in this process — the pass that also does the /// expensive recursive re-own. /// /// A planted config dir is planted once, before the host ever starts, so one deep pass at startup /// closes it; repeating it on every `create_private_dir` call (the library CRUD calls it per write) /// would re-walk the whole config tree — recordings, art cache — for nothing. #[cfg(windows)] fn first_hardening_of(dir: &std::path::Path) -> bool { use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; static SEEN: OnceLock>> = OnceLock::new(); SEEN.get_or_init(|| Mutex::new(HashSet::new())) .lock() .map(|mut s| s.insert(dir.to_path_buf())) .unwrap_or(false) } /// Re-apply the secret-file DACL to a file that **already exists** — including re-owning it to /// Administrators. /// /// [`write_secret_file`] hardens what it writes, but a file that was planted before the host first /// ran was never written by us: it is owned by whoever created it, and an owner always retains /// `WRITE_DAC`, so re-ACLing without re-owning leaves them able to put their access straight back. /// Used on startup for `host.env`, whose contents become the SYSTEM service's environment and /// command line (2026-08-05 review H-4). Best-effort and never fatal. #[cfg(windows)] pub fn restrict_existing_secret_file(path: &std::path::Path) { if !path.exists() { return; } let icacls = icacls_path(); let _ = std::process::Command::new(&icacls) .arg(path.as_os_str()) .args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); restrict_to_system_admins(path); } /// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a /// non-root user pre-created is not a privilege boundary the way `%ProgramData%` is. #[cfg(not(windows))] pub fn restrict_existing_secret_file(_path: &std::path::Path) {} /// `icacls` by absolute path — a privileged service must never resolve it through `PATH`. #[cfg(windows)] fn icacls_path() -> String { std::env::var("SystemRoot") .map(|r| format!("{r}\\System32\\icacls.exe")) .unwrap_or_else(|_| "icacls".to_string()) } /// Best-effort Windows DACL lockdown of the config *directory* (the companion to /// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users` /// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the /// `punktfunk` dir or plant a `host.env`/`apps.json` that the privileged SYSTEM service then trusts /// (LPE; security-review 2026-06-28 #3). This re-owns the dir to Administrators (defeating a /// pre-creation), strips inheritance, and sets an explicit DACL: SYSTEM/Administrators/OWNER full /// (object+container inherit so child files/dirs inherit it), and Users **read-only** (so existing /// reads of non-secret config keep working but a local user can no longer write/plant). Secret files /// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs /// (locale-independent) via the absolute `%SystemRoot%` path; never fatal. #[cfg(windows)] fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) { let icacls = icacls_path(); // Reset ownership to Administrators first, so a dir a non-admin may have pre-created can't keep // OWNER control (an owner always retains WRITE_DAC and can put its access straight back). // // `deep` (once per directory per process — see `first_hardening_of`) also re-owns the CONTENTS. // Re-owning only the directory left every file the attacker had already created still owned by // them, and therefore still theirs to rewrite, which is half of why the 2026-08-05 review's H-4 // was exploitable end to end. A planted tree is planted once, before the host first runs, so one // deep pass at startup closes it without re-walking recordings and art cache on every write. let mut own = std::process::Command::new(&icacls); own.arg(dir.as_os_str()) .args(["/setowner", "*S-1-5-32-544"]); // BUILTIN\Administrators if deep { own.args(["/T", "/C", "/Q"]); // recurse, continue on error, quiet } let _ = own .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); let status = std::process::Command::new(&icacls) .arg(dir.as_os_str()) .args([ "/inheritance:r", "/grant:r", "*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM "/grant:r", "*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators // NO inheritable OWNER RIGHTS (`*S-1-3-4`) here, deliberately. It used to be granted // `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it — // so a file a local user created before the hardening ran stayed writable by them even // after the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and // Administrators cover every account that legitimately writes here; a non-elevated // manual run gets read-only config, which is the intended boundary rather than a // regression — this directory drives command execution as SYSTEM. "/grant:r", "*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant) ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); match status { Ok(s) if s.success() => {} _ => tracing::warn!( dir = %dir.display(), "config-dir DACL hardening did not fully succeed — a local user may be able to plant config files" ), } } /// Write `contents` to `path` as an **owner-only secret**: created and re-chmod'd **0600** on Unix /// (never even briefly group/world-readable), and DACL-restricted to SYSTEM/Administrators/owner on /// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used /// for the host private key and the persisted trust stores so a local unprivileged user can neither /// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing). /// /// **Windows ordering caveat** (2026-08-05 review L-17): this is create-then-`icacls`, not /// create-with-DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`, and this crate is /// `#![forbid(unsafe_code)]` so it cannot call `CreateFileW` itself. The file therefore exists /// briefly under its INHERITED ACL, and a failed `icacls` is a warning rather than an error. /// /// What makes that acceptable is the DIRECTORY, and only the directory: every caller writes into /// the config dir, which [`create_private_dir`] now hardens unconditionally and BEFORE the first /// read of anything in it (review H-4/M-1), granting `BUILTIN\Users` read-only and no create. The /// inherited ACL a secret is born with is therefore already SYSTEM/Administrators-only, and the /// `icacls` below is defence in depth rather than the thing standing between a local user and the /// host key. Keep that ordering — if the directory hardening is ever moved back after a read, this /// window becomes real again. pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { use std::io::Write; let mut opts = std::fs::OpenOptions::new(); opts.write(true).create(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; opts.mode(0o600); } let mut f = opts.open(path)?; f.write_all(contents)?; f.flush()?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); } #[cfg(windows)] restrict_to_system_admins(path); Ok(()) } /// Best-effort Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to /// SYSTEM, Administrators, and OWNER RIGHTS (the creating account — the SYSTEM service or a manually /// running user keeps access). Without this the host key under the default Users-readable /// `%ProgramData%` ACL is readable by ANY local user. Uses `icacls` with hard-coded SIDs /// (locale-independent) via the absolute `%SystemRoot%` path (a privileged service must not trust /// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour). #[cfg(windows)] fn restrict_to_system_admins(path: &std::path::Path) { let icacls = icacls_path(); let status = std::process::Command::new(icacls) .arg(path.as_os_str()) .args([ "/inheritance:r", "/grant:r", "*S-1-5-18:(F)", // NT AUTHORITY\SYSTEM "/grant:r", "*S-1-5-32-544:(F)", // BUILTIN\Administrators "/grant:r", "*S-1-3-4:(F)", // OWNER RIGHTS ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); match status { Ok(s) if s.success() => {} _ => tracing::warn!( path = %path.display(), "icacls hardening did not succeed — this secret may be readable by other local users" ), } }