Skip to main content

secret_write/
lib.rs

1//! Write secret data with platform-appropriate restrictive permissions.
2//!
3//! Carved out of `cli/src/platform/mod.rs` so every trust crate shares one
4//! byte-identical secret writer. Pure `std` + `std::process::Command`; no app
5//! coupling and no `windows` crate (the DACL path shells out to `icacls`).
6
7use std::io::{self, Write};
8use std::path::Path;
9
10/// Write secret data with platform-appropriate restrictive permissions.
11///
12/// Unix: creates with mode 0o600 (owner rw only) — no world-readable window.
13/// Windows: applies per-user DACL via icacls (inheritance disabled, owner-only).
14/// %APPDATA% already restricts to the user by default, so the icacls call is
15/// defense-in-depth matching chmod 0600 semantics.
16///
17/// Upgrade note: the Windows path uses `icacls` for pragmatic zero-dependency
18/// delivery. A future iteration should switch to `SetNamedSecurityInfoW` via
19/// the `windows` crate for a locale-safe, shell-free API call.
20pub struct SecretFile;
21
22impl SecretFile {
23    /// Write `data` to `path`, creating parent directories as needed.
24    /// On success, the file is readable only by the current user.
25    pub fn write(path: impl AsRef<Path>, data: &[u8]) -> io::Result<()> {
26        let path = path.as_ref();
27        if let Some(parent) = path.parent() {
28            std::fs::create_dir_all(parent)?;
29        }
30        SecretFile::write_raw(path, data)?;
31        SecretFile::restrict(path)?;
32        Ok(())
33    }
34
35    /// Write a string as UTF-8.
36    pub fn write_str(path: impl AsRef<Path>, s: &str) -> io::Result<()> {
37        SecretFile::write(path, s.as_bytes())
38    }
39
40    /// Restrict an existing file to owner-only access.
41    /// For files created by external processes (e.g. ssh-keygen).
42    pub fn restrict(path: impl AsRef<Path>) -> io::Result<()> {
43        let path = path.as_ref();
44        #[cfg(unix)]
45        {
46            use std::os::unix::fs::PermissionsExt;
47            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
48        }
49        #[cfg(windows)]
50        {
51            restrict_dacl(path)?;
52        }
53        #[cfg(not(any(unix, windows)))]
54        {
55            let _ = path;
56        }
57        Ok(())
58    }
59
60    /// Atomic write: write to temp file, fsync, then rename over original.
61    /// This prevents data loss on ENOSPC/crash - either old file or new file,
62    /// never a truncated/empty file.
63    fn write_raw(path: &Path, data: &[u8]) -> io::Result<()> {
64        let dir = path.parent().unwrap_or(std::path::Path::new("."));
65        let temp = dir.join(format!("{}.tmp.{}", path.file_name().unwrap_or_default().to_string_lossy(), std::process::id()));
66        // Write to temp file
67        {
68            let mut opts = std::fs::OpenOptions::new();
69            opts.write(true).create(true).truncate(true);
70            #[cfg(unix)]
71            {
72                use std::os::unix::fs::OpenOptionsExt;
73                opts.mode(0o600);
74            }
75            let mut f = opts.open(&temp)?;
76            f.write_all(data)?;
77            f.sync_all()?;
78        }
79        // Atomic rename over original
80        std::fs::rename(&temp, path)?;
81        // Best-effort: fsync parent dir for crash durability
82        if let Some(parent) = path.parent() {
83            if let Ok(dir) = std::fs::OpenOptions::new().read(true).open(parent) {
84                let _ = dir.sync_all();
85            }
86        }
87        Ok(())
88    }
89}
90
91/// Resolve the current process's user SID from the access token.
92///
93/// We deliberately do NOT use the `%USERNAME%` env var: in a Windows SERVICE
94/// context (LocalSystem, a machine account, or any non-interactive logon)
95/// USERNAME is frequently absent, so keying the ACL off it makes
96/// `restrict_dacl` fail for a reason unrelated to key exposure. Even when set,
97/// a display name is locale- and rename-fragile.
98///
99/// `whoami /user` reads the calling process's token directly (the same
100/// TokenUser SID an interactive user would get), is present on every supported
101/// Windows SKU, and needs no added dependency, so it works identically whether
102/// filament runs interactively or as a service. Fail-closed: if no SID can be
103/// parsed we return Err (the caller fails loud) rather than guessing a
104/// principal.
105#[cfg(windows)]
106fn current_user_sid() -> io::Result<String> {
107    let out = std::process::Command::new("whoami")
108        .args(["/user", "/fo", "csv", "/nh"])
109        .output()
110        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("failed to run whoami: {e}")))?;
111    if !out.status.success() {
112        return Err(io::Error::new(
113            io::ErrorKind::Other,
114            format!("whoami /user failed with exit code {:?}", out.status.code()),
115        ));
116    }
117    // Output is one CSV row, no header: "DOMAIN\\user","S-1-5-21-...".
118    // Extract the SID token (well-known `S-1-...` form) by scanning fields
119    // rather than trusting exact quoting/positions across locales. A SID
120    // contains only 'S', digits, and '-', so it is isolated cleanly by
121    // splitting on comma / quote / whitespace.
122    let stdout = String::from_utf8_lossy(&out.stdout);
123    let sid = stdout
124        .split(|c: char| c == ',' || c == '"' || c.is_whitespace())
125        .find(|tok| tok.starts_with("S-1-"));
126    match sid {
127        Some(s) if !s.is_empty() => Ok(s.to_string()),
128        _ => Err(io::Error::new(
129            io::ErrorKind::Other,
130            format!("could not parse a user SID from whoami output: {:?}", stdout.trim()),
131        )),
132    }
133}
134
135#[cfg(windows)]
136fn restrict_dacl(path: &Path) -> io::Result<()> {
137    // Grant to the current user's SID (from the process token), NOT %USERNAME%.
138    // icacls accepts a raw SID via the `*S-1-...` syntax, which is the
139    // canonical, service-safe, locale-independent principal.
140    let sid = current_user_sid()?;
141    let path_str = path.display().to_string();
142    // Capture (not inherit) icacls' stdout: even with `/Q` it prints the noisy
143    // "Successfully processed 1 files; Failed processing 0 files" banner to the
144    // parent's stdout, which leaks into every managed-key install. Buffer it and
145    // only surface the output when the call actually fails.
146    let out = std::process::Command::new("icacls")
147        .args([&path_str, "/inheritance:r", "/grant:r", &format!("*{sid}:(F)"), "/Q"])
148        .output()
149        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("failed to run icacls: {e}")))?;
150    if !out.status.success() {
151        return Err(io::Error::new(
152            io::ErrorKind::Other,
153            format!(
154                "icacls failed with exit code {:?}: {}{}",
155                out.status.code(),
156                String::from_utf8_lossy(&out.stdout).trim(),
157                String::from_utf8_lossy(&out.stderr).trim(),
158            ),
159        ));
160    }
161    Ok(())
162}