crates/veilvoice-cli/src/lock.rs

what this file is for · veilvoice-cli · 347 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 lock` manages the application lock from the command line.
//!
//! The lock guards the desktop app: with one set, VeilVoice asks for a password
//! before it will show anything or start a live scramble. Managing it from here
//! exists because a headless machine still has a config directory, and because
//! anything the GUI can do to a file on disk should be inspectable without the
//! GUI.
//!
//! Every path through this module prints [`veilvoice_crypto::lock::SCOPE`], for
//! one reason: a lock the user believes is stronger than it is has made them
//! *less* safe, not more.
//!
//! # In plain words
//!
//! Sets, changes and clears the passphrase that opens the desktop application,
//! from a terminal.
//!
//! It is the same lock the window uses and the same file, so the two cannot get
//! out of step. What it is worth is printed with it: it stops somebody who picks
//! up your unlocked computer, and it does not stop somebody holding your disk.

use crate::atrest::{prompt_secret, read_new_password};
use crate::theme::{colour, field, heading, ok, paint, warn};
use clap::Subcommand;
use std::path::PathBuf;
use veilvoice_crypto::{kdf, lock, LockStore};


#[derive(Subcommand)]
pub enum Action {
    /// Report whether a lock is set, and where it lives.
    Status,
    /// Set a lock. Refuses if one is already configured.
    Set,
    /// Change the password on an existing lock.
    Change,
    /// Remove the lock, after proving the current password.
    Remove,
}



/// Where the lock is kept for this invocation.
///
/// Without `--path` the lock lives in the vault: two copies under names derived
/// from a per-installation value, one of them administrator-owned where the
/// platform allows it. With `--path` it is one plain file at the path given,
/// which is what a script or a test wants and what this command has always
/// done. The two are kept apart rather than blended, because a command that
/// silently wrote somewhere other than the path it was handed would be worse
/// than either.
enum Site {
    Default,
    Explicit(PathBuf),
}


impl Site {

    /// Where the lock is kept: the path given on the command line, or the
    /// platform default.
    fn resolve(explicit: Option<PathBuf>) -> Result<Self, String> {
        match explicit {
            Some(p) => Ok(Self::Explicit(p)),
            None => {
                lock::default_dir().ok_or_else(|| {
                    "cannot work out where this platform keeps configuration \
                     (no APPDATA, XDG_CONFIG_HOME or HOME), so pass --path"
                        .to_string()
                })?;
                Ok(Self::Default)
            }
        }
    }



    /// What to print as the location. The vault's own file names are derived
    /// and would mean nothing to a reader, so the directory is what is shown.
    fn describe(&self) -> String {
        match self {
            Self::Explicit(p) => p.display().to_string(),
            Self::Default => match lock::default_dir() {
                Some(d) => format!("{} (two copies, derived names)", d.display()),
                None => "unknown".to_string(),
            },
        }
    }



    /// Open the lock, and say whether a missing copy had to be rebuilt.
    fn open(&self) -> Result<(Option<LockStore>, bool), String> {
        match self {
            Self::Explicit(p) => LockStore::open(p)
                .map(|s| (s, false))
                .map_err(|e| e.to_string()),
            Self::Default => lock::open_default().map_err(|e| e.to_string()),
        }
    }



    /// Make a lock here for the first time, deriving the verifier from
    /// `password`.
    fn create(&self, password: &[u8]) -> Result<(), String> {
        let params = kdf::KdfParams::default();
        match self {
            Self::Explicit(p) => LockStore::create(p, password, params).map(|_| ()),
            Self::Default => lock::create_default(password, params).map(|_| ()),
        }
        .map_err(|e| e.to_string())
    }

}


/// Print the honest scope note, wrapped for a terminal.
fn print_scope() {
    println!("{}", paint(colour::MUTED, "  What this is worth:"));
    for line in wrap(lock::SCOPE, 66) {
        println!("{}", paint(colour::MUTED, &format!("    {line}")));
    }
}



/// Greedy word wrap. The scope note is single-sourced from the crypto crate, so
/// it arrives as one long string and has to be broken here rather than there.
pub fn wrap(text: &str, width: usize) -> Vec<String> {
    let mut lines = Vec::new();
    let mut current = String::new();
    for word in text.split_whitespace() {
        if !current.is_empty() && current.len() + 1 + word.len() > width {
            lines.push(std::mem::take(&mut current));
        }
        if !current.is_empty() {
            current.push(' ');
        }
        current.push_str(word);
    }
    if !current.is_empty() {
        lines.push(current);
    }
    lines
}



/// Dispatch `veilvoice lock` to the subcommand that was asked for.
pub fn run(action: Action, path: Option<PathBuf>) -> Result<(), String> {
    let site = Site::resolve(path)?;
    println!("{}", heading("App lock"));
    println!("{}", field("File", &site.describe()));

    match action {
        Action::Status => status(&site),
        Action::Set => set(&site),
        Action::Change => change(&site),
        Action::Remove => remove(&site),
    }
}



/// `veilvoice lock status`: whether a lock is set, and where it lives.
///
/// Says nothing about the passphrase, not even how long it is. What a reader
/// learns here is what somebody holding the machine already knows.
fn status(site: &Site) -> Result<(), String> {
    let (store, restored) = site.open()?;
    match store {
        None => {
            println!("{}", field("State", "not set"));
            println!();
            println!(
                "{}",
                paint(colour::MUTED, "  Set one with: veilvoice lock set")
            );
        }
        Some(store) => {
            println!("{}", field("State", "set"));
            println!(
                "{}",
                field("Failed attempts", &store.failures().to_string())
            );
            match store.cooldown() {
                Some(wait) => println!(
                    "{}",
                    warn(&format!(
                        "rate limited, {} s before the next attempt",
                        wait.as_secs()
                    ))
                ),
                None => println!("{}", field("Rate limit", "not currently in force")),
            }
            if !store.every_copy_current() {
                println!();
                println!(
                    "{}",
                    warn(
                        "the administrator-owned copy of this lock still holds the \
                         previous password; run once as an administrator to finish"
                    )
                );
            }
            if store.tampered() || restored {
                println!();
                println!(
                    "{}",
                    warn("this lock reports interference; open the app to read it")
                );
            }
        }
    }
    println!();
    print_scope();
    Ok(())
}



/// `veilvoice lock set`: make a lock, asking for the passphrase twice.
///
/// Refuses if one is already set rather than replacing it. Overwriting a lock
/// is `change`, which asks for the old passphrase first, and the difference
/// matters: a `set` that silently replaced would lock somebody out of their
/// own vault.
fn set(site: &Site) -> Result<(), String> {
    if site.open()?.0.is_some() {
        return Err("a lock is already set here, so use `veilvoice lock change`".into());
    }
    println!();
    print_scope();
    println!();
    println!(
        "{}",
        paint(
            colour::MUTED,
            "  This password unlocks the app. Do NOT reuse the passphrase you"
        )
    );
    println!(
        "{}",
        paint(
            colour::MUTED,
            "  use for encrypted recordings. They are deliberately separate,"
        )
    );
    println!(
        "{}",
        paint(
            colour::MUTED,
            "  so that unlocking the app does not unseal the recordings."
        )
    );

    let password = read_new_password()?;
    println!(
        "{}",
        paint(
            colour::MUTED,
            "  Deriving verifier (Argon2id, deliberately slow)..."
        )
    );
    site.create(password.expose())?;
    println!("{}", ok("app lock set"));
    Ok(())
}



/// `veilvoice lock change`: replace the passphrase, after proving the old one.
fn change(site: &Site) -> Result<(), String> {
    let mut store = open_or_explain(site)?;
    let current = prompt_secret("Current password: ")?;
    println!("{}", paint(colour::MUTED, "  Now the new one."));
    let new = read_new_password()?;
    store
        .change_password(current.expose(), new.expose())
        .map_err(|e| e.to_string())?;
    println!("{}", ok("app lock password changed"));
    Ok(())
}



/// `veilvoice lock remove`: take the lock off, after proving the passphrase.
fn remove(site: &Site) -> Result<(), String> {
    let store = open_or_explain(site)?;
    let current = prompt_secret("Current password: ")?;
    store.remove(current.expose()).map_err(|e| e.to_string())?;
    println!(
        "{}",
        ok("app lock removed, and VeilVoice will open freely again")
    );
    Ok(())
}



/// The lock store, or a message saying what to do rather than a bare error.
fn open_or_explain(site: &Site) -> Result<LockStore, String> {
    site.open()?
        .0
        .ok_or_else(|| "no lock is set here, so use `veilvoice lock set`".to_string())
}


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

    #[test]
    fn wrapping_keeps_every_word_and_respects_the_width() {
        let lines = wrap(lock::SCOPE, 40);
        assert!(lines.len() > 1);
        assert!(lines.iter().all(|l| l.len() <= 40 || !l.contains(' ')));
        let rejoined = lines.join(" ");
        let original: Vec<&str> = lock::SCOPE.split_whitespace().collect();
        assert_eq!(rejoined.split_whitespace().collect::<Vec<_>>(), original);
    }

    #[test]
    fn wrapping_handles_an_empty_string() {
        assert!(wrap("", 20).is_empty());
    }

    #[test]
    fn an_explicit_path_wins_over_the_platform_default() {
        let chosen = PathBuf::from("somewhere/else.bin");
        let site = Site::resolve(Some(chosen.clone())).unwrap();
        assert!(matches!(&site, Site::Explicit(p) if p == &chosen));
        assert_eq!(site.describe(), chosen.display().to_string());
    }

    /// Without `--path` the command must go to the vault, not to the single
    /// file the vault replaced. Getting this wrong would leave the CLI and the
    /// window looking at two different locks.
    #[test]
    fn no_path_means_the_vault_rather_than_one_named_file() {
        if lock::default_dir().is_none() {
            return;
        }
        let site = Site::resolve(None).unwrap();
        assert!(matches!(site, Site::Default));
        assert!(site.describe().contains("two copies"));
    }

    /// The lock lifecycle through the same store the subcommands drive. The
    /// prompts themselves need a terminal, so this exercises the layer beneath.
    #[test]
    fn a_lock_can_be_set_proven_and_removed() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("applock.bin");
        let weak = kdf::KdfParams::weak_for_tests();

        let site = Site::Explicit(path.clone());
        assert!(LockStore::open(&path).unwrap().is_none());
        LockStore::create(&path, b"app password", weak).unwrap();
        assert!(status(&site).is_ok());

        let mut store = LockStore::open(&path).unwrap().unwrap();
        assert!(store.unlock(b"recording password").is_err());
        store.unlock(b"app password").unwrap();

        LockStore::open(&path)
            .unwrap()
            .unwrap()
            .remove(b"app password")
            .unwrap();
        assert!(!path.exists());
    }
}