crates/veilvoice-gui/src/vault_store.rs

what this file is for · veilvoice-gui · 589 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 desktop application keeps its own files, and what the app lock
//! buys for them.
//!
//! # The short version, and it is the honest one
//!
//! **With an app lock set**, everything VeilVoice writes about itself lives in
//! [`veilvoice_crypto::hoard`]: encrypted, padded to a few fixed sizes, under
//! filenames derived from the lock passphrase, with decoy files sown among
//! them. Somebody who opens the folder without the passphrase cannot tell
//! which file is the settings, which is the integrity record, which holds
//! anything, and which is junk.
//!
//! **With no app lock**, none of that is possible and none of it is claimed.
//! There is no passphrase, so there is no key, so there is nothing to derive a
//! name from or encrypt with. The files sit in the open under their own names,
//! exactly as they did before this module existed, and the security tab says
//! so in those words.
//!
//! That is the whole bargain, and it is the answer to a fair question about
//! the app lock: what is it actually *for*, if it is only a password prompt on
//! a window whose files anybody can read? This is what it is for. Setting a
//! passphrase is what turns the folder from a set of labelled files into a set
//! of indistinguishable ones.
//!
//! # What it still does not buy
//!
//! Repeated here rather than left in the crypto crate, because this is the
//! module the application calls and the place somebody looks:
//!
//! - It does not hide that VeilVoice is installed. The folder is named
//!   `veilvoice` and the lock file is in it under its own name -- it has to
//!   be, since it is what checks the passphrase.
//! - It does not stop anybody deleting the folder.
//! - It is no protection at all while the application is open and unlocked.
//! - Anybody who has the passphrase has everything.
//!
//! # Moving in, and the risk that comes with it
//!
//! The first unlock after a lock is set migrates the existing plain files in:
//! each is read, written as a hoard record, and the original securely erased.
//!
//! This is the moment to be plain about a consequence that is easy to
//! under-state. Once the files are in the hoard, **the passphrase is the only
//! way back to them**. Losing it does not lock you out of a window whose files
//! you could still read by hand; it loses the settings, the integrity record
//! and the policies for good. [`veilvoice_crypto::lock`] keeps a second copy of
//! the lock for exactly this reason, and the setup screen says the sentence out
//! loud rather than burying it.

use std::path::{Path, PathBuf};

use veilvoice_crypto::hoard::{Audit, Hoard, StoreKey};


/// The logical names of every record the application keeps.
///
/// Named here rather than spelled at each call site so that the migration, the
/// audit and the readers cannot disagree about what exists. A record not in
/// this list is not migrated and not audited.
///
/// # What is deliberately not here, and why
///
/// `settings.conf` is **not** an obfuscated record, and this is the one place
/// that decision is explained rather than assumed.
///
/// The settings file says which theme to use, how big the window was, and
/// whether movement is reduced. All three are needed to draw the window --
/// including the lock screen itself, which is the first thing drawn and the
/// last thing that could wait. A record inside the hoard cannot be read until
/// a passphrase has produced a key, and a passphrase cannot be typed until
/// there is a window to type it into. Putting the settings in there would mean
/// every locked VeilVoice opened with the default theme at the default size
/// and then jumped to the user's when they unlocked.
///
/// So the settings stay in the open, and what that costs is stated plainly:
/// somebody reading the folder learns which theme you chose, roughly how big
/// your window is, and which optional features you switched on. They do not
/// learn what you have processed, what VeilVoice measured while doing it, what
/// it found when it checked itself, or what any of the other records hold.
///
/// This is a real limit rather than a temporary one. Closing it properly means
/// splitting appearance out from everything else so only the handful of
/// drawing settings sit in the open, and that is worth doing; it is not worth
/// pretending is already done.
pub mod records {
    /// What the application measured about its own running.
    pub const MEASURED: &str = "measured";

    /// Every record, with the plain filename each one migrates from.
    ///
    /// The plain name is what the file was called before there was a lock, and
    /// is what it goes back to being if the lock is removed.
    ///
    /// # Adding to this list is not free
    ///
    /// A name here is migrated in on the first unlock and the plain file is
    /// **shredded**. So a file listed here that anything still reads by its
    /// plain path is a file that gets destroyed, silently, on somebody's next
    /// unlock. `every_record_is_actually_read_through_the_store` refuses that,
    /// and it exists because the first version of this module listed three
    /// records and read one.
    ///
    /// `integrity.manifest` and `last-crash.txt` were on this list and are
    /// deliberately not now. Both are still read elsewhere by their plain
    /// paths, and the manifest is read by `veilvoice guard` from the *command
    /// line*, which has no unlocked session and therefore no key. Moving it in
    /// would mean prompting for the app-lock passphrase on every
    /// `veilvoice guard` run: a different feature with a different argument,
    /// not a detail of this one.
    pub const ALL: &[(&str, &str)] = &[(MEASURED, "measured.dat")];
}



/// How many decoys a folder is kept stocked with.
///
/// Enough that the real records are a minority of what is there, few enough
/// that the folder is not absurd. The exact number is not a security
/// parameter: it blurs the count of real records, it does not hide it, and
/// pretending otherwise would be the overstatement this project spends its
/// time avoiding.
const DECOYS: usize = 24;



/// The application's own storage, locked or not.
#[derive(Default)]
pub struct VaultStore {
    /// The directory everything lives in, whether obfuscated or not.
    dir: Option<PathBuf>,
    /// The obfuscated store, once a passphrase has produced its key.
    hoard: Option<Hoard>,
}


impl VaultStore {

    /// Point at the program folder. Nothing is read or written yet.
    pub fn new(dir: Option<PathBuf>) -> Self {
        Self { dir, hoard: None }
    }



    /// The program folder, if this platform has one.
    pub fn dir(&self) -> Option<&Path> {
        self.dir.as_deref()
    }



    /// Whether records are currently obfuscated.
    ///
    /// False before an unlock and false when there is no lock at all, which
    /// are different situations with the same answer: in neither of them is
    /// anything hidden.
    pub fn is_obfuscated(&self) -> bool {
        self.hoard.is_some()
    }



    /// Take the key from an unlock and open the hoard with it.
    ///
    /// Migrates any plain files in on the way, and tops the decoys up. Returns
    /// what the audit found, so the caller can put a tamper report in front of
    /// somebody who has just proved they own the machine.
    pub fn unlocked(&mut self, key: StoreKey) -> Result<Audit, String> {
        let Some(dir) = self.dir.clone() else {
            return Err("this system has no program folder".to_string());
        };
        let hoard = Hoard::open(&dir, key);

        for (logical, plain) in records::ALL {
            let path = dir.join(plain);
            if !path.exists() {
                continue;
            }
            let bytes = std::fs::read(&path)
                .map_err(|e| format!("could not read {plain} to move it in: {e}"))?;
            hoard
                .write(logical, &bytes)
                .map_err(|e| format!("could not store {plain}: {e}"))?;
            // Erased rather than deleted: a settings file that says which
            // vault you use should not be recoverable from free space after
            // VeilVoice has told you it is now encrypted.
            let _ =
                veilvoice_crypto::shred::shred_file(&path, veilvoice_crypto::shred::Passes::Triple);
            let _ = std::fs::remove_file(&path);
        }

        // Kept stocked rather than sown once. A folder whose decoy count never
        // changes while its record count does is a folder that leaks the
        // difference over time.
        let present = hoard.audit().map_err(|e| e.to_string())?;
        if present.unrecognised < DECOYS {
            let _ = hoard.sow_decoys(DECOYS - present.unrecognised);
        }

        let audit = hoard.audit().map_err(|e| e.to_string())?;
        self.hoard = Some(hoard);
        Ok(audit)
    }



    /// Forget the key. Called when the window locks.
    pub fn locked(&mut self) {
        self.hoard = None;
    }



    /// Read a record, from the hoard if it is open and from the plain file if
    /// it is not.
    pub fn read(&self, logical: &str) -> Result<Option<Vec<u8>>, String> {
        if let Some(hoard) = &self.hoard {
            return hoard.read(logical).map_err(|e| e.to_string());
        }
        let Some(path) = self.plain_path(logical) else {
            return Ok(None);
        };
        match std::fs::read(path) {
            Ok(bytes) => Ok(Some(bytes)),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e.to_string()),
        }
    }



    /// Write a record, obfuscated if there is a key and plain if there is not.
    pub fn write(&self, logical: &str, bytes: &[u8]) -> Result<(), String> {
        if let Some(hoard) = &self.hoard {
            return hoard.write(logical, bytes).map_err(|e| e.to_string());
        }
        let Some(path) = self.plain_path(logical) else {
            return Err("this system has no program folder".to_string());
        };
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
        }
        veilvoice_crypto::privatefile::write_owner_only(&path, bytes).map_err(|e| e.to_string())
    }



    /// Where a record sits when nothing is obfuscating it.
    fn plain_path(&self, logical: &str) -> Option<PathBuf> {
        let dir = self.dir.as_ref()?;
        let name = records::ALL
            .iter()
            .find(|(key, _)| *key == logical)
            .map(|(_, plain)| *plain)?;
        Some(dir.join(name))
    }

}

#[cfg(test)]
mod tests {
    use super::*;
    use veilvoice_crypto::amnesia::Secret;

    fn key(byte: u8) -> StoreKey {
        let mut raw = [byte; 32];
        StoreKey::from_secret(Secret::new(&mut raw))
    }

    #[test]
    fn without_a_lock_it_writes_plain_files_under_their_own_names() {
        let dir = tempfile::tempdir().unwrap();
        let store = VaultStore::new(Some(dir.path().to_path_buf()));
        assert!(!store.is_obfuscated());
        store.write(records::MEASURED, b"theme=dark").unwrap();
        assert!(
            dir.path().join("measured.dat").exists(),
            "with no passphrase there is no key, so there is nothing to hide with"
        );
        assert_eq!(
            store.read(records::MEASURED).unwrap().unwrap(),
            b"theme=dark"
        );
    }

    #[test]
    fn unlocking_moves_the_plain_files_in_and_removes_them() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = VaultStore::new(Some(dir.path().to_path_buf()));
        store.write(records::MEASURED, b"theme=dark").unwrap();
        assert!(dir.path().join("measured.dat").exists());

        store.unlocked(key(7)).unwrap();
        assert!(store.is_obfuscated());
        assert!(
            !dir.path().join("measured.dat").exists(),
            "the plain copy has to go, or the obfuscation is decoration"
        );
        assert_eq!(
            store.read(records::MEASURED).unwrap().unwrap(),
            b"theme=dark",
            "and the contents have to survive the move"
        );
    }

    #[test]
    fn once_unlocked_no_filename_says_what_it_holds() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = VaultStore::new(Some(dir.path().to_path_buf()));
        store.write(records::MEASURED, b"theme=dark").unwrap();
        store.unlocked(key(7)).unwrap();
        store.write(records::MEASURED, b"4 runs").unwrap();

        for entry in std::fs::read_dir(dir.path()).unwrap() {
            let name = entry.unwrap().file_name().to_string_lossy().into_owned();
            for (_, plain) in records::ALL {
                assert_ne!(&name, plain, "a plain name survived the move");
            }
        }
    }

    #[test]
    fn decoys_outnumber_the_records() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = VaultStore::new(Some(dir.path().to_path_buf()));
        store.write(records::MEASURED, b"x").unwrap();
        store.unlocked(key(7)).unwrap();
        let files = std::fs::read_dir(dir.path()).unwrap().count();
        assert!(
            files >= DECOYS,
            "only {files} files: the real ones are not lost in a crowd"
        );
    }

    #[test]
    fn a_second_unlock_does_not_keep_adding_decoys() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = VaultStore::new(Some(dir.path().to_path_buf()));
        store.unlocked(key(7)).unwrap();
        let first = std::fs::read_dir(dir.path()).unwrap().count();
        store.locked();
        store.unlocked(key(7)).unwrap();
        let second = std::fs::read_dir(dir.path()).unwrap().count();
        assert_eq!(
            first, second,
            "a folder that grows by two dozen files per unlock is its own signal"
        );
    }

    #[test]
    fn the_wrong_passphrase_finds_nothing_rather_than_erroring() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = VaultStore::new(Some(dir.path().to_path_buf()));
        store.write(records::MEASURED, b"theme=dark").unwrap();
        store.unlocked(key(7)).unwrap();

        let mut other = VaultStore::new(Some(dir.path().to_path_buf()));
        other.unlocked(key(9)).unwrap();
        assert_eq!(
            other.read(records::MEASURED).unwrap(),
            None,
            "a different key derives names nothing is stored under"
        );
    }

    #[test]
    fn locking_forgets_the_key() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = VaultStore::new(Some(dir.path().to_path_buf()));
        store.unlocked(key(7)).unwrap();
        assert!(store.is_obfuscated());
        store.locked();
        assert!(!store.is_obfuscated());
    }

    #[test]
    fn an_edited_record_is_reported_by_the_audit() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = VaultStore::new(Some(dir.path().to_path_buf()));
        store.write(records::MEASURED, b"theme=dark").unwrap();
        store.unlocked(key(7)).unwrap();

        let hoard = Hoard::open(dir.path(), key(7));
        let path = hoard.path_for(records::MEASURED).unwrap();
        let mut bytes = std::fs::read(&path).unwrap();
        let last = bytes.len() - 1;
        bytes[last] ^= 0xff;
        std::fs::write(&path, &bytes).unwrap();

        store.locked();
        let audit = store.unlocked(key(7)).unwrap();
        assert!(!audit.is_clean());
        assert!(audit.tampered.contains(&records::MEASURED.to_string()));
    }

    /// **F-142.** A record that is migrated but never read is a file destroyed.
    ///
    /// The first version of this module listed `integrity.manifest` and
    /// `last-crash.txt` beside `measured.dat`. Migration reads each plain
    /// file, stores it, and shreds the original -- so on the first unlock
    /// after setting an app lock, the integrity baseline `veilvoice guard`
    /// compares against and the crash log the next launch offers to report
    /// would both have been erased, with nothing reading them back. Neither is
    /// read through this store; both are still read by their plain paths, one
    /// of them from a command line that has no key at all.
    ///
    /// The same shape as F-141 -- written through one path, read through
    /// another -- found by auditing for that shape rather than by somebody
    /// losing a file. So it is pinned: a name in `ALL` has to be read through
    /// the store somewhere, or this fails.
    #[test]
    fn every_record_is_actually_read_through_the_store() {
        let sources = [
            include_str!("vault_store.rs"),
            include_str!("app.rs"),
            include_str!("settings.rs"),
            include_str!("security.rs"),
            include_str!("integrity.rs"),
            include_str!("crashlog.rs"),
        ];
        for (logical, plain) in records::ALL {
            let constant = logical.to_uppercase();
            let read = sources.iter().any(|src| {
                src.contains(&format!("records::{constant}"))
                    && (src.contains("store.read") || src.contains("Measured::load"))
            });
            assert!(
                read,
                "{plain:?} is migrated into the store and shredded, and nothing \
                 reads it back through the store. That destroys it. Either read \
                 it through `VaultStore`, or take it out of `records::ALL`."
            );
        }
    }

    #[test]
    fn every_record_has_a_plain_name_to_migrate_from() {
        // A record added to `ALL` without one would silently never migrate.
        for (logical, plain) in records::ALL {
            assert!(!logical.is_empty());
            assert!(!plain.is_empty());
            assert!(plain.contains('.'), "{plain} does not look like a filename");
        }
    }
}


/// What the application measured about its own running, kept between sessions.
///
/// Small on purpose. These are the numbers the About panel shows and then
/// forgets: how long a frame took, which renderer drew it, how much faster
/// than real time the engine ran. Keeping them makes the panel able to say
/// "and it was like this last time too", which is the difference between a
/// number and a measurement.
///
/// It is written through [`VaultStore`], so with an app lock set it is
/// encrypted under a derived name like everything else there, and with no lock
/// it is a plain file called `measured.dat`. Nothing about it is sent anywhere:
/// VeilVoice has no networking crate in its dependency graph and this is not
/// the exception.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Measured {
    /// The best frame time seen, in milliseconds.
    pub frame_ms: f32,
    /// How many times faster than real time the engine last ran.
    pub speed: f32,
    /// How many sessions have been recorded.
    pub sessions: u32,
}


impl Measured {

    /// Read it back, or the defaults if nothing has been recorded.
    ///
    /// A record that does not parse reads as absent rather than as an error.
    /// These are numbers for a panel; a corrupt one is worth losing silently,
    /// and it is emphatically not worth blocking a launch over. A record that
    /// fails to *authenticate* is a different matter and is reported by the
    /// audit, which runs at unlock.
    pub fn load(store: &VaultStore) -> Self {
        let Ok(Some(bytes)) = store.read(records::MEASURED) else {
            return Self::default();
        };
        let Ok(text) = String::from_utf8(bytes) else {
            return Self::default();
        };
        let mut out = Self::default();
        for line in text.lines() {
            let Some((key, value)) = line.split_once('=') else {
                continue;
            };
            match key.trim() {
                "frame_ms" => out.frame_ms = value.trim().parse().unwrap_or(0.0),
                "speed" => out.speed = value.trim().parse().unwrap_or(0.0),
                "sessions" => out.sessions = value.trim().parse().unwrap_or(0),
                _ => {}
            }
        }
        out
    }



    /// Write it, obfuscated when there is a key and plain when there is not.
    pub fn save(&self, store: &VaultStore) -> Result<(), String> {
        let text = format!(
            "frame_ms = {:.3}\nspeed = {:.2}\nsessions = {}\n",
            self.frame_ms, self.speed, self.sessions
        );
        store.write(records::MEASURED, text.as_bytes())
    }



    /// Fold this session's numbers in.
    ///
    /// The frame time keeps the *best* seen rather than the last: a frame that
    /// took 300 ms because the machine was swapping says nothing about what
    /// VeilVoice costs, and the number people want from this panel is what it
    /// can do rather than what happened once.
    pub fn record(&mut self, frame_ms: f32, speed: f32) {
        if frame_ms > 0.0 && (self.frame_ms <= 0.0 || frame_ms < self.frame_ms) {
            self.frame_ms = frame_ms;
        }
        if speed > 0.0 {
            self.speed = speed;
        }
    }

}


#[cfg(test)]
mod measured_tests {
    use super::*;
    use veilvoice_crypto::amnesia::Secret;

    fn key(byte: u8) -> StoreKey {
        let mut raw = [byte; 32];
        StoreKey::from_secret(Secret::new(&mut raw))
    }

    #[test]
    fn it_round_trips_through_a_plain_folder() {
        let dir = tempfile::tempdir().unwrap();
        let store = VaultStore::new(Some(dir.path().to_path_buf()));
        let m = Measured {
            frame_ms: 4.25,
            speed: 98.5,
            sessions: 3,
        };
        m.save(&store).unwrap();
        assert_eq!(Measured::load(&store), m);
    }

    #[test]
    fn it_round_trips_through_an_obfuscated_one() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = VaultStore::new(Some(dir.path().to_path_buf()));
        store.unlocked(key(7)).unwrap();
        let m = Measured {
            frame_ms: 4.25,
            speed: 98.5,
            sessions: 3,
        };
        m.save(&store).unwrap();
        assert_eq!(Measured::load(&store), m);
    }

    #[test]
    fn the_numbers_do_not_appear_in_the_file_once_locked() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = VaultStore::new(Some(dir.path().to_path_buf()));
        store.unlocked(key(7)).unwrap();
        Measured {
            frame_ms: 4.25,
            speed: 98.5,
            sessions: 3,
        }
        .save(&store)
        .unwrap();

        for entry in std::fs::read_dir(dir.path()).unwrap() {
            let bytes = std::fs::read(entry.unwrap().path()).unwrap();
            assert!(
                !bytes.windows(4).any(|w| w == b"4.25"),
                "a measurement is readable on disk"
            );
        }
    }

    #[test]
    fn nothing_recorded_reads_as_zeroes_rather_than_failing() {
        let dir = tempfile::tempdir().unwrap();
        let store = VaultStore::new(Some(dir.path().to_path_buf()));
        assert_eq!(Measured::load(&store), Measured::default());
    }

    #[test]
    fn a_corrupt_record_reads_as_absent() {
        let dir = tempfile::tempdir().unwrap();
        let store = VaultStore::new(Some(dir.path().to_path_buf()));
        store
            .write(records::MEASURED, b"\xff\xfe not text")
            .unwrap();
        assert_eq!(Measured::load(&store), Measured::default());
    }

    #[test]
    fn the_best_frame_time_is_kept_not_the_last() {
        let mut m = Measured::default();
        m.record(8.0, 90.0);
        m.record(4.0, 91.0);
        m.record(300.0, 92.0);
        assert_eq!(m.frame_ms, 4.0, "a swap storm is not a measurement");
        assert_eq!(m.speed, 92.0, "the speed is the latest run, and says so");
    }
}