Skip to main content

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}