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}