dig_keystore/backend/file.rs
1//! Filesystem backend.
2//!
3//! # What this does
4//!
5//! Stores each [`BackendKey`] as a `<root>/<key>.dks` file (`.dks` = "DIG
6//! keystore"). Writes are atomic (tmp file + rename). Deletes best-effort
7//! overwrite the file with zeros before unlinking.
8//!
9//! # Atomicity
10//!
11//! On **POSIX**: `rename(2)` is atomic within a filesystem. We write to
12//! `<key>.dks.tmp.<random>`, `fsync` the file handle, then `rename` onto the
13//! final name. If the process crashes between the open and the rename, the
14//! tmp file is orphaned but the original `<key>.dks` (if any) is intact.
15//!
16//! On **Windows**: Rust's `std::fs::rename` wraps `MoveFileExW` with the
17//! `MOVEFILE_REPLACE_EXISTING` flag, which is atomic enough for our purposes
18//! (Windows does not provide a fully-atomic rename-across-replace on all
19//! filesystems but the behaviour is "either old or new contents — never a
20//! torn write").
21//!
22//! # Permissions
23//!
24//! On Unix, the keystore root directory (on creation) and every written file
25//! are restricted to mode `0700` / `0600` — reachable only by the owning user
26//! — and that restriction is **verified after the fact**, not merely
27//! requested. A path that is still group- or other-accessible fails the write
28//! with [`KeystoreError::InsecurePermissions`] rather than succeeding quietly,
29//! because a `chmod` on a filesystem without POSIX modes reports success and
30//! changes nothing. See [`is_owner_only`].
31//!
32//! **On Windows there is no equivalent floor.** Standard NTFS ACL inheritance
33//! applies and this crate does not narrow it, so a blob inherits whatever its
34//! parent directory grants. Restricting it would mean an explicit owner-only
35//! DACL, which requires Win32 FFI, and this package pins `unsafe_code =
36//! "forbid"` as a spec property (`SPEC.md` §12/§13.2, conformance C-15) — so
37//! that enforcement cannot live here. It belongs beside the platform hardware
38//! providers in a separate workspace member (dig_ecosystem #1693). Until then,
39//! operators on a shared user account should not rely on this crate for access
40//! control on Windows.
41//!
42//! Either way this is defence in depth. The blob is already sealed with
43//! AES-256-GCM under an Argon2id-hardened key (`SPEC.md` §3–§5); permissions
44//! decide who may *attempt* an offline attack on it, not whether one succeeds.
45//!
46//! # Secure delete
47//!
48//! On modern SSDs, a single-pass overwrite cannot guarantee the sectors are
49//! unrecoverable — the SSD's flash translation layer may have copied them
50//! elsewhere. This crate does a single zero pass as a best-effort. For
51//! high-value keys on untrusted hardware, use full-disk encryption (LUKS,
52//! BitLocker) which zero-keys the entire volume on wipe.
53//!
54//! # References
55//!
56//! - [POSIX `rename(2)`](https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html)
57//! - [Windows `MoveFileExW`](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw)
58//! - [DJB on secure-delete on SSDs](https://cr.yp.to/bib/2009/coker.pdf)
59
60use std::fs;
61use std::io::{self, Read, Write};
62use std::path::{Path, PathBuf};
63
64use crate::backend::{BackendKey, KeychainBackend};
65use crate::error::{KeystoreError, Result};
66
67/// File extension for keystore blobs. Stands for "DIG KeyStore".
68const EXT: &str = "dks";
69
70/// Every group and other permission bit — the set that must be clear on any
71/// path holding sealed key material.
72const GROUP_AND_OTHER_BITS: u32 = 0o077;
73
74/// Whether `mode` grants access to nobody but the owner.
75///
76/// This is the property the backend actually promises. It is deliberately
77/// phrased over the *observed* bits rather than over "did `chmod` return
78/// `Ok`", because the two are not the same thing: on a filesystem with no
79/// POSIX mode support a `chmod` succeeds and changes nothing, so a successful
80/// call is no evidence at all that the file is protected.
81///
82/// Only the low nine permission bits are considered; file-type and setuid
83/// bits carried in the same word are irrelevant to who may read the blob.
84///
85/// Compiled on every platform even though only Unix calls it, so that its
86/// behaviour is testable on any build host. A `#[cfg(unix)]` predicate is
87/// unfalsifiable on a Windows developer machine, which is where most of this
88/// crate's consumers are written.
89#[cfg_attr(not(unix), allow(dead_code))]
90fn is_owner_only(mode: u32) -> bool {
91 mode & GROUP_AND_OTHER_BITS == 0
92}
93
94/// Request owner-only permissions on `path`, then verify they took effect.
95///
96/// `requested` is the mode to ask for (`0o700` for the root directory,
97/// `0o600` for a blob). The request's own error is intentionally ignored: it
98/// is the verification below, not the call's return value, that decides
99/// whether the path is safe to hold key material.
100///
101/// On non-Unix hosts this is a no-op — see the module docs for what does and
102/// does not protect a blob on Windows.
103#[allow(unused_variables)]
104fn enforce_owner_only(path: &Path, requested: u32) -> Result<()> {
105 #[cfg(unix)]
106 {
107 use std::os::unix::fs::PermissionsExt;
108
109 let _ = fs::set_permissions(path, fs::Permissions::from_mode(requested));
110
111 let mode = fs::metadata(path)?.permissions().mode() & 0o777;
112 if !is_owner_only(mode) {
113 return Err(KeystoreError::InsecurePermissions {
114 path: path.display().to_string(),
115 mode,
116 });
117 }
118 }
119 Ok(())
120}
121
122/// Create `path` for writing, born owner-only where the platform allows it.
123///
124/// `File::create` opens with `0666 & ~umask`, so on a default umask the tmp
125/// blob exists at `0644` for the window between the open and the narrowing
126/// `chmod`. Requesting the mode in the `open(2)` call itself removes that
127/// window: the file never exists under a permissive mode at all. `create_new`
128/// additionally refuses to follow a symlink planted on the tmp path.
129///
130/// One window is *not* closed by this, and is not closable from user space: a
131/// process holding a directory fd opened before the root was tightened can
132/// still `openat` inside it, and read permission granted at open time survives
133/// any later `chmod`. That is why the root is brought to a verified `0700`
134/// before any tmp file is created, rather than relying on the blob mode alone.
135fn create_owner_only(path: &Path) -> Result<fs::File> {
136 let mut opts = fs::OpenOptions::new();
137 opts.write(true).create_new(true);
138 #[cfg(unix)]
139 {
140 use std::os::unix::fs::OpenOptionsExt;
141 opts.mode(0o600);
142 }
143 Ok(opts.open(path)?)
144}
145
146/// Filesystem-backed keychain.
147///
148/// Thread-safe — `KeychainBackend` is `Send + Sync`, and all operations use
149/// OS-level atomic primitives (rename, unlink). Multiple `FileBackend`
150/// instances pointing at the same root directory coexist without mutual
151/// serialization; the tmp-file names include a random suffix so concurrent
152/// writes to the same `BackendKey` do not step on each other's tmp files.
153///
154/// # Example
155///
156/// ```no_run
157/// use std::sync::Arc;
158/// use dig_keystore::{
159/// backend::{FileBackend, BackendKey, KeychainBackend},
160/// };
161///
162/// let backend: Arc<dyn KeychainBackend> = Arc::new(
163/// FileBackend::new("/var/lib/dig/keys")
164/// );
165/// backend.write(&BackendKey::new("v1"), b"...").unwrap();
166/// # drop(backend);
167/// ```
168pub struct FileBackend {
169 /// Directory that contains all `<key>.dks` files owned by this backend.
170 root: PathBuf,
171}
172
173impl FileBackend {
174 /// Create a new file backend rooted at `root`.
175 ///
176 /// The directory is **not** created immediately — it is lazily created on
177 /// the first `write` call (with mode `0700` on Unix). This lets callers
178 /// construct a `FileBackend` in tests without side effects; no files are
179 /// written until the first `write`.
180 ///
181 /// # Example
182 ///
183 /// ```
184 /// use dig_keystore::backend::FileBackend;
185 /// let be = FileBackend::new("/var/lib/dig/keys");
186 /// let _ = be; // directory not created yet
187 /// ```
188 pub fn new(root: impl Into<PathBuf>) -> Self {
189 Self { root: root.into() }
190 }
191
192 /// The root directory this backend writes to.
193 pub fn root(&self) -> &Path {
194 &self.root
195 }
196
197 /// Build the full path for a `BackendKey`.
198 fn path_for(&self, key: &BackendKey) -> PathBuf {
199 let mut p = self.root.clone();
200 p.push(format!("{}.{}", key.as_str(), EXT));
201 p
202 }
203
204 /// Create the root directory if it does not already exist, and hold it to
205 /// the owner-only floor whether or not this call created it.
206 ///
207 /// Called from `write` to support the "lazy directory creation" behaviour.
208 /// On Unix the directory is restricted to mode `0700` — so only the owning
209 /// user can list or enter it — and that is verified, not assumed.
210 ///
211 /// **The check runs on every write, not only on the creation path.** An
212 /// earlier shape returned early when the root already existed, which left
213 /// the floor unreachable for exactly the roots that need it most: one
214 /// created by a version that requested `0700` without checking the result,
215 /// on a filesystem where that request does nothing, stays unverified
216 /// forever.
217 ///
218 /// The exposure that closes is the root's **write** bits rather than its
219 /// read bits — blobs carry their own verified `0600`, so a permissive root
220 /// does not expose sealed bytes, but group- or world-writable grants
221 /// `unlink` and `create` inside it. That is blob substitution (rolling a
222 /// victim back to an older sealed seed) and deletion, on the directory
223 /// holding an account master seed.
224 ///
225 /// **A permissive mode is repaired; a symlinked root is refused.** The two
226 /// resolve in opposite directions because they are different kinds of
227 /// claim. A mode is a property of the intended directory: the backend can
228 /// correct it in one syscall and then *verify* that it did, so refusing
229 /// instead would turn any drift — a restore from backup, a `chmod` by the
230 /// user — into a permanent brick on master-seed writes, and hand anyone who
231 /// can merely widen the mode a denial primitive over a condition the crate
232 /// can fix. A symlink is a claim about *which directory the keystore is*,
233 /// and no syscall makes an attacker-chosen directory into the intended one;
234 /// since `set_permissions` and `metadata` both follow links, "repairing" it
235 /// would mean chmodding that directory to `0700` and sealing the seed
236 /// inside it. Fail closed where the invariant cannot be established, repair
237 /// where it can be established and confirmed.
238 fn ensure_root(&self) -> Result<()> {
239 // `symlink_metadata` inspects the root itself; `exists()` and
240 // `metadata()` both follow links, and so do `set_permissions` and the
241 // verification read below. Following a link here would mean chmodding
242 // and then seeding a directory chosen by whoever planted it.
243 match fs::symlink_metadata(&self.root) {
244 Ok(meta) if meta.file_type().is_symlink() => Err(KeystoreError::UnsafeRoot {
245 path: self.root.display().to_string(),
246 reason: "it is a symbolic link; pass the resolved target if that is intended",
247 }),
248 Ok(meta) if !meta.is_dir() => Err(KeystoreError::UnsafeRoot {
249 path: self.root.display().to_string(),
250 reason: "it exists and is not a directory",
251 }),
252 Ok(_) => enforce_owner_only(&self.root, 0o700),
253 Err(e) if e.kind() == io::ErrorKind::NotFound => {
254 fs::create_dir_all(&self.root)?;
255 enforce_owner_only(&self.root, 0o700)
256 }
257 Err(e) => Err(KeystoreError::from(e)),
258 }
259 }
260}
261
262impl KeychainBackend for FileBackend {
263 /// Read the entire file at `<root>/<key>.dks`.
264 ///
265 /// Returns `KeystoreError::Backend` wrapping an `io::Error` with
266 /// `ErrorKind::NotFound` if the file does not exist.
267 fn read(&self, key: &BackendKey) -> Result<Vec<u8>> {
268 let path = self.path_for(key);
269 let mut f = fs::File::open(&path)?;
270 let mut buf = Vec::new();
271 f.read_to_end(&mut buf)?;
272 Ok(buf)
273 }
274
275 /// Atomically write `data` to `<root>/<key>.dks`.
276 ///
277 /// Steps:
278 /// 1. Ensure `root` exists, is a directory rather than a symlink, and is
279 /// verified owner-only.
280 /// 2. Create sibling `<key>.dks.tmp.<random>` file with mode `0600`
281 /// requested in the `open(2)` call on Unix, then verify the mode that
282 /// actually took effect before any bytes are written —
283 /// so a root that cannot hold key material safely yields
284 /// [`KeystoreError::InsecurePermissions`] and an empty, removed tmp
285 /// file rather than an exposed blob.
286 /// 3. Write `data`, `fsync` the file handle.
287 /// 4. `rename` the tmp file onto the final name.
288 /// 5. On Unix, `fsync` the containing directory so the rename is durable.
289 /// 6. On error in step 4, best-effort unlink the tmp file.
290 ///
291 /// The random suffix in step 2 is **not** cryptographic — it exists only
292 /// to disambiguate two concurrent writes to the same key from the same
293 /// process. Uses a hash of `(nanoseconds_since_epoch, pid)`.
294 fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
295 self.ensure_root()?;
296 let final_path = self.path_for(key);
297 let mut tmp_path = final_path.clone();
298 let rand_suffix: u64 = fastrand_suffix();
299 tmp_path.set_extension(format!("{EXT}.tmp.{rand_suffix:016x}"));
300
301 // Stage the bytes into the tmp file. Written as a closure so the file
302 // handle is dropped by leaving scope — and so EVERY failure in here,
303 // not just a rename failure, gets the same cleanup below. An earlier
304 // shape orphaned the tmp file whenever `write_all` or `sync_all`
305 // failed.
306 let staged = (|| -> Result<()> {
307 let mut f = create_owner_only(&tmp_path)?;
308 // Restrict the file BEFORE any ciphertext reaches it. A keystore
309 // that cannot protect its own blobs must write nothing at all,
310 // rather than report success over a world-readable seed.
311 enforce_owner_only(&tmp_path, 0o600)?;
312 f.write_all(data)?;
313 // fsync the file so the bytes hit durable storage before rename.
314 // Without this, a crash between write() and rename() would leave
315 // a zero-length tmp file and no keystore data at all.
316 f.sync_all()?;
317 Ok(())
318 })();
319
320 if let Err(e) = staged {
321 // The handle is already closed, so this also succeeds on Windows,
322 // where an open file cannot be unlinked.
323 let _ = fs::remove_file(&tmp_path);
324 return Err(e);
325 }
326
327 // Atomic rename. On POSIX this is truly atomic within a filesystem;
328 // on Windows it's "effectively atomic" via MoveFileExW.
329 fs::rename(&tmp_path, &final_path).map_err(|e| {
330 // Best-effort cleanup of the tmp file on rename failure.
331 let _ = fs::remove_file(&tmp_path);
332 KeystoreError::from(e)
333 })?;
334
335 // fsync the containing directory on Unix so the rename is durable
336 // across a crash. No-op on Windows (directory fsync isn't a concept).
337 #[cfg(unix)]
338 {
339 if let Ok(dir) = fs::File::open(&self.root) {
340 let _ = dir.sync_all();
341 }
342 }
343
344 Ok(())
345 }
346
347 /// Best-effort secure delete, then unlink.
348 ///
349 /// Steps:
350 /// 1. No-op if file does not exist (idempotent).
351 /// 2. Open the file for writing; overwrite with zeros in 4 KiB chunks.
352 /// 3. `fsync` the overwritten file so zeros hit storage.
353 /// 4. `unlink` the file.
354 ///
355 /// Step 2 is best-effort. On SSDs with flash translation layer or on
356 /// copy-on-write filesystems (btrfs, ZFS), the zero pass may not reach
357 /// the sectors that held the ciphertext. Use full-disk encryption for
358 /// stronger guarantees.
359 fn delete(&self, key: &BackendKey) -> Result<()> {
360 let path = self.path_for(key);
361 if !path.exists() {
362 return Ok(());
363 }
364
365 if let Ok(metadata) = fs::metadata(&path) {
366 let len = metadata.len();
367 if let Ok(mut f) = fs::OpenOptions::new().write(true).open(&path) {
368 let zeros = vec![0u8; 4096];
369 let mut remaining = len as usize;
370 while remaining > 0 {
371 let n = remaining.min(zeros.len());
372 if f.write_all(&zeros[..n]).is_err() {
373 break;
374 }
375 remaining -= n;
376 }
377 let _ = f.sync_all();
378 }
379 }
380
381 fs::remove_file(&path)?;
382 Ok(())
383 }
384
385 /// Enumerate keys whose names start with `prefix`.
386 ///
387 /// Scans the root directory; skips any file that:
388 /// - does not end in `.dks`
389 /// - has a non-UTF-8 name
390 /// - does not start with `prefix`
391 ///
392 /// Returns an empty vec if the root directory does not exist.
393 fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
394 if !self.root.exists() {
395 return Ok(Vec::new());
396 }
397 let mut out = Vec::new();
398 for entry in fs::read_dir(&self.root)? {
399 let entry = entry?;
400 let name = entry.file_name();
401 let name = match name.to_str() {
402 Some(s) => s,
403 None => continue,
404 };
405 let Some(stem) = name.strip_suffix(&format!(".{EXT}")) else {
406 continue;
407 };
408 if stem.starts_with(prefix) {
409 out.push(BackendKey::new(stem.to_string()));
410 }
411 }
412 Ok(out)
413 }
414
415 /// Cheap override — `Path::exists` stats without opening the file.
416 fn exists(&self, key: &BackendKey) -> Result<bool> {
417 Ok(self.path_for(key).exists())
418 }
419}
420
421/// Quick, non-cryptographic random suffix for tmp filenames.
422///
423/// We do NOT use this for anything security-sensitive — it only disambiguates
424/// concurrent tmp files. Uses `(nanoseconds_since_epoch * golden_ratio_prime) + pid`
425/// for a spread uniform enough to avoid collisions across processes on the same host.
426///
427/// If two tmp files happen to collide, the loser will fail the final
428/// `fs::rename` with `AlreadyExists` (on Windows) or succeed but overwrite
429/// the other tmp (on Unix); either way the actual final `.dks` file is
430/// unaffected.
431fn fastrand_suffix() -> u64 {
432 use std::time::{SystemTime, UNIX_EPOCH};
433 let ns = SystemTime::now()
434 .duration_since(UNIX_EPOCH)
435 .map(|d| d.as_nanos() as u64)
436 .unwrap_or(0);
437 let pid = std::process::id() as u64;
438 // 0x9E37_79B9_7F4A_7C15 = 2^64 / golden ratio — gives uniform spread.
439 ns.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(pid)
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use tempfile::TempDir;
446
447 /// **Proves:** `FileBackend::write` followed by `FileBackend::read`
448 /// recovers the same bytes.
449 ///
450 /// **Why it matters:** The basic "file actually persists" check. This
451 /// exercises the full tmp-file + rename path including directory
452 /// creation, mode setting, `fsync`, and `rename`.
453 ///
454 /// **Catches:** a regression where `write` skips the rename step (file
455 /// left in `<name>.tmp.XXX` form) or `read` opens the wrong path.
456 #[test]
457 fn write_then_read_roundtrip() {
458 let dir = TempDir::new().unwrap();
459 let be = FileBackend::new(dir.path().to_path_buf());
460 let key = BackendKey::new("test");
461 be.write(&key, b"hello").unwrap();
462 let out = be.read(&key).unwrap();
463 assert_eq!(out, b"hello");
464 }
465
466 /// **Proves:** two sequential `write` calls to the same key leave no
467 /// `.tmp.` residue in the directory — meaning the tmp-then-rename
468 /// dance successfully cleaned up intermediate files.
469 ///
470 /// **Why it matters:** If tmp files accumulated, `list` would return
471 /// them to callers, disk space would leak, and operators would have to
472 /// manually clean up. The second `write` also asserts that the newer
473 /// content (`"second"`) overwrote the older (`"first"`) — atomicity's
474 /// visible behaviour.
475 ///
476 /// **Catches:** a regression where the rename fails silently and the
477 /// tmp file is not deleted; a regression where the final file is not
478 /// actually renamed on top of the previous one.
479 #[test]
480 fn write_is_atomic_on_rename_failure() {
481 let dir = TempDir::new().unwrap();
482 let be = FileBackend::new(dir.path().to_path_buf());
483 let key = BackendKey::new("atomic");
484 be.write(&key, b"first").unwrap();
485 be.write(&key, b"second").unwrap();
486 assert_eq!(be.read(&key).unwrap(), b"second");
487 // No .tmp files should linger.
488 let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
489 for e in entries {
490 let name = e.unwrap().file_name();
491 let s = name.to_string_lossy().into_owned();
492 assert!(!s.contains(".tmp."), "leftover tmp file: {s}");
493 }
494 }
495
496 /// **Proves:** after `delete`, the file is gone and `exists` returns `false`.
497 ///
498 /// **Why it matters:** Confirms the delete path actually unlinks the
499 /// file. This is the final action in `Keystore::delete`; a regression
500 /// here would leave keystore files behind after an operator thought
501 /// they had wiped them.
502 ///
503 /// **Catches:** a regression where `delete` only overwrites (secure
504 /// wipe) without unlinking; where `exists` checks a stale cache; or
505 /// where `delete` silently errors on the unlink step.
506 #[test]
507 fn delete_removes_file() {
508 let dir = TempDir::new().unwrap();
509 let be = FileBackend::new(dir.path().to_path_buf());
510 let key = BackendKey::new("delete_me");
511 be.write(&key, b"bye").unwrap();
512 assert!(be.exists(&key).unwrap());
513 be.delete(&key).unwrap();
514 assert!(!be.exists(&key).unwrap());
515 }
516
517 /// **Proves:** deleting a non-existent key is a no-op success — not an
518 /// error.
519 ///
520 /// **Why it matters:** The [`KeychainBackend`] contract requires
521 /// `delete` to be idempotent. Callers (e.g., `dig-validator keys remove`)
522 /// can call `delete` without first checking existence; a double-call
523 /// after a concurrent delete should not fail.
524 ///
525 /// **Catches:** a regression where `delete` returns `NotFound` for
526 /// missing files.
527 #[test]
528 fn delete_is_idempotent() {
529 let dir = TempDir::new().unwrap();
530 let be = FileBackend::new(dir.path().to_path_buf());
531 be.delete(&BackendKey::new("never_existed")).unwrap();
532 }
533
534 /// **Proves:** `list("alph")` returns exactly `["alpha", "alpha2"]`
535 /// when the directory contains `alpha.dks`, `alpha2.dks`, and `beta.dks`.
536 ///
537 /// **Why it matters:** Prefix-based listing is what enables CLI tools
538 /// like `dig-validator keys list` to enumerate all keystores of a given
539 /// operator. Strict prefix matching (not substring, not suffix) must
540 /// be pinned.
541 ///
542 /// **Catches:** `starts_with` → `contains` regression (which would
543 /// include `beta` if prefix were `"eta"`); failure to strip the `.dks`
544 /// extension.
545 #[test]
546 fn list_with_prefix() {
547 let dir = TempDir::new().unwrap();
548 let be = FileBackend::new(dir.path().to_path_buf());
549 be.write(&BackendKey::new("alpha"), b"a").unwrap();
550 be.write(&BackendKey::new("alpha2"), b"a").unwrap();
551 be.write(&BackendKey::new("beta"), b"b").unwrap();
552 let mut keys = be.list("alph").unwrap();
553 keys.sort_by_key(|k| k.0.clone());
554 assert_eq!(
555 keys,
556 vec![BackendKey::new("alpha"), BackendKey::new("alpha2")]
557 );
558 }
559
560 /// **Proves:** reading a non-existent key returns a `KeystoreError::Backend`
561 /// wrapping an `io::Error` with `ErrorKind::NotFound`.
562 ///
563 /// **Why it matters:** The default [`KeychainBackend::exists`] impl
564 /// relies on this specific error shape to distinguish "not present"
565 /// from "I/O failed." If `read` returned a generic `InvalidInput` or
566 /// similar, `exists` would misclassify missing keys.
567 ///
568 /// **Catches:** a regression where `read` eats the OS error and
569 /// returns a custom `KeystoreError` variant, breaking the default
570 /// `exists` implementation.
571 #[test]
572 fn read_nonexistent_returns_error() {
573 let dir = TempDir::new().unwrap();
574 let be = FileBackend::new(dir.path().to_path_buf());
575 let err = be.read(&BackendKey::new("missing")).unwrap_err();
576 let is_not_found = match &err {
577 KeystoreError::Backend(io) => io.kind() == std::io::ErrorKind::NotFound,
578 _ => false,
579 };
580 assert!(is_not_found);
581 }
582
583 /// **Proves:** `FileBackend::write` lazily creates the root directory
584 /// (and intermediate parents) when the first write arrives.
585 ///
586 /// **Why it matters:** Operators may point the validator at
587 /// `~/.dig/keys/` before that directory exists. Requiring them to
588 /// `mkdir -p` first is poor UX. This test pins the "lazy mkdir" on
589 /// first write behaviour so `FileBackend::new` can remain side-effect-free.
590 ///
591 /// **Catches:** a regression where `write` assumes the dir exists and
592 /// fails with `NotFound` on first call; or where `new` eagerly creates
593 /// the dir (unwanted in tests).
594 #[test]
595 fn creates_root_dir() {
596 let dir = TempDir::new().unwrap();
597 let sub = dir.path().join("nested/keys");
598 let be = FileBackend::new(sub.clone());
599 assert!(!sub.exists());
600 be.write(&BackendKey::new("k"), b"x").unwrap();
601 assert!(sub.exists());
602 }
603
604 /// `is_owner_only` accepts exactly those modes that grant nobody but the
605 /// owner any access.
606 ///
607 /// **Why it matters:** this predicate is the whole of the permission
608 /// guarantee. Everything else in `enforce_owner_only` is plumbing around
609 /// its answer, so a predicate that is merely *nearly* right silently
610 /// downgrades the at-rest floor for dig-app's account seed and dig-node's
611 /// seed store, which are this backend's production callers.
612 ///
613 /// **Catches:** each of the plausible near-miss implementations. `0o400`
614 /// and `0o000` rule out an equality test against `0o600`; `0o640` rules
615 /// out a predicate that only inspects the *other* triad (and any
616 /// `mode & 0o077 != 0o077` inversion, which would read group-readable as
617 /// safe); `0o604` rules out one that only inspects the *group* triad.
618 #[test]
619 fn owner_only_predicate_rejects_every_non_owner_bit() {
620 // No access for group or other, at varying owner permissions.
621 for mode in [0o000, 0o400, 0o600, 0o700] {
622 assert!(
623 is_owner_only(mode),
624 "{mode:04o} grants nobody but the owner"
625 );
626 }
627
628 // A single group or other bit is enough to fail, in either triad.
629 for mode in [0o640, 0o604, 0o644, 0o060, 0o006, 0o660, 0o777] {
630 assert!(!is_owner_only(mode), "{mode:04o} reaches beyond the owner");
631 }
632 }
633
634 /// A written blob, and the root that holds it, really are owner-only on
635 /// disk — not merely requested to be.
636 ///
637 /// **Why it matters:** `SPEC.md` §10.3 / conformance C-14 state mode
638 /// `0700` for the root and `0600` for blobs as a normative property. It
639 /// was previously requested with the result discarded, so nothing
640 /// observed whether it held.
641 ///
642 /// **Catches:** a regression that drops the `enforce_owner_only` call
643 /// from either `ensure_root` or `write`, or that reorders the blob's
644 /// restriction after `write_all` so ciphertext lands at the umask default
645 /// first.
646 ///
647 /// Unix-only because Windows has no POSIX mode. That makes it
648 /// unfalsifiable on a Windows build host, which is why the predicate above
649 /// is tested separately and unconditionally.
650 #[cfg(unix)]
651 #[test]
652 fn written_blob_and_root_are_owner_only_on_disk() {
653 use std::os::unix::fs::PermissionsExt;
654
655 let dir = TempDir::new().unwrap();
656 let root = dir.path().join("keys");
657 let be = FileBackend::new(root.clone());
658 be.write(&BackendKey::new("seed"), b"sealed").unwrap();
659
660 let root_mode = fs::metadata(&root).unwrap().permissions().mode() & 0o777;
661 assert_eq!(root_mode, 0o700, "root dir mode");
662
663 let blob_mode = fs::metadata(root.join("seed.dks"))
664 .unwrap()
665 .permissions()
666 .mode()
667 & 0o777;
668 assert_eq!(blob_mode, 0o600, "blob mode");
669 }
670
671 /// An **already-existing** permissive root is brought back to `0700` on the
672 /// next write, not left alone.
673 ///
674 /// **Why it matters:** the floor is worthless if it only applies to roots
675 /// this version created. A root created by 0.8.x — which requested `0700`
676 /// and discarded the result — is precisely the one at risk, and it exists
677 /// before any 0.9.0 write reaches it. The exposure is the root's *write*
678 /// bits: blobs carry their own verified `0600`, but a group- or
679 /// world-writable root grants `unlink` and `create`, which is blob
680 /// substitution (rolling a victim back to an older sealed seed) and
681 /// deletion, on the directory holding an account master seed.
682 ///
683 /// **Catches:** the `if self.root.exists() { return Ok(()); }` early
684 /// return. Under it this test sees `0o755` and fails, because
685 /// `enforce_owner_only` never runs on the existing-root path.
686 /// `written_blob_and_root_are_owner_only_on_disk` above cannot catch it:
687 /// its root is a fresh non-existent path, so it only ever exercises the
688 /// creation branch.
689 ///
690 /// **Why this asserts tightening rather than `InsecurePermissions`:** on a
691 /// root the process owns, `chmod` succeeds, so the permissive mode is
692 /// repaired and there is nothing to refuse. Erroring instead would fail a
693 /// host the crate can simply fix. `InsecurePermissions` stays reserved for
694 /// the unrepairable case — a filesystem where the `chmod` does nothing, a
695 /// foreign-owned root where it returns `EPERM`, an immutable attribute.
696 /// The *production call sites* therefore cannot reach the refusal on a
697 /// mode-honouring filesystem the process owns; the refusal itself is not
698 /// unreachable, and
699 /// `enforce_owner_only_refuses_a_mode_it_could_not_bring_to_the_floor`
700 /// drives it directly.
701 #[cfg(unix)]
702 #[test]
703 fn existing_permissive_root_is_tightened_on_write() {
704 use std::os::unix::fs::PermissionsExt;
705
706 let dir = TempDir::new().unwrap();
707 let root = dir.path().join("keys");
708 fs::create_dir_all(&root).unwrap();
709 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
710 assert_eq!(
711 fs::metadata(&root).unwrap().permissions().mode() & 0o777,
712 0o755,
713 "fixture must start group/other-accessible, or it proves nothing"
714 );
715
716 let be = FileBackend::new(root.clone());
717 be.write(&BackendKey::new("seed"), b"sealed").unwrap();
718
719 assert_eq!(
720 fs::metadata(&root).unwrap().permissions().mode() & 0o777,
721 0o700,
722 "an existing root must be brought to the floor, not skipped"
723 );
724 }
725
726 /// The verify half of chmod-then-verify actually refuses.
727 ///
728 /// **Property:** when the mode observed after the request is *not*
729 /// owner-only, `enforce_owner_only` returns `InsecurePermissions` carrying
730 /// the bits it saw — it does not return `Ok` on the strength of the
731 /// `chmod` having succeeded.
732 ///
733 /// **Why this is the load-bearing assertion of the whole change:** the
734 /// thesis of 0.9.0 is "verify the outcome, do not trust the request". The
735 /// request's own `Result` is discarded on purpose in `enforce_owner_only`;
736 /// the refusal below is the entire reason that is safe. Without this test
737 /// the fail-closed block can be deleted with a green suite, returning the
738 /// crate to the 0.8.x shape — `set_permissions` called and its result
739 /// thrown away with nothing observing the bits.
740 ///
741 /// **Fixture design.** The refusal cannot be provoked through `write`,
742 /// whose call sites always request an owner-only mode on a path the
743 /// process owns, so a `write`-level fixture would need a mode-ignoring
744 /// mount or a second uid — neither available in a test, which is what
745 /// previously left this branch untested. The requested mode is a
746 /// *parameter*, so asking for a permissive one drives the same verified
747 /// read the production path performs, hermetically: no root, no second
748 /// uid, no exotic mount. `0o755` is used rather than `0o777` because it
749 /// leaves the owner triad at its production value, so the assertion is
750 /// about the group and other bits and nothing else.
751 ///
752 /// **Catches:** deletion of the fail-closed block in `enforce_owner_only`,
753 /// and any narrowing of `is_owner_only` reached through it. Asserting the
754 /// observed `mode` — not merely that the call erred — also rules out a
755 /// refusal that reports the mode it *asked* for instead of the one on
756 /// disk, which would make the diagnostic useless on exactly the mount
757 /// classes it exists to diagnose.
758 #[cfg(unix)]
759 #[test]
760 fn enforce_owner_only_refuses_a_mode_it_could_not_bring_to_the_floor() {
761 use std::os::unix::fs::PermissionsExt;
762
763 let dir = TempDir::new().unwrap();
764 let root = dir.path().join("permissive");
765 fs::create_dir_all(&root).unwrap();
766
767 let err = enforce_owner_only(&root, 0o755)
768 .expect_err("a mode granting group and other access must be refused, not accepted");
769
770 match err {
771 KeystoreError::InsecurePermissions { path, mode } => {
772 assert_eq!(mode, 0o755, "the reported mode must be the one on disk");
773 assert_eq!(path, root.display().to_string(), "reported path");
774 }
775 other => panic!("expected InsecurePermissions, got {other:?}"),
776 }
777
778 // The refusal describes the state it found, so the mode really is the
779 // permissive one — the fixture is not silently owner-only already.
780 assert_eq!(
781 fs::metadata(&root).unwrap().permissions().mode() & 0o777,
782 0o755,
783 "fixture must remain group/other-accessible, or it proves nothing"
784 );
785 }
786
787 /// A symlinked root is refused, not followed.
788 ///
789 /// **Property:** `write` on a root that is a symbolic link returns
790 /// `UnsafeRoot` and touches neither the target's mode nor its contents.
791 ///
792 /// **Why refuse here when a permissive mode is repaired:** a mode is a
793 /// property of the intended directory that the backend can correct and
794 /// then verify. A symlink is a claim about *which* directory the keystore
795 /// is, and no syscall makes an attacker-chosen directory into the intended
796 /// one. Both `set_permissions` and `metadata` follow links, so the
797 /// alternative is chmodding a directory of someone else's choosing to
798 /// `0700` and sealing an account master seed inside it.
799 ///
800 /// **Catches:** reverting `symlink_metadata` to `exists()`/`metadata()`.
801 ///
802 /// **The side effects are asserted before the error, deliberately.** Under
803 /// that revert the write returns `Ok`, so an `expect_err` placed first
804 /// panics and the two assertions that name the actual damage never run —
805 /// the proof would fire on "no error" rather than on the primitive. Ordered
806 /// this way, the failure a reverting change sees is the chmod reaching
807 /// through the link, which is what is new in this diff. The error
808 /// assertion still has to be there: a write that failed for some later,
809 /// unrelated reason would leave the victim equally untouched.
810 #[cfg(unix)]
811 #[test]
812 fn symlinked_root_is_refused_and_its_target_is_untouched() {
813 use std::os::unix::fs::PermissionsExt;
814
815 let dir = TempDir::new().unwrap();
816 let victim = dir.path().join("victim");
817 fs::create_dir_all(&victim).unwrap();
818 fs::set_permissions(&victim, fs::Permissions::from_mode(0o755)).unwrap();
819
820 let root = dir.path().join("keys");
821 std::os::unix::fs::symlink(&victim, &root).unwrap();
822
823 let result = FileBackend::new(root.clone()).write(&BackendKey::new("seed"), b"sealed");
824
825 assert_eq!(
826 fs::metadata(&victim).unwrap().permissions().mode() & 0o777,
827 0o755,
828 "the link's target must not be chmodded through the link"
829 );
830 assert!(
831 !victim.join("seed.dks").exists(),
832 "the sealed blob must not land in the link's target"
833 );
834 assert!(
835 matches!(result, Err(KeystoreError::UnsafeRoot { .. })),
836 "a symlinked root must be refused, got {result:?}"
837 );
838 }
839}