Skip to main content

vti_common/
secure_file.rs

1//! Cross-platform file / directory permission tightening for
2//! secret-bearing paths (bootstrap seeds, keystores, export bundles).
3//!
4//! # Unix
5//!
6//! `restrict_file_to_owner` → `chmod 0600`, `restrict_dir_to_owner` →
7//! `chmod 0700`. Mirrors the discipline already applied inline at
8//! existing call sites.
9//!
10//! # Windows
11//!
12//! `icacls <path> /inheritance:r /grant:r <user>:(F)` — removes any
13//! inherited ACEs and replaces the DACL with a single full-control
14//! grant to the current user. This is defence-in-depth on top of the
15//! user-profile defaults (which already keep other local users out,
16//! but inherited admin / Users group grants can slip through on
17//! misconfigured boxes or when the data lives outside the profile).
18//!
19//! Shell-out to `icacls` rather than native `SetNamedSecurityInfoW`
20//! because `icacls` is universally available on every supported Windows
21//! version, gets the quirks right (inheritance flags, SID lookup), and
22//! doesn't force the crate to carry a pile of unsafe Windows API code
23//! on a platform we don't exercise in CI. A future iteration can swap
24//! to the native API if `icacls` becomes insufficient.
25//!
26//! Errors are non-fatal at call sites: callers log a warning and
27//! continue, matching how the existing Unix `PermissionsExt` calls are
28//! already wired (best-effort hardening, not a correctness gate).
29
30use std::path::Path;
31
32/// Restrict `path` (a file) so only the owner can read / write.
33///
34/// Returns `Ok(())` on platforms where the operation either succeeded
35/// or is a no-op (everything non-Unix / non-Windows falls through —
36/// Unix gets 0600, Windows gets an icacls-applied user-only DACL).
37pub fn restrict_file_to_owner(path: &Path) -> std::io::Result<()> {
38    #[cfg(unix)]
39    {
40        use std::os::unix::fs::PermissionsExt;
41        let mut perm = std::fs::metadata(path)?.permissions();
42        perm.set_mode(0o600);
43        std::fs::set_permissions(path, perm)?;
44    }
45    #[cfg(windows)]
46    {
47        apply_windows_user_only_dacl(path)?;
48    }
49    #[cfg(not(any(unix, windows)))]
50    {
51        let _ = path;
52    }
53    Ok(())
54}
55
56/// Restrict `path` (a directory) so only the owner can traverse / read /
57/// write. On Unix: `0700`. On Windows: inheritance removed and DACL
58/// replaced with full control to the current user only.
59pub fn restrict_dir_to_owner(path: &Path) -> std::io::Result<()> {
60    #[cfg(unix)]
61    {
62        use std::os::unix::fs::PermissionsExt;
63        let mut perm = std::fs::metadata(path)?.permissions();
64        perm.set_mode(0o700);
65        std::fs::set_permissions(path, perm)?;
66    }
67    #[cfg(windows)]
68    {
69        apply_windows_user_only_dacl(path)?;
70    }
71    #[cfg(not(any(unix, windows)))]
72    {
73        let _ = path;
74    }
75    Ok(())
76}
77
78/// Write `bytes` to a new file at `path` that only the owner can read.
79///
80/// For exports that carry secret material, such as backup envelopes.
81///
82/// - The file is opened with `create_new`, so an existing path (including a
83///   symlink, dangling or not) is never truncated or followed. The call fails
84///   with [`std::io::ErrorKind::AlreadyExists`] and the caller decides whether
85///   to remove the old file first.
86/// - On Unix the file is created with mode `0600`, so it is not readable by
87///   anyone else at any point, including between create and write.
88/// - The data is flushed with `sync_all` before returning.
89/// - On Windows the owner-only DACL from [`restrict_file_to_owner`] is applied
90///   after the write.
91///
92/// Unlike the other helpers in this module, a hardening failure is an error.
93/// If any step after the file is created fails, the file is removed, so no
94/// secret is left behind with the wrong permissions and a retry is not
95/// blocked by `AlreadyExists`.
96pub fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
97    let mut opts = std::fs::OpenOptions::new();
98    opts.write(true).create_new(true);
99    #[cfg(unix)]
100    {
101        use std::os::unix::fs::OpenOptionsExt;
102        opts.mode(0o600);
103    }
104    let file = opts.open(path)?;
105
106    if let Err(e) = write_and_harden(file, path, bytes) {
107        let _ = std::fs::remove_file(path);
108        return Err(e);
109    }
110    Ok(())
111}
112
113fn write_and_harden(mut file: std::fs::File, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
114    use std::io::Write;
115
116    file.write_all(bytes)?;
117    file.sync_all()?;
118    drop(file);
119    #[cfg(windows)]
120    restrict_file_to_owner(path)?;
121    #[cfg(not(windows))]
122    let _ = path;
123    Ok(())
124}
125
126#[cfg(windows)]
127fn apply_windows_user_only_dacl(path: &Path) -> std::io::Result<()> {
128    use std::process::Command;
129
130    // Resolve the current user. `USERNAME` is present on every modern
131    // Windows shell environment; fall back to `USERDOMAIN\USERNAME`
132    // only if the plain name is missing. A missing `USERNAME` means
133    // the process is running in an unusual execution context
134    // (service, scheduled task without user context, etc.) — surface
135    // that via an error rather than silently leaving the file
136    // wide-open.
137    let user = std::env::var("USERNAME").map_err(|_| {
138        std::io::Error::new(
139            std::io::ErrorKind::NotFound,
140            "USERNAME env var not set — cannot apply Windows user-only DACL",
141        )
142    })?;
143    let user_trimmed = user.trim();
144    if user_trimmed.is_empty() {
145        return Err(std::io::Error::new(
146            std::io::ErrorKind::InvalidData,
147            "USERNAME is empty — cannot apply Windows user-only DACL",
148        ));
149    }
150
151    let path_str = path.to_str().ok_or_else(|| {
152        std::io::Error::new(
153            std::io::ErrorKind::InvalidInput,
154            "path is not valid UTF-8 — cannot pass to icacls",
155        )
156    })?;
157
158    // `icacls <path> /inheritance:r /grant:r "user:(F)"`
159    //   /inheritance:r → remove inherited ACEs
160    //   /grant:r       → replace any existing grant for <user>
161    //   user:(F)       → full control
162    let output = Command::new("icacls")
163        .arg(path_str)
164        .arg("/inheritance:r")
165        .arg("/grant:r")
166        .arg(format!("{user_trimmed}:(F)"))
167        .output()?;
168
169    if !output.status.success() {
170        return Err(std::io::Error::other(format!(
171            "icacls failed ({}): {}",
172            output.status,
173            String::from_utf8_lossy(&output.stderr).trim()
174        )));
175    }
176    Ok(())
177}
178
179#[cfg(all(test, unix))]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn restrict_file_sets_0600_on_unix() {
185        use std::os::unix::fs::PermissionsExt;
186        let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
187        std::fs::create_dir_all(&tmp).unwrap();
188        let f = tmp.join("secret.bin");
189        std::fs::write(&f, b"sensitive").unwrap();
190
191        // Start with permissive mode so we can see the change.
192        let mut perm = std::fs::metadata(&f).unwrap().permissions();
193        perm.set_mode(0o644);
194        std::fs::set_permissions(&f, perm).unwrap();
195
196        restrict_file_to_owner(&f).expect("restrict_file_to_owner succeeds");
197
198        let mode = std::fs::metadata(&f).unwrap().permissions().mode();
199        assert_eq!(mode & 0o777, 0o600);
200        let _ = std::fs::remove_dir_all(&tmp);
201    }
202
203    #[test]
204    fn restrict_dir_sets_0700_on_unix() {
205        use std::os::unix::fs::PermissionsExt;
206        let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
207        std::fs::create_dir_all(&tmp).unwrap();
208
209        let mut perm = std::fs::metadata(&tmp).unwrap().permissions();
210        perm.set_mode(0o755);
211        std::fs::set_permissions(&tmp, perm).unwrap();
212
213        restrict_dir_to_owner(&tmp).expect("restrict_dir_to_owner succeeds");
214
215        let mode = std::fs::metadata(&tmp).unwrap().permissions().mode();
216        assert_eq!(mode & 0o777, 0o700);
217        let _ = std::fs::remove_dir_all(&tmp);
218    }
219
220    /// A plain `std::fs::write` lands at `0644` under the common `022` umask.
221    /// The secret-file helper must not.
222    #[test]
223    fn write_secret_file_is_0600_under_a_022_umask() {
224        use std::os::unix::fs::PermissionsExt;
225        let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
226        std::fs::create_dir_all(&tmp).unwrap();
227        let f = tmp.join("export.vtcbak");
228
229        // SAFETY: `umask` has no memory-safety preconditions; it only swaps the
230        // process file-creation mask, which is restored straight after.
231        let previous = unsafe { libc::umask(0o022) };
232        let result = write_secret_file(&f, b"sensitive");
233        unsafe { libc::umask(previous) };
234        result.expect("write_secret_file succeeds");
235
236        let mode = std::fs::metadata(&f).unwrap().permissions().mode();
237        assert_eq!(mode & 0o777, 0o600, "got {:o}", mode & 0o777);
238        assert_eq!(std::fs::read(&f).unwrap(), b"sensitive");
239        let _ = std::fs::remove_dir_all(&tmp);
240    }
241
242    #[test]
243    fn write_secret_file_refuses_an_existing_path() {
244        let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
245        std::fs::create_dir_all(&tmp).unwrap();
246        let f = tmp.join("export.vtcbak");
247        std::fs::write(&f, b"original").unwrap();
248
249        let err = write_secret_file(&f, b"replacement").unwrap_err();
250        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
251        // The existing file is neither truncated nor removed.
252        assert_eq!(std::fs::read(&f).unwrap(), b"original");
253        let _ = std::fs::remove_dir_all(&tmp);
254    }
255
256    #[test]
257    fn write_secret_file_does_not_follow_a_symlink() {
258        let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
259        std::fs::create_dir_all(&tmp).unwrap();
260        let target = tmp.join("elsewhere");
261        let link = tmp.join("export.vtcbak");
262        std::os::unix::fs::symlink(&target, &link).unwrap();
263
264        let err = write_secret_file(&link, b"sensitive").unwrap_err();
265        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
266        assert!(!target.exists(), "the symlink target must not be created");
267        let _ = std::fs::remove_dir_all(&tmp);
268    }
269}