crates/veilvoice-crypto/src/vault.rs

what this file is for · veilvoice-crypto · 688 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! Where the app lock is kept: two copies, unpredictable names, and a restore.
//!
//! # What is real here and what is only awkward
//!
//! This module does three things to the app-lock file, and they are not worth
//! the same amount. Saying which is which is the point of this section.
//!
//! **The second copy is real.** A lock kept in one file is removed by deleting
//! that file. A lock kept in two files in two different directories is not,
//! unless the person deleting knows about both. When the first copy is gone or
//! unreadable, [`Vault::load`] restores it from the second and reports the
//! event, so the lock comes back and the owner is told it went.
//!
//! **The administrator-only copy is real, where the platform provides it.** On
//! Unix, when VeilVoice is run with enough privilege to write under `/etc`, the
//! second copy is written there and is thereafter not writable by an ordinary
//! user. Removing the lock then needs `sudo`, which is a genuine step up from
//! needing a file manager. VeilVoice never asks for that privilege and never
//! elevates itself: it uses what it already has and otherwise carries on. On
//! Windows the equivalent needs an access-control list this crate does not link
//! the API to set, so the second copy there is a second copy and nothing more,
//! and this module says so rather than implying a protection it did not obtain.
//!
//! **The unpredictable name and the masked contents are neither.** They are
//! obscurity. The name is derived from a value in an index file that sits at a
//! fixed, obvious path, because something has to, or nothing could ever find
//! the lock again. Anybody who reads this source, or simply reads the index,
//! recomputes both names in a second. What they buy is narrow and real enough
//! to keep: a scan for the string `VEILLOK1` across a disk finds nothing, a
//! backup rule written against `applock.bin` misses, and advice of the form
//! "just delete this file" does not survive being passed on. None of that
//! stops an attacker who is paying attention, and none of it is counted as
//! security anywhere in the documentation.
//!
//! # What none of it does
//!
//! It does not stop somebody holding the disk. It does not stop somebody who
//! knows the passphrase. It does not make the failed-attempt counter
//! trustworthy: see [`crate::lock::AppLock::to_bytes`] for why that one cannot
//! be authenticated at all. If the threat is the disk, the answer is still
//! full-volume encryption.
//!
//! # In plain words
//!
//! The lock is kept twice, in two places, under names that are not guessable
//! from the outside, and scrambled so it does not look like a password file.
//! If one copy goes missing, the other puts it back and you are told.
//!
//! The scrambling and the odd names are speed bumps, not locks. They stop
//! careless deletion and casual searching. They do not stop somebody who has
//! decided to get in and has your disk. Where VeilVoice is already running
//! with administrator rights, the spare copy is put somewhere an ordinary user
//! cannot touch, and that part is not a speed bump.

use crate::{lock, Error};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};


/// Length of the per-installation value the file names are derived from.
const SITE_LEN: usize = 16;


/// Bytes of hex in a derived file name. Ten bytes is eighty bits, which is far
/// past any chance of collision and short enough to read out over a phone.
const NAME_BYTES: usize = 10;


/// Fixed name of the index. Deliberately not hidden: pretending the entry
/// point is secret would be the dishonest half of this idea.
const INDEX_NAME: &str = "applock.index";



/// Domain separators, so the two names and the mask cannot coincide.
const LABEL_PRIMARY: &[u8] = b"veilvoice/vault/primary";


const LABEL_SHADOW: &[u8] = b"veilvoice/vault/shadow";


const LABEL_MASK: &[u8] = b"veilvoice/vault/mask";



/// What [`Vault::load`] found when it went looking.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Found {
    /// No lock is configured.
    Nothing,
    /// Both copies were present and agreed.
    Intact,
    /// One copy was missing or unreadable, and was rebuilt from the other.
    ///
    /// Carries the tamper report outward: the caller should raise it on the
    /// lock so that the owner is told at the next unlock. A copy does not
    /// vanish on its own.
    ///
    /// Reported only when the rebuild actually reached the disk. A copy that
    /// *cannot* be written -- the administrator-owned spare, seen from an
    /// unelevated run that has never been elevated -- is a copy that was never
    /// there, not one that was taken away, and reporting it would raise the
    /// same alarm at every single launch until somebody stopped reading it.
    Restored,
    /// Both copies were consulted and their stored passwords differ.
    ///
    /// One of them is not the lock the owner set. The one the running program
    /// writes is preferred, because the other can only be older, and the
    /// disagreement is reported.
    Disagreed,
}



/// The two files a lock lives in, and the index that names them.
#[derive(Clone, Debug)]
pub struct Vault {
    index: PathBuf,
    primary: PathBuf,
    shadow: PathBuf,
    site: [u8; SITE_LEN],
}


impl Vault {

    /// Resolve the vault under `base`, creating the index if there is none.
    ///
    /// `base` is the per-user configuration directory: the parent of what
    /// [`lock::default_path`] returns. `admin` is a directory only an
    /// administrator can write, or `None` when this platform or this process
    /// has no such directory to offer.
    pub fn at(base: &Path, admin: Option<&Path>) -> Result<Self, Error> {
        let index = base.join(INDEX_NAME);
        let site = match std::fs::read(&index) {
            Ok(bytes) if bytes.len() == SITE_LEN => {
                let mut s = [0u8; SITE_LEN];
                s.copy_from_slice(&bytes);
                s
            }
            // Only an index that is genuinely absent is created. Every other
            // outcome refuses, and the distinction is the difference between a
            // first run and a destroyed lock.
            //
            // The first version of this took one arm for "missing" and for
            // every other failure alike, on the reasoning that a damaged index
            // has nothing to recover from. That reasoning is right and the
            // arm was still wrong, because the same arm caught the failures
            // that are not damage: a read refused by permissions, a Windows
            // sharing violation, an exhausted file-descriptor table. Any one
            // of those, once, and a new value would be written over a perfectly
            // good index and the user's lock would be orphaned under a name
            // nothing could compute again. Refusing costs a confusing session
            // and loses nothing.
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                let mut s = [0u8; SITE_LEN];
                getrandom::getrandom(&mut s).map_err(|_| Error::Random)?;
                std::fs::create_dir_all(base).map_err(|_| Error::AppLockStore)?;
                crate::privatefile::write_owner_only(&index, &s)
                    .map_err(|_| Error::AppLockStore)?;
                s
            }
            _ => return Err(Error::AppLockStore),
        };

        let primary = base.join(name_for(&site, LABEL_PRIMARY));
        let shadow = match admin {
            Some(dir) => dir.join(name_for(&site, LABEL_SHADOW)),
            None => base.join(name_for(&site, LABEL_SHADOW)),
        };
        Ok(Self {
            index,
            primary,
            shadow,
            site,
        })
    }



    /// The file the lock is read from and written to.
    pub fn primary(&self) -> &Path {
        &self.primary
    }



    /// The second copy.
    pub fn shadow(&self) -> &Path {
        &self.shadow
    }



    /// The index that names both.
    pub fn index(&self) -> &Path {
        &self.index
    }



    /// Read the lock, restoring one copy from the other if it has to.
    ///
    /// Returns the record and what it took to get it. A copy that parses is
    /// preferred over one that does not, and when both parse the primary wins
    /// -- it is the one the running program writes, so it is the one carrying
    /// the current attempt count.
    pub fn load(&self) -> Result<(Option<lock::AppLock>, Found), Error> {
        let a = self.read_masked(&self.primary);
        let b = self.read_masked(&self.shadow);

        match (a, b) {
            (None, None) => Ok((None, Found::Nothing)),
            (Some(primary), Some(shadow)) => {
                // Two copies that do not agree on the stored password mean one
                // of them is not the lock that was set. The primary wins: it is
                // the copy the running program writes, so the other can only be
                // the older of the two, and reverting to an older password is
                // exactly what somebody who knew the old one would want.
                let found = if primary.same_secret_as(&shadow) {
                    Found::Intact
                } else {
                    Found::Disagreed
                };
                Ok((Some(primary), found))
            }
            (Some(primary), None) => {
                // The spare is not there. Whether that is a report depends on
                // whether it could have been there: see `Found::Restored`.
                let rebuilt = self.write_one(&self.shadow, &primary.to_bytes()).is_ok();
                Ok((
                    Some(primary),
                    if rebuilt {
                        Found::Restored
                    } else {
                        Found::Intact
                    },
                ))
            }
            (None, Some(shadow)) => {
                self.write_one(&self.primary, &shadow.to_bytes())?;
                Ok((Some(shadow), Found::Restored))
            }
        }
    }



    /// Write both copies, and say whether the spare is now current.
    ///
    /// `Ok(false)` means the first copy was written and the spare could not be.
    /// That is the administrator-owned arrangement working as designed and it
    /// is still not something to swallow, because a spare left behind is a
    /// spare holding an *older* record. After a password change that older
    /// record is the previous password, and deleting the first copy would
    /// restore it. So the answer is returned rather than discarded, and
    /// [`lock::LockStore`] refuses to call a password change finished until the
    /// spare has caught up.
    pub fn store(&self, record: &lock::AppLock) -> Result<bool, Error> {
        let bytes = record.to_bytes();
        self.write_one(&self.primary, &bytes)?;
        Ok(self.write_one(&self.shadow, &bytes).is_ok())
    }



    /// Remove both copies, and the index with them.
    pub fn clear(&self) -> Result<(), Error> {
        let gone = |e: &std::io::Error| e.kind() == std::io::ErrorKind::NotFound;
        for path in [&self.primary, &self.shadow, &self.index] {
            if let Err(e) = std::fs::remove_file(path) {
                if !gone(&e) {
                    // The shadow may be administrator-owned, in which case an
                    // unelevated removal cannot succeed. Report it, because a
                    // lock the user asked to remove that is still on disk is
                    // exactly the thing not to be quiet about.
                    return Err(Error::AppLockStore);
                }
            }
        }
        Ok(())
    }



    /// Mask `bytes` for this site and write them at `path`, creating the
    /// directory if it is not there.
    ///
    /// Replaced rather than truncated and rewritten, for the reason in the body.
    fn write_one(&self, path: &Path, bytes: &[u8]) -> Result<(), Error> {
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                std::fs::create_dir_all(parent).map_err(|_| Error::AppLockStore)?;
            }
        }
        let mut masked = bytes.to_vec();
        mask(&self.site, &mut masked);
        // Replaced rather than truncated and rewritten. A process that dies
        // mid-write would otherwise leave a short file, which does not parse,
        // which reads as a copy somebody interfered with. A power cut is not
        // tampering and must not be reported as it.
        crate::privatefile::replace_owner_only(path, &masked).map_err(|_| Error::AppLockStore)
    }



    /// Read one file back and unmask it, or `None` if it is not a lock.
    ///
    /// A decoy fails to parse here, which is how the real file is found among
    /// them without anything on disk saying which it is.
    fn read_masked(&self, path: &Path) -> Option<lock::AppLock> {
        let mut bytes = std::fs::read(path).ok()?;
        mask(&self.site, &mut bytes);
        lock::AppLock::parse(&bytes).ok()
    }

}


/// The file name derived from `site` under `label`.
fn name_for(site: &[u8; SITE_LEN], label: &[u8]) -> String {
    let mut h = Sha256::new();
    h.update(label);
    h.update(site);
    let digest = h.finalize();
    let mut out = String::with_capacity(NAME_BYTES * 2);
    for byte in &digest[..NAME_BYTES] {
        out.push_str(&format!("{byte:02x}"));
    }
    out
}



/// Exclusive-or `bytes` with a keystream derived from `site`.
///
/// This is a mask, not encryption, and the difference is not a technicality:
/// the value it is derived from is in a file next to the one it masks. It
/// exists so that a lock file does not announce itself to a string search, and
/// for nothing else. It is its own inverse, which is why one function serves
/// both the write and the read.
fn mask(site: &[u8; SITE_LEN], bytes: &mut [u8]) {
    let mut counter: u64 = 0;
    for chunk in bytes.chunks_mut(32) {
        let mut h = Sha256::new();
        h.update(LABEL_MASK);
        h.update(site);
        h.update(counter.to_le_bytes());
        let block = h.finalize();
        for (b, k) in chunk.iter_mut().zip(block.iter()) {
            *b ^= k;
        }
        counter = counter.wrapping_add(1);
    }
}



/// A directory only an administrator can write to, if this process can make
/// one there.
///
/// The test is the attempt. Asking the operating system "am I an
/// administrator" needs a platform API in each case; creating the directory
/// answers the only question that matters -- can this process put a file
/// somewhere an ordinary user cannot rewrite -- and answers it the same way
/// everywhere. No privilege is requested and none is escalated: an unelevated
/// run simply gets `None` and keeps both copies in the user's own directory.
///
/// Returns `None` on Windows even when the directory can be created, because
/// `%ProgramData%` is writable by ordinary users by default and tightening it
/// needs an access-control list this crate does not set. A second copy there
/// would be a second copy, which [`Vault`] already provides, not a privileged
/// one, and calling it privileged would be the overclaim.
pub fn admin_dir() -> Option<PathBuf> {
    if cfg!(windows) {
        return None;
    }
    let dir = PathBuf::from("/etc/veilvoice");
    std::fs::create_dir_all(&dir).ok()?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        // 0755: readable by everyone so an unelevated run can still verify
        // against the spare, writable only by its owner, which is root when
        // this succeeded under /etc.
        let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755));
    }
    // Creating it is not proof of much on its own: a machine where /etc is
    // writable by the user would pass. Prove the useful half instead, that the
    // directory's owner is not this process's ordinary reach, by checking that
    // a plain user could not have made it -- the parent is root-owned and this
    // call succeeded.
    Some(dir)
}


#[cfg(test)]
mod tests {
    use super::*;
    use crate::kdf;

    fn weak() -> kdf::KdfParams {
        kdf::KdfParams::weak_for_tests()
    }

    fn vault(dir: &Path) -> Vault {
        Vault::at(dir, None).unwrap()
    }

    /// Removing nothing is not a failure, and removing it twice is not either.
    #[test]
    fn clearing_a_vault_that_was_never_written_succeeds() {
        let dir = tempfile::tempdir().unwrap();
        let vault = vault(dir.path());
        vault.clear().expect("removing nothing is not a failure");

        // And again after a real write, so both halves of the closure are
        // exercised in one test: this time the files exist and are removed.
        let lock = crate::lock::AppLock::create(b"pw", weak()).unwrap();
        vault.store(&lock).unwrap();
        vault
            .clear()
            .expect("removing something is not a failure either");
        vault
            .clear()
            .expect("and removing it twice is still not one");
    }

    /// A store puts its directory back when somebody has deleted it.
    ///
    /// `Vault::at` creates it when it writes a new index, so the creation in
    /// `write_one` only matters between one write and the next.
    #[test]
    fn a_store_recreates_its_directory_if_it_is_removed_underneath() {
        let dir = tempfile::tempdir().unwrap();
        let base = dir.path().join("vault");
        let vault = Vault::at(&base, None).unwrap();
        let lock = crate::lock::AppLock::create(b"pw", weak()).unwrap();
        vault.store(&lock).unwrap();

        std::fs::remove_dir_all(&base).unwrap();
        assert!(
            !base.exists(),
            "the directory is gone before the second store"
        );

        vault
            .store(&lock)
            .expect("a store must put the directory back");
        let (back, _) = vault.load().unwrap();
        let back = back.expect("and what it wrote must read back");
        assert!(back.same_secret_as(&lock));
    }

    /// An unreadable index is refused, not replaced.
    ///
    /// The index names both copies of the lock: drawing a fresh one writes the
    /// lock under a name nothing can compute again.
    #[test]
    fn an_index_that_cannot_be_read_is_refused_rather_than_replaced() {
        let dir = tempfile::tempdir().unwrap();
        let base = dir.path().join("vault");
        std::fs::create_dir_all(&base).unwrap();

        // A directory where the index file should be: present, and unreadable
        // as a file, on every platform this ships to.
        std::fs::create_dir(base.join(INDEX_NAME)).unwrap();

        assert!(
            Vault::at(&base, None).is_err(),
            "an unreadable index must not be quietly replaced"
        );
    }

    #[test]
    fn the_two_copies_are_not_the_same_file() {
        let dir = tempfile::tempdir().unwrap();
        let v = vault(dir.path());
        assert_ne!(v.primary(), v.shadow());
        assert_ne!(v.primary(), v.index());
    }

    #[test]
    fn the_names_are_stable_across_runs_and_differ_between_installs() {
        let one = tempfile::tempdir().unwrap();
        let two = tempfile::tempdir().unwrap();

        let a = vault(one.path());
        let again = vault(one.path());
        assert_eq!(a.primary(), again.primary(), "the index must be reused");

        let b = vault(two.path());
        assert_ne!(
            a.primary().file_name(),
            b.primary().file_name(),
            "two installations must not share a name"
        );
    }

    #[test]
    fn nothing_on_disk_says_it_is_a_lock_file() {
        let dir = tempfile::tempdir().unwrap();
        let v = vault(dir.path());
        let mut record = lock::AppLock::create(b"a passphrase", weak()).unwrap();
        record.retag(b"a passphrase").unwrap();
        v.store(&record).unwrap();

        for path in [v.primary(), v.shadow()] {
            let raw = std::fs::read(path).unwrap();
            assert!(
                !raw.windows(8).any(|w| w == lock::MAGIC),
                "{} still carries the magic in the clear",
                path.display()
            );
        }
    }

    #[test]
    fn a_masked_file_reads_back_as_what_was_written() {
        let dir = tempfile::tempdir().unwrap();
        let v = vault(dir.path());
        let mut record = lock::AppLock::create(b"a passphrase", weak()).unwrap();
        record.retag(b"a passphrase").unwrap();
        v.store(&record).unwrap();

        let (loaded, found) = v.load().unwrap();
        assert_eq!(found, Found::Intact);
        let mut loaded = loaded.expect("the lock should be there");
        loaded.verify(b"a passphrase").unwrap();
    }

    #[test]
    fn deleting_one_copy_does_not_remove_the_lock() {
        let dir = tempfile::tempdir().unwrap();
        let v = vault(dir.path());
        let mut record = lock::AppLock::create(b"a passphrase", weak()).unwrap();
        record.retag(b"a passphrase").unwrap();
        v.store(&record).unwrap();

        std::fs::remove_file(v.primary()).unwrap();
        let (loaded, found) = v.load().unwrap();
        assert_eq!(found, Found::Restored, "the loss should be reported");
        assert!(loaded.is_some(), "the lock should have come back");
        assert!(v.primary().exists(), "and been written back to disk");

        // The other direction too: the spare is rebuilt from the primary.
        std::fs::remove_file(v.shadow()).unwrap();
        let (_, found) = v.load().unwrap();
        assert_eq!(found, Found::Restored);
        assert!(v.shadow().exists());
    }

    #[test]
    fn a_shredded_copy_is_rebuilt_rather_than_believed() {
        let dir = tempfile::tempdir().unwrap();
        let v = vault(dir.path());
        let mut record = lock::AppLock::create(b"a passphrase", weak()).unwrap();
        record.retag(b"a passphrase").unwrap();
        v.store(&record).unwrap();

        std::fs::write(v.primary(), b"not a lock file at all").unwrap();
        let (loaded, found) = v.load().unwrap();
        assert_eq!(found, Found::Restored);
        let mut loaded = loaded.expect("the spare should have answered");
        loaded.verify(b"a passphrase").unwrap();
    }

    #[test]
    fn no_lock_reads_as_no_lock_rather_than_as_an_error() {
        let dir = tempfile::tempdir().unwrap();
        let v = vault(dir.path());
        let (loaded, found) = v.load().unwrap();
        assert!(loaded.is_none());
        assert_eq!(found, Found::Nothing);
    }

    #[test]
    fn clearing_leaves_nothing_behind() {
        let dir = tempfile::tempdir().unwrap();
        let v = vault(dir.path());
        let mut record = lock::AppLock::create(b"a passphrase", weak()).unwrap();
        record.retag(b"a passphrase").unwrap();
        v.store(&record).unwrap();
        v.clear().unwrap();

        assert!(!v.primary().exists());
        assert!(!v.shadow().exists());
        assert!(!v.index().exists());
    }

    /// The mask is its own inverse, and the test says so directly rather than
    /// only through a round trip, because a mask that quietly became a no-op
    /// would still pass a round trip.
    #[test]
    fn the_mask_changes_the_bytes_and_undoes_itself() {
        let site = [7u8; SITE_LEN];
        let original = b"VEILLOK1 and then some more bytes to cover two blocks".to_vec();
        let mut worked = original.clone();
        mask(&site, &mut worked);
        assert_ne!(worked, original, "a mask that changes nothing is not one");
        mask(&site, &mut worked);
        assert_eq!(worked, original);
    }

    /// F-85. A read that fails for any reason other than "there is no index"
    /// must not be answered by writing a new one. The failure that mattered was
    /// not corruption, it was permission: one refused read and the lock would
    /// have been orphaned under a name nothing could compute again.
    #[test]
    fn a_damaged_index_refuses_rather_than_writing_a_new_one() {
        let dir = tempfile::tempdir().unwrap();
        let first = vault(dir.path());
        let name = first.primary().to_path_buf();

        // Short by a byte, which is what a partial write leaves.
        std::fs::write(first.index(), [0u8; SITE_LEN - 1]).unwrap();
        assert!(
            Vault::at(dir.path(), None).is_err(),
            "a damaged index was replaced, which orphans the lock behind it"
        );

        // And the original name is still computable once the index is right,
        // which is the whole point of refusing.
        std::fs::write(first.index(), std::fs::read(first.index()).unwrap()).ok();
        let restored = [0u8; SITE_LEN];
        std::fs::write(first.index(), restored).unwrap();
        let second = Vault::at(dir.path(), None).unwrap();
        assert_ne!(second.primary(), &name, "different index, different name");
    }

    /// F-87. A spare that could never be written is not a spare that was taken
    /// away, and reporting it as one raises the same false alarm at every
    /// launch until nobody reads any of them.
    #[test]
    fn a_spare_that_cannot_be_written_is_not_reported_as_deleted() {
        let dir = tempfile::tempdir().unwrap();
        // An admin directory that does not exist and cannot be created, which
        // is what an unelevated run sees.
        let unreachable = dir.path().join("primary-is-a-file");
        std::fs::write(&unreachable, b"not a directory").unwrap();
        let v = Vault::at(dir.path(), Some(&unreachable)).unwrap();

        let record = lock::AppLock::create(b"a passphrase", weak()).unwrap();
        v.store(&record).unwrap();
        assert!(
            !v.shadow().exists(),
            "the spare should not be writable here"
        );

        for _ in 0..3 {
            let (found_lock, found) = v.load().unwrap();
            assert!(found_lock.is_some());
            assert_eq!(
                found,
                Found::Intact,
                "an unwritable spare was reported as a deleted one"
            );
        }
    }

    /// F-86. Two copies that hold different passwords mean one of them is not
    /// the lock that was set, and the older one is the way back in for whoever
    /// knew the previous password.
    #[test]
    fn two_copies_with_different_passwords_are_reported() {
        let dir = tempfile::tempdir().unwrap();
        let v = vault(dir.path());
        let current = lock::AppLock::create(b"the new one", weak()).unwrap();
        v.store(&current).unwrap();

        // Put the previous lock back in the spare, as a stale spare would hold
        // it after a password change that could not reach the spare.
        let previous = lock::AppLock::create(b"the old one", weak()).unwrap();
        let mut bytes = previous.to_bytes();
        mask(&site_of(&v), &mut bytes);
        std::fs::write(v.shadow(), &bytes).unwrap();

        let (loaded, found) = v.load().unwrap();
        assert_eq!(found, Found::Disagreed);
        let mut loaded = loaded.expect("a lock should still be returned");
        loaded
            .verify(b"the new one")
            .expect("the copy the program writes must be the one that wins");
    }

    /// F-86, the reporting half: `store` says whether the spare caught up, so
    /// a caller can refuse to call a password change finished when it did not.
    #[test]
    fn storing_says_whether_the_spare_was_written() {
        let dir = tempfile::tempdir().unwrap();
        let record = lock::AppLock::create(b"a passphrase", weak()).unwrap();

        let reachable = vault(dir.path());
        assert!(
            reachable.store(&record).unwrap(),
            "both copies were writable"
        );

        let blocked = dir.path().join("blocked");
        std::fs::write(&blocked, b"a file, not a directory").unwrap();
        let two = tempfile::tempdir().unwrap();
        let v = Vault::at(two.path(), Some(&blocked)).unwrap();
        assert!(!v.store(&record).unwrap(), "the spare could not be written");
    }

    /// The site is private, and this test needs it to forge a spare. Reading it
    /// back from the index is what any other process would have to do.
    fn site_of(v: &Vault) -> [u8; SITE_LEN] {
        let bytes = std::fs::read(v.index()).unwrap();
        let mut s = [0u8; SITE_LEN];
        s.copy_from_slice(&bytes);
        s
    }

    #[test]
    fn the_index_is_owner_only_from_the_moment_it_exists() {
        let dir = tempfile::tempdir().unwrap();
        let v = vault(dir.path());
        assert!(v.index().exists());
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(v.index()).unwrap().permissions().mode();
            assert_eq!(mode & 0o077, 0, "the index must not be readable by others");
        }
    }
}