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}