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        Ok(())
54    }
55
56    /// Atomic write: write to temp file, fsync, then rename over original.
57    /// This prevents data loss on ENOSPC/crash - either old file or new file,
58    /// never a truncated/empty file.
59    fn write_raw(path: &Path, data: &[u8]) -> io::Result<()> {
60        let dir = path.parent().unwrap_or(std::path::Path::new("."));
61        let temp = dir.join(format!("{}.tmp.{}", path.file_name().unwrap_or_default().to_string_lossy(), std::process::id()));
62        // Write to temp file
63        {
64            let mut opts = std::fs::OpenOptions::new();
65            opts.write(true).create(true).truncate(true);
66            #[cfg(unix)]
67            {
68                use std::os::unix::fs::OpenOptionsExt;
69                opts.mode(0o600);
70            }
71            let mut f = opts.open(&temp)?;
72            f.write_all(data)?;
73            f.sync_all()?;
74        }
75        // Atomic rename over original
76        std::fs::rename(&temp, path)?;
77        // Best-effort: fsync parent dir for crash durability
78        if let Some(parent) = path.parent() {
79            if let Ok(dir) = std::fs::OpenOptions::new().read(true).open(parent) {
80                let _ = dir.sync_all();
81            }
82        }
83        Ok(())
84    }
85}
86
87/// Resolve the current process's user SID from the access token.
88///
89/// We deliberately do NOT use the `%USERNAME%` env var: in a Windows SERVICE
90/// context (LocalSystem, a machine account, or any non-interactive logon)
91/// USERNAME is frequently absent, so keying the ACL off it makes
92/// `restrict_dacl` fail for a reason unrelated to key exposure. Even when set,
93/// a display name is locale- and rename-fragile.
94///
95/// `whoami /user` reads the calling process's token directly (the same
96/// TokenUser SID an interactive user would get), is present on every supported
97/// Windows SKU, and needs no added dependency, so it works identically whether
98/// filament runs interactively or as a service. Fail-closed: if no SID can be
99/// parsed we return Err (the caller fails loud) rather than guessing a
100/// principal.
101#[cfg(windows)]
102fn current_user_sid() -> io::Result<String> {
103    let out = std::process::Command::new("whoami")
104        .args(["/user", "/fo", "csv", "/nh"])
105        .output()
106        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("failed to run whoami: {e}")))?;
107    if !out.status.success() {
108        return Err(io::Error::new(
109            io::ErrorKind::Other,
110            format!("whoami /user failed with exit code {:?}", out.status.code()),
111        ));
112    }
113    // Output is one CSV row, no header: "DOMAIN\\user","S-1-5-21-...".
114    // Extract the SID token (well-known `S-1-...` form) by scanning fields
115    // rather than trusting exact quoting/positions across locales. A SID
116    // contains only 'S', digits, and '-', so it is isolated cleanly by
117    // splitting on comma / quote / whitespace.
118    let stdout = String::from_utf8_lossy(&out.stdout);
119    let sid = stdout
120        .split(|c: char| c == ',' || c == '"' || c.is_whitespace())
121        .find(|tok| tok.starts_with("S-1-"));
122    match sid {
123        Some(s) if !s.is_empty() => Ok(s.to_string()),
124        _ => Err(io::Error::new(
125            io::ErrorKind::Other,
126            format!("could not parse a user SID from whoami output: {:?}", stdout.trim()),
127        )),
128    }
129}
130
131#[cfg(windows)]
132fn restrict_dacl(path: &Path) -> io::Result<()> {
133    // Grant to the current user's SID (from the process token), NOT %USERNAME%.
134    // icacls accepts a raw SID via the `*S-1-...` syntax, which is the
135    // canonical, service-safe, locale-independent principal.
136    let sid = current_user_sid()?;
137    let path_str = path.display().to_string();
138    // Capture (not inherit) icacls' stdout: even with `/Q` it prints the noisy
139    // "Successfully processed 1 files; Failed processing 0 files" banner to the
140    // parent's stdout, which leaks into every managed-key install. Buffer it and
141    // only surface the output when the call actually fails.
142    let out = std::process::Command::new("icacls")
143        .args([&path_str, "/inheritance:r", "/grant:r", &format!("*{sid}:(F)"), "/Q"])
144        .output()
145        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("failed to run icacls: {e}")))?;
146    if !out.status.success() {
147        return Err(io::Error::new(
148            io::ErrorKind::Other,
149            format!(
150                "icacls failed with exit code {:?}: {}{}",
151                out.status.code(),
152                String::from_utf8_lossy(&out.stdout).trim(),
153                String::from_utf8_lossy(&out.stderr).trim(),
154            ),
155        ));
156    }
157    Ok(())
158}