1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Craton Software Company
/// Platform-specific file ACL restriction helpers.
///
/// On Windows, uses the Win32 Security API to set a protected DACL that grants
/// access only to the current user — the equivalent of Unix chmod 0o600.
///
/// This module is separated from the audit module because the audit module uses
/// `#![forbid(unsafe_code)]`, while the Win32 FFI calls require `unsafe`.
#[cfg(windows)]
use std::path::Path;
/// Restrict a file's DACL to the current process owner on Windows.
/// Returns an error string on failure so the caller can decide whether to
/// continue or abort.
#[cfg(windows)]
pub(crate) fn restrict_file_to_owner(path: &Path) -> Result<(), String> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::HANDLE;
let wide_path: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
// SAFETY: All FFI calls use valid, correctly-sized buffers. The file must
// already exist (caller just created/opened it). These are well-documented
// Win32 security APIs for setting file DACLs.
unsafe {
use windows_sys::Win32::Foundation::{CloseHandle, GetLastError};
use windows_sys::Win32::Security::Authorization::SetNamedSecurityInfoW;
use windows_sys::Win32::Security::{
AddAccessAllowedAce, GetLengthSid, GetTokenInformation, InitializeAcl,
ACCESS_ALLOWED_ACE, ACL as WIN_ACL, ACL_REVISION, TOKEN_USER,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
// Step 1: Get the current process token.
let mut token_handle: HANDLE = std::ptr::null_mut();
if OpenProcessToken(
GetCurrentProcess(),
0x0008, /* TOKEN_QUERY */
&mut token_handle,
) == 0
{
let err = GetLastError();
return Err(format!("OpenProcessToken failed (error {})", err));
}
// Step 2: Query token for user SID.
let mut return_length: u32 = 0;
let _ = GetTokenInformation(
token_handle,
1, /* TokenUser */
std::ptr::null_mut(),
0,
&mut return_length,
);
if return_length == 0 {
CloseHandle(token_handle);
return Err("GetTokenInformation returned zero length".to_string());
}
let mut token_user_buf: Vec<u8> = vec![0u8; return_length as usize];
if GetTokenInformation(
token_handle,
1, // TokenUser
token_user_buf.as_mut_ptr() as *mut _,
return_length,
&mut return_length,
) == 0
{
let err = GetLastError();
CloseHandle(token_handle);
return Err(format!("GetTokenInformation failed (error {})", err));
}
// TOKEN_USER layout: first field is SID_AND_ATTRIBUTES { Sid: PSID, ... }.
// Validate buffer is large enough for the TOKEN_USER struct before casting.
if token_user_buf.len() < std::mem::size_of::<TOKEN_USER>() {
CloseHandle(token_handle);
return Err(format!(
"token_user_buf too small ({} < {})",
token_user_buf.len(),
std::mem::size_of::<TOKEN_USER>()
));
}
let token_user = &*(token_user_buf.as_ptr() as *const TOKEN_USER);
let user_sid = token_user.User.Sid;
CloseHandle(token_handle);
// Step 3: Build a minimal ACL with a single ALLOW entry for the owner.
let sid_length = GetLengthSid(user_sid);
// Validate SID length is reasonable (SID max is ~68 bytes per MS docs)
if sid_length == 0 || sid_length > 256 {
return Err(format!("unexpected SID length {}", sid_length));
}
// ACE size formula per Microsoft docs:
// sizeof(ACCESS_ALLOWED_ACE) - sizeof(DWORD) + GetLengthSid(pSid)
// The struct embeds the first DWORD of the SID inline (`SidStart`), so we
// subtract one DWORD to avoid double-counting before adding the full SID.
// Total ACL buffer = ACL header + the single ACE.
let acl_header_size = std::mem::size_of::<WIN_ACL>();
let ace_size = std::mem::size_of::<ACCESS_ALLOWED_ACE>()
.checked_sub(std::mem::size_of::<u32>())
.and_then(|n| n.checked_add(sid_length as usize))
.ok_or_else(|| "ACE size computation overflowed".to_string())?;
let acl_size_usize = acl_header_size
.checked_add(ace_size)
.ok_or_else(|| "ACL size computation overflowed".to_string())?;
let acl_size: u32 = u32::try_from(acl_size_usize)
.map_err(|_| format!("ACL size {} exceeds u32::MAX", acl_size_usize))?;
let mut acl_buf: Vec<u8> = vec![0u8; acl_size as usize];
let acl_ptr = acl_buf.as_mut_ptr() as *mut WIN_ACL;
if InitializeAcl(acl_ptr, acl_size, ACL_REVISION) == 0 {
let err = GetLastError();
return Err(format!("InitializeAcl failed (error {})", err));
}
// GENERIC_READ | GENERIC_WRITE | DELETE | SYNCHRONIZE.
//
// DELETE (0x00010000) is required so the owner can rename or unlink
// the file — audit log rotation, lockout JSON replacement, and backup
// tmp-file rename all call `std::fs::rename`, which fails without it.
//
// SYNCHRONIZE (0x00100000) is required because Rust's
// `std::fs::OpenOptions` requests it implicitly when opening files
// for read or write; omitting it would make even basic `File::open`
// fail with ERROR_ACCESS_DENIED after the DACL is applied.
let access_mask: u32 = 0x80000000 | 0x40000000 | 0x00010000 | 0x00100000;
if AddAccessAllowedAce(acl_ptr, ACL_REVISION, access_mask, user_sid) == 0 {
let err = GetLastError();
return Err(format!("AddAccessAllowedAce failed (error {})", err));
}
// Step 4: Apply the protected DACL to the file.
//
// SE_FILE_OBJECT=1, DACL_SECURITY_INFORMATION=4, PROTECTED_DACL=0x80000000.
// PROTECTED_DACL_SECURITY_INFORMATION sets SE_DACL_PROTECTED on the
// resulting security descriptor, which blocks inheritance from the
// parent directory — a parent ACL cannot grant write to others.
let result = SetNamedSecurityInfoW(
wide_path.as_ptr() as *const u16,
1, // SE_FILE_OBJECT
0x00000004 | 0x80000000,
std::ptr::null_mut(),
std::ptr::null_mut(),
acl_ptr as *const WIN_ACL,
std::ptr::null_mut(),
);
if result != 0 {
return Err(format!("SetNamedSecurityInfoW failed (error {})", result,));
}
}
Ok(())
}
#[cfg(all(test, windows))]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
/// Smoke test: on a freshly created file, restrict_file_to_owner must
/// succeed. Any miscalculated ACL buffer size would cause InitializeAcl
/// or AddAccessAllowedAce to fail with ERROR_INSUFFICIENT_BUFFER and
/// surface here as Err.
#[test]
fn restrict_file_to_owner_succeeds_on_temp_file() {
let mut f = NamedTempFile::new().expect("create temp file");
writeln!(f, "hsm-test").expect("write temp file");
let path = f.path().to_path_buf();
restrict_file_to_owner(&path)
.unwrap_or_else(|e| panic!("restrict_file_to_owner failed: {e}"));
}
/// Readback test: after restrict_file_to_owner succeeds, the file's DACL
/// must parse cleanly and contain exactly one ACE at the expected
/// revision. This catches bugs where the ACL buffer is the wrong size
/// but InitializeAcl doesn't reject it (e.g. oversized buffer leaves
/// garbage past the ACE the OS may or may not tolerate).
#[test]
fn restricted_dacl_has_single_ace_at_expected_revision() {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows_sys::Win32::Security::{
GetAclInformation, ACL as WIN_ACL, ACL_INFORMATION_CLASS, ACL_REVISION,
ACL_SIZE_INFORMATION, DACL_SECURITY_INFORMATION,
};
let mut f = NamedTempFile::new().expect("create temp file");
writeln!(f, "hsm-test").expect("write temp file");
let path = f.path().to_path_buf();
restrict_file_to_owner(&path).expect("restrict_file_to_owner");
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
// SAFETY: we own the path, pass correctly-typed out-pointers, and
// free the returned security descriptor via LocalFree per docs.
unsafe {
let mut dacl: *mut WIN_ACL = std::ptr::null_mut();
let mut sd: *mut core::ffi::c_void = std::ptr::null_mut();
let rv = GetNamedSecurityInfoW(
wide.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut dacl,
std::ptr::null_mut(),
&mut sd,
);
assert_eq!(rv, 0, "GetNamedSecurityInfoW failed: {}", rv);
assert!(!dacl.is_null(), "DACL pointer was null");
// AclRevision is u8, check against the constant we used.
assert_eq!(
(*dacl).AclRevision,
ACL_REVISION as u8,
"unexpected ACL revision",
);
// Exactly one ACE — the owner grant we installed.
const ACL_SIZE_INFO: ACL_INFORMATION_CLASS = 2;
let mut info = ACL_SIZE_INFORMATION {
AceCount: 0,
AclBytesInUse: 0,
AclBytesFree: 0,
};
let ok = GetAclInformation(
dacl,
(&mut info) as *mut _ as *mut core::ffi::c_void,
std::mem::size_of::<ACL_SIZE_INFORMATION>() as u32,
ACL_SIZE_INFO,
);
assert_ne!(ok, 0, "GetAclInformation failed");
assert_eq!(
info.AceCount, 1,
"expected exactly one ACE, got {}",
info.AceCount
);
assert_eq!(
info.AclBytesFree, 0,
"ACL buffer was oversized by {} bytes — indicates miscalculated size",
info.AclBytesFree,
);
// Note: GetNamedSecurityInfoW allocates `sd` and the caller must
// LocalFree it. We intentionally leak it here — the test process
// exits immediately after, and avoiding LocalFree means we don't
// need to add a windows-sys feature for it just to run a test.
let _ = sd;
}
}
/// Regression test for the audit-log rotation bug: after
/// `restrict_file_to_owner` runs, the owner must still be able to rename
/// the file. Without DELETE in the access mask, `std::fs::rename` fails
/// with ERROR_ACCESS_DENIED on the second rotation onward, breaking
/// `rotate_log_files`, lockout JSON replacement, and backup tmp-file
/// rename.
#[test]
fn restricted_file_can_be_renamed_by_owner() {
// Use NamedTempFile to pick a unique path, then drop the handle so the
// file is closed before we set the DACL — matches how the production
// call sites use restrict_file_to_owner (after close, not while open).
let f = NamedTempFile::new().expect("create temp file");
let src = f.path().to_path_buf();
// Persist so the temp file isn't auto-deleted when `f` is dropped.
let _ = f.persist(&src).expect("persist temp file");
std::fs::write(&src, b"hsm-test").expect("write src");
restrict_file_to_owner(&src).expect("restrict_file_to_owner");
let mut dst = src.clone();
dst.set_extension("renamed");
std::fs::rename(&src, &dst).unwrap_or_else(|e| {
panic!(
"rename {} -> {} failed after restrict_file_to_owner: {}",
src.display(),
dst.display(),
e,
)
});
// Cleanup — best effort; if this fails the OS will reap on reboot.
let _ = std::fs::remove_file(&dst);
}
}