crates/veilvoice-crypto/src/decoy.rs
what this file is for · veilvoice-crypto · 464 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
//! A second passphrase that opens a different, empty VeilVoice.
//!
//! # What this is for, and what it is honestly worth
//!
//! Somebody can be made to unlock a program. A decoy passphrase gives them
//! something true to say: it opens VeilVoice, the application works, and there
//! is nothing in it.
//!
//! **It does not give you deniability, and anyone who tells you otherwise is
//! selling something.** VeilVoice is open source. This file is published. An
//! adversary who knows what they are looking at knows the feature exists, can
//! read exactly how it works, and can simply ask for the other passphrase. What
//! a decoy buys is a way to *comply* without revealing; what it does not buy is
//! any argument that there is nothing more to reveal. [`SCOPE`] says that in
//! the words a front end must show, and it is the most important thing this
//! crate produces.
//!
//! # The destructive duress passphrase is deliberately not here
//!
//! The roadmap asked for two things: a decoy, and a duress passphrase that
//! destroys data. The second is not shipped, and [`WHY_NO_DESTRUCTION`] is the
//! reason in full. In short: VeilVoice cannot promise a file is gone.
//!
//! On flash storage a write does not overwrite. The controller maps a logical
//! block to a new physical page and leaves the old one holding the data until
//! it is garbage-collected, which may be minutes or may be never, and no
//! program running as an ordinary user can reach it. This project already
//! documents that about its own secure-erase feature and refuses to overstate
//! it there.
//!
//! A destructive duress passphrase would be believed in exactly the situation
//! where being wrong costs the most. Somebody types it expecting the recordings
//! to be gone; the ciphertext is still in unmapped pages; and they then behave
//! as though it is not. **A control people rely on and that does not work is
//! worse than no control at all.** So there is not one.
//!
//! # Typing the wrong one by mistake
//!
//! The other failure the roadmap named, and the reason this shape was chosen.
//! Because the decoy destroys nothing, typing it by accident costs a
//! relaunch and nothing else. There is no state to recover and no decision that
//! cannot be taken back. That is not a happy accident; it is why the
//! destructive design was rejected rather than made safer.
//!
//! # Both passphrases are checked the same way
//!
//! Which one matched must not be visible in how long the check took. Both are
//! derived with the same Argon2id parameters and compared in constant time, and
//! **both are always derived** even when the first one matches: returning early
//! would make a real passphrase measurably faster than a decoy, which tells an
//! observer with a stopwatch which of the two they just watched somebody type.
//!
//! # In plain words
//!
//! You can set a second passphrase. Typing it opens VeilVoice normally, except
//! that it is empty: no recordings, no projects, no history. It is there for
//! the situation where somebody is standing over you asking you to unlock your
//! computer.
//!
//! Two honest warnings, and please read them.
//!
//! It does **not** hide the fact that a second passphrase might exist. This
//! program's source code is public and this feature is described in it, so
//! anybody who recognises VeilVoice can ask you for the other one. It buys you
//! a way to hand something over. It does not buy you an argument.
//!
//! And there is **no passphrase that destroys your recordings**, deliberately.
//! On modern storage, deleting a file does not reliably remove it, so a feature
//! that claimed to would be lying to you at the worst possible moment.
use crate::{kdf, Error};
/// Which passphrase was given.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Opened {
/// The real one. Everything is here.
Real,
/// The decoy. VeilVoice opens, and it is empty.
Decoy,
/// Neither.
Wrong,
}
/// A pair of passphrase verifiers, checked together.
///
/// Holds no passphrase and no key: only the Argon2id output of each, and the
/// salt each was derived with.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Pair {
real: Verifier,
decoy: Option<Verifier>,
params: kdf::KdfParams,
}
/// One passphrase's stored form.
#[derive(Clone, Debug, PartialEq, Eq)]
struct Verifier {
salt: [u8; kdf::SALT_LEN],
expected: Vec<u8>,
}
impl Verifier {
fn create(passphrase: &[u8], params: kdf::KdfParams) -> Result<Self, Error> {
let salt = kdf::random_salt()?;
let key = kdf::derive_key(passphrase, &salt, params)?;
Ok(Self {
salt,
expected: key.expose().to_vec(),
})
}
/// Derive and compare. Always does the full derivation.
fn matches(&self, passphrase: &[u8], params: kdf::KdfParams) -> Result<bool, Error> {
let key = kdf::derive_key(passphrase, &self.salt, params)?;
Ok(constant_time_eq(key.expose(), &self.expected))
}
}
/// Compare without letting the time taken depend on where they differ.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut difference = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
difference |= x ^ y;
}
difference == 0
}
/// How similar two passphrases may be before the pair is refused.
///
/// A decoy that differs from the real passphrase by one character is not a
/// decoy: somebody watching a keyboard learns both at once, and somebody
/// typing under pressure gives away the wrong one.
pub const LEAST_DIFFERENCE: usize = 4;
/// Why a pair was refused.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refused {
/// A passphrase was empty.
Empty,
/// The two are the same.
Identical,
/// The two are too alike to tell apart under pressure.
TooAlike {
/// How many characters differ.
differing: usize,
/// How many must.
least: usize,
},
/// The key derivation failed.
Crypto(String),
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "a passphrase cannot be empty"),
Self::Identical => write!(
f,
"the decoy is the same as the real passphrase, so it would open the \
real thing"
),
Self::TooAlike { differing, least } => write!(
f,
"the two passphrases differ in only {differing} place(s) and need at \
least {least}. A decoy that is nearly the real one is not a decoy: \
somebody watching you type learns both at once, and somebody typing \
under pressure gives away the wrong one"
),
Self::Crypto(why) => write!(f, "{why}"),
}
}
}
impl std::error::Error for Refused {}
/// How many positions two passphrases differ in.
///
/// Length difference counts, so `"hunter2"` against `"hunter2222"` is three
/// apart rather than zero.
fn differences(a: &str, b: &str) -> usize {
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
let shared = a.len().min(b.len());
let mut count = a.len().abs_diff(b.len());
for i in 0..shared {
if a[i] != b[i] {
count += 1;
}
}
count
}
impl Pair {
/// A real passphrase with no decoy. This is the ordinary case.
pub fn only_real(passphrase: &str, params: kdf::KdfParams) -> Result<Self, Refused> {
if passphrase.is_empty() {
return Err(Refused::Empty);
}
Ok(Self {
real: Verifier::create(passphrase.as_bytes(), params)
.map_err(|e| Refused::Crypto(e.to_string()))?,
decoy: None,
params,
})
}
/// A real passphrase and a decoy.
///
/// Refuses a decoy too close to the real one. See [`Refused::TooAlike`]:
/// this is the one check that decides whether the feature is worth having.
pub fn with_decoy(real: &str, decoy: &str, params: kdf::KdfParams) -> Result<Self, Refused> {
if real.is_empty() || decoy.is_empty() {
return Err(Refused::Empty);
}
if real == decoy {
return Err(Refused::Identical);
}
let differing = differences(real, decoy);
if differing < LEAST_DIFFERENCE {
return Err(Refused::TooAlike {
differing,
least: LEAST_DIFFERENCE,
});
}
Ok(Self {
real: Verifier::create(real.as_bytes(), params)
.map_err(|e| Refused::Crypto(e.to_string()))?,
decoy: Some(
Verifier::create(decoy.as_bytes(), params)
.map_err(|e| Refused::Crypto(e.to_string()))?,
),
params,
})
}
/// Whether a decoy is set at all.
pub fn has_decoy(&self) -> bool {
self.decoy.is_some()
}
/// Which passphrase this is.
///
/// **Both are always derived**, even when the first matches. Returning
/// early would make the real passphrase measurably faster than the decoy,
/// and somebody with a stopwatch would learn which of the two they had just
/// watched being typed. Argon2id at the configured cost takes long enough
/// for that difference to be obvious.
pub fn open(&self, given: &str) -> Result<Opened, Error> {
let real = self.real.matches(given.as_bytes(), self.params)?;
let decoy = match &self.decoy {
Some(verifier) => verifier.matches(given.as_bytes(), self.params)?,
// No decoy set. The work is still done against the real verifier's
// salt so that having a decoy and not having one take the same
// time, which is itself worth hiding: an observer must not be able
// to tell that this copy has one configured.
None => {
let _ = self.real.matches(given.as_bytes(), self.params)?;
false
}
};
Ok(if real {
Opened::Real
} else if decoy {
Opened::Decoy
} else {
Opened::Wrong
})
}
}
/// What a decoy is worth, in the words a front end must show.
pub const SCOPE: &str = "\
A decoy passphrase opens VeilVoice with nothing in it. It is a way to comply \
with somebody who is standing over you, without handing over your recordings.
It does NOT hide that a second passphrase might exist. VeilVoice is open source \
and this feature is documented, so anybody who recognises the program knows it \
is there and can simply ask you for the other one. A decoy buys you something \
to hand over. It does not buy you an argument that there is nothing more.
Nothing is destroyed, and nothing can be. Typing the decoy by mistake costs you \
a relaunch and nothing else.";
/// Why no passphrase destroys anything, and why that is the honest choice.
pub const WHY_NO_DESTRUCTION: &str = "\
There is no passphrase that deletes your recordings, deliberately.
On modern storage a write does not overwrite. The drive's controller puts the \
new data in a fresh physical page and leaves the old one holding the original \
until it is collected later, which may be minutes and may be never. No program \
running as an ordinary user can reach those pages. VeilVoice already says this \
about its secure-erase feature rather than overstating it, and the same fact \
governs here.
So a destructive passphrase would be believed at exactly the moment when being \
wrong costs the most: somebody types it, assumes the recordings are gone, and \
acts accordingly while the ciphertext is still on the disk. A control that \
people rely on and that does not work is worse than no control at all.";
#[cfg(test)]
mod tests {
use super::*;
fn params() -> kdf::KdfParams {
// The weak parameters, so the suite is not a minute of Argon2id.
kdf::KdfParams::weak_for_tests()
}
#[test]
fn the_real_passphrase_opens_the_real_thing() {
let pair = Pair::only_real("correct horse battery", params()).unwrap();
assert_eq!(pair.open("correct horse battery").unwrap(), Opened::Real);
assert_eq!(pair.open("something else entirely").unwrap(), Opened::Wrong);
assert!(!pair.has_decoy());
}
#[test]
fn the_decoy_opens_the_empty_one() {
let pair = Pair::with_decoy("the real one", "a different thing", params()).unwrap();
assert_eq!(pair.open("the real one").unwrap(), Opened::Real);
assert_eq!(pair.open("a different thing").unwrap(), Opened::Decoy);
assert_eq!(pair.open("neither of them").unwrap(), Opened::Wrong);
assert!(pair.has_decoy());
}
/// **The check that decides whether the feature is worth having.** A decoy
/// one character from the real passphrase is not a decoy.
#[test]
fn a_decoy_too_close_to_the_real_one_is_refused() {
let refused = Pair::with_decoy("hunter2000", "hunter2001", params()).unwrap_err();
assert!(matches!(refused, Refused::TooAlike { differing: 1, .. }));
let words = refused.to_string();
assert!(words.contains("watching you type"), "{words}");
assert!(words.contains("under pressure"), "{words}");
assert_eq!(
Pair::with_decoy("same", "same", params()).unwrap_err(),
Refused::Identical
);
assert_eq!(
Pair::with_decoy("", "anything at all", params()).unwrap_err(),
Refused::Empty
);
}
/// Length counts as difference, or "hunter2" and "hunter2222" would pass.
#[test]
fn a_longer_version_of_the_same_passphrase_is_still_too_close() {
assert_eq!(differences("hunter2", "hunter2222"), 3);
assert_eq!(differences("abcd", "abcd"), 0);
assert_eq!(differences("abcd", "wxyz"), 4);
assert!(Pair::with_decoy("hunter2", "hunter222", params()).is_err());
assert!(Pair::with_decoy("hunter2", "totally different", params()).is_ok());
}
/// **Both are always derived.** Returning as soon as the real one matches
/// would make it measurably faster than the decoy, and Argon2id takes long
/// enough that somebody with a stopwatch would see it.
#[test]
fn matching_the_real_one_still_derives_the_decoy() {
let source = include_str!("decoy.rs");
let start = source.find("pub fn open(").expect("the function");
let end = source[start..].find("\n }\n").expect("its end") + start;
let body = &source[start..end];
// No early return between the two derivations.
let real_at = body.find("let real =").expect("the real derivation");
let decoy_at = body.find("let decoy =").expect("the decoy derivation");
assert!(real_at < decoy_at);
assert!(
!body[real_at..decoy_at].contains("return"),
"an early return here is a timing oracle:\n{}",
&body[real_at..decoy_at]
);
}
/// Having a decoy and not having one must take the same time, or an
/// observer learns that this copy has one configured.
#[test]
fn a_pair_with_no_decoy_still_does_the_second_derivation() {
let source = include_str!("decoy.rs");
let start = source.find("pub fn open(").expect("the function");
let end = source[start..].find("\n }\n").expect("its end") + start;
let body = &source[start..end];
let none_arm = body.find("None => {").expect("the no-decoy arm");
let after = &body[none_arm..];
assert!(
after.contains("self.real.matches"),
"the no-decoy case must still do the work:\n{after}"
);
}
/// Nothing in this crate deletes anything. The argument for that is only as
/// good as the code continuing not to.
#[test]
fn nothing_here_destroys_anything() {
let source = include_str!("decoy.rs");
let body = source.split("#[cfg(test)]").next().unwrap();
let code: String = body
.lines()
.filter(|line| {
let t = line.trim_start();
!t.starts_with("//") && !t.starts_with("///") && !t.starts_with("//!")
})
.collect::<Vec<_>>()
.join("\n");
for destructive in [
"remove_file",
"remove_dir",
"shred",
"truncate",
"set_len",
"File::create",
] {
assert!(
!code.contains(destructive),
"{destructive} appears in a crate whose whole argument is that it \
destroys nothing"
);
}
}
/// **The most important thing this crate outputs.** A reader who takes a
/// decoy for deniability is worse off than one who never had it.
#[test]
fn the_scope_note_refuses_to_promise_deniability() {
let scope = SCOPE.to_lowercase();
assert!(scope.contains("does not hide"), "{scope}");
assert!(scope.contains("open source"), "{scope}");
assert!(
scope.contains("ask you for the other one"),
"the concrete thing an adversary does: {scope}"
);
assert!(scope.contains("does not buy you an argument"), "{scope}");
assert!(scope.contains("nothing is destroyed"), "{scope}");
for overclaim in ["undetectable", "plausible deniability", "untraceable"] {
assert!(!scope.contains(overclaim), "\"{overclaim}\" in:\n{scope}");
}
}
/// The refusal to ship destruction states the mechanism, not just the
/// conclusion, because the conclusion alone reads as laziness.
#[test]
fn the_refusal_to_destroy_explains_the_storage_and_not_only_the_choice() {
let why = WHY_NO_DESTRUCTION.to_lowercase();
assert!(why.contains("a write does not overwrite"), "{why}");
assert!(why.contains("fresh physical page"), "{why}");
assert!(why.contains("may be never"), "{why}");
assert!(
why.contains("worse than no control at all"),
"the reason it is a refusal rather than a gap: {why}"
);
}
#[test]
fn comparing_is_constant_time_and_length_safe() {
assert!(constant_time_eq(b"abcd", b"abcd"));
assert!(!constant_time_eq(b"abcd", b"abce"));
assert!(!constant_time_eq(b"abcd", b"abc"));
assert!(constant_time_eq(b"", b""));
}
}