crates/veilvoice-gui/src/integrity.rs

what this file is for · veilvoice-gui · 386 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
//! The integrity record, taken and checked by the window rather than by hand.
//!
//! # What this adds to `veilvoice-guard`
//!
//! Nothing, cryptographically. Every hash, every comparison and every honest
//! limit is [`veilvoice_guard`]'s, and [`veilvoice_guard::SCOPE`] is what the
//! interface prints. What this module adds is that it happens at all: the
//! command-line `veilvoice guard init` has always been there and has always
//! been a thing somebody had to know to run.
//!
//! # When it runs
//!
//! At the first launch that finds no record, one is taken. At every launch
//! after that, the record is checked. Both happen on a worker thread, because
//! reading and hashing the installed files is disk work and the drawing thread
//! does none.
//!
//! # Sealing, and the passphrase problem underneath it
//!
//! A record written in the clear beside the files it describes is rewritten by
//! anybody who can change those files. Sealing it under a passphrase raises
//! that to needing the passphrase as well, which is a real improvement and is
//! what [`veilvoice_guard::Manifest::seal`] is for.
//!
//! The awkward part is which passphrase, and when. A record cannot be sealed
//! by a program that has no secret, and at the moment a window opens it has
//! none. So:
//!
//! * With an app lock set, the record is sealed under the **app-lock
//!   passphrase**, and is taken and checked at the moment of unlocking, which
//!   is the one moment that passphrase exists. That is the arrangement worth
//!   having.
//! * With no app lock, the record is written **in the clear** and the interface
//!   says so, in those words. It still catches accidental corruption, a failed
//!   update and a careless overwrite. It does not catch somebody who thought to
//!   rewrite it, and pretending otherwise by sealing it under a key stored
//!   beside it would be a decoration, not a protection.
//!
//! # In plain words
//!
//! VeilVoice writes down what its own files look like the first time it runs,
//! and checks them every time after that.
//!
//! If you have set an app lock, that record is locked with the same passphrase,
//! so changing the files *and* the record needs your passphrase too. If you have
//! not, the record is readable, and it will still spot a file that changed by
//! accident but not one changed by somebody covering their tracks.

use std::path::PathBuf;
use std::sync::mpsc;
use veilvoice_guard::Manifest;
use zeroize::Zeroize;


/// What the record has to say, as far as this window knows.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum State {
    /// Nothing has been asked yet.
    Idle,
    /// A worker thread is reading and hashing.
    Working,
    /// A record was taken for the first time.
    Recorded {
        /// Whether it was sealed under the app-lock passphrase.
        sealed: bool,
    },
    /// Everything on disk matches the record.
    Clean {
        /// How many files matched.
        files: usize,
        /// Whether the record that was consulted was a sealed one.
        sealed: bool,
    },
    /// Something differs. The strings are [`veilvoice_guard::Change::describe`].
    Changed(Vec<String>),
    /// The check could not be run, and why.
    Failed(String),
}



/// The integrity record as the window drives it.
pub struct Integrity {
    state: State,
    pending: Option<mpsc::Receiver<State>>,
}


impl Default for Integrity {

    fn default() -> Self {
        Self {
            state: State::Idle,
            pending: None,
        }
    }

}

impl Integrity {

    /// What the last completed check found.
    pub fn state(&self) -> &State {
        &self.state
    }



    /// Whether a check is running, so the window keeps repainting while it is.
    pub fn is_busy(&self) -> bool {
        self.pending.is_some()
    }



    /// Whether the record found a difference worth showing the user.
    pub fn changed(&self) -> bool {
        matches!(self.state, State::Changed(_))
    }



    /// Take or check the record, off the drawing thread.
    ///
    /// `password` is the app-lock passphrase when there is one. It is consumed
    /// by the worker and dropped there rather than being held by this struct,
    /// so a passphrase does not sit in the window's state for the life of the
    /// session.
    ///
    /// Calling this while a check is already running does nothing. A second
    /// walk of the same files would only race the first to the same answer.
    pub fn start(&mut self, password: Option<String>) {
        if self.pending.is_some() {
            return;
        }
        let (tx, rx) = mpsc::channel();
        self.pending = Some(rx);
        self.state = State::Working;
        // Detached on purpose. The window must never join a thread that is
        // reading the disk, and there is nothing to clean up if it outlives a
        // close: it holds no handle the process needs back.
        std::thread::spawn(move || {
            let mut password = password;
            let state = run(password.as_deref());
            if let Some(pw) = &mut password {
                pw.zeroize();
            }
            let _ = tx.send(state);
        });
    }



    /// Collect a finished check. Returns true when the state changed, which is
    /// the window's cue to repaint.
    pub fn poll(&mut self) -> bool {
        let Some(rx) = &self.pending else {
            return false;
        };
        match rx.try_recv() {
            Ok(state) => {
                self.state = state;
                self.pending = None;
                true
            }
            Err(mpsc::TryRecvError::Empty) => false,
            Err(mpsc::TryRecvError::Disconnected) => {
                self.state = State::Failed("the integrity check stopped unexpectedly".into());
                self.pending = None;
                true
            }
        }
    }

}


/// Where the record is kept, beside the app lock and under the same rules.
///
/// The same path `veilvoice guard` uses, so the window and the command line
/// read one record rather than two.
pub fn record_path() -> Option<PathBuf> {
    veilvoice_crypto::lock::default_path().map(|p| p.with_file_name("integrity.manifest"))
}



/// The sealed record sits beside the plain one under the container suffix.
fn sealed_path(base: &std::path::Path) -> PathBuf {
    veilvoice_crypto::container::veil_path(base)
}



/// The files worth watching: the running program, and nothing assumed.
///
/// Deliberately short. A manifest over a directory somebody else installs into
/// reports every legitimate update as a change, and a report that cries wolf on
/// every update is one nobody reads. The binary is the file that matters and it
/// is the file this can name without guessing.
fn targets() -> Vec<PathBuf> {
    let mut out = Vec::new();
    if let Ok(exe) = std::env::current_exe() {
        out.push(exe);
    }
    out
}



/// The whole of the work, on the worker thread.
fn run(password: Option<&str>) -> State {
    let Some(plain) = record_path() else {
        return State::Failed("no configuration directory on this platform".into());
    };
    let sealed = sealed_path(&plain);
    let files = targets();
    if files.is_empty() {
        return State::Failed("cannot find this program's own file".into());
    }

    // A sealed record is preferred over a plain one wherever both exist. The
    // other order would let anybody who can write the directory downgrade the
    // check by dropping a plain record beside the sealed one.
    let existing = match (password, sealed.exists()) {
        (Some(pw), true) => match std::fs::read(&sealed)
            .ok()
            .and_then(|bytes| Manifest::open_sealed(pw.as_bytes(), &bytes).ok())
        {
            Some(m) => Some((m, true)),
            // A sealed record that will not open is not an absent record.
            // Treating it as absent would overwrite the evidence with a fresh
            // record of whatever is on disk now, which is exactly what somebody
            // who had changed those files would want to happen.
            //
            // The passphrase cannot be the reason: this only runs after an
            // unlock that proved it. So the honest report is that the record
            // will not open, and that a record that will not open is one of the
            // things this is here to notice.
            None => {
                return State::Failed(
                    "the sealed record of VeilVoice's own files will not open. It has been \
                     changed or damaged since it was written."
                        .into(),
                )
            }
        },
        _ => Manifest::load(&plain).ok().map(|m| (m, false)),
    };

    match existing {
        Some((manifest, was_sealed)) => {
            let report = manifest.check::<PathBuf>(&[]);
            if !report.is_clean() {
                return State::Changed(report.changes.iter().map(|c| c.describe()).collect());
            }
            // A plain record found while a passphrase is in hand is upgraded.
            // Somebody who sets an app lock after their first run would
            // otherwise keep the readable record for ever, having done exactly
            // what would earn them the sealed one.
            //
            // Only after the check has come back clean. Sealing a record that
            // no longer matches the files would seal somebody else's version of
            // them and call it authoritative.
            if let (Some(pw), false) = (password, was_sealed) {
                if let Ok(bytes) = manifest.seal(pw.as_bytes()) {
                    if write_private(&sealed, &bytes).is_ok() {
                        let _ = std::fs::remove_file(&plain);
                        return State::Clean {
                            files: report.unchanged,
                            sealed: true,
                        };
                    }
                }
            }
            State::Clean {
                files: report.unchanged,
                sealed: was_sealed,
            }
        }
        None => match Manifest::of(&files) {
            Err(e) => State::Failed(e.to_string()),
            Ok(manifest) => match password {
                Some(pw) => match manifest.seal(pw.as_bytes()) {
                    Err(e) => State::Failed(e.to_string()),
                    Ok(bytes) => match write_private(&sealed, &bytes) {
                        Err(e) => State::Failed(e),
                        Ok(()) => {
                            // A plain record left beside a sealed one is a
                            // downgrade waiting to be used.
                            let _ = std::fs::remove_file(&plain);
                            State::Recorded { sealed: true }
                        }
                    },
                },
                None => match manifest.save(&plain) {
                    Err(e) => State::Failed(e.to_string()),
                    Ok(()) => State::Recorded { sealed: false },
                },
            },
        },
    }
}



fn write_private(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            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())
}


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

    #[test]
    fn a_fresh_record_is_idle_and_not_busy() {
        let guard = Integrity::default();
        assert_eq!(guard.state(), &State::Idle);
        assert!(!guard.is_busy());
        assert!(!guard.changed());
    }

    #[test]
    fn polling_with_nothing_running_reports_no_change() {
        let mut guard = Integrity::default();
        assert!(!guard.poll());
    }

    #[test]
    fn a_disconnected_worker_is_reported_rather_than_waited_for() {
        let mut guard = Integrity::default();
        let (tx, rx) = mpsc::channel();
        guard.pending = Some(rx);
        drop(tx);
        assert!(guard.poll());
        assert!(matches!(guard.state(), State::Failed(_)));
        assert!(!guard.is_busy());
    }

    /// The window must never wait on the disk. This is the same guard the
    /// drawing thread carries, applied to the one module that reads files.
    #[test]
    fn nothing_here_blocks_the_drawing_thread() {
        let source = include_str!("integrity.rs").replace("\r\n", "\n");
        // Everything above the test module. The tests themselves name the very
        // calls they forbid, and the first version of this counted its own
        // assertion as the violation.
        let shipped = source.split("#[cfg(test)]").next().unwrap_or("");
        let body: String = shipped
            .lines()
            .filter(|line| !line.trim_start().starts_with("//"))
            .collect::<Vec<_>>()
            .join("\n");
        let waits = body.matches("recv()").count() - body.matches("try_recv()").count();
        assert_eq!(waits, 0, "a blocking receive reached the window's own code");
        assert!(
            !body.contains(".join()"),
            "the window must not join the worker thread"
        );
    }

    /// A sealed record must win over a plain one wherever both are present, or
    /// dropping a plain file beside the sealed one downgrades the check.
    #[test]
    fn the_sealed_record_is_preferred_over_a_plain_one() {
        let source = include_str!("integrity.rs").replace("\r\n", "\n");
        let start = source.find("fn run(password").expect("run has to exist");
        let body = &source[start..];
        let sealed_at = body.find("Manifest::open_sealed").expect("sealed read");
        let plain_at = body.find("Manifest::load").expect("plain read");
        assert!(
            sealed_at < plain_at,
            "the plain record is consulted before the sealed one"
        );
    }

    #[test]
    fn the_record_sits_beside_the_app_lock() {
        let (Some(record), Some(lock)) = (record_path(), veilvoice_crypto::lock::default_path())
        else {
            return;
        };
        assert_eq!(record.parent(), lock.parent());
        assert!(record.ends_with("integrity.manifest"));
    }

    #[test]
    fn the_sealed_name_is_not_the_plain_one() {
        let plain = PathBuf::from("/somewhere/integrity.manifest");
        assert_ne!(sealed_path(&plain), plain);
    }

    /// The watched set has to name a real file rather than a guess about where
    /// somebody installed this.
    #[test]
    fn the_watched_files_are_ones_that_actually_exist() {
        for path in targets() {
            assert!(
                path.exists(),
                "{} was recorded and is not there",
                path.display()
            );
        }
    }
}