crates/veilvoice-cli/src/atrest.rs
what this file is for · veilvoice-cli · 455 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
//! Encryption at rest for the recordings VeilVoice writes, and the passphrase
//! prompts that feed it.
//!
//! # Why this is the default
//!
//! De-identification and confidentiality are different problems, and VeilVoice
//! only solves the first: the words survive on purpose, so a veiled recording
//! sitting on disk is still a recording of everything that was said. Writing it
//! in the clear by default would quietly leave the second problem unsolved for
//! everyone who did not think to ask.
//!
//! So the result is sealed into a [`container`], with Argon2id or the X25519
//! plus ML-KEM-768 hybrid, unless the user asks for plaintext, and asking for
//! plaintext prints [`PLAINTEXT_WARNING`] and, on a terminal, waits for an
//! answer.
//!
//! # Never through a plaintext file
//!
//! The WAV is encoded in memory and sealed there. It is never written to disk
//! and then encrypted, because a plaintext file that is created and deleted is
//! precisely what [`veilvoice_crypto::shred`] explains cannot be reliably taken
//! back on flash storage.
//!
//! # In plain words
//!
//! Asks for a passphrase and encrypts the recording VeilVoice has just written.
//!
//! It is on by default, and the reason is worth stating: the words survive
//! de-identification on purpose, so an unencrypted result is still a recording of
//! everything that was said. Veiling the voice and leaving the file open protects
//! the speaker and not the conversation.
//!
//! Writing one unencrypted is allowed, and asks first.
use crate::theme::{colour, err, paint, warn};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use veilvoice_crypto::{container, hybrid, kdf, Secret};
use zeroize::Zeroize;
/// What the user is told before a recording is written in the clear.
///
/// Kept here as data rather than inline `println!`s so the test suite can
/// assert it still says the uncomfortable part.
pub const PLAINTEXT_WARNING: &[&str] = &[
"The de-identified recording will be written to disk unencrypted.",
"",
"VeilVoice destroys the voiceprint, not the words. Anyone who can read",
"this file can still hear everything that was said: another user, a",
"backup, a sync client, anyone who later gets the disk.",
"",
"Deleting it afterwards is not a fix: on an SSD, SD card or USB stick",
"the original blocks can survive every overwrite. That is why at-rest",
"encryption is the default rather than an option you have to find.",
"",
"The file will be created readable only by your account. That is a file",
"permission and nothing more. It does not survive a copy, a backup, or",
"anyone who has the disk.",
];
/// How a recording is to be sealed.
pub enum Recipient<'a> {
/// Argon2id over a passphrase typed at the prompt.
Password,
/// The X25519 + ML-KEM-768 hybrid, to a recipient's public key file.
PublicKey(&'a Path),
}
/// Seal `plaintext` and write it to `<path>.veil`, returning where it landed.
pub fn seal_to_disk(
path: &Path,
plaintext: &[u8],
recipient: Recipient<'_>,
) -> Result<PathBuf, String> {
let sealed = match recipient {
Recipient::PublicKey(key_path) => {
let encoded = crate::read_named(key_path)?;
let pk = hybrid::PublicKey::from_bytes(&encoded).map_err(|e| e.to_string())?;
container::seal_to_public_key(&pk, plaintext).map_err(|e| e.to_string())?
}
Recipient::Password => {
println!(
"{}",
paint(
colour::MUTED,
" Choose a passphrase for this recording. It is separate from",
)
);
println!(
"{}",
paint(
colour::MUTED,
" the app lock, and there is no way to recover it."
)
);
let password = read_new_password()?;
println!(
"{}",
paint(
colour::MUTED,
" Deriving key (Argon2id, this is meant to be slow)..."
)
);
container::seal_with_password(password.expose(), plaintext, kdf::KdfParams::default())
.map_err(|e| e.to_string())?
}
};
let out = container::veil_path(path);
std::fs::write(&out, &sealed).map_err(|e| format!("{}: {e}", out.display()))?;
Ok(out)
}
/// Print the plaintext warning and, on an interactive terminal, require an
/// explicit answer before continuing.
///
/// Non-interactive callers, meaning scripts, pipelines and CI, still see it on
/// stderr but are not blocked on a prompt nobody is there to answer. They asked
/// for plaintext on the command line, which is as explicit as it gets.
pub fn confirm_plaintext(assume_yes: bool) -> Result<(), String> {
println!();
println!("{}", err("WRITING THIS RECORDING UNENCRYPTED"));
for line in PLAINTEXT_WARNING {
println!("{}", paint(colour::MUTED, &format!(" {line}")));
}
println!();
if assume_yes || !std::io::stdin().is_terminal() {
eprintln!(
"{}",
warn("continuing without at-rest encryption, as asked")
);
return Ok(());
}
print!(" Type UNENCRYPTED to continue: ");
use std::io::Write;
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.map_err(|e| e.to_string())?;
if answer.trim() != "UNENCRYPTED" {
return Err("cancelled, and nothing was written".into());
}
Ok(())
}
/// Move a typed password into page-locked, zeroizing storage, wiping the
/// `String` it arrived in.
///
/// `rpassword` hands back an ordinary `String`, which is an ordinary heap
/// allocation that can be paged out and is not wiped when it is dropped. That
/// is a window this crate cannot remove, because something has to receive the
/// keystrokes. It can be made as short as possible, which is what this
/// does: copy into a [`Secret`], wipe the copy, wipe the original, and hand
/// back the only remaining version.
///
/// No `unsafe`, so the intermediate `Vec` is a real second copy for a moment.
/// It is wiped by `Secret::new` before this returns. Writing through
/// `String::as_bytes_mut` would avoid it and is not worth an `unsafe` block in
/// a crate that has none.
fn into_secret(mut typed: String) -> Secret {
let mut bytes = typed.as_bytes().to_vec();
let secret = Secret::new(&mut bytes);
typed.zeroize();
secret
}
/// What to say when there is no terminal to ask on.
///
/// **F-109.** Every one of these prompts used to surface the operating
/// system's own error, so `veilvoice anonymise recording.wav` run from a
/// script, a cron job, a CI step or anything with its input redirected failed
/// with:
///
/// ```text
/// ✗ No such device or address (os error 6)
/// ```
///
/// That is `ENXIO` from opening the console, and it says nothing: not what was
/// being asked for, not why it failed, and not one of the three ways to
/// proceed. The message is also different on Windows, so nobody could search
/// for it and find the same answer twice.
///
/// `confirm_plaintext` in this same file already got this right, checking for
/// a terminal before asking anything. The prompts did not, which is the same
/// defect in the same file with a different door, and is the shape this
/// project has recorded most often.
fn no_terminal(asked_for: &str) -> String {
// Built line by line rather than as one continued literal, so the
// indentation in this file is not the indentation on the reader's screen.
//
// Every flag named here is attributed to the command that has it, and that
// is not tidiness. The first version of this message offered
// `--encrypt-to` and `--encrypt false --yes` to everybody, because it was
// written while fixing `anonymise`. `veilvoice encrypt` spells the same
// idea `--to` and has no `--encrypt` at all, and `lock`, `guard` and
// `policy` have none of them: a dozen callers share these prompts, and a
// message that names flags the command in front of the reader does not
// have is worse than one that names none. Caught by running `veilvoice
// encrypt` with no terminal and reading what it suggested.
let lines = [
"",
" This is what happens in a script, a scheduled job, or anything with",
" its input redirected.",
"",
" Run it in a terminal, if somebody is there to type.",
"",
" Or, for the two commands that write a recording, seal it to a public",
" key instead, which types nothing and is what works in a script:",
"",
" veilvoice anonymise <FILE> --encrypt-to <PUBKEY>",
" veilvoice encrypt <FILE> --to <PUBKEY>",
"",
" Make the key once, in a terminal, with veilvoice keygen.",
"",
" veilvoice anonymise can also write a recording with no encryption at",
" all, using --encrypt false --yes. That leaves every word that was",
" said readable by anyone who gets the file.",
"",
" Nothing was written.",
];
format!(
"there is no terminal here to ask for {asked_for}.\n{}",
lines.join("\n")
)
}
/// Whether a passphrase can be asked for at all.
///
/// Checked before prompting rather than after failing, so the answer is the
/// same on every platform. `rpassword` reports a missing console differently
/// on Windows and Unix, and a message a reader can search for should not
/// depend on which.
fn can_prompt() -> bool {
std::io::stdin().is_terminal()
}
/// Prompt once, without echoing, and keep the answer in a [`Secret`].
pub fn prompt_secret(prompt: &str) -> Result<Secret, String> {
if !can_prompt() {
return Err(no_terminal("a passphrase"));
}
let typed = rpassword::prompt_password(prompt).map_err(|e| e.to_string())?;
Ok(into_secret(typed))
}
/// Read a password twice, without echoing it, and check the two agree.
pub fn read_new_password() -> Result<Secret, String> {
if !can_prompt() {
return Err(no_terminal("a passphrase"));
}
let first = rpassword::prompt_password("Passphrase: ").map_err(|e| e.to_string())?;
if first.is_empty() {
return Err("passphrase must not be empty".into());
}
let again = rpassword::prompt_password("Repeat: ").map_err(|e| e.to_string())?;
// Compared before either is moved into a `Secret`, then both are wiped
// whichever way the comparison went.
let matched = first == again;
let first = into_secret(first);
let _ = into_secret(again);
if !matched {
return Err("passphrases do not match".into());
}
Ok(first)
}
#[cfg(test)]
mod tests {
use super::*;
/// The warning has one job. If it is ever softened into reassurance, this
/// is what stops it shipping.
#[test]
fn the_warning_states_the_actual_consequence() {
let text = PLAINTEXT_WARNING.join(" ").to_lowercase();
assert!(text.contains("unencrypted"));
assert!(
text.contains("everything that was said"),
"the words surviving must be spelled out"
);
assert!(
text.contains("deleting it afterwards is not a fix"),
"the flash-retention trap must be stated"
);
for reassurance in ["safe", "secure", "protected"] {
assert!(
!text.contains(reassurance),
"reassuring word: {reassurance}"
);
}
// The owner-only permission the plaintext now gets must be described as
// the small thing it is. If it ever reads as a substitute for the
// encryption being declined, this is what stops it shipping.
assert!(
text.contains("a file permission and nothing more"),
"the permission must be belittled, not offered as consolation"
);
assert!(
text.contains("anyone who has the disk"),
"the limit of a file permission must be stated"
);
}
#[test]
fn sealed_output_goes_beside_the_recording_with_a_veil_suffix() {
assert_eq!(
container::veil_path(Path::new("clip.veiled.wav")),
PathBuf::from("clip.veiled.wav.veil")
);
}
/// A sealed recording must not contain its own audio in the clear. This is
/// the property the whole default exists for.
#[test]
fn a_sealed_recording_does_not_contain_its_plaintext() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("clip.veiled.wav");
let audio = veilvoice_audio::io::wav_bytes(&veilvoice_audio::io::Audio {
samples: vec![0.25; 4_000],
sample_rate: 48_000,
})
.unwrap();
// Public-key mode needs no prompt, so it is the one that can be tested
// end to end here; both modes share `finish` inside the container.
let (sk, pk) = hybrid::SecretKey::generate().unwrap();
let key_path = dir.path().join("veilvoice.pub");
std::fs::write(&key_path, pk.to_bytes()).unwrap();
let out = seal_to_disk(&path, &audio, Recipient::PublicKey(&key_path)).unwrap();
assert_eq!(out, container::veil_path(&path));
assert!(!path.exists(), "the plaintext must never reach the disk");
let sealed = std::fs::read(&out).unwrap();
assert_eq!(&sealed[..8], container::MAGIC);
assert!(
!sealed.windows(4).any(|w| w == b"RIFF"),
"the WAV header is visible in the container"
);
assert_eq!(
container::open_with_secret_key(&sk, &sealed).unwrap(),
audio
);
}
#[test]
fn a_missing_public_key_is_reported_rather_than_panicking() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nowhere.pub");
let result = seal_to_disk(
&dir.path().join("clip.wav"),
b"audio",
Recipient::PublicKey(&missing),
);
assert!(result.is_err());
}
}
#[cfg(test)]
mod no_terminal_tests {
use super::*;
/// **F-109.** No terminal is explained, not reported as an errno.
///
/// `veilvoice anonymise recording.wav` from a script, a scheduled job or
/// anything with its input redirected used to fail with
/// `No such device or address (os error 6)`, which is `ENXIO` from opening
/// the console. It names nothing that was being asked for, nothing about
/// why, and none of the ways on. It is also a different string on Windows,
/// so nobody could search for it and get the same answer twice.
#[test]
fn the_no_terminal_message_says_what_was_wanted_and_how_to_proceed() {
let message = no_terminal("a passphrase");
assert!(
message.starts_with("there is no terminal here to ask for a passphrase."),
"the first line has to say what could not be asked for: {message}"
);
// Every flag is named beside the command that has it.
//
// The first version of this message offered `--encrypt-to` and
// `--encrypt false --yes` to every caller, because it was written
// while fixing `anonymise`. `veilvoice encrypt` spells that `--to`
// and has no `--encrypt`; `lock`, `guard` and `policy` have none of
// them. A dozen callers share these prompts, so an unattributed flag
// is a flag the reader's command probably does not have.
for (flag, command) in [
("--encrypt-to", "veilvoice anonymise"),
("--encrypt false --yes", "veilvoice anonymise"),
("--to <PUBKEY>", "veilvoice encrypt"),
] {
let at = message
.find(flag)
.unwrap_or_else(|| panic!("the message no longer mentions {flag}: {message}"));
let before = &message[..at];
let line_start = before.rfind('\n').map(|n| n + 1).unwrap_or(0);
let context = &message[line_start..];
let context = &context[..context.find('\n').unwrap_or(context.len())];
assert!(
context.contains(command)
|| before.rfind(command).is_some_and(|c| {
// Named earlier in the same paragraph is fine; named
// nowhere is not.
message[c..at].matches("\n\n").count() == 0
}),
"{flag} is offered without saying it belongs to {command}, and \
most callers of this message are not that command: {message}"
);
}
assert!(
message.contains("veilvoice keygen"),
"the message has to say where the public key comes from: {message}"
);
assert!(
message.contains("Nothing was written."),
"a refusal has to say that nothing was written, or the reader \
cannot tell whether a half-finished file is lying around"
);
// The operating system's own words are what this replaced.
assert!(
!message.contains("os error"),
"the errno is back in the message: {message}"
);
}
/// The same guidance whichever prompt could not run.
///
/// Two functions prompt, and both refuse through here. A reader who hits
/// one and then the other should not get two different accounts of the
/// same situation.
#[test]
fn both_prompts_refuse_with_the_same_explanation() {
let source = include_str!("atrest.rs").replace("\r\n", "\n");
for function in ["pub fn prompt_secret(", "pub fn read_new_password("] {
let body = source
.split(function)
.nth(1)
.unwrap_or_else(|| panic!("{function} has to be findable"));
let body = body.split("\npub fn ").next().unwrap();
assert!(
body.contains("can_prompt()") && body.contains("no_terminal("),
"{function} does not check for a terminal before prompting, so \
it will surface the operating system's error instead"
);
}
}
}