car-secrets 0.51.0

Cross-platform secret store for Common Agent Runtime
Documentation
//! Restrict a file or directory to its owner — the cross-platform analogue of
//! `chmod 0600` / `chmod 0700`.
//!
//! Unix callers already set the mode at create time via `OpenOptions::mode` /
//! `set_permissions`, which Windows can't honor. [`harden_owner_only`] fills
//! that gap: on Windows it replaces the file's inherited ACL with an explicit
//! SYSTEM + current-user Full-Control DACL (and re-homes ownership) via
//! `icacls`, so a privacy-bounded file (an action ledger, an E2E key, a run
//! store) isn't left readable by every other account on the box. On non-Windows
//! it is a no-op, so callers can invoke it unconditionally after creating the
//! file.
//!
//! This mirrors the auth-token hardening in `car-daemon-client::auth_token`;
//! it is a best-effort, defense-in-depth layer (a malicious *elevated* admin
//! can still take ownership — that adversary is defeated by encryption at rest,
//! not ACLs).

use std::path::Path;

/// Tighten `path` (a file or directory) to owner-only access. No-op off Windows.
/// Best-effort: individual `icacls` steps are logged-and-swallowed on failure so
/// a hardening miss never fails the caller's write.
pub fn harden_owner_only(path: &Path) {
    #[cfg(target_os = "windows")]
    harden_windows_acl(path);
    #[cfg(not(target_os = "windows"))]
    let _ = path;
}

#[cfg(target_os = "windows")]
fn harden_windows_acl(path: &Path) {
    use std::process::Command;

    let path_str = match path.to_str() {
        Some(s) => s,
        None => {
            tracing::warn!(?path, "ACL hardening skipped: path is not valid UTF-8");
            return;
        }
    };

    let run_icacls = |args: &[&str]| -> std::io::Result<()> {
        let status = Command::new("icacls").args(args).status()?;
        if status.success() {
            Ok(())
        } else {
            Err(std::io::Error::other(format!(
                "icacls {args:?} exited with status {status}"
            )))
        }
    };

    // Order matters: grant the required ACEs FIRST, then drop inheritance —
    // the reverse would briefly leave the path with no ACEs at all.

    // SYSTEM via well-known SID (locale-independent) so a service context can
    // still open the file.
    let _ = run_icacls(&[path_str, "/grant:r", "*S-1-5-18:F"]);

    // Name the exact process-token identity, not `%USERNAME%`: an inherited or
    // bare account name can resolve to a different principal, and the variable
    // is routinely absent in service/scrubbed environments.
    let user_sid = match current_process_sid_string() {
        Ok(sid) => sid,
        Err(error) => {
            tracing::warn!(
                ?error,
                ?path,
                "ACL hardening stopped after the SYSTEM grant: the current process SID could \
                 not be read, so inherited ACEs are left in place"
            );
            return;
        }
    };
    if let Err(error) = run_icacls(&[path_str, "/grant:r", &format!("*{user_sid}:F")]) {
        tracing::warn!(
            ?error,
            ?path,
            "ACL hardening stopped after the SYSTEM grant: the process SID grant failed, so \
             inherited ACEs are left in place"
        );
        return;
    }

    // Drop inherited ACEs — after this only the explicit SYSTEM + owner ACEs
    // remain (inherited BUILTIN\Administrators access is gone).
    if let Err(e) = run_icacls(&[path_str, "/inheritance:r"]) {
        tracing::warn!(?e, ?path, "ACL hardening: /inheritance:r failed");
    }

    // `/inheritance:r` removes only *inherited* ACEs; an *explicit*
    // BUILTIN\Administrators ACE (stamped when the file is written elevated)
    // survives it. Strip it via the well-known SID (S-1-5-32-544) so the final
    // DACL is exactly SYSTEM + owner. No-ops when absent.
    let _ = run_icacls(&[path_str, "/remove:g", "*S-1-5-32-544"]);
    let _ = run_icacls(&[path_str, "/remove:d", "*S-1-5-32-544"]);

    // Re-home ownership to the launching user: an elevated writer leaves the
    // path owned by Administrators, and an owner keeps WRITE_DAC (could
    // re-grant read). Best-effort — setting an owner other than self needs
    // SeRestorePrivilege, which a non-elevated context lacks; the ACE removal
    // above is the load-bearing step.
    let owner = format!("*{user_sid}");
    if let Err(e) = run_icacls(&[path_str, "/setowner", &owner]) {
        tracing::warn!(?e, ?path, "ACL hardening: /setowner failed");
    }
}

#[cfg(target_os = "windows")]
fn current_process_sid_string() -> std::io::Result<String> {
    use windows::core::PWSTR;
    use windows::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL};
    use windows::Win32::Security::Authorization::ConvertSidToStringSidW;
    use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
    use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};

    let windows_error = |error: windows::core::Error| std::io::Error::other(error.to_string());
    let mut token = HANDLE::default();
    unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
        .map_err(windows_error)?;

    let result = (|| {
        let mut needed = 0u32;
        let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut needed) };
        if needed == 0 {
            return Err(std::io::Error::last_os_error());
        }
        let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
        let mut buffer = vec![0usize; words];
        unsafe {
            GetTokenInformation(
                token,
                TokenUser,
                Some(buffer.as_mut_ptr().cast()),
                needed,
                &mut needed,
            )
        }
        .map_err(windows_error)?;
        let user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
        let mut sid_text = PWSTR::null();
        unsafe { ConvertSidToStringSidW(user.User.Sid, &mut sid_text) }.map_err(windows_error)?;
        let sid = unsafe { sid_text.to_string() }
            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error));
        unsafe {
            let _ = LocalFree(HLOCAL(sid_text.0.cast()));
        }
        sid
    })();

    let _ = unsafe { CloseHandle(token) };
    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn harden_is_a_noop_on_a_missing_path_and_never_panics() {
        // The whole point is best-effort: a bad path must not panic the caller.
        harden_owner_only(Path::new("this/path/does/not/exist/xyz"));
    }
}