dig-keystore 0.9.0

Encrypted secret-key storage for DIG Network binaries (BLS signing + L1 wallet keys). AES-256-GCM + Argon2id, typed per-scheme magic files, zeroizing memory hygiene.
Documentation
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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
//! Filesystem backend.
//!
//! # What this does
//!
//! Stores each [`BackendKey`] as a `<root>/<key>.dks` file (`.dks` = "DIG
//! keystore"). Writes are atomic (tmp file + rename). Deletes best-effort
//! overwrite the file with zeros before unlinking.
//!
//! # Atomicity
//!
//! On **POSIX**: `rename(2)` is atomic within a filesystem. We write to
//! `<key>.dks.tmp.<random>`, `fsync` the file handle, then `rename` onto the
//! final name. If the process crashes between the open and the rename, the
//! tmp file is orphaned but the original `<key>.dks` (if any) is intact.
//!
//! On **Windows**: Rust's `std::fs::rename` wraps `MoveFileExW` with the
//! `MOVEFILE_REPLACE_EXISTING` flag, which is atomic enough for our purposes
//! (Windows does not provide a fully-atomic rename-across-replace on all
//! filesystems but the behaviour is "either old or new contents — never a
//! torn write").
//!
//! # Permissions
//!
//! On Unix, the keystore root directory (on creation) and every written file
//! are restricted to mode `0700` / `0600` — reachable only by the owning user
//! — and that restriction is **verified after the fact**, not merely
//! requested. A path that is still group- or other-accessible fails the write
//! with [`KeystoreError::InsecurePermissions`] rather than succeeding quietly,
//! because a `chmod` on a filesystem without POSIX modes reports success and
//! changes nothing. See [`is_owner_only`].
//!
//! **On Windows there is no equivalent floor.** Standard NTFS ACL inheritance
//! applies and this crate does not narrow it, so a blob inherits whatever its
//! parent directory grants. Restricting it would mean an explicit owner-only
//! DACL, which requires Win32 FFI, and this package pins `unsafe_code =
//! "forbid"` as a spec property (`SPEC.md` §12/§13.2, conformance C-15) — so
//! that enforcement cannot live here. It belongs beside the platform hardware
//! providers in a separate workspace member (dig_ecosystem #1693). Until then,
//! operators on a shared user account should not rely on this crate for access
//! control on Windows.
//!
//! Either way this is defence in depth. The blob is already sealed with
//! AES-256-GCM under an Argon2id-hardened key (`SPEC.md` §3–§5); permissions
//! decide who may *attempt* an offline attack on it, not whether one succeeds.
//!
//! # Secure delete
//!
//! On modern SSDs, a single-pass overwrite cannot guarantee the sectors are
//! unrecoverable — the SSD's flash translation layer may have copied them
//! elsewhere. This crate does a single zero pass as a best-effort. For
//! high-value keys on untrusted hardware, use full-disk encryption (LUKS,
//! BitLocker) which zero-keys the entire volume on wipe.
//!
//! # References
//!
//! - [POSIX `rename(2)`](https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html)
//! - [Windows `MoveFileExW`](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw)
//! - [DJB on secure-delete on SSDs](https://cr.yp.to/bib/2009/coker.pdf)

use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};

use crate::backend::{BackendKey, KeychainBackend};
use crate::error::{KeystoreError, Result};

/// File extension for keystore blobs. Stands for "DIG KeyStore".
const EXT: &str = "dks";

/// Every group and other permission bit — the set that must be clear on any
/// path holding sealed key material.
const GROUP_AND_OTHER_BITS: u32 = 0o077;

/// Whether `mode` grants access to nobody but the owner.
///
/// This is the property the backend actually promises. It is deliberately
/// phrased over the *observed* bits rather than over "did `chmod` return
/// `Ok`", because the two are not the same thing: on a filesystem with no
/// POSIX mode support a `chmod` succeeds and changes nothing, so a successful
/// call is no evidence at all that the file is protected.
///
/// Only the low nine permission bits are considered; file-type and setuid
/// bits carried in the same word are irrelevant to who may read the blob.
///
/// Compiled on every platform even though only Unix calls it, so that its
/// behaviour is testable on any build host. A `#[cfg(unix)]` predicate is
/// unfalsifiable on a Windows developer machine, which is where most of this
/// crate's consumers are written.
#[cfg_attr(not(unix), allow(dead_code))]
fn is_owner_only(mode: u32) -> bool {
    mode & GROUP_AND_OTHER_BITS == 0
}

/// Request owner-only permissions on `path`, then verify they took effect.
///
/// `requested` is the mode to ask for (`0o700` for the root directory,
/// `0o600` for a blob). The request's own error is intentionally ignored: it
/// is the verification below, not the call's return value, that decides
/// whether the path is safe to hold key material.
///
/// On non-Unix hosts this is a no-op — see the module docs for what does and
/// does not protect a blob on Windows.
#[allow(unused_variables)]
fn enforce_owner_only(path: &Path, requested: u32) -> Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        let _ = fs::set_permissions(path, fs::Permissions::from_mode(requested));

        let mode = fs::metadata(path)?.permissions().mode() & 0o777;
        if !is_owner_only(mode) {
            return Err(KeystoreError::InsecurePermissions {
                path: path.display().to_string(),
                mode,
            });
        }
    }
    Ok(())
}

/// Create `path` for writing, born owner-only where the platform allows it.
///
/// `File::create` opens with `0666 & ~umask`, so on a default umask the tmp
/// blob exists at `0644` for the window between the open and the narrowing
/// `chmod`. Requesting the mode in the `open(2)` call itself removes that
/// window: the file never exists under a permissive mode at all. `create_new`
/// additionally refuses to follow a symlink planted on the tmp path.
///
/// One window is *not* closed by this, and is not closable from user space: a
/// process holding a directory fd opened before the root was tightened can
/// still `openat` inside it, and read permission granted at open time survives
/// any later `chmod`. That is why the root is brought to a verified `0700`
/// before any tmp file is created, rather than relying on the blob mode alone.
fn create_owner_only(path: &Path) -> Result<fs::File> {
    let mut opts = fs::OpenOptions::new();
    opts.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        opts.mode(0o600);
    }
    Ok(opts.open(path)?)
}

/// Filesystem-backed keychain.
///
/// Thread-safe — `KeychainBackend` is `Send + Sync`, and all operations use
/// OS-level atomic primitives (rename, unlink). Multiple `FileBackend`
/// instances pointing at the same root directory coexist without mutual
/// serialization; the tmp-file names include a random suffix so concurrent
/// writes to the same `BackendKey` do not step on each other's tmp files.
///
/// # Example
///
/// ```no_run
/// use std::sync::Arc;
/// use dig_keystore::{
///     backend::{FileBackend, BackendKey, KeychainBackend},
/// };
///
/// let backend: Arc<dyn KeychainBackend> = Arc::new(
///     FileBackend::new("/var/lib/dig/keys")
/// );
/// backend.write(&BackendKey::new("v1"), b"...").unwrap();
/// # drop(backend);
/// ```
pub struct FileBackend {
    /// Directory that contains all `<key>.dks` files owned by this backend.
    root: PathBuf,
}

impl FileBackend {
    /// Create a new file backend rooted at `root`.
    ///
    /// The directory is **not** created immediately — it is lazily created on
    /// the first `write` call (with mode `0700` on Unix). This lets callers
    /// construct a `FileBackend` in tests without side effects; no files are
    /// written until the first `write`.
    ///
    /// # Example
    ///
    /// ```
    /// use dig_keystore::backend::FileBackend;
    /// let be = FileBackend::new("/var/lib/dig/keys");
    /// let _ = be;  // directory not created yet
    /// ```
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self { root: root.into() }
    }

    /// The root directory this backend writes to.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Build the full path for a `BackendKey`.
    fn path_for(&self, key: &BackendKey) -> PathBuf {
        let mut p = self.root.clone();
        p.push(format!("{}.{}", key.as_str(), EXT));
        p
    }

    /// Create the root directory if it does not already exist, and hold it to
    /// the owner-only floor whether or not this call created it.
    ///
    /// Called from `write` to support the "lazy directory creation" behaviour.
    /// On Unix the directory is restricted to mode `0700` — so only the owning
    /// user can list or enter it — and that is verified, not assumed.
    ///
    /// **The check runs on every write, not only on the creation path.** An
    /// earlier shape returned early when the root already existed, which left
    /// the floor unreachable for exactly the roots that need it most: one
    /// created by a version that requested `0700` without checking the result,
    /// on a filesystem where that request does nothing, stays unverified
    /// forever.
    ///
    /// The exposure that closes is the root's **write** bits rather than its
    /// read bits — blobs carry their own verified `0600`, so a permissive root
    /// does not expose sealed bytes, but group- or world-writable grants
    /// `unlink` and `create` inside it. That is blob substitution (rolling a
    /// victim back to an older sealed seed) and deletion, on the directory
    /// holding an account master seed.
    ///
    /// **A permissive mode is repaired; a symlinked root is refused.** The two
    /// resolve in opposite directions because they are different kinds of
    /// claim. A mode is a property of the intended directory: the backend can
    /// correct it in one syscall and then *verify* that it did, so refusing
    /// instead would turn any drift — a restore from backup, a `chmod` by the
    /// user — into a permanent brick on master-seed writes, and hand anyone who
    /// can merely widen the mode a denial primitive over a condition the crate
    /// can fix. A symlink is a claim about *which directory the keystore is*,
    /// and no syscall makes an attacker-chosen directory into the intended one;
    /// since `set_permissions` and `metadata` both follow links, "repairing" it
    /// would mean chmodding that directory to `0700` and sealing the seed
    /// inside it. Fail closed where the invariant cannot be established, repair
    /// where it can be established and confirmed.
    fn ensure_root(&self) -> Result<()> {
        // `symlink_metadata` inspects the root itself; `exists()` and
        // `metadata()` both follow links, and so do `set_permissions` and the
        // verification read below. Following a link here would mean chmodding
        // and then seeding a directory chosen by whoever planted it.
        match fs::symlink_metadata(&self.root) {
            Ok(meta) if meta.file_type().is_symlink() => Err(KeystoreError::UnsafeRoot {
                path: self.root.display().to_string(),
                reason: "it is a symbolic link; pass the resolved target if that is intended",
            }),
            Ok(meta) if !meta.is_dir() => Err(KeystoreError::UnsafeRoot {
                path: self.root.display().to_string(),
                reason: "it exists and is not a directory",
            }),
            Ok(_) => enforce_owner_only(&self.root, 0o700),
            Err(e) if e.kind() == io::ErrorKind::NotFound => {
                fs::create_dir_all(&self.root)?;
                enforce_owner_only(&self.root, 0o700)
            }
            Err(e) => Err(KeystoreError::from(e)),
        }
    }
}

impl KeychainBackend for FileBackend {
    /// Read the entire file at `<root>/<key>.dks`.
    ///
    /// Returns `KeystoreError::Backend` wrapping an `io::Error` with
    /// `ErrorKind::NotFound` if the file does not exist.
    fn read(&self, key: &BackendKey) -> Result<Vec<u8>> {
        let path = self.path_for(key);
        let mut f = fs::File::open(&path)?;
        let mut buf = Vec::new();
        f.read_to_end(&mut buf)?;
        Ok(buf)
    }

    /// Atomically write `data` to `<root>/<key>.dks`.
    ///
    /// Steps:
    /// 1. Ensure `root` exists, is a directory rather than a symlink, and is
    ///    verified owner-only.
    /// 2. Create sibling `<key>.dks.tmp.<random>` file with mode `0600`
    ///    requested in the `open(2)` call on Unix, then verify the mode that
    ///    actually took effect before any bytes are written —
    ///    so a root that cannot hold key material safely yields
    ///    [`KeystoreError::InsecurePermissions`] and an empty, removed tmp
    ///    file rather than an exposed blob.
    /// 3. Write `data`, `fsync` the file handle.
    /// 4. `rename` the tmp file onto the final name.
    /// 5. On Unix, `fsync` the containing directory so the rename is durable.
    /// 6. On error in step 4, best-effort unlink the tmp file.
    ///
    /// The random suffix in step 2 is **not** cryptographic — it exists only
    /// to disambiguate two concurrent writes to the same key from the same
    /// process. Uses a hash of `(nanoseconds_since_epoch, pid)`.
    fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
        self.ensure_root()?;
        let final_path = self.path_for(key);
        let mut tmp_path = final_path.clone();
        let rand_suffix: u64 = fastrand_suffix();
        tmp_path.set_extension(format!("{EXT}.tmp.{rand_suffix:016x}"));

        // Stage the bytes into the tmp file. Written as a closure so the file
        // handle is dropped by leaving scope — and so EVERY failure in here,
        // not just a rename failure, gets the same cleanup below. An earlier
        // shape orphaned the tmp file whenever `write_all` or `sync_all`
        // failed.
        let staged = (|| -> Result<()> {
            let mut f = create_owner_only(&tmp_path)?;
            // Restrict the file BEFORE any ciphertext reaches it. A keystore
            // that cannot protect its own blobs must write nothing at all,
            // rather than report success over a world-readable seed.
            enforce_owner_only(&tmp_path, 0o600)?;
            f.write_all(data)?;
            // fsync the file so the bytes hit durable storage before rename.
            // Without this, a crash between write() and rename() would leave
            // a zero-length tmp file and no keystore data at all.
            f.sync_all()?;
            Ok(())
        })();

        if let Err(e) = staged {
            // The handle is already closed, so this also succeeds on Windows,
            // where an open file cannot be unlinked.
            let _ = fs::remove_file(&tmp_path);
            return Err(e);
        }

        // Atomic rename. On POSIX this is truly atomic within a filesystem;
        // on Windows it's "effectively atomic" via MoveFileExW.
        fs::rename(&tmp_path, &final_path).map_err(|e| {
            // Best-effort cleanup of the tmp file on rename failure.
            let _ = fs::remove_file(&tmp_path);
            KeystoreError::from(e)
        })?;

        // fsync the containing directory on Unix so the rename is durable
        // across a crash. No-op on Windows (directory fsync isn't a concept).
        #[cfg(unix)]
        {
            if let Ok(dir) = fs::File::open(&self.root) {
                let _ = dir.sync_all();
            }
        }

        Ok(())
    }

    /// Best-effort secure delete, then unlink.
    ///
    /// Steps:
    /// 1. No-op if file does not exist (idempotent).
    /// 2. Open the file for writing; overwrite with zeros in 4 KiB chunks.
    /// 3. `fsync` the overwritten file so zeros hit storage.
    /// 4. `unlink` the file.
    ///
    /// Step 2 is best-effort. On SSDs with flash translation layer or on
    /// copy-on-write filesystems (btrfs, ZFS), the zero pass may not reach
    /// the sectors that held the ciphertext. Use full-disk encryption for
    /// stronger guarantees.
    fn delete(&self, key: &BackendKey) -> Result<()> {
        let path = self.path_for(key);
        if !path.exists() {
            return Ok(());
        }

        if let Ok(metadata) = fs::metadata(&path) {
            let len = metadata.len();
            if let Ok(mut f) = fs::OpenOptions::new().write(true).open(&path) {
                let zeros = vec![0u8; 4096];
                let mut remaining = len as usize;
                while remaining > 0 {
                    let n = remaining.min(zeros.len());
                    if f.write_all(&zeros[..n]).is_err() {
                        break;
                    }
                    remaining -= n;
                }
                let _ = f.sync_all();
            }
        }

        fs::remove_file(&path)?;
        Ok(())
    }

    /// Enumerate keys whose names start with `prefix`.
    ///
    /// Scans the root directory; skips any file that:
    /// - does not end in `.dks`
    /// - has a non-UTF-8 name
    /// - does not start with `prefix`
    ///
    /// Returns an empty vec if the root directory does not exist.
    fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
        if !self.root.exists() {
            return Ok(Vec::new());
        }
        let mut out = Vec::new();
        for entry in fs::read_dir(&self.root)? {
            let entry = entry?;
            let name = entry.file_name();
            let name = match name.to_str() {
                Some(s) => s,
                None => continue,
            };
            let Some(stem) = name.strip_suffix(&format!(".{EXT}")) else {
                continue;
            };
            if stem.starts_with(prefix) {
                out.push(BackendKey::new(stem.to_string()));
            }
        }
        Ok(out)
    }

    /// Cheap override — `Path::exists` stats without opening the file.
    fn exists(&self, key: &BackendKey) -> Result<bool> {
        Ok(self.path_for(key).exists())
    }
}

/// Quick, non-cryptographic random suffix for tmp filenames.
///
/// We do NOT use this for anything security-sensitive — it only disambiguates
/// concurrent tmp files. Uses `(nanoseconds_since_epoch * golden_ratio_prime) + pid`
/// for a spread uniform enough to avoid collisions across processes on the same host.
///
/// If two tmp files happen to collide, the loser will fail the final
/// `fs::rename` with `AlreadyExists` (on Windows) or succeed but overwrite
/// the other tmp (on Unix); either way the actual final `.dks` file is
/// unaffected.
fn fastrand_suffix() -> u64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    let ns = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(0);
    let pid = std::process::id() as u64;
    // 0x9E37_79B9_7F4A_7C15 = 2^64 / golden ratio — gives uniform spread.
    ns.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(pid)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    /// **Proves:** `FileBackend::write` followed by `FileBackend::read`
    /// recovers the same bytes.
    ///
    /// **Why it matters:** The basic "file actually persists" check. This
    /// exercises the full tmp-file + rename path including directory
    /// creation, mode setting, `fsync`, and `rename`.
    ///
    /// **Catches:** a regression where `write` skips the rename step (file
    /// left in `<name>.tmp.XXX` form) or `read` opens the wrong path.
    #[test]
    fn write_then_read_roundtrip() {
        let dir = TempDir::new().unwrap();
        let be = FileBackend::new(dir.path().to_path_buf());
        let key = BackendKey::new("test");
        be.write(&key, b"hello").unwrap();
        let out = be.read(&key).unwrap();
        assert_eq!(out, b"hello");
    }

    /// **Proves:** two sequential `write` calls to the same key leave no
    /// `.tmp.` residue in the directory — meaning the tmp-then-rename
    /// dance successfully cleaned up intermediate files.
    ///
    /// **Why it matters:** If tmp files accumulated, `list` would return
    /// them to callers, disk space would leak, and operators would have to
    /// manually clean up. The second `write` also asserts that the newer
    /// content (`"second"`) overwrote the older (`"first"`) — atomicity's
    /// visible behaviour.
    ///
    /// **Catches:** a regression where the rename fails silently and the
    /// tmp file is not deleted; a regression where the final file is not
    /// actually renamed on top of the previous one.
    #[test]
    fn write_is_atomic_on_rename_failure() {
        let dir = TempDir::new().unwrap();
        let be = FileBackend::new(dir.path().to_path_buf());
        let key = BackendKey::new("atomic");
        be.write(&key, b"first").unwrap();
        be.write(&key, b"second").unwrap();
        assert_eq!(be.read(&key).unwrap(), b"second");
        // No .tmp files should linger.
        let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
        for e in entries {
            let name = e.unwrap().file_name();
            let s = name.to_string_lossy().into_owned();
            assert!(!s.contains(".tmp."), "leftover tmp file: {s}");
        }
    }

    /// **Proves:** after `delete`, the file is gone and `exists` returns `false`.
    ///
    /// **Why it matters:** Confirms the delete path actually unlinks the
    /// file. This is the final action in `Keystore::delete`; a regression
    /// here would leave keystore files behind after an operator thought
    /// they had wiped them.
    ///
    /// **Catches:** a regression where `delete` only overwrites (secure
    /// wipe) without unlinking; where `exists` checks a stale cache; or
    /// where `delete` silently errors on the unlink step.
    #[test]
    fn delete_removes_file() {
        let dir = TempDir::new().unwrap();
        let be = FileBackend::new(dir.path().to_path_buf());
        let key = BackendKey::new("delete_me");
        be.write(&key, b"bye").unwrap();
        assert!(be.exists(&key).unwrap());
        be.delete(&key).unwrap();
        assert!(!be.exists(&key).unwrap());
    }

    /// **Proves:** deleting a non-existent key is a no-op success — not an
    /// error.
    ///
    /// **Why it matters:** The [`KeychainBackend`] contract requires
    /// `delete` to be idempotent. Callers (e.g., `dig-validator keys remove`)
    /// can call `delete` without first checking existence; a double-call
    /// after a concurrent delete should not fail.
    ///
    /// **Catches:** a regression where `delete` returns `NotFound` for
    /// missing files.
    #[test]
    fn delete_is_idempotent() {
        let dir = TempDir::new().unwrap();
        let be = FileBackend::new(dir.path().to_path_buf());
        be.delete(&BackendKey::new("never_existed")).unwrap();
    }

    /// **Proves:** `list("alph")` returns exactly `["alpha", "alpha2"]`
    /// when the directory contains `alpha.dks`, `alpha2.dks`, and `beta.dks`.
    ///
    /// **Why it matters:** Prefix-based listing is what enables CLI tools
    /// like `dig-validator keys list` to enumerate all keystores of a given
    /// operator. Strict prefix matching (not substring, not suffix) must
    /// be pinned.
    ///
    /// **Catches:** `starts_with` → `contains` regression (which would
    /// include `beta` if prefix were `"eta"`); failure to strip the `.dks`
    /// extension.
    #[test]
    fn list_with_prefix() {
        let dir = TempDir::new().unwrap();
        let be = FileBackend::new(dir.path().to_path_buf());
        be.write(&BackendKey::new("alpha"), b"a").unwrap();
        be.write(&BackendKey::new("alpha2"), b"a").unwrap();
        be.write(&BackendKey::new("beta"), b"b").unwrap();
        let mut keys = be.list("alph").unwrap();
        keys.sort_by_key(|k| k.0.clone());
        assert_eq!(
            keys,
            vec![BackendKey::new("alpha"), BackendKey::new("alpha2")]
        );
    }

    /// **Proves:** reading a non-existent key returns a `KeystoreError::Backend`
    /// wrapping an `io::Error` with `ErrorKind::NotFound`.
    ///
    /// **Why it matters:** The default [`KeychainBackend::exists`] impl
    /// relies on this specific error shape to distinguish "not present"
    /// from "I/O failed." If `read` returned a generic `InvalidInput` or
    /// similar, `exists` would misclassify missing keys.
    ///
    /// **Catches:** a regression where `read` eats the OS error and
    /// returns a custom `KeystoreError` variant, breaking the default
    /// `exists` implementation.
    #[test]
    fn read_nonexistent_returns_error() {
        let dir = TempDir::new().unwrap();
        let be = FileBackend::new(dir.path().to_path_buf());
        let err = be.read(&BackendKey::new("missing")).unwrap_err();
        let is_not_found = match &err {
            KeystoreError::Backend(io) => io.kind() == std::io::ErrorKind::NotFound,
            _ => false,
        };
        assert!(is_not_found);
    }

    /// **Proves:** `FileBackend::write` lazily creates the root directory
    /// (and intermediate parents) when the first write arrives.
    ///
    /// **Why it matters:** Operators may point the validator at
    /// `~/.dig/keys/` before that directory exists. Requiring them to
    /// `mkdir -p` first is poor UX. This test pins the "lazy mkdir" on
    /// first write behaviour so `FileBackend::new` can remain side-effect-free.
    ///
    /// **Catches:** a regression where `write` assumes the dir exists and
    /// fails with `NotFound` on first call; or where `new` eagerly creates
    /// the dir (unwanted in tests).
    #[test]
    fn creates_root_dir() {
        let dir = TempDir::new().unwrap();
        let sub = dir.path().join("nested/keys");
        let be = FileBackend::new(sub.clone());
        assert!(!sub.exists());
        be.write(&BackendKey::new("k"), b"x").unwrap();
        assert!(sub.exists());
    }

    /// `is_owner_only` accepts exactly those modes that grant nobody but the
    /// owner any access.
    ///
    /// **Why it matters:** this predicate is the whole of the permission
    /// guarantee. Everything else in `enforce_owner_only` is plumbing around
    /// its answer, so a predicate that is merely *nearly* right silently
    /// downgrades the at-rest floor for dig-app's account seed and dig-node's
    /// seed store, which are this backend's production callers.
    ///
    /// **Catches:** each of the plausible near-miss implementations. `0o400`
    /// and `0o000` rule out an equality test against `0o600`; `0o640` rules
    /// out a predicate that only inspects the *other* triad (and any
    /// `mode & 0o077 != 0o077` inversion, which would read group-readable as
    /// safe); `0o604` rules out one that only inspects the *group* triad.
    #[test]
    fn owner_only_predicate_rejects_every_non_owner_bit() {
        // No access for group or other, at varying owner permissions.
        for mode in [0o000, 0o400, 0o600, 0o700] {
            assert!(
                is_owner_only(mode),
                "{mode:04o} grants nobody but the owner"
            );
        }

        // A single group or other bit is enough to fail, in either triad.
        for mode in [0o640, 0o604, 0o644, 0o060, 0o006, 0o660, 0o777] {
            assert!(!is_owner_only(mode), "{mode:04o} reaches beyond the owner");
        }
    }

    /// A written blob, and the root that holds it, really are owner-only on
    /// disk — not merely requested to be.
    ///
    /// **Why it matters:** `SPEC.md` §10.3 / conformance C-14 state mode
    /// `0700` for the root and `0600` for blobs as a normative property. It
    /// was previously requested with the result discarded, so nothing
    /// observed whether it held.
    ///
    /// **Catches:** a regression that drops the `enforce_owner_only` call
    /// from either `ensure_root` or `write`, or that reorders the blob's
    /// restriction after `write_all` so ciphertext lands at the umask default
    /// first.
    ///
    /// Unix-only because Windows has no POSIX mode. That makes it
    /// unfalsifiable on a Windows build host, which is why the predicate above
    /// is tested separately and unconditionally.
    #[cfg(unix)]
    #[test]
    fn written_blob_and_root_are_owner_only_on_disk() {
        use std::os::unix::fs::PermissionsExt;

        let dir = TempDir::new().unwrap();
        let root = dir.path().join("keys");
        let be = FileBackend::new(root.clone());
        be.write(&BackendKey::new("seed"), b"sealed").unwrap();

        let root_mode = fs::metadata(&root).unwrap().permissions().mode() & 0o777;
        assert_eq!(root_mode, 0o700, "root dir mode");

        let blob_mode = fs::metadata(root.join("seed.dks"))
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(blob_mode, 0o600, "blob mode");
    }

    /// An **already-existing** permissive root is brought back to `0700` on the
    /// next write, not left alone.
    ///
    /// **Why it matters:** the floor is worthless if it only applies to roots
    /// this version created. A root created by 0.8.x — which requested `0700`
    /// and discarded the result — is precisely the one at risk, and it exists
    /// before any 0.9.0 write reaches it. The exposure is the root's *write*
    /// bits: blobs carry their own verified `0600`, but a group- or
    /// world-writable root grants `unlink` and `create`, which is blob
    /// substitution (rolling a victim back to an older sealed seed) and
    /// deletion, on the directory holding an account master seed.
    ///
    /// **Catches:** the `if self.root.exists() { return Ok(()); }` early
    /// return. Under it this test sees `0o755` and fails, because
    /// `enforce_owner_only` never runs on the existing-root path.
    /// `written_blob_and_root_are_owner_only_on_disk` above cannot catch it:
    /// its root is a fresh non-existent path, so it only ever exercises the
    /// creation branch.
    ///
    /// **Why this asserts tightening rather than `InsecurePermissions`:** on a
    /// root the process owns, `chmod` succeeds, so the permissive mode is
    /// repaired and there is nothing to refuse. Erroring instead would fail a
    /// host the crate can simply fix. `InsecurePermissions` stays reserved for
    /// the unrepairable case — a filesystem where the `chmod` does nothing, a
    /// foreign-owned root where it returns `EPERM`, an immutable attribute.
    /// The *production call sites* therefore cannot reach the refusal on a
    /// mode-honouring filesystem the process owns; the refusal itself is not
    /// unreachable, and
    /// `enforce_owner_only_refuses_a_mode_it_could_not_bring_to_the_floor`
    /// drives it directly.
    #[cfg(unix)]
    #[test]
    fn existing_permissive_root_is_tightened_on_write() {
        use std::os::unix::fs::PermissionsExt;

        let dir = TempDir::new().unwrap();
        let root = dir.path().join("keys");
        fs::create_dir_all(&root).unwrap();
        fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
        assert_eq!(
            fs::metadata(&root).unwrap().permissions().mode() & 0o777,
            0o755,
            "fixture must start group/other-accessible, or it proves nothing"
        );

        let be = FileBackend::new(root.clone());
        be.write(&BackendKey::new("seed"), b"sealed").unwrap();

        assert_eq!(
            fs::metadata(&root).unwrap().permissions().mode() & 0o777,
            0o700,
            "an existing root must be brought to the floor, not skipped"
        );
    }

    /// The verify half of chmod-then-verify actually refuses.
    ///
    /// **Property:** when the mode observed after the request is *not*
    /// owner-only, `enforce_owner_only` returns `InsecurePermissions` carrying
    /// the bits it saw — it does not return `Ok` on the strength of the
    /// `chmod` having succeeded.
    ///
    /// **Why this is the load-bearing assertion of the whole change:** the
    /// thesis of 0.9.0 is "verify the outcome, do not trust the request". The
    /// request's own `Result` is discarded on purpose in `enforce_owner_only`;
    /// the refusal below is the entire reason that is safe. Without this test
    /// the fail-closed block can be deleted with a green suite, returning the
    /// crate to the 0.8.x shape — `set_permissions` called and its result
    /// thrown away with nothing observing the bits.
    ///
    /// **Fixture design.** The refusal cannot be provoked through `write`,
    /// whose call sites always request an owner-only mode on a path the
    /// process owns, so a `write`-level fixture would need a mode-ignoring
    /// mount or a second uid — neither available in a test, which is what
    /// previously left this branch untested. The requested mode is a
    /// *parameter*, so asking for a permissive one drives the same verified
    /// read the production path performs, hermetically: no root, no second
    /// uid, no exotic mount. `0o755` is used rather than `0o777` because it
    /// leaves the owner triad at its production value, so the assertion is
    /// about the group and other bits and nothing else.
    ///
    /// **Catches:** deletion of the fail-closed block in `enforce_owner_only`,
    /// and any narrowing of `is_owner_only` reached through it. Asserting the
    /// observed `mode` — not merely that the call erred — also rules out a
    /// refusal that reports the mode it *asked* for instead of the one on
    /// disk, which would make the diagnostic useless on exactly the mount
    /// classes it exists to diagnose.
    #[cfg(unix)]
    #[test]
    fn enforce_owner_only_refuses_a_mode_it_could_not_bring_to_the_floor() {
        use std::os::unix::fs::PermissionsExt;

        let dir = TempDir::new().unwrap();
        let root = dir.path().join("permissive");
        fs::create_dir_all(&root).unwrap();

        let err = enforce_owner_only(&root, 0o755)
            .expect_err("a mode granting group and other access must be refused, not accepted");

        match err {
            KeystoreError::InsecurePermissions { path, mode } => {
                assert_eq!(mode, 0o755, "the reported mode must be the one on disk");
                assert_eq!(path, root.display().to_string(), "reported path");
            }
            other => panic!("expected InsecurePermissions, got {other:?}"),
        }

        // The refusal describes the state it found, so the mode really is the
        // permissive one — the fixture is not silently owner-only already.
        assert_eq!(
            fs::metadata(&root).unwrap().permissions().mode() & 0o777,
            0o755,
            "fixture must remain group/other-accessible, or it proves nothing"
        );
    }

    /// A symlinked root is refused, not followed.
    ///
    /// **Property:** `write` on a root that is a symbolic link returns
    /// `UnsafeRoot` and touches neither the target's mode nor its contents.
    ///
    /// **Why refuse here when a permissive mode is repaired:** a mode is a
    /// property of the intended directory that the backend can correct and
    /// then verify. A symlink is a claim about *which* directory the keystore
    /// is, and no syscall makes an attacker-chosen directory into the intended
    /// one. Both `set_permissions` and `metadata` follow links, so the
    /// alternative is chmodding a directory of someone else's choosing to
    /// `0700` and sealing an account master seed inside it.
    ///
    /// **Catches:** reverting `symlink_metadata` to `exists()`/`metadata()`.
    ///
    /// **The side effects are asserted before the error, deliberately.** Under
    /// that revert the write returns `Ok`, so an `expect_err` placed first
    /// panics and the two assertions that name the actual damage never run —
    /// the proof would fire on "no error" rather than on the primitive. Ordered
    /// this way, the failure a reverting change sees is the chmod reaching
    /// through the link, which is what is new in this diff. The error
    /// assertion still has to be there: a write that failed for some later,
    /// unrelated reason would leave the victim equally untouched.
    #[cfg(unix)]
    #[test]
    fn symlinked_root_is_refused_and_its_target_is_untouched() {
        use std::os::unix::fs::PermissionsExt;

        let dir = TempDir::new().unwrap();
        let victim = dir.path().join("victim");
        fs::create_dir_all(&victim).unwrap();
        fs::set_permissions(&victim, fs::Permissions::from_mode(0o755)).unwrap();

        let root = dir.path().join("keys");
        std::os::unix::fs::symlink(&victim, &root).unwrap();

        let result = FileBackend::new(root.clone()).write(&BackendKey::new("seed"), b"sealed");

        assert_eq!(
            fs::metadata(&victim).unwrap().permissions().mode() & 0o777,
            0o755,
            "the link's target must not be chmodded through the link"
        );
        assert!(
            !victim.join("seed.dks").exists(),
            "the sealed blob must not land in the link's target"
        );
        assert!(
            matches!(result, Err(KeystoreError::UnsafeRoot { .. })),
            "a symlinked root must be refused, got {result:?}"
        );
    }
}