car-secrets 0.50.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"]);

    // The launching user. USERNAME can be absent in a bare service context;
    // skip cleanly when missing (the SYSTEM grant keeps the daemon working).
    if let Some(u) = std::env::var_os("USERNAME").and_then(|v| v.into_string().ok()) {
        let grant = format!("{u}:F");
        let _ = run_icacls(&[path_str, "/grant:r", &grant]);
    }

    // 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.
    if let Some(u) = std::env::var_os("USERNAME").and_then(|v| v.into_string().ok()) {
        if let Err(e) = run_icacls(&[path_str, "/setowner", &u]) {
            tracing::warn!(?e, ?path, "ACL hardening: /setowner failed");
        }
    }
}

#[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"));
    }
}