1use std::fs::{self, File};
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22use crate::coordinate::Coordinate;
23use crate::crypto::{KEY_LEN, SealedRecord, open};
24use crate::error::CoreError;
25use crate::record::SecretRecord;
26
27pub const FRAME_MAGIC: &[u8; 4] = b"KOVR";
29pub const FRAME_VERSION: u32 = 1;
31pub const RECORD_EXT: &str = "sec";
33
34const HEADER_LEN: usize = 4 + 4; #[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Quarantined {
40 pub id: String,
42 pub reason: String,
44}
45
46#[derive(Debug, Default)]
48pub struct LoadOutcome {
49 pub records: Vec<(String, SecretRecord)>,
51 pub quarantined: Vec<Quarantined>,
53}
54
55pub fn record_path_for_id(dir: &Path, id: &str) -> PathBuf {
58 dir.join(format!("{id}.{RECORD_EXT}"))
59}
60
61pub fn record_path(dir: &Path, coord: &Coordinate) -> Result<PathBuf, CoreError> {
63 Ok(record_path_for_id(dir, &coord.storage_id()?))
64}
65
66fn frame(sealed: &SealedRecord) -> Result<Vec<u8>, CoreError> {
68 let payload =
69 serde_json::to_vec(sealed).map_err(|e| CoreError::Serialization(e.to_string()))?;
70 let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
71 out.extend_from_slice(FRAME_MAGIC);
72 out.extend_from_slice(&FRAME_VERSION.to_le_bytes());
73 out.extend_from_slice(&payload);
74 Ok(out)
75}
76
77fn unframe(bytes: &[u8]) -> Result<SealedRecord, CoreError> {
79 if bytes.len() < HEADER_LEN || &bytes[..4] != FRAME_MAGIC {
80 return Err(CoreError::Serialization(
81 "not a kovra record frame".to_string(),
82 ));
83 }
84 let version = u32::from_le_bytes(bytes[4..8].try_into().expect("checked length"));
85 if version != FRAME_VERSION {
86 return Err(CoreError::Serialization(format!(
87 "unsupported record frame version {version}"
88 )));
89 }
90 serde_json::from_slice(&bytes[HEADER_LEN..])
91 .map_err(|e| CoreError::Serialization(e.to_string()))
92}
93
94#[cfg(unix)]
102pub(crate) fn restrict(path: &Path, mode: u32) -> Result<(), CoreError> {
103 use std::os::unix::fs::PermissionsExt;
104 fs::set_permissions(path, fs::Permissions::from_mode(mode))
105 .map_err(|e| CoreError::Io(format!("chmod {mode:o}: {e}")))
106}
107
108#[cfg(windows)]
109pub(crate) fn restrict(path: &Path, _mode: u32) -> Result<(), CoreError> {
110 windows_acl::restrict_owner_only(path)
112}
113
114#[cfg(not(any(unix, windows)))]
115pub(crate) fn restrict(_path: &Path, _mode: u32) -> Result<(), CoreError> {
116 Ok(())
117}
118
119#[cfg(windows)]
129mod windows_acl {
130 use std::os::windows::ffi::OsStrExt;
131 use std::path::Path;
132
133 use windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, LocalFree};
134 use windows::Win32::Security::Authorization::{
135 EXPLICIT_ACCESS_W, MULTIPLE_TRUSTEE_OPERATION, SE_FILE_OBJECT, SET_ACCESS,
136 SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W,
137 };
138 use windows::Win32::Security::{
139 ACE_FLAGS, ACL, CreateWellKnownSid, DACL_SECURITY_INFORMATION, GetTokenInformation,
140 PROTECTED_DACL_SECURITY_INFORMATION, PSID, SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER,
141 TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid,
142 };
143 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
144 use windows::core::{PCWSTR, PWSTR};
145
146 use crate::error::CoreError;
147
148 const GENERIC_ALL: u32 = 0x1000_0000;
150
151 pub(crate) fn restrict_owner_only(path: &Path) -> Result<(), CoreError> {
152 unsafe { apply(path) }.map_err(|e| CoreError::Io(format!("set ACL on {path:?}: {e}")))
155 }
156
157 fn explicit_access(sid: PSID) -> EXPLICIT_ACCESS_W {
159 EXPLICIT_ACCESS_W {
160 grfAccessPermissions: GENERIC_ALL,
161 grfAccessMode: SET_ACCESS,
162 grfInheritance: ACE_FLAGS(0), Trustee: TRUSTEE_W {
164 pMultipleTrustee: core::ptr::null_mut(),
165 MultipleTrusteeOperation: MULTIPLE_TRUSTEE_OPERATION(0),
166 TrusteeForm: TRUSTEE_IS_SID,
167 TrusteeType: TRUSTEE_IS_UNKNOWN,
168 ptstrName: PWSTR(sid.0 as *mut u16),
170 },
171 }
172 }
173
174 unsafe fn apply(path: &Path) -> windows::core::Result<()> {
175 let mut token = HANDLE::default();
177 unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)? };
178 let mut len = 0u32;
179 let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut len) };
181 let mut buf = vec![0u8; len as usize];
182 let info = unsafe {
183 GetTokenInformation(
184 token,
185 TokenUser,
186 Some(buf.as_mut_ptr().cast()),
187 len,
188 &mut len,
189 )
190 };
191 unsafe { CloseHandle(token).ok() };
192 info?;
193 let token_user = unsafe { &*(buf.as_ptr() as *const TOKEN_USER) };
195 let user_sid = token_user.User.Sid;
196
197 let mut system_buf = [0u8; SECURITY_MAX_SID_SIZE as usize];
199 let mut admins_buf = [0u8; SECURITY_MAX_SID_SIZE as usize];
200 let mut n = SECURITY_MAX_SID_SIZE;
201 unsafe {
202 CreateWellKnownSid(
203 WinLocalSystemSid,
204 None,
205 Some(PSID(system_buf.as_mut_ptr().cast())),
206 &mut n,
207 )?
208 };
209 let mut n2 = SECURITY_MAX_SID_SIZE;
210 unsafe {
211 CreateWellKnownSid(
212 WinBuiltinAdministratorsSid,
213 None,
214 Some(PSID(admins_buf.as_mut_ptr().cast())),
215 &mut n2,
216 )?
217 };
218 let system_sid = PSID(system_buf.as_mut_ptr().cast());
219 let admins_sid = PSID(admins_buf.as_mut_ptr().cast());
220
221 let entries = [
223 explicit_access(user_sid),
224 explicit_access(system_sid),
225 explicit_access(admins_sid),
226 ];
227 let mut new_acl: *mut ACL = core::ptr::null_mut();
228 let rc = unsafe { SetEntriesInAclW(Some(&entries), None, &mut new_acl) };
229 if rc.0 != 0 {
230 return Err(windows::core::Error::from_hresult(
231 windows::core::HRESULT::from_win32(rc.0),
232 ));
233 }
234
235 let wide: Vec<u16> = path
238 .as_os_str()
239 .encode_wide()
240 .chain(core::iter::once(0))
241 .collect();
242 let rc = unsafe {
243 SetNamedSecurityInfoW(
244 PCWSTR(wide.as_ptr()),
245 SE_FILE_OBJECT,
246 DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
247 None,
248 None,
249 Some(new_acl as *const ACL),
250 None,
251 )
252 };
253 unsafe { LocalFree(Some(HLOCAL(new_acl.cast()))) };
255 if rc.0 != 0 {
256 return Err(windows::core::Error::from_hresult(
257 windows::core::HRESULT::from_win32(rc.0),
258 ));
259 }
260 Ok(())
261 }
262}
263
264pub fn ensure_dir(dir: &Path) -> Result<(), CoreError> {
266 if !dir.exists() {
267 fs::create_dir_all(dir).map_err(|e| CoreError::Io(format!("create {dir:?}: {e}")))?;
268 restrict(dir, 0o700)?;
269 }
270 Ok(())
271}
272
273pub fn write_record(
276 dir: &Path,
277 coord: &Coordinate,
278 sealed: &SealedRecord,
279) -> Result<(), CoreError> {
280 ensure_dir(dir)?;
281 let path = record_path(dir, coord)?;
282 let tmp = path.with_extension(format!("{RECORD_EXT}.tmp"));
283 let bytes = frame(sealed)?;
284
285 {
286 let mut f = File::create(&tmp).map_err(|e| CoreError::Io(format!("create tmp: {e}")))?;
287 f.write_all(&bytes)
288 .map_err(|e| CoreError::Io(format!("write tmp: {e}")))?;
289 f.sync_all()
290 .map_err(|e| CoreError::Io(format!("fsync tmp: {e}")))?;
291 }
292 restrict(&tmp, 0o600)?;
293
294 if path.exists() {
295 let bak = path.with_extension(format!("{RECORD_EXT}.bak"));
296 fs::rename(&path, &bak).map_err(|e| CoreError::Io(format!("rotate .bak: {e}")))?;
297 }
298 fs::rename(&tmp, &path).map_err(|e| CoreError::Io(format!("rename into place: {e}")))?;
299 Ok(())
300}
301
302pub fn read_record(
306 dir: &Path,
307 coord: &Coordinate,
308 key: &[u8; KEY_LEN],
309) -> Result<Option<SecretRecord>, CoreError> {
310 let path = record_path(dir, coord)?;
311 if !path.exists() {
312 return Ok(None);
313 }
314 let bytes = fs::read(&path).map_err(|e| CoreError::Io(format!("read record: {e}")))?;
315 let sealed = unframe(&bytes)?;
316 Ok(Some(open(&sealed, key)?))
317}
318
319pub fn load_all(dir: &Path, key: &[u8; KEY_LEN]) -> Result<LoadOutcome, CoreError> {
323 let mut outcome = LoadOutcome::default();
324 if !dir.exists() {
325 return Ok(outcome);
326 }
327 let entries = fs::read_dir(dir).map_err(|e| CoreError::Io(format!("read_dir: {e}")))?;
328 for entry in entries {
329 let entry = entry.map_err(|e| CoreError::Io(format!("dir entry: {e}")))?;
330 let path = entry.path();
331 if path.extension().and_then(|e| e.to_str()) != Some(RECORD_EXT) {
332 continue; }
334 let id = path
335 .file_stem()
336 .and_then(|s| s.to_str())
337 .unwrap_or_default()
338 .to_string();
339
340 let opened = fs::read(&path)
341 .map_err(|e| CoreError::Io(format!("read: {e}")))
342 .and_then(|bytes| unframe(&bytes))
343 .and_then(|sealed| open(&sealed, key));
344
345 match opened {
346 Ok(record) => outcome.records.push((id, record)),
347 Err(e) => outcome.quarantined.push(Quarantined {
348 id,
349 reason: e.to_string(),
350 }),
351 }
352 }
353 Ok(outcome)
354}
355
356pub fn delete_record(dir: &Path, coord: &Coordinate) -> Result<(), CoreError> {
359 let path = record_path(dir, coord)?;
360 if path.exists() {
361 fs::remove_file(&path).map_err(|e| CoreError::Io(format!("remove record: {e}")))?;
362 }
363 Ok(())
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::crypto::seal;
370 use crate::secret::SecretValue;
371 use crate::sensitivity::Sensitivity;
372
373 fn key() -> [u8; KEY_LEN] {
374 [0x11; KEY_LEN]
375 }
376
377 fn coord(s: &str) -> Coordinate {
378 s.parse().unwrap()
379 }
380
381 fn literal(value: &str) -> SecretRecord {
382 SecretRecord::Literal {
383 value: SecretValue::from(value),
384 sensitivity: Sensitivity::Medium,
385 revealable: false,
386 environment: "prod".to_string(),
387 component: "db".to_string(),
388 key: "password".to_string(),
389 description: None,
390 created: "2026-05-30T00:00:00Z".to_string(),
391 updated: "2026-05-30T00:00:00Z".to_string(),
392 }
393 }
394
395 #[test]
396 fn write_then_read_round_trips() {
397 let dir = tempfile::tempdir().unwrap();
398 let c = coord("secret:prod/db/password");
399 let sealed = seal(&literal("hunter2"), &key()).unwrap();
400 write_record(dir.path(), &c, &sealed).unwrap();
401
402 let got = read_record(dir.path(), &c, &key()).unwrap().unwrap();
403 match got {
404 SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"hunter2"),
405 other => panic!("expected literal, got {other:?}"),
406 }
407 }
408
409 #[test]
410 fn read_missing_is_none() {
411 let dir = tempfile::tempdir().unwrap();
412 let c = coord("secret:prod/db/absent");
413 assert!(read_record(dir.path(), &c, &key()).unwrap().is_none());
414 }
415
416 #[test]
417 fn record_file_has_extension_and_hashed_name() {
418 let dir = tempfile::tempdir().unwrap();
419 let c = coord("secret:prod/db/password");
420 write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
421 let path = record_path(dir.path(), &c).unwrap();
422 assert!(path.exists());
423 let name = path.file_name().unwrap().to_str().unwrap();
425 assert!(name.ends_with(".sec"));
426 assert!(!name.contains("password"));
427 }
428
429 #[cfg(unix)]
430 #[test]
431 fn written_record_is_0600() {
432 use std::os::unix::fs::PermissionsExt;
433 let dir = tempfile::tempdir().unwrap();
434 let c = coord("secret:prod/db/password");
435 write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
436 let mode = fs::metadata(record_path(dir.path(), &c).unwrap())
437 .unwrap()
438 .permissions()
439 .mode();
440 assert_eq!(mode & 0o777, 0o600);
441 }
442
443 #[cfg(windows)]
448 #[test]
449 fn windows_restrict_makes_dacl_protected() {
450 use std::os::windows::ffi::OsStrExt;
451
452 use windows::Win32::Foundation::{HLOCAL, LocalFree};
453 use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
454 use windows::Win32::Security::{
455 DACL_SECURITY_INFORMATION, GetSecurityDescriptorControl, PSECURITY_DESCRIPTOR,
456 SE_DACL_PROTECTED,
457 };
458 use windows::core::PCWSTR;
459
460 let dir = tempfile::tempdir().unwrap();
461 let path = dir.path().join("secret.bin");
462 fs::write(&path, b"x").unwrap();
463 restrict(&path, 0o600).unwrap();
464
465 let wide: Vec<u16> = path
466 .as_os_str()
467 .encode_wide()
468 .chain(core::iter::once(0))
469 .collect();
470 let mut psd = PSECURITY_DESCRIPTOR::default();
471 let rc = unsafe {
473 GetNamedSecurityInfoW(
474 PCWSTR(wide.as_ptr()),
475 SE_FILE_OBJECT,
476 DACL_SECURITY_INFORMATION,
477 None,
478 None,
479 None,
480 None,
481 &mut psd,
482 )
483 };
484 assert_eq!(rc.0, 0, "GetNamedSecurityInfoW failed: {}", rc.0);
485 let mut control = 0u16;
486 let mut revision = 0u32;
487 unsafe { GetSecurityDescriptorControl(psd, &mut control, &mut revision).unwrap() };
489 unsafe { LocalFree(Some(HLOCAL(psd.0.cast()))) };
491
492 assert!(
493 control & SE_DACL_PROTECTED.0 != 0,
494 "DACL must be protected (inheritance stripped); control={control:#x}"
495 );
496 }
497
498 #[test]
499 fn overwrite_rotates_previous_to_bak() {
500 let dir = tempfile::tempdir().unwrap();
501 let c = coord("secret:prod/db/password");
502 write_record(dir.path(), &c, &seal(&literal("v1"), &key()).unwrap()).unwrap();
503 write_record(dir.path(), &c, &seal(&literal("v2"), &key()).unwrap()).unwrap();
504
505 let current = read_record(dir.path(), &c, &key()).unwrap().unwrap();
507 match current {
508 SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"v2"),
509 other => panic!("expected literal, got {other:?}"),
510 }
511 let bak = record_path(dir.path(), &c)
513 .unwrap()
514 .with_extension(format!("{RECORD_EXT}.bak"));
515 assert!(bak.exists());
516 }
517
518 #[test]
519 fn load_all_quarantines_corrupt_and_loads_siblings() {
520 let dir = tempfile::tempdir().unwrap();
521 let good = coord("secret:prod/db/good");
522 let bad = coord("secret:prod/db/bad");
523 write_record(
524 dir.path(),
525 &good,
526 &seal(&literal("good-val"), &key()).unwrap(),
527 )
528 .unwrap();
529 write_record(
530 dir.path(),
531 &bad,
532 &seal(&literal("bad-val"), &key()).unwrap(),
533 )
534 .unwrap();
535
536 let bad_path = record_path(dir.path(), &bad).unwrap();
539 let mut bytes = fs::read(&bad_path).unwrap();
540 let last = bytes.len() - 1;
541 bytes[last] ^= 0xff;
542 fs::write(&bad_path, &bytes).unwrap();
543
544 let outcome = load_all(dir.path(), &key()).unwrap();
545 assert_eq!(outcome.records.len(), 1, "the good record still loads");
546 assert_eq!(
547 outcome.quarantined.len(),
548 1,
549 "the bad record is quarantined"
550 );
551 assert_eq!(outcome.quarantined[0].id, bad.storage_id().unwrap());
552 assert!(!outcome.quarantined[0].reason.contains("bad-val"));
554 }
555
556 #[test]
557 fn load_all_quarantines_garbage_file() {
558 let dir = tempfile::tempdir().unwrap();
559 ensure_dir(dir.path()).unwrap();
560 fs::write(dir.path().join("deadbeef.sec"), b"not a frame").unwrap();
561 let outcome = load_all(dir.path(), &key()).unwrap();
562 assert!(outcome.records.is_empty());
563 assert_eq!(outcome.quarantined.len(), 1);
564 }
565
566 #[test]
567 fn delete_removes_record() {
568 let dir = tempfile::tempdir().unwrap();
569 let c = coord("secret:prod/db/password");
570 write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
571 delete_record(dir.path(), &c).unwrap();
572 assert!(read_record(dir.path(), &c, &key()).unwrap().is_none());
573 }
574
575 #[test]
576 fn placeholder_coordinate_is_rejected() {
577 let dir = tempfile::tempdir().unwrap();
578 let c = coord("secret:${ENV}/db/password");
579 assert!(matches!(
580 write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()),
581 Err(CoreError::NotStorable(_))
582 ));
583 }
584}