crates/veilvoice-cli/src/guard.rs
what this file is for · veilvoice-cli · 359 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
//! `veilvoice guard` -- record what VeilVoice's files should be, and check them.
//!
//! Detection, not prevention. See [`veilvoice_guard::SCOPE`], which every path
//! through this module prints, for the same reason the app lock prints its own:
//! a protection someone over-trusts has made them less safe, not more.
//!
//! # What the three steps actually do
//!
//! * **`init`** walks the files that make up this installation and records a
//! SHA-256 for each. Optionally sealed with a passphrase, so the record
//! itself cannot be quietly rewritten to match tampered files.
//! * **`check`** re-walks and reports what is **modified**, **removed** and
//! **added**. All three matter: an added file in the installation directory
//! is as interesting as a changed one.
//! * **`blame`** tries to say *which process* made a change, and says plainly
//! when it cannot.
//!
//! # Why attribution usually fails, and why that is reported rather than hidden
//!
//! Attribution needs the operating system to have been recording. On Linux that
//! means an `auditd` watch; on Windows a SACL on the path plus the audit policy
//! enabled, and reading it needs elevation. Neither is on by default on a
//! normal machine.
//!
//! So the common answer is "something changed this file and I cannot tell you
//! what", and this module prints exactly that rather than an empty list. An
//! empty list reads as *nothing happened*, which is the opposite of the truth,
//! and is the same mistake as a monitor reporting an empty machine because a
//! registry query silently matched nothing.
//!
//! # The bound, again
//!
//! A manifest running as the user protects nothing from that user, and detects
//! rather than prevents even when it works. Anything that can write these files
//! can write the manifest beside them. That is why the passphrase-sealed record
//! exists, why [`veilvoice_guard::SCOPE`] is printed on every path through this
//! module, and why the word "tamper-proof" appears nowhere in it.
//!
//! # In plain words
//!
//! Writes down what VeilVoice's own files should look like, and checks later that
//! they still do.
//!
//! It notices changes. It does not prevent them, and every path through it says
//! so, because a check somebody believes is a lock is worse than no check.
use crate::theme::{colour, err, field, heading, ok, paint, warn};
use clap::Subcommand;
use std::path::{Path, PathBuf};
use veilvoice_guard::{blame_path, manifest_files_in, Manifest, SCOPE};
#[derive(Subcommand)]
pub enum Action {
/// Record the current state of the watched files.
Init {
/// Files to record. Defaults to the running binary and the app lock.
files: Vec<PathBuf>,
/// Seal the record with a passphrase, so it cannot be rewritten to
/// match a tampered file without knowing it.
#[arg(long)]
sealed: bool,
},
/// Compare the watched files against the record.
Check {
/// Also report files that have appeared in this directory.
#[arg(long, value_name = "DIR")]
watch_dir: Option<PathBuf>,
},
/// Show where the record is kept, and what it is worth.
Status,
}
/// Where the manifest lives, beside the app lock.
fn manifest_path(explicit: Option<PathBuf>) -> Result<PathBuf, String> {
if let Some(path) = explicit {
return Ok(path);
}
let lock = veilvoice_crypto::lock::default_path().ok_or_else(|| {
"cannot work out where this platform keeps configuration \
(no APPDATA, XDG_CONFIG_HOME or HOME) - pass --path"
.to_string()
})?;
Ok(lock.with_file_name("integrity.manifest"))
}
/// A sealed manifest sits beside the plain one, with a different suffix.
fn sealed_path(base: &Path) -> PathBuf {
veilvoice_crypto::container::veil_path(base)
}
/// Say what the integrity record detects and what it cannot, before it is
/// used.
fn print_scope() {
println!("{}", paint(colour::MUTED, " What this is worth:"));
for line in crate::lock::wrap(SCOPE, 66) {
println!("{}", paint(colour::MUTED, &format!(" {line}")));
}
}
/// The files worth watching when the user names none: the running binary, and
/// the app lock beside it.
fn default_targets() -> Vec<PathBuf> {
let mut targets = Vec::new();
if let Ok(exe) = std::env::current_exe() {
targets.push(exe);
}
if let Some(lock) = veilvoice_crypto::lock::default_path() {
if lock.exists() {
targets.push(lock);
}
}
targets
}
/// Dispatch `veilvoice guard` to the subcommand that was asked for.
pub fn run(action: Action, path: Option<PathBuf>) -> Result<(), String> {
let store = manifest_path(path)?;
println!("{}", heading("Integrity"));
println!("{}", field("Record", &store.display().to_string()));
match action {
Action::Init { files, sealed } => init(&store, files, sealed),
Action::Check { watch_dir } => check(&store, watch_dir),
Action::Status => status(&store),
}
}
/// `veilvoice guard init`: record what these files are now.
///
/// Everything afterwards compares against this moment, so a record taken on a
/// machine that was already interfered with records the interference as
/// normal. That is printed rather than left to be discovered.
fn init(store: &Path, files: Vec<PathBuf>, sealed: bool) -> Result<(), String> {
let targets = if files.is_empty() {
default_targets()
} else {
files
};
if targets.is_empty() {
return Err("nothing to record - name some files".into());
}
let manifest = Manifest::of(&targets).map_err(|e| e.to_string())?;
if manifest.is_empty() {
return Err("none of those files could be read".into());
}
for recorded in manifest.paths() {
println!(
"{}",
paint(colour::MUTED, &format!(" recorded {recorded}"))
);
}
if sealed {
println!(
"{}",
paint(
colour::MUTED,
" Choose a passphrase for the record. Keep it somewhere other than"
)
);
println!(
"{}",
paint(
colour::MUTED,
" beside the record, or sealing it proves nothing."
)
);
let password = crate::atrest::read_new_password()?;
let bytes = manifest
.seal(password.expose())
.map_err(|e| e.to_string())?;
let out = sealed_path(store);
std::fs::write(&out, bytes).map_err(|e| format!("{}: {e}", out.display()))?;
println!(
"{}",
ok(&format!("sealed record written to {}", out.display()))
);
} else {
manifest.save(store).map_err(|e| e.to_string())?;
println!("{}", ok(&format!("record written to {}", store.display())));
println!(
"{}",
warn("this record is unsealed - anything that can rewrite your files can rewrite it")
);
}
println!();
print_scope();
Ok(())
}
/// Load whichever form of the record exists, asking for a passphrase only if
/// the sealed one is the one that is there.
fn load(store: &Path) -> Result<Manifest, String> {
let sealed = sealed_path(store);
if sealed.exists() {
let bytes = std::fs::read(&sealed).map_err(|e| format!("{}: {e}", sealed.display()))?;
let password = crate::atrest::prompt_secret("Record passphrase: ")?;
return Manifest::open_sealed(password.expose(), &bytes).map_err(|e| e.to_string());
}
if store.exists() {
return Manifest::load(store).map_err(|e| e.to_string());
}
Err("no record here yet - run `veilvoice guard init` first".into())
}
/// `veilvoice guard check`: compare the files against the record and report.
///
/// Where the platform allows it, a changed file comes with a best-effort guess
/// at what changed it, and the guess is labelled as one.
fn check(store: &Path, watch_dir: Option<PathBuf>) -> Result<(), String> {
let manifest = load(store)?;
let sealed = sealed_path(store);
let extra = match &watch_dir {
// The record lives in the directory it watches, so it would otherwise
// report itself as a new file every time -- noise that trains the user
// to ignore the output, which is the one thing this must not do.
Some(dir) => manifest_files_in(dir)
.map_err(|e| e.to_string())?
.into_iter()
.filter(|found| found != store && found != &sealed)
.collect(),
None => Vec::new(),
};
let report = manifest.check(&extra);
println!("{}", field("Files recorded", &manifest.len().to_string()));
println!("{}", field("Unchanged", &report.unchanged.to_string()));
println!();
if report.is_clean() {
println!("{}", ok("nothing has changed"));
println!();
print_scope();
return Ok(());
}
println!("{}", err("SOMETHING HAS CHANGED"));
// Attribution is usually unavailable. Say so per change, but explain the
// remedy once at the end -- repeating a paragraph after every line buries
// the changes themselves, which are the thing being reported.
let mut remedy_once = None;
for change in &report.changes {
println!(
"{}",
paint(colour::RED, &format!(" {}", change.describe()))
);
let who = blame_path(Path::new(change.path()));
println!(
"{}",
paint(colour::MUTED, &format!(" by: {}", who.describe()))
);
if let veilvoice_guard::Blame::Unknown { remedy, .. } = &who {
// `remedy` is a `&'static str`, so copy it out rather than
// borrowing `who`, which is dropped at the end of this iteration.
remedy_once = Some(*remedy);
}
}
if let Some(remedy) = remedy_once {
println!();
println!(
"{}",
paint(
colour::MUTED,
" To get a name for the program responsible:"
)
);
for line in crate::lock::wrap(remedy, 66) {
println!("{}", paint(colour::MUTED, &format!(" {line}")));
}
}
println!();
print_scope();
// A changed file is the answer the user asked for, not a failure of the
// command, but the exit code has to let a script notice.
Err("integrity check failed - see the changes above".into())
}
/// `veilvoice guard status`: what the record covers, and when it was taken.
fn status(store: &Path) -> Result<(), String> {
let sealed = sealed_path(store);
let state = if sealed.exists() {
"sealed record present"
} else if store.exists() {
"unsealed record present"
} else {
"no record yet"
};
println!("{}", field("State", state));
if sealed.exists() {
println!("{}", field("Sealed record", &sealed.display().to_string()));
}
if !sealed.exists() && store.exists() {
println!(
"{}",
warn("unsealed - anything that can rewrite your files can rewrite this too")
);
println!(
"{}",
paint(
colour::MUTED,
" Re-run with: veilvoice guard init --sealed"
)
);
}
println!();
print_scope();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sealed_record_sits_beside_the_plain_one() {
let base = PathBuf::from("/tmp/integrity.manifest");
assert_eq!(
sealed_path(&base),
PathBuf::from("/tmp/integrity.manifest.veil")
);
}
#[test]
fn an_explicit_path_wins_over_the_platform_default() {
let chosen = PathBuf::from("somewhere/else.manifest");
assert_eq!(manifest_path(Some(chosen.clone())).unwrap(), chosen);
}
/// The end-to-end flow, through the same manifest the subcommands drive.
/// The passphrase prompts need a terminal, so the sealed path is covered by
/// the crate's own tests instead.
#[test]
fn a_record_notices_a_changed_file() {
let dir = tempfile::tempdir().unwrap();
let watched = dir.path().join("veilvoice.exe");
std::fs::write(&watched, b"the real binary").unwrap();
let store = dir.path().join("integrity.manifest");
Manifest::of(&[&watched]).unwrap().save(&store).unwrap();
assert!(load(&store).unwrap().check::<&Path>(&[]).is_clean());
std::fs::write(&watched, b"not the real binary").unwrap();
let report = load(&store).unwrap().check::<&Path>(&[]);
assert!(!report.is_clean());
assert!(report.changes[0].describe().starts_with("modified:"));
}
#[test]
fn checking_without_a_record_explains_rather_than_panicking() {
let dir = tempfile::tempdir().unwrap();
let err = load(&dir.path().join("nothing.manifest")).unwrap_err();
assert!(err.contains("guard init"), "{err}");
}
}