crates/veilvoice-crypto/src/lock.rs
what this file is for · veilvoice-crypto · 2264 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 application lock: an Argon2id password verifier with a rate limit.
//!
//! # What this is worth, stated before anything else
//!
//! **This is not a security boundary against someone holding the disk.** It
//! cannot be. A local application has nowhere to hide a secret from the machine
//! it runs on: whoever can read this file can also delete it, and deleting it
//! removes the lock. That is not a defect in the implementation, it is what a
//! local lock *is*.
//!
//! What it does buy is real and worth having: someone who picks up your unlocked
//! computer, such as a flatmate, a colleague or a border officer with your
//! session open,
//! cannot open VeilVoice, see which files you have processed, or start a live
//! scramble. That is the threat this defends against, and [`SCOPE`] says so in
//! the words the user sees.
//!
//! If the threat is an attacker with the disk, the answer is full-volume
//! encryption (LUKS, BitLocker, FileVault) plus [`crate::container`] for the
//! recordings themselves. Neither of those is replaced by this module.
//!
//! # Why a verifier and not a key
//!
//! The lock stores `Argon2id(domain ‖ password, salt)`, split by HKDF into a
//! verifier and a tag key, and compares the verifier in constant time. It
//! derives no key that encrypts anything, and that is still true of this
//! module after roadmap item 86.
//!
//! What changed is one level up. The desktop application can now be told to
//! seal every recording it writes *with the app-lock passphrase*, and it does
//! that through [`crate::container`] in the ordinary way, with a fresh salt
//! per file. So the recordings do not depend on this file: delete the lock and
//! they still open, given the passphrase. Nothing here holds a key to them and
//! nothing here can.
//!
//! The property that is given up by switching that on is not cryptographic, it
//! is human, and it belongs in the interface rather than in a comment: one
//! passphrase then opens the application and the archive together. The default
//! is still two separate secrets, and `docs/USER_GUIDE.md` section 5.4 states
//! the trade in the words a user reads.
//!
//! The stored verifier is a password hash sitting on disk in the clear, and
//! must be treated like one. Argon2id at the default cost (256 MiB, t=3, p=4)
//! makes an offline attack expensive per guess, which matters for a decent
//! passphrase and does not save a bad one.
//!
//! # The tag, and the one tamper claim this file can honestly make
//!
//! Every record carries a 16-byte authentication tag over all the bytes before
//! it, keyed by a value that exists only while a correct passphrase is in
//! memory. The tag key is the second half of one Argon2id run, split from the
//! verifier by HKDF, so publishing the verifier, which the file does by
//! sitting on disk, says nothing about the tag key.
//!
//! That buys exactly one thing, and it is worth naming precisely. Somebody who
//! edits this file without knowing the passphrase cannot leave the edit
//! looking authentic. Resetting the failure counter to zero, winding the
//! last-failure timestamp back to escape a wait, or dropping the Argon2id cost
//! so that a guess is cheap. All three are edits, and all three are caught at
//! the next successful unlock, because that is the moment the tag key exists.
//!
//! It buys nothing at all against the two attacks people expect it to stop.
//! Deleting the file still removes the lock. Replacing the file wholesale with
//! a lock the attacker created still lets the attacker in, because their own
//! record is authentic under *their* passphrase. [`crate::vault`] answers the
//! first with a second copy and answers the second not at all.
//!
//! The tamper flag, once raised, is stored and is cleared only by an unlock
//! that also proves the passphrase. So the report survives a restart, and the
//! person who caused it cannot dismiss it.
//!
//! # Rate limiting
//!
//! Wrong attempts are counted and the count is **persisted**, so killing the
//! process does not hand an attacker a fresh budget. After three free attempts
//! the wait doubles: 5 s, 10 s, 20 s … capped at fifteen minutes.
//!
//! The counter is stored in the same unauthenticated file as the verifier, and
//! the wait is measured against the system clock. Someone who can edit the file
//! or move the clock defeats both. Again: casual access, not the disk.
//!
//! # Separate from the recording password
//!
//! The password that unlocks the app and the password that encrypts recordings
//! are two different passwords, on purpose, so that unlocking the app is not the
//! same act as unsealing everything it has ever written. To make that structural
//! rather than merely conventional, the verifier is derived over a domain-
//! separated input, so the same passphrase used in both places still produces
//! unrelated values.
//!
//! # In plain words
//!
//! The lock on the application window.
//!
//! It asks for a passphrase before VeilVoice will open, and it slows down after
//! repeated wrong answers so that guessing is not worth trying.
//!
//! **It is not protection against somebody who has your disk.** It stops the
//! person who picks up your unlocked laptop, and that is genuinely worth having,
//! but anybody who can read the files directly is not stopped by a program
//! deciding whether to show you a window. Encrypting your recordings is what
//! protects them; this protects the session.
use crate::{kdf, Error, Secret};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// What the app lock protects against, and what it does not, in the words a
/// front-end should show the user.
///
/// Single-sourced so the CLI and the GUI cannot drift into two different
/// promises, and asserted by the tests so it cannot quietly become a boast.
pub const SCOPE: &str = "The app lock keeps someone who picks up your unlocked computer out of \
VeilVoice. If the stored password is swapped or its cost weakened, the \
next unlock reports it, and a second copy is kept, so to delete the lock \
you have to find both. It is still not tamper-proof and it is not disk \
encryption: anyone holding the disk can attack the stored password hash \
offline. If that is the threat, encrypt the whole volume.";
/// Magic bytes at the start of a lock file.
pub const MAGIC: &[u8; 8] = b"VEILLOK1";
/// Format version this build writes.
pub const FORMAT_VERSION: u8 = 2;
/// Exact size of a version 2 lock file, in bytes.
pub const LOCK_LEN: usize = 125;
/// Exact size of a version 1 lock file, which this build still reads.
pub const LOCK_LEN_V1: usize = 84;
/// Domain separator, so the app-lock secret can never coincide with a key
/// derived from the same passphrase anywhere else in this crate.
const DOMAIN: &[u8] = b"veilvoice/app-lock/v1\0";
/// HKDF label for the half of the derivation that is written to disk.
const INFO_VERIFIER: &[u8] = b"veilvoice/app-lock/verifier";
/// HKDF label for the half that is not, and that authenticates the record.
const INFO_TAG: &[u8] = b"veilvoice/app-lock/tag";
/// HKDF label for the obfuscated store key. A third branch of the same
/// Argon2id run, independent of the other two: publishing the verifier, which
/// the file does by existing, says nothing about this.
///
/// This is the branch that gives the lock something to hold. Before it, the
/// lock verified a passphrase and nothing else depended on it; now
/// [`crate::hoard`] names and opens every record in the program folder with
/// this key, so the passphrase is what makes those files readable rather than
/// merely what makes the window open.
const INFO_STORE: &[u8] = b"veilvoice/app-lock/store";
/// Length of the record tag. Sixteen bytes of Poly1305, as everywhere else.
const TAG_LEN: usize = crate::aead::TAG_LEN;
/// Length of the tagged part of a record, which is also the offset of the
/// nonce that follows it.
const BODY_LEN: usize = 73;
/// Failed attempts allowed before the wait starts.
const FREE_ATTEMPTS: u32 = 3;
/// The first enforced wait, in seconds. It doubles from here.
const BASE_DELAY_SECS: u64 = 5;
/// The longest the wait ever gets. Beyond a quarter of an hour the attacker has
/// long since moved to attacking the file directly, and the honest user is the
/// only one still being punished.
const MAX_DELAY_SECS: u64 = 15 * 60;
/// How long to refuse the next attempt after `failures` consecutive failures.
///
/// Public because it is the whole of the rate-limit policy, and a policy nobody
/// can read or test is not a policy.
pub fn delay_secs(failures: u32) -> u64 {
let Some(step) = failures.checked_sub(FREE_ATTEMPTS + 1) else {
return 0;
};
// Guard the shift before it can overflow; everything past this is capped
// anyway.
if step >= 12 {
return MAX_DELAY_SECS;
}
(BASE_DELAY_SECS << step).min(MAX_DELAY_SECS)
}
/// Seconds since the Unix epoch, negative before it.
fn unix_now() -> i64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(d) => d.as_secs().min(i64::MAX as u64) as i64,
Err(e) => -(e.duration().as_secs().min(i64::MAX as u64) as i64),
}
}
/// A password verifier plus its attempt history.
///
/// This is the on-disk state. Most callers want [`LockStore`], which ties it to
/// a file and persists every attempt.
#[derive(Clone, Debug)]
pub struct AppLock {
params: kdf::KdfParams,
salt: [u8; kdf::SALT_LEN],
/// Held in a [`Secret`] for the constant-time comparison and the wipe on
/// drop. Page-locking it is over-caution, since the same bytes are on disk, but
/// costs nothing and keeps one type for key-shaped material.
verifier: Secret,
failures: u32,
/// Unix seconds of the most recent failure, or 0 if there has not been one.
last_failure: i64,
/// Raised when a record failed its tag, and kept raised across restarts
/// until an unlock clears it. See [`AppLock::tampered`].
tampered: bool,
/// Which format this record was read from, so that a version 1 file can be
/// rewritten as version 2 the first time the passphrase is available.
version: u8,
/// Nonce for the record tag, drawn fresh on every write.
///
/// Fresh rather than fixed because Poly1305 authenticates each message
/// under a one-time key derived from the key *and the nonce*. Two records
/// tagged under one nonce would hand an observer of this file two
/// equations in the same two unknowns, which is enough to solve for the
/// one-time key and forge a third. The file is rewritten after every
/// failed attempt, so "two records" is an afternoon, not a corner case.
tag_nonce: [u8; crate::aead::NONCE_LEN],
/// The tag as read from disk. `None` for a version 1 record, which has no
/// tag, and for a freshly created one, which has not been written yet.
tag: Option<[u8; TAG_LEN]>,
}
impl AppLock {
/// Create a lock for `password`.
pub fn create(password: &[u8], params: kdf::KdfParams) -> Result<Self, Error> {
let salt = kdf::random_salt()?;
let (verifier, _) = derive_pair(password, &salt, params)?;
let mut lock = Self {
params,
salt,
verifier,
failures: 0,
last_failure: 0,
tampered: false,
version: FORMAT_VERSION,
tag_nonce: [0u8; crate::aead::NONCE_LEN],
tag: None,
};
// Tag it here rather than leaving that to the caller. A record created
// and written without a tag would be indistinguishable on disk from a
// version 1 file, and would go one more unlock before it was covered.
lock.retag(password)?;
Ok(lock)
}
/// Derive the key that names and opens the obfuscated program folder.
///
/// Separate from [`Self::verify`] rather than returned by it, because most
/// unlocks do not need it and the ones that do want it at a different
/// moment. It costs a full Argon2id run, so callers hold the result for as
/// long as the session is unlocked rather than deriving it per record.
///
/// The password is **not** checked here: a wrong one yields a key that
/// derives names nothing is stored under, which reads as an empty folder
/// rather than an error. Call [`Self::verify`] first, and act on what it
/// says.
pub fn store_key(&self, password: &[u8]) -> Result<crate::hoard::StoreKey, Error> {
let (_, _, store) = derive_keys(password, &self.salt, self.params)?;
Ok(crate::hoard::StoreKey::from_secret(store))
}
/// Check `password`, recording the outcome.
///
/// Returns [`Error::AppLockCooldown`] while the rate limit is in force,
/// without touching the KDF. An attacker should not be able to spend our
/// CPU either.
pub fn verify(&mut self, password: &[u8]) -> Result<(), Error> {
self.verify_at(password, unix_now())
}
/// Check a passphrase against this lock as of `now`.
///
/// Taking the time as an argument rather than reading the clock is what lets
/// the cooldown be tested at all: a test that had to wait out the delay would
/// not be run.
fn verify_at(&mut self, password: &[u8], now: i64) -> Result<(), Error> {
if let Some(wait) = self.cooldown_at(now) {
return Err(Error::AppLockCooldown(wait));
}
let (candidate, tag_key) = derive_pair(password, &self.salt, self.params)?;
if candidate != self.verifier {
self.failures = self.failures.saturating_add(1);
self.last_failure = now;
return Err(Error::AppLockRejected);
}
// The passphrase is right, so the tag key exists and the record can be
// checked against it. This is the only moment it can be: before now
// there was nothing to check with.
//
// A version 1 record has no tag and cannot be judged either way. It is
// upgraded rather than accused: `version` carries the answer out to
// `LockStore`, which rewrites the file as version 2 while it still has
// the passphrase.
if self.version >= 2 && !self.tag_matches(&tag_key) {
self.tampered = true;
}
self.failures = 0;
self.last_failure = 0;
Ok(())
}
/// Whether two records hold the same stored password.
///
/// Compares the salt as well as the verifier, because two locks made from
/// the same passphrase have different salts and therefore different
/// verifiers: the question being asked is "are these the same lock", not
/// "would the same passphrase open both".
///
/// Constant time, although the values being compared are both already on
/// disk. It costs nothing and keeps one habit for this kind of material
/// rather than two.
pub fn same_secret_as(&self, other: &Self) -> bool {
use subtle::ConstantTimeEq;
let salts = self.salt.ct_eq(&other.salt);
let verifiers = self.verifier.expose().ct_eq(other.verifier.expose());
bool::from(salts & verifiers)
}
/// Whether a record has been found edited by somebody without the
/// passphrase, at any point since this was last cleared.
///
/// Sticky on purpose. A report that a restart clears is a report an
/// attacker clears.
pub fn tampered(&self) -> bool {
self.tampered
}
/// Clear the tamper report, after proving the passphrase.
///
/// Takes the password rather than trusting an earlier unlock, so that
/// nothing can dismiss the report except the person who can open the lock.
pub fn acknowledge(&mut self, password: &[u8]) -> Result<(), Error> {
self.verify(password)?;
self.tampered = false;
Ok(())
}
/// Whether the file's tamper tag is the one this key computes.
///
/// Compared in constant time, and a tag that cannot be computed is treated as
/// matching: see the note in the body for why that is the safe direction.
fn tag_matches(&self, tag_key: &Secret) -> bool {
let Ok(want) = self.tag(tag_key) else {
// A failure to compute the tag is a broken build or an exhausted
// machine, not evidence about the file. Do not call it tampering.
return true;
};
match &self.tag {
Some(have) => bool::from(subtle::ConstantTimeEq::ct_eq(&want[..], &have[..])),
None => false,
}
}
/// The authentication tag over everything in the record before it.
///
/// Poly1305 over an empty message with the record as associated data: a
/// keyed tag built from the AEAD already in this crate, rather than a
/// second MAC construction to review.
fn tag(&self, tag_key: &Secret) -> Result<[u8; TAG_LEN], Error> {
let sealed = crate::aead::seal(tag_key, &self.tag_nonce, &self.body(), &[])?;
let mut out = [0u8; TAG_LEN];
if sealed.len() != TAG_LEN {
return Err(Error::Encrypt);
}
out.copy_from_slice(&sealed);
Ok(out)
}
/// Seconds still to wait before another attempt is accepted.
pub fn cooldown(&self) -> Option<Duration> {
self.cooldown_at(unix_now()).map(Duration::from_secs)
}
/// How long is left of the delay after the last failed attempt, if any.
///
/// `None` means try now.
fn cooldown_at(&self, now: i64) -> Option<u64> {
let wait = delay_secs(self.failures);
if wait == 0 {
return None;
}
// A clock that moved backwards since the last failure gives a negative
// elapsed time. Treat that as no time having passed rather than as
// credit against the wait.
let elapsed = now.saturating_sub(self.last_failure).max(0) as u64;
(elapsed < wait).then(|| wait - elapsed)
}
/// Consecutive failed attempts recorded so far.
pub fn failures(&self) -> u32 {
self.failures
}
/// The Argon2id cost this lock was created with.
pub fn params(&self) -> kdf::KdfParams {
self.params
}
/// The bytes the tag covers.
///
/// Not the whole record. The failed-attempt counter and its timestamp are
/// deliberately outside, and the reason is worth stating rather than
/// leaving to be discovered: they are written at the one moment the tag
/// key does not exist. A wrong passphrase has to be counted, and counting
/// it means a write, and the write cannot be authenticated by a key that
/// only a right passphrase produces. Putting them inside would mean either
/// re-tagging with a stale tag -- so every honest typo would be reported
/// as tampering -- or not counting failures at all.
///
/// So the rate limit is exactly as defeatable by an editor as it was
/// before, and [`SCOPE`] does not claim otherwise. What the tag does cover
/// is the part an attacker actually wants: the verifier, the Argon2id
/// cost, and the tamper flag itself.
fn body(&self) -> [u8; BODY_LEN] {
let mut out = [0u8; BODY_LEN];
out[..8].copy_from_slice(MAGIC);
out[8] = FORMAT_VERSION;
// 9..12 stay zero: reserved.
out[12..16].copy_from_slice(&self.params.m_cost.to_le_bytes());
out[16..20].copy_from_slice(&self.params.t_cost.to_le_bytes());
out[20..24].copy_from_slice(&self.params.p_cost.to_le_bytes());
out[24..40].copy_from_slice(&self.salt);
out[40..72].copy_from_slice(self.verifier.expose());
out[72] = u8::from(self.tampered);
out
}
/// Draw a fresh nonce and re-tag the record under `password`.
///
/// Called when the passphrase is in hand: at creation, at a change, and
/// after a successful unlock. A version 1 record becomes version 2 here,
/// which is the whole of the upgrade path.
pub fn retag(&mut self, password: &[u8]) -> Result<(), Error> {
let (_, tag_key) = derive_pair(password, &self.salt, self.params)?;
self.tag_nonce = crate::aead::random_nonce()?;
self.version = FORMAT_VERSION;
self.tag = Some(self.tag(&tag_key)?);
Ok(())
}
/// Serialise exactly as it appears on disk.
///
/// ```text
/// offset size field covered by the tag
/// 0 8 magic "VEILLOK1" yes
/// 8 1 format version (2) yes
/// 9 3 reserved, must be zero yes
/// 12 4 Argon2id m_cost (KiB, little-endian) yes
/// 16 4 Argon2id t_cost yes
/// 20 4 Argon2id p_cost yes
/// 24 16 salt yes
/// 40 32 verifier yes
/// 72 1 tamper report: 1 raised, 0 acknowledged yes
/// 73 24 tag nonce, fresh on every re-tag no
/// 97 16 tag over bytes 0..73 no
/// 113 4 consecutive failed attempts no
/// 117 8 Unix seconds of the most recent failure no
/// ```
///
/// A record that has never been tagged -- one read from a version 1 file
/// and not yet unlocked -- is written back as version 1, so that a failed
/// attempt against an old lock still records itself. [`AppLock::retag`]
/// is what moves it forward, and it needs the passphrase to do so.
pub fn to_bytes(&self) -> Vec<u8> {
let Some(tag) = self.tag else {
let mut out = Vec::with_capacity(LOCK_LEN_V1);
out.extend_from_slice(MAGIC);
out.push(1);
out.extend_from_slice(&[0u8; 3]);
out.extend_from_slice(&self.params.m_cost.to_le_bytes());
out.extend_from_slice(&self.params.t_cost.to_le_bytes());
out.extend_from_slice(&self.params.p_cost.to_le_bytes());
out.extend_from_slice(&self.salt);
out.extend_from_slice(self.verifier.expose());
out.extend_from_slice(&self.failures.to_le_bytes());
out.extend_from_slice(&self.last_failure.to_le_bytes());
debug_assert_eq!(out.len(), LOCK_LEN_V1);
return out;
};
let mut out = Vec::with_capacity(LOCK_LEN);
out.extend_from_slice(&self.body());
out.extend_from_slice(&self.tag_nonce);
out.extend_from_slice(&tag);
out.extend_from_slice(&self.failures.to_le_bytes());
out.extend_from_slice(&self.last_failure.to_le_bytes());
debug_assert_eq!(out.len(), LOCK_LEN);
out
}
/// Parse a lock file, version 1 or version 2.
///
/// Nothing is authenticated here. Deciding whether the tag is right needs
/// the passphrase, which parsing does not have, so parsing answers only
/// "is this a lock file" and leaves the rest to [`AppLock::verify`].
pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < 9 {
return Err(Error::Truncated);
}
if &bytes[..8] != MAGIC {
return Err(Error::BadMagic);
}
let version = bytes[8];
let want = match version {
1 => LOCK_LEN_V1,
2 => LOCK_LEN,
other => return Err(Error::UnsupportedVersion(other)),
};
if bytes.len() < want {
return Err(Error::Truncated);
}
if bytes.len() > want {
return Err(Error::BadHeader);
}
// Refuse a future flag rather than ignore it, as the container does.
if bytes[9..12] != [0u8; 3] {
return Err(Error::BadHeader);
}
let u32_at =
|o: usize| u32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]);
let i64_at = |o: usize| {
let mut b = [0u8; 8];
b.copy_from_slice(&bytes[o..o + 8]);
i64::from_le_bytes(b)
};
let params = kdf::KdfParams {
m_cost: u32_at(12),
t_cost: u32_at(16),
p_cost: u32_at(20),
};
// Validate the costs here rather than only at the first verification.
// They are attacker-controlled -- this file is read before anyone has
// authenticated -- and `KdfParams::checked` is the single funnel that
// bounds them (see F-2 and F-3). Refusing at parse time means the
// failure is reported as "this lock file is broken", which is true and
// actionable, rather than as a password that never works.
//
// F-91. `within` rather than `checked`, and the difference matters more
// here than anywhere else this crate reads a cost from a file.
// `checked` alone permits four gigabytes of Argon2 memory, which is
// deliberate for a container: somebody chose to open that file, it is
// slow, and they can decide to stop waiting. Nobody chooses to open
// this one. It is read at launch, before anything has been
// authenticated, and a value of four gigabytes on a modest machine is
// not a wait, it is an allocation failure, and this build aborts on
// one. The window would then fail to start with no way in.
//
// Found by the coverage-guided campaign after the format changed: it
// produced a header declaring 1,664 MiB and libFuzzer flagged the unit
// as slow. That the recovery is now harder is this cycle's own doing:
// the vault's file names are derived rather than fixed, so "delete the
// lock file and start again" needs the index read first.
//
// The ceiling is `UNATTENDED_MAX_M_COST`, four times what this program
// has ever written into one of these files.
params.within(kdf::KdfParams::UNATTENDED_MAX_M_COST)?;
let mut salt = [0u8; kdf::SALT_LEN];
salt.copy_from_slice(&bytes[24..40]);
let mut verifier_bytes = [0u8; kdf::KEY_LEN];
verifier_bytes.copy_from_slice(&bytes[40..72]);
let verifier = Secret::new(&mut verifier_bytes);
let mut tag_nonce = [0u8; crate::aead::NONCE_LEN];
let mut tag = None;
let mut tampered = false;
let (failures, last_failure) = if version >= 2 {
// The flag is one bit written in one byte, so anything but 0 or 1
// is an edit. Refuse it rather than normalising it: normalising
// would make two different files parse to the same record, and a
// file read before anybody has authenticated is the last place to
// accept bytes it does not then write back. Refusing is also the
// safe direction, because a lock file that will not parse leaves
// the lock in force and sends `crate::vault` to the second copy.
tampered = match bytes[72] {
0 => false,
1 => true,
_ => return Err(Error::BadHeader),
};
tag_nonce.copy_from_slice(&bytes[73..97]);
let mut t = [0u8; TAG_LEN];
t.copy_from_slice(&bytes[97..113]);
tag = Some(t);
(u32_at(113), i64_at(117))
} else {
(u32_at(72), i64_at(76))
};
Ok(Self {
params,
salt,
verifier,
failures,
last_failure,
tampered,
version,
tag_nonce,
tag,
})
}
}
/// Derive the verifier and the tag key for `password`.
///
/// One Argon2id run, split in two by HKDF. Two runs would double the cost of
/// every unlock for no gain: HKDF outputs under distinct labels are
/// independent, so publishing the verifier -- which the file does, by existing
/// -- reveals nothing about the tag key.
///
/// The domain separator is prepended before the Argon2id run so that neither
/// half can collide with a container key derived from the same passphrase and
/// salt. The joined buffer is wiped as soon as it has been consumed.
fn derive_pair(
password: &[u8],
salt: &[u8],
params: kdf::KdfParams,
) -> Result<(Secret, Secret), Error> {
let (verifier, tag_key, _) = derive_keys(password, salt, params)?;
Ok((verifier, tag_key))
}
/// As [`derive_pair`], and the store key with it.
///
/// One Argon2id run produces all three. Callers that need the store key must
/// use this rather than deriving it separately: a second run would double the
/// cost of every unlock for no gain, and the whole point of the HKDF split is
/// that one expensive step can feed as many independent labels as are wanted.
fn derive_keys(
password: &[u8],
salt: &[u8],
params: kdf::KdfParams,
) -> Result<(Secret, Secret, Secret), Error> {
let mut bound = Vec::with_capacity(DOMAIN.len() + password.len());
bound.extend_from_slice(DOMAIN);
bound.extend_from_slice(password);
let bound = Secret::new(&mut bound);
let root = kdf::derive_key(bound.expose(), salt, params)?;
let hk = hkdf::Hkdf::<sha2::Sha256>::from_prk(root.expose()).map_err(|_| Error::Kdf)?;
let mut verifier = Secret::zeroed(kdf::KEY_LEN);
let mut tag_key = Secret::zeroed(kdf::KEY_LEN);
hk.expand(INFO_VERIFIER, verifier.expose_mut())
.map_err(|_| Error::Kdf)?;
hk.expand(INFO_TAG, tag_key.expose_mut())
.map_err(|_| Error::Kdf)?;
let mut store_key = Secret::zeroed(kdf::KEY_LEN);
hk.expand(INFO_STORE, store_key.expose_mut())
.map_err(|_| Error::Kdf)?;
Ok((verifier, tag_key, store_key))
}
/// An [`AppLock`] bound to a file, which is persisted after every attempt.
///
/// Persisting on failure is the point: a rate limit that a process restart
/// clears is not a rate limit.
pub struct LockStore {
backing: Backing,
lock: AppLock,
/// Whether the last write reached the spare as well as the first copy. See
/// [`LockStore::every_copy_current`].
every_copy_current: bool,
}
/// Where a [`LockStore`] keeps its record.
///
/// Two, because the two are asked for by different callers. The default
/// location is a [`crate::vault::Vault`]: two copies under unguessable names,
/// one of them administrator-owned where the platform allows it. An explicit
/// path is one plain file, which is what `veilvoice lock --path` is for and
/// what a script pointing at a temporary directory expects.
#[derive(Clone, Debug)]
enum Backing {
File(PathBuf),
Vault(crate::vault::Vault),
}
impl Backing {
/// The path this lock is really kept at, whether it is a plain file or
/// the real one among a vault's decoys.
fn primary(&self) -> &Path {
match self {
Self::File(p) => p,
Self::Vault(v) => v.primary(),
}
}
}
impl LockStore {
/// Load the lock at `path`, or `Ok(None)` if no lock is configured there.
///
/// A file that exists but does not parse is an error, not an absent lock:
/// silently treating a corrupt lock as "unlocked" would turn one bad byte
/// into an open door.
pub fn open(path: &Path) -> Result<Option<Self>, Error> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(_) => return Err(Error::AppLockStore),
};
Ok(Some(Self {
backing: Backing::File(path.to_path_buf()),
lock: AppLock::parse(&bytes)?,
every_copy_current: true,
}))
}
/// Create a lock at `path`, refusing to overwrite one already there.
///
/// The refusal is done by the *creation* rather than by a prior
/// `path.exists()` test. Checking and then writing is a race, and the two
/// ways it loses both matter here: another process can win between the two
/// steps, and a symbolic link planted at the lock path would have been
/// followed, so `fs::write` would have overwritten whatever it pointed at.
/// `create_new` asks the kernel to fail if anything is already there,
/// which is one atomic answer to both.
pub fn create(path: &Path, password: &[u8], params: kdf::KdfParams) -> Result<Self, Error> {
let store = Self {
backing: Backing::File(path.to_path_buf()),
lock: AppLock::create(password, params)?,
every_copy_current: true,
};
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|_| Error::AppLockStore)?;
}
}
write_private(path, &store.lock.to_bytes(), true)?;
Ok(store)
}
/// Check `password` and persist the outcome.
///
/// A failure to write the updated attempt count does not change the verdict
/// because the attempt really did succeed or fail, so the write is best-effort
/// here. The consequence of losing it is a rate limit that resets, which is
/// already true of anyone who can delete the file.
pub fn unlock(&mut self, password: &[u8]) -> Result<(), Error> {
let result = self.lock.verify(password);
if result.is_ok() {
// The passphrase is in hand, so this is the moment to draw a fresh
// nonce, re-tag, and move a version 1 record forward. It is also
// the only moment a raised tamper flag can be written with a tag
// that will stand up to the next check.
let _ = self.lock.retag(password);
}
// Nothing to persist if we never got as far as an attempt.
if !matches!(result, Err(Error::AppLockCooldown(_))) {
if let Ok(current) = self.save() {
self.every_copy_current = current;
}
}
result
}
/// Whether the stored record has been found edited by somebody without the
/// passphrase. See [`AppLock::tampered`].
pub fn tampered(&self) -> bool {
self.lock.tampered()
}
/// Clear the tamper report, after proving the passphrase, and persist that.
///
/// One key derivation, not three. The first version called `unlock` and
/// then `AppLock::acknowledge`, which verifies again, and each of those is
/// a full Argon2id run at 256 MiB. Three of them is the better part of a
/// minute on a slow machine to dismiss a message, and a control nobody will
/// wait for is a control nobody uses. `unlock` has already proved the
/// passphrase by the time the flag is cleared.
pub fn acknowledge(&mut self, password: &[u8]) -> Result<(), Error> {
self.unlock(password)?;
self.lock.tampered = false;
self.lock.retag(password)?;
self.every_copy_current = self.save()?;
Ok(())
}
/// Raise the tamper report from outside, and persist it if the passphrase
/// allows.
///
/// [`crate::vault`] calls this when it finds the two copies of a lock
/// disagreeing, which is evidence this module cannot see on its own. The
/// flag is held in memory either way; persisting it needs an unlock, so a
/// report raised now becomes durable at the next successful one.
pub fn report_tamper(&mut self) {
self.lock.tampered = true;
}
/// Replace the password, after proving the current one.
pub fn change_password(&mut self, current: &[u8], new: &[u8]) -> Result<(), Error> {
self.unlock(current)?;
let carried = self.lock.tampered;
self.lock = AppLock::create(new, self.lock.params)?;
// A new passphrase is not an acknowledgement. Somebody who changes the
// password without ever reading the report should still see it, so the
// flag is carried across and the record re-tagged under the new key.
self.lock.tampered = carried;
self.lock.retag(new)?;
self.every_copy_current = self.save()?;
// A change that did not reach the spare has left the previous password
// sitting in a file this process cannot rewrite. Anybody who deletes
// the first copy gets the old password back, so the change is reported
// as incomplete rather than as done.
if !self.every_copy_current {
return Err(Error::AppLockSpareStale);
}
Ok(())
}
/// Remove the lock, after proving the password.
///
/// Proving it is a courtesy to the honest user, not a control: the file can
/// simply be deleted by anyone who can reach it, which [`SCOPE`] says.
pub fn remove(mut self, current: &[u8]) -> Result<(), Error> {
self.unlock(current)?;
match &self.backing {
Backing::Vault(v) => v.clear(),
Backing::File(path) => std::fs::remove_file(path).map_err(|_| Error::AppLockStore),
}
}
/// Seconds still to wait before another attempt is accepted.
pub fn cooldown(&self) -> Option<Duration> {
self.lock.cooldown()
}
/// Derive the key that names and opens the obfuscated program folder.
///
/// Delegates to [`AppLock::store_key`], and carries the same warning: the
/// password is not checked here, so call [`Self::unlock`] first and act on
/// what it says.
pub fn store_key(&self, password: &[u8]) -> Result<crate::hoard::StoreKey, Error> {
self.lock.store_key(password)
}
/// Consecutive failed attempts recorded so far.
pub fn failures(&self) -> u32 {
self.lock.failures()
}
/// Where this lock is stored. The first of the two copies, when it is
/// vault-backed.
pub fn path(&self) -> &Path {
self.backing.primary()
}
/// Write the record, and say whether every copy of it is now current.
///
/// `Ok(false)` only ever comes from a vault whose administrator-owned spare
/// could not be written. A single file is either written or an error.
fn save(&self) -> Result<bool, Error> {
match &self.backing {
Backing::Vault(v) => v.store(&self.lock),
Backing::File(path) => {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|_| Error::AppLockStore)?;
}
}
write_private(path, &self.lock.to_bytes(), false).map(|()| true)
}
}
}
/// Whether the last write reached every copy.
///
/// False when the administrator-owned spare could not be updated, which
/// leaves it holding an older record. Callers show this; a spare carrying
/// the previous password is a way back in for anybody who knew it.
pub fn every_copy_current(&self) -> bool {
self.every_copy_current
}
}
/// Open the lock at the default location, wherever this platform keeps it.
///
/// Returns `Ok(None)` when no lock is configured, and `Err` when the
/// environment does not say where a configuration directory is -- a caller
/// that cannot find one should say so rather than scatter a lock file into the
/// working directory.
///
/// The second element is true when the two copies did not agree: one had gone
/// and was rebuilt from the other, or both were there and held different stored
/// passwords. Neither happens on its own, so the caller should treat it as a
/// tamper report and show it; [`LockStore::report_tamper`] is how to make it
/// stick.
pub fn open_default() -> Result<(Option<LockStore>, bool), Error> {
let base = default_dir().ok_or(Error::AppLockStore)?;
open_in(&base)
}
/// The name the lock had before the vault: one file, under the obvious name.
const LEGACY_NAME: &str = "applock.bin";
/// Open a vault-backed lock under `base`, adopting a pre-vault file if one is
/// there.
///
/// Split out of [`open_default`] so it can be tested against a directory a
/// test owns, which is the half that was missing when F-141 shipped.
pub fn open_in(base: &Path) -> Result<(Option<LockStore>, bool), Error> {
let vault = crate::vault::Vault::at(base, crate::vault::admin_dir().as_deref())?;
let (record, found) = vault.load()?;
// Both outcomes are evidence somebody has been at the files: a copy that
// went, or two copies that no longer hold the same password.
let restored = matches!(
found,
crate::vault::Found::Restored | crate::vault::Found::Disagreed
);
if let Some(lock) = record {
return Ok((
Some(LockStore {
backing: Backing::Vault(vault),
lock,
every_copy_current: true,
}),
restored,
));
}
// Nothing in the vault. There may still be a lock in the file the vault
// replaced, and there will be for every user who set one through the
// window in 0.1.17 -- see F-141. Ignoring it would take their lock away
// and hand them the same error again.
if let Some(lock) = read_legacy(base)? {
let mut store = LockStore {
backing: Backing::Vault(vault),
lock,
every_copy_current: true,
};
store.every_copy_current = store.save()?;
// Only once the vault holds it. A crash between the two leaves the
// old file, which is still the copy that opens.
let _ = std::fs::remove_file(base.join(LEGACY_NAME));
return Ok((Some(store), restored));
}
Ok((None, restored))
}
/// Read a pre-vault lock file, if one is there.
///
/// A file that exists but does not parse is reported rather than ignored: it
/// is somebody's lock, and treating it as absent is how a lock silently
/// becomes no lock.
fn read_legacy(base: &Path) -> Result<Option<AppLock>, Error> {
match std::fs::read(base.join(LEGACY_NAME)) {
Ok(bytes) => AppLock::parse(&bytes).map(Some),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(_) => Err(Error::AppLockStore),
}
}
/// Create a lock at the default location, refusing to replace one already
/// there.
pub fn create_default(password: &[u8], params: kdf::KdfParams) -> Result<LockStore, Error> {
let base = default_dir().ok_or(Error::AppLockStore)?;
create_in(&base, password, params)
}
/// Create a vault-backed lock under `base`.
///
/// Split out of [`create_default`] for the same reason as [`open_in`]: so a
/// test can drive creation and opening against one directory it owns, and
/// catch the two disagreeing about where a lock lives.
pub fn create_in(base: &Path, password: &[u8], params: kdf::KdfParams) -> Result<LockStore, Error> {
let vault = crate::vault::Vault::at(base, crate::vault::admin_dir().as_deref())?;
if vault.load()?.0.is_some() || read_legacy(base)?.is_some() {
return Err(Error::AppLockExists);
}
let mut store = LockStore {
backing: Backing::Vault(vault),
lock: AppLock::create(password, params)?,
every_copy_current: true,
};
store.every_copy_current = store.save()?;
Ok(store)
}
/// Write the lock file so it is owner-only from the moment it exists.
///
/// The previous version used `fs::write`, which creates with the process
/// umask, usually world-readable, and chmod'd afterwards, so the stored
/// password verifier was readable by every other local user for the window
/// between the two calls. That window reopened on **every save**, and a save
/// happens after every failed unlock attempt.
///
/// `exclusive` additionally requires that nothing is already at the path, which
/// is how a lock is created without a check-then-write race and without
/// following a symbolic link planted there. See
/// [`crate::privatefile`] for the whole argument.
fn write_private(path: &Path, bytes: &[u8], exclusive: bool) -> Result<(), Error> {
let result = if exclusive {
crate::privatefile::write_owner_only_new(path, bytes)
} else {
crate::privatefile::write_owner_only(path, bytes)
};
result.map_err(|_| Error::AppLockStore)
}
/// Where the lock file lives, given a platform and an environment.
///
/// Separated from [`default_path`] so every branch can be tested from any
/// machine. The Windows answer is the one that mattered here and the one that
/// could not previously be checked outside Windows, which is a large part of
/// why **F-143** survived.
///
/// # F-143: an empty variable is not a directory
///
/// `var_os` returns `Some("")` for a variable that is set to nothing, and
/// `PathBuf::from("")` joined with anything is a *relative* path. An empty
/// `HOME` produced `.config/veilvoice/applock.bin`; an empty `APPDATA`
/// produced `veilvoice\applock.bin`. Either scatters the lock into whatever
/// directory the program was started in, and then silently fails to find it on
/// the next launch from anywhere else -- the same disappearing lock as F-141,
/// from an unrelated cause.
///
/// `XDG_CONFIG_HOME` was filtered for exactly this. The other two were not.
///
/// An environment with an empty `APPDATA` is not exotic: services, stripped
/// sandboxes and some installer contexts all produce one.
fn config_path(
windows: bool,
macos: bool,
appdata: Option<std::ffi::OsString>,
home: Option<std::ffi::OsString>,
xdg: Option<std::ffi::OsString>,
) -> Option<PathBuf> {
let set = |v: Option<std::ffi::OsString>| v.filter(|s| !s.is_empty());
let base = if windows {
PathBuf::from(set(appdata)?)
} else if macos {
let mut p = PathBuf::from(set(home)?);
p.push("Library/Application Support");
p
} else {
match set(xdg) {
Some(v) => PathBuf::from(v),
None => {
let mut p = PathBuf::from(set(home)?);
p.push(".config");
p
}
}
};
Some(base.join("veilvoice").join("applock.bin"))
}
/// The name of the folder that makes a copy of VeilVoice keep its state beside
/// itself.
///
/// Fixed, and not configurable, because the thing it turns on has to be
/// visible: somebody looking at a folder on a memory stick can see whether the
/// settings are in it.
pub const PORTABLE_DIR: &str = "veilvoice-data";
/// Where a portable copy keeps its state, when it is one.
///
/// # Why this is opted into rather than detected
///
/// "Beside the program if that is writable" would move an ordinary
/// installation's settings the day somebody unpacked it somewhere writable, and
/// the symptom would be an empty vault: everything still on the disk, and the
/// program looking in the other place. So it is not guessed. A directory named
/// [`PORTABLE_DIR`] beside the executable turns it on, and removing that
/// directory turns it off. Both are things a person does deliberately and can
/// see they have done.
///
/// The directory has to exist already. Creating it here would make every copy
/// portable on its first run, which is the guess this avoids.
fn portable_dir() -> Option<PathBuf> {
let beside = std::env::current_exe().ok()?.parent()?.join(PORTABLE_DIR);
// A file of that name is not a folder of that name, and following it would
// put the lock somewhere that cannot hold it.
beside.is_dir().then_some(beside)
}
/// Which of the two locations a copy is using, given what is beside it and what
/// the platform says.
///
/// Split out so both branches are testable from any machine, which is the same
/// reason [`config_path`] is split out, and for the same reason: the branch
/// that mattered in **F-143** could not be checked from the machine that wrote
/// it.
///
/// The portable folder wins where it exists. That is the whole point of it, and
/// it is the answer even when the platform also offers a configuration
/// directory: somebody who put that folder beside the program asked for the
/// state to be there.
fn choose_base(portable: Option<PathBuf>, config: Option<PathBuf>) -> Option<PathBuf> {
portable.map(|p| p.join("applock.bin")).or(config)
}
/// The configuration directory the vault keeps its files in, if the
/// environment says where one is.
pub fn default_dir() -> Option<PathBuf> {
default_path().and_then(|p| p.parent().map(Path::to_path_buf))
}
/// The platform's own configuration directory, whether or not it is the one in
/// use.
///
/// [`default_dir`] answers "where is the state", which is the portable folder
/// when there is one. This answers "where would it be if there were not", which
/// is the other half of the question somebody installing a portable copy is
/// being asked: what to carry the settings *to*.
pub fn platform_dir() -> Option<PathBuf> {
let path = config_path(
cfg!(windows),
cfg!(target_os = "macos"),
std::env::var_os("APPDATA"),
std::env::var_os("HOME"),
std::env::var_os("XDG_CONFIG_HOME"),
)?;
path.is_absolute()
.then_some(path)?
.parent()
.map(Path::to_path_buf)
}
/// Whether this copy is keeping its state beside itself.
///
/// Reported rather than inferred by a caller comparing paths: the About tab
/// says which of the two arrangements is in use, and working that out by
/// looking at a path would be a second copy of the rule.
pub fn is_portable() -> bool {
portable_dir().is_some()
}
/// Where the lock file lives, if there is anywhere for it.
///
/// Two answers, in this order. A folder named [`PORTABLE_DIR`] beside the
/// program, when one is there: a copy on a memory stick then keeps its
/// settings, its vaults and its lock on the stick, and stays a copy on a stick.
/// Otherwise this platform's configuration directory.
///
/// The configuration directory is resolved from environment variables rather
/// than a directories crate: it is twenty lines, it adds no dependency to a
/// security crate, and it returns `None` instead of guessing when the
/// environment does not say. A caller that gets `None` should tell the user it
/// cannot find a config directory rather than scattering a lock file into the
/// working directory.
///
/// This is the path a caller naming one gets, and the anchor a dozen other
/// settings files are derived from -- policy, capture, sentry. It is no longer
/// where the lock itself is kept by default: [`open_default`] goes through
/// [`crate::vault`], whose files sit in the same directory under names derived
/// from its index.
pub fn default_path() -> Option<PathBuf> {
let path = choose_base(
portable_dir(),
config_path(
cfg!(windows),
cfg!(target_os = "macos"),
std::env::var_os("APPDATA"),
std::env::var_os("HOME"),
std::env::var_os("XDG_CONFIG_HOME"),
),
)?;
// The invariant the whole function exists for, checked rather than
// reasoned about: a relative answer is no answer, because it means a lock
// file in whatever directory the program was started in.
//
// Here rather than inside `config_path` because `Path::is_absolute` judges
// by the rules of the machine it runs on, and `config_path` is called with
// a *simulated* platform by the tests -- a Windows path is not absolute to
// Linux, so checking it in there would fail every cross-platform test
// while proving nothing. Here, the platform is real.
path.is_absolute().then_some(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn weak() -> kdf::KdfParams {
kdf::KdfParams::weak_for_tests()
}
#[test]
fn a_folder_beside_the_program_wins_over_the_platform_directory() {
// The whole of the portable arrangement: a copy on a stick keeps its
// state on the stick. Tested through `choose_base` because
// `current_exe` is the machine's answer rather than something a test
// can set.
let beside = PathBuf::from("/media/stick/veilvoice-data");
let config = PathBuf::from("/home/someone/.config/veilvoice/applock.bin");
assert_eq!(
choose_base(Some(beside.clone()), Some(config.clone())),
Some(beside.join("applock.bin"))
);
assert_eq!(choose_base(None, Some(config.clone())), Some(config));
}
#[test]
fn a_portable_folder_is_an_answer_even_where_the_platform_has_none() {
// A stripped environment that says nothing about a configuration
// directory is exactly the case a stick is carried into.
let beside = PathBuf::from("/media/stick/veilvoice-data");
assert_eq!(
choose_base(Some(beside.clone()), None),
Some(beside.join("applock.bin"))
);
assert_eq!(choose_base(None, None), None);
}
#[test]
fn nothing_beside_the_program_is_created_by_asking() {
// The opt-in has to be a thing a person did. If asking made the folder,
// every copy would become portable on its first run and an ordinary
// installation's settings would move without anybody choosing it.
let before = portable_dir();
let again = portable_dir();
assert_eq!(before, again, "asking twice gave two answers");
if let Some(dir) = before {
assert!(dir.is_dir(), "a folder that is not there was reported");
}
}
#[test]
fn the_name_that_turns_it_on_is_the_one_that_is_looked_for() {
// Said in the documentation, in the About tab and in the user guide.
// One constant, so those cannot drift apart from what is read.
assert_eq!(PORTABLE_DIR, "veilvoice-data");
assert!(
!PORTABLE_DIR.contains(std::path::MAIN_SEPARATOR),
"the marker has to be one folder beside the program"
);
}
#[test]
fn the_right_password_opens_it_and_a_wrong_one_does_not() {
let mut lock = AppLock::create(b"open sesame", weak()).unwrap();
assert!(matches!(
lock.verify(b"open sesamf"),
Err(Error::AppLockRejected)
));
assert_eq!(lock.verify(b"open sesame"), Ok(()));
}
#[test]
fn a_success_clears_the_failure_history() {
let mut lock = AppLock::create(b"pw", weak()).unwrap();
for _ in 0..3 {
let _ = lock.verify(b"nope");
}
assert_eq!(lock.failures(), 3);
lock.verify(b"pw").unwrap();
assert_eq!(lock.failures(), 0);
assert!(lock.cooldown().is_none());
}
/// A lock file of exactly the right length parses; one byte short does not.
///
/// `parse` refuses `bytes.len() < want`, and mutation testing turned that
/// into `<=`, which rejects the exact length every real lock file has. The
/// suite never handed it a buffer of exactly `want`: it tested rubbish,
/// which is too short, and real files, which come through `to_bytes` and
/// so were never measured against the boundary from below.
#[test]
fn a_lock_file_is_accepted_at_exactly_its_length_and_not_one_byte_under() {
let lock = AppLock::create(b"pw", weak()).unwrap();
let bytes = lock.to_bytes();
assert_eq!(bytes.len(), LOCK_LEN, "a version 2 record is LOCK_LEN long");
AppLock::parse(&bytes).expect("exactly the right length must parse");
assert!(
matches!(
AppLock::parse(&bytes[..bytes.len() - 1]),
Err(Error::Truncated)
),
"one byte short is truncated"
);
}
/// The header boundary from both sides: nine bytes reaches the version.
#[test]
fn nine_bytes_is_enough_to_reach_the_version_and_eight_is_not() {
let mut nine = Vec::from(MAGIC);
nine.push(99); // a version this build does not know
assert!(
matches!(AppLock::parse(&nine), Err(Error::UnsupportedVersion(99))),
"nine bytes is enough to read the version and report it"
);
assert!(
matches!(AppLock::parse(&nine[..8]), Err(Error::Truncated)),
"eight is not"
);
}
/// A legacy lock file that cannot be read is an error, not an absent lock.
///
/// Telling somebody upgrading that they have no lock is how a lock silently
/// becomes no lock.
#[test]
fn a_legacy_lock_that_cannot_be_read_is_not_reported_as_absent() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(LEGACY_NAME)).unwrap();
assert!(
open_in(dir.path()).is_err(),
"a directory where the old lock file should be is not an absent lock"
);
}
/// Where the lock would live without a portable folder, which is a different
/// question from where it does live.
#[test]
fn the_platform_directory_is_a_real_path() {
let dir = platform_dir().expect("this platform has a configuration directory");
assert!(!dir.as_os_str().is_empty(), "and it is not the empty path");
assert!(
dir.is_absolute(),
"a configuration directory is an absolute path: {dir:?}"
);
}
/// A save puts its directory back when somebody has deleted it.
///
/// Every other path has already created it, so this is the only case that
/// reaches the creation in `save`.
#[test]
fn a_save_recreates_its_directory_if_it_is_removed_underneath() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("state");
let path = base.join("lock");
let mut store = LockStore::create(&path, b"pw", weak()).unwrap();
std::fs::remove_dir_all(&base).unwrap();
assert!(
!base.exists(),
"the directory is gone before the next write"
);
// An unlock persists the attempt count, which is a save.
store.unlock(b"pw").unwrap();
assert!(path.exists(), "the save must put the file back");
let reopened = LockStore::open(&path).unwrap().expect("and it reads back");
assert!(reopened.tampered() == store.tampered());
}
/// Only `NotFound` means there is no lock. Reporting a file that exists but
/// cannot be read as absent turns a permissions problem into an open door.
#[test]
fn a_path_that_is_not_a_readable_file_is_an_error_rather_than_no_lock() {
let dir = tempfile::tempdir().unwrap();
// Genuinely absent: the one case that really is `Ok(None)`.
assert!(LockStore::open(&dir.path().join("nothing-here"))
.unwrap()
.is_none());
// A directory where a file should be. Reading it fails with something
// that is not `NotFound` on every platform this ships to.
let in_the_way = dir.path().join("in-the-way");
std::fs::create_dir(&in_the_way).unwrap();
assert!(
LockStore::open(&in_the_way).is_err(),
"a directory in place of the lock file is not an absent lock"
);
}
/// The vault can raise the tamper report, and unlocking does not clear it.
#[test]
fn a_report_raised_from_outside_is_visible_and_survives_an_unlock() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("lock");
let mut store = LockStore::create(&path, b"pw", weak()).unwrap();
assert!(!store.tampered());
store.report_tamper();
assert!(store.tampered(), "the report must be visible at once");
// And persisting it needs an unlock, after which it is still raised:
// an unlock is not an acknowledgement.
store.unlock(b"pw").unwrap();
assert!(store.tampered(), "unlocking does not clear a report");
store.acknowledge(b"pw").unwrap();
assert!(!store.tampered(), "acknowledging does");
}
/// The wait and the every-copy-current answer, in states where each matters.
#[test]
fn the_store_reports_its_wait_and_whether_every_copy_is_current() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("lock");
let mut store = LockStore::create(&path, b"pw", weak()).unwrap();
// A single file is either written or an error, so every copy is
// current by construction here. That is the `true` half.
assert!(store.every_copy_current());
assert!(store.cooldown().is_none(), "no failures, no wait");
for _ in 0..4 {
let _ = store.unlock(b"nope");
}
let waiting = store.cooldown().expect("four failures earn a wait");
assert!(
waiting.as_secs() > 0 && waiting.as_secs() <= MAX_DELAY_SECS,
"the wait is a real number of seconds, not a default Duration: {waiting:?}"
);
assert!(
store.every_copy_current(),
"still a single file, still current"
);
}
/// The default path names a real file in a real directory. Which path it is
/// belongs to the machine, so only its shape is asserted.
#[test]
fn the_default_lock_path_is_an_actual_path() {
let path = default_path().expect("this platform has somewhere to keep a lock");
assert!(
path.file_name().is_some(),
"the default path names a file: {path:?}"
);
assert!(
path.parent()
.map(|p| !p.as_os_str().is_empty())
.unwrap_or(false),
"and sits in a directory: {path:?}"
);
let dir = default_dir().expect("and that directory can be named on its own");
assert!(!dir.as_os_str().is_empty(), "which is not the empty path");
assert!(
path.starts_with(&dir),
"the file is inside the directory: {path:?} against {dir:?}"
);
}
/// The rate limit is the whole defence against a script guessing all night,
/// so its shape is asserted rather than assumed.
#[test]
fn the_wait_is_free_then_doubles_then_caps() {
assert_eq!(delay_secs(0), 0);
assert_eq!(delay_secs(3), 0, "three attempts are free");
assert_eq!(delay_secs(4), 5);
assert_eq!(delay_secs(5), 10);
assert_eq!(delay_secs(6), 20);
assert_eq!(delay_secs(20), MAX_DELAY_SECS);
assert_eq!(delay_secs(u32::MAX), MAX_DELAY_SECS, "must not overflow");
// The cap is asserted as a number, not as `MAX_DELAY_SECS`. Comparing
// it against the constant that defines it is a check that agrees with
// itself: mutation testing replaced `15 * 60` with `15 + 60` and every
// line above still passed, because every line above was reading the
// mutated value from both sides. That is F-71's shape in a third
// place, and the fix is the same one: state the number the
// documentation states.
assert_eq!(MAX_DELAY_SECS, 900, "the cap is a quarter of an hour");
assert_eq!(BASE_DELAY_SECS, 5);
assert_eq!(FREE_ATTEMPTS, 3);
assert_eq!(delay_secs(20), 900);
}
/// Same lock means the salt **and** the verifier match.
///
/// One half matching is the case somebody would construct; two records
/// agreeing about everything or nothing cannot tell `&` from `|`.
#[test]
fn two_locks_agreeing_on_one_half_are_not_the_same_lock() {
let a = AppLock::create(b"pw", weak()).unwrap();
// Same salt, different verifier: a record whose salt was copied from
// a real one to make it look like that lock.
let mut wearing_its_salt = AppLock::create(b"other", weak()).unwrap();
wearing_its_salt.salt = a.salt;
assert!(
!a.same_secret_as(&wearing_its_salt),
"a matching salt alone must not make it the same lock"
);
// Same verifier, different salt. Not reachable from a passphrase,
// since the salt feeds the derivation, but this is a comparison of two
// records and a record is whatever is on disk.
let mut wearing_its_verifier = AppLock::create(b"other", weak()).unwrap();
wearing_its_verifier.verifier = a.verifier.clone();
assert!(
!a.same_secret_as(&wearing_its_verifier),
"a matching verifier alone must not make it the same lock"
);
assert!(a.same_secret_as(&a.clone()), "a lock is itself");
}
/// Clearing the tamper report costs the passphrase. A report anybody can
/// dismiss is a report an attacker dismisses.
#[test]
fn a_tamper_report_is_not_cleared_by_the_wrong_passphrase() {
let mut lock = AppLock::create(b"pw", weak()).unwrap();
lock.tampered = true;
assert!(
lock.acknowledge(b"nope").is_err(),
"the wrong passphrase must not clear the report"
);
assert!(lock.tampered(), "and must leave it raised");
lock.acknowledge(b"pw").unwrap();
assert!(!lock.tampered(), "the right one clears it");
}
/// The remaining wait shrinks with the clock, and a clock that went backwards
/// is not credit against it.
#[test]
fn the_wait_is_reported_and_counts_down() {
let mut lock = AppLock::create(b"pw", weak()).unwrap();
for _ in 0..4 {
let _ = lock.verify_at(b"nope", 1_000);
}
let wait = delay_secs(lock.failures());
assert!(wait > 0, "four failures must earn a wait");
assert_eq!(
lock.cooldown_at(1_000),
Some(wait),
"no time has passed, so the whole wait remains"
);
assert_eq!(
lock.cooldown_at(1_001),
Some(wait - 1),
"a second later, a second less"
);
assert_eq!(
lock.cooldown_at(1_000 + wait as i64),
None,
"once the wait is served there is none left"
);
assert_eq!(
lock.cooldown_at(900),
Some(wait),
"a clock that went backwards is not credit against the wait"
);
// And the public form, which reads the real clock. It needs its own
// lock, failed against that same clock: the one above recorded its
// failures at second 1,000 of the epoch, so by the real clock its wait
// was served decades ago and `None` is the right answer. Asserting
// `is_some` here is what kills the mutant that made `cooldown` always
// answer `None`.
let mut against_the_real_clock = AppLock::create(b"pw", weak()).unwrap();
for _ in 0..4 {
let _ = against_the_real_clock.verify(b"nope");
}
assert!(against_the_real_clock.cooldown().is_some());
}
/// The clock is the real one. Every other test passes its own time, so
/// nothing else would notice a constant.
#[test]
fn the_clock_reads_the_present_rather_than_a_constant() {
let now = unix_now();
// 2020-01-01 and 2100-01-01. Wide enough that no reasonable machine
// clock trips it, narrow enough that 0, 1 and -1 all fall outside.
assert!(
(1_577_836_800..4_102_444_800).contains(&now),
"unix_now answered {now}, which is not a plausible present"
);
}
#[test]
fn a_rate_limited_attempt_is_refused_without_consulting_the_password() {
let mut lock = AppLock::create(b"pw", weak()).unwrap();
for _ in 0..4 {
let _ = lock.verify_at(b"nope", 1_000);
}
// Even the correct password is refused while the wait is in force.
assert!(matches!(
lock.verify_at(b"pw", 1_000),
Err(Error::AppLockCooldown(5))
));
// And once it has elapsed, it opens.
assert_eq!(lock.verify_at(b"pw", 1_005), Ok(()));
}
/// Rolling the clock backwards must not be a way to shorten the wait.
#[test]
fn a_clock_that_moves_backwards_does_not_shorten_the_wait() {
let mut lock = AppLock::create(b"pw", weak()).unwrap();
for _ in 0..4 {
let _ = lock.verify_at(b"nope", 10_000);
}
assert!(matches!(
lock.verify_at(b"pw", 9_000),
Err(Error::AppLockCooldown(5))
));
}
#[test]
fn it_round_trips_through_its_file_format() {
let mut lock = AppLock::create(b"pw", weak()).unwrap();
let _ = lock.verify_at(b"wrong", 4_242);
let bytes = lock.to_bytes();
assert_eq!(bytes.len(), LOCK_LEN);
assert_eq!(&bytes[..8], MAGIC);
let mut back = AppLock::parse(&bytes).unwrap();
assert_eq!(back.failures(), 1);
assert_eq!(back.params(), weak());
assert_eq!(back.verify(b"pw"), Ok(()));
}
/// A failed attempt must survive a restart, or the rate limit is theatre.
#[test]
fn the_failure_count_survives_a_reload() {
let mut lock = AppLock::create(b"pw", weak()).unwrap();
for _ in 0..4 {
let _ = lock.verify_at(b"wrong", 100);
}
let reloaded = AppLock::parse(&lock.to_bytes()).unwrap();
assert_eq!(reloaded.failures(), 4);
assert_eq!(reloaded.cooldown_at(100), Some(5));
}
#[test]
fn malformed_lock_files_are_rejected_cleanly() {
let lock = AppLock::create(b"pw", weak()).unwrap();
let good = lock.to_bytes();
assert!(matches!(AppLock::parse(b"short"), Err(Error::Truncated)));
let mut long = good.clone();
long.push(0);
assert!(matches!(AppLock::parse(&long), Err(Error::BadHeader)));
let mut magic = good.clone();
magic[0] = b'X';
assert!(matches!(AppLock::parse(&magic), Err(Error::BadMagic)));
let mut version = good.clone();
version[8] = 7;
assert!(matches!(
AppLock::parse(&version),
Err(Error::UnsupportedVersion(7))
));
let mut reserved = good.clone();
reserved[9] = 1;
assert!(matches!(AppLock::parse(&reserved), Err(Error::BadHeader)));
// Costs are attacker-controlled and must be bounded at parse time, not
// only when a password is eventually tried against them.
for (offset, value) in [(12usize, u32::MAX), (20, u32::MAX), (16, 0)] {
let mut bad = good.clone();
bad[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
assert!(
matches!(AppLock::parse(&bad), Err(Error::KdfParams)),
"offset {offset} value {value} was accepted"
);
}
}
/// Regression: the verifier used to be written with the process umask and
/// only chmod'd afterwards, so it was world-readable for a window on every
/// single save, and a save happens after every failed attempt.
#[cfg(unix)]
#[test]
fn the_lock_file_is_owner_only_from_the_moment_it_exists() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("applock.bin");
let mut store = LockStore::create(&path, b"pw", weak()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "created with mode {mode:o}");
// And it stays that way across the rewrite a failed attempt triggers.
let _ = store.unlock(b"nope");
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "after a save, mode is {mode:o}");
}
/// Creating must fail atomically rather than by testing `exists()` first,
/// so a symbolic link at the lock path is refused instead of followed.
#[cfg(unix)]
#[test]
fn a_symlink_at_the_lock_path_is_not_followed() {
let dir = tempfile::tempdir().unwrap();
let victim = dir.path().join("victim");
std::fs::write(&victim, b"important").unwrap();
let path = dir.path().join("applock.bin");
std::os::unix::fs::symlink(&victim, &path).unwrap();
assert!(matches!(
LockStore::create(&path, b"pw", weak()),
Err(Error::AppLockStore)
));
assert_eq!(std::fs::read(&victim).unwrap(), b"important");
}
/// The verifier must not be a key anything else could derive from the same
/// passphrase and salt. Domain separation is what guarantees that.
#[test]
fn the_verifier_is_domain_separated_from_container_keys() {
let salt = [5u8; kdf::SALT_LEN];
let plain = kdf::derive_key(b"same passphrase", &salt, weak()).unwrap();
let (bound, _) = derive_pair(b"same passphrase", &salt, weak()).unwrap();
assert_ne!(plain, bound, "app lock and container key must not coincide");
}
/// The half of the derivation that is written to disk must say nothing
/// about the half that authenticates the record, or the tag is worth
/// nothing to anybody holding the file.
#[test]
fn the_stored_verifier_and_the_tag_key_are_independent() {
let salt = [5u8; kdf::SALT_LEN];
let (verifier, tag_key) = derive_pair(b"same passphrase", &salt, weak()).unwrap();
assert_ne!(verifier, tag_key);
}
/// **F-141.** A lock set through the interface has to be there next time.
///
/// The desktop application created locks with `LockStore::create`, which
/// writes the single pre-vault file, and loaded them with `open_default`,
/// which reads the vault. Nothing tested the two against each other, so
/// nothing noticed they were using different files: the lock appeared to
/// be set, was gone on the next launch, and the second attempt failed with
/// `AppLockStore` because `applock.bin` was already there.
/// **F-143.** An empty environment variable must read as absent.
///
/// Every branch, from any machine: the Windows answer is the one that
/// mattered and the one that could not previously be checked off Windows.
#[test]
fn an_empty_config_variable_is_never_a_relative_path() {
let empty = Some(std::ffi::OsString::from(""));
for (windows, macos, appdata, home, xdg) in [
(true, false, empty.clone(), None, None),
(false, true, None, empty.clone(), None),
(false, false, None, empty.clone(), None),
(false, false, None, empty.clone(), empty.clone()),
(false, false, None, None, empty.clone()),
] {
let answer = config_path(windows, macos, appdata, home, xdg);
assert_eq!(
answer, None,
"an empty variable produced {answer:?}. A relative answer \
scatters the lock into the working directory, which is the \
outcome this function exists to prevent."
);
}
}
#[test]
fn a_missing_config_variable_is_also_absent() {
assert_eq!(config_path(true, false, None, None, None), None);
assert_eq!(config_path(false, true, None, None, None), None);
assert_eq!(config_path(false, false, None, None, None), None);
}
#[test]
fn each_platform_puts_the_lock_where_that_platform_keeps_configuration() {
let os = std::ffi::OsString::from;
assert_eq!(
config_path(
true,
false,
Some(os("C:\\Users\\a\\AppData\\Roaming")),
None,
None
),
Some(PathBuf::from(
"C:\\Users\\a\\AppData\\Roaming/veilvoice/applock.bin"
))
);
assert_eq!(
config_path(false, true, None, Some(os("/Users/a")), None),
Some(PathBuf::from(
"/Users/a/Library/Application Support/veilvoice/applock.bin"
))
);
assert_eq!(
config_path(false, false, None, Some(os("/home/a")), None),
Some(PathBuf::from("/home/a/.config/veilvoice/applock.bin"))
);
assert_eq!(
config_path(false, false, None, Some(os("/home/a")), Some(os("/cfg"))),
Some(PathBuf::from("/cfg/veilvoice/applock.bin")),
"XDG_CONFIG_HOME wins over HOME when it is set to something"
);
}
#[test]
fn a_relative_config_directory_is_refused_on_the_real_platform() {
// `config_path` builds what it is given; `default_path` is where the
// answer is judged, because only there is the platform the real one.
// Somebody with `XDG_CONFIG_HOME=cfg` gets no answer rather than a
// lock file relative to wherever they happened to be standing.
let relative = config_path(
false,
false,
None,
None,
Some(std::ffi::OsString::from("cfg")),
);
assert!(
relative.is_some_and(|p| !p.is_absolute()),
"the pure function builds it; judging it is default_path's job"
);
if let Some(real) = default_path() {
assert!(
real.is_absolute(),
"default_path must never return a relative path"
);
}
}
#[test]
fn a_lock_that_was_created_can_be_opened_again() {
let dir = tempfile::tempdir().unwrap();
create_in(dir.path(), b"passphrase", weak()).unwrap();
let (found, _) = open_in(dir.path()).unwrap();
let mut found = found.expect("a lock that was just created has to be there");
assert!(found.unlock(b"passphrase").is_ok());
}
/// The sharp half of F-141: *where* creation writes.
///
/// The round trip above passes even with the bug present, now that
/// `open_in` adopts a pre-vault file -- adoption would quietly paper over
/// exactly the mistake that caused this. So this pins the thing that was
/// actually wrong: creation goes to the vault, and does not leave the
/// single file behind for the next creation to trip over.
#[test]
fn creation_writes_the_vault_and_not_the_pre_vault_file() {
let dir = tempfile::tempdir().unwrap();
create_in(dir.path(), b"passphrase", weak()).unwrap();
assert!(
!dir.path().join(LEGACY_NAME).exists(),
"creation wrote the pre-vault file, which is F-141 exactly"
);
}
#[test]
fn setting_a_lock_twice_is_refused_rather_than_failing_on_the_file() {
let dir = tempfile::tempdir().unwrap();
create_in(dir.path(), b"first", weak()).unwrap();
assert!(create_in(dir.path(), b"second", weak()).is_err());
// And the first still opens: a refused creation must not damage it.
let (found, _) = open_in(dir.path()).unwrap();
assert!(found.unwrap().unlock(b"first").is_ok());
}
/// A lock written by a version that predates the vault is adopted.
///
/// Every user who set one through the window in 0.1.17 has one of these,
/// under a passphrase they chose and expect to still work.
#[test]
fn a_pre_vault_lock_file_is_adopted_on_open() {
let dir = tempfile::tempdir().unwrap();
let legacy = dir.path().join(LEGACY_NAME);
LockStore::create(&legacy, b"from an older build", weak()).unwrap();
let (found, _) = open_in(dir.path()).unwrap();
let mut found = found.expect("the older file has to be found");
assert!(
found.unlock(b"from an older build").is_ok(),
"and open with the passphrase it was set with"
);
assert!(
!legacy.exists(),
"once the vault holds it the single file goes, or the next creation trips over \
it exactly as F-141 did"
);
let (again, _) = open_in(dir.path()).unwrap();
assert!(again.unwrap().unlock(b"from an older build").is_ok());
}
#[test]
fn a_pre_vault_file_blocks_a_new_lock_rather_than_being_overwritten() {
let dir = tempfile::tempdir().unwrap();
LockStore::create(&dir.path().join(LEGACY_NAME), b"old", weak()).unwrap();
assert!(
create_in(dir.path(), b"new", weak()).is_err(),
"somebody's existing lock must not be replaced without their passphrase, \
whichever file it happens to be in"
);
}
#[test]
fn an_empty_directory_has_no_lock_and_that_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let (found, restored) = open_in(dir.path()).unwrap();
assert!(found.is_none());
assert!(!restored);
}
#[test]
fn all_three_branches_of_one_run_are_independent() {
let salt = [5u8; kdf::SALT_LEN];
let (verifier, tag_key, store) = derive_keys(b"same passphrase", &salt, weak()).unwrap();
assert_ne!(verifier, tag_key);
assert_ne!(verifier, store);
assert_ne!(tag_key, store);
}
#[test]
fn the_store_key_does_not_follow_from_the_verifier_on_disk() {
// The verifier is published by the lock file simply existing. If the
// store key could be recovered from it, the obfuscated folder would be
// readable by anybody holding the disk, and the whole scheme would be
// decoration. Distinct HKDF labels are what prevent that; this is the
// test that says so.
let salt = [5u8; kdf::SALT_LEN];
let (verifier, _, store) = derive_keys(b"passphrase", &salt, weak()).unwrap();
assert_ne!(verifier.expose(), store.expose());
}
#[test]
fn the_store_key_is_the_same_every_time_for_one_passphrase() {
// Names are derived from it, so an unstable key would lose every
// record on the next unlock.
let lock = AppLock::create(b"steady", weak()).unwrap();
let a = lock.store_key(b"steady").unwrap();
let b = lock.store_key(b"steady").unwrap();
let hoard_a = crate::hoard::Hoard::open(std::path::Path::new("/tmp"), a);
let hoard_b = crate::hoard::Hoard::open(std::path::Path::new("/tmp"), b);
assert_eq!(
hoard_a.name_for("settings").unwrap(),
hoard_b.name_for("settings").unwrap()
);
}
#[test]
fn a_different_passphrase_derives_a_different_store_key() {
let lock = AppLock::create(b"right", weak()).unwrap();
let right = crate::hoard::Hoard::open(
std::path::Path::new("/tmp"),
lock.store_key(b"right").unwrap(),
);
let wrong = crate::hoard::Hoard::open(
std::path::Path::new("/tmp"),
lock.store_key(b"wrong").unwrap(),
);
assert_ne!(
right.name_for("settings").unwrap(),
wrong.name_for("settings").unwrap()
);
}
#[test]
fn the_stored_verifier_is_not_the_password_or_a_bare_hash_of_it() {
let lock = AppLock::create(b"hunter2", weak()).unwrap();
let bytes = lock.to_bytes();
assert!(
!bytes.windows(7).any(|w| w == b"hunter2"),
"the password itself is in the file"
);
}
#[test]
fn the_store_persists_attempts_across_reopening() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("applock.bin");
let mut store = LockStore::create(&path, b"pw", weak()).unwrap();
assert!(path.exists());
assert!(matches!(store.unlock(b"no"), Err(Error::AppLockRejected)));
let mut reopened = LockStore::open(&path).unwrap().expect("lock is there");
assert_eq!(reopened.failures(), 1, "the failure did not reach disk");
reopened.unlock(b"pw").unwrap();
assert_eq!(reopened.failures(), 0);
}
#[test]
fn no_lock_file_means_no_lock_but_a_broken_one_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("applock.bin");
assert!(LockStore::open(&missing).unwrap().is_none());
let corrupt = dir.path().join("corrupt.bin");
std::fs::write(&corrupt, vec![0u8; LOCK_LEN]).unwrap();
assert!(
LockStore::open(&corrupt).is_err(),
"a corrupt lock must not read as an absent one"
);
}
#[test]
fn creating_over_an_existing_lock_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("applock.bin");
LockStore::create(&path, b"pw", weak()).unwrap();
assert!(matches!(
LockStore::create(&path, b"other", weak()),
Err(Error::AppLockStore)
));
}
#[test]
fn changing_and_removing_need_the_current_password() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("applock.bin");
let mut store = LockStore::create(&path, b"first", weak()).unwrap();
assert!(store.change_password(b"wrong", b"second").is_err());
store.change_password(b"first", b"second").unwrap();
assert!(matches!(
store.unlock(b"first"),
Err(Error::AppLockRejected)
));
store.unlock(b"second").unwrap();
let store = LockStore::open(&path).unwrap().unwrap();
assert!(store.remove(b"nope").is_err());
assert!(path.exists(), "a failed removal must leave the lock alone");
let store = LockStore::open(&path).unwrap().unwrap();
store.remove(b"second").unwrap();
assert!(!path.exists());
}
/// The claim the tag exists to support, stated as a test so it cannot
/// quietly stop being true: an edit made without the passphrase is caught
/// at the next unlock.
#[test]
fn swapping_the_stored_verifier_is_reported_at_the_next_unlock() {
let honest = AppLock::create(b"the real one", weak()).unwrap();
let attacker = AppLock::create(b"the attacker's", weak()).unwrap();
// Splice the attacker's verifier into the owner's record, leaving
// everything else including the tag alone. This is the attack: a lock
// that opens to a password the owner never chose.
let mut bytes = honest.to_bytes();
let theirs = attacker.to_bytes();
bytes[24..72].copy_from_slice(&theirs[24..72]);
let mut edited = AppLock::parse(&bytes).unwrap();
assert!(!edited.tampered(), "nothing has been checked yet");
edited.verify(b"the attacker's").unwrap();
assert!(
edited.tampered(),
"the record was edited by somebody without the passphrase and \
nothing said so"
);
}
/// Weakening the stored cost is the other edit worth making, and it is
/// answered before the tag is even consulted: the cost is an input to the
/// derivation, so a changed cost produces a different verifier and the
/// owner's own passphrase stops matching. Refusal, not a warning, which is
/// the stronger of the two answers.
#[test]
fn weakening_the_argon_cost_stops_the_lock_opening_at_all() {
let lock = AppLock::create(b"pw", kdf::KdfParams::default()).unwrap();
let mut bytes = lock.to_bytes();
bytes[12..16].copy_from_slice(&weak().m_cost.to_le_bytes());
bytes[16..20].copy_from_slice(&weak().t_cost.to_le_bytes());
bytes[20..24].copy_from_slice(&weak().p_cost.to_le_bytes());
let mut edited = AppLock::parse(&bytes).unwrap();
assert!(matches!(edited.verify(b"pw"), Err(Error::AppLockRejected)));
}
#[test]
fn clearing_the_tamper_flag_by_hand_puts_it_straight_back() {
let honest = AppLock::create(b"pw", weak()).unwrap();
let attacker = AppLock::create(b"other", weak()).unwrap();
let mut bytes = honest.to_bytes();
bytes[24..72].copy_from_slice(&attacker.to_bytes()[24..72]);
let mut raised = AppLock::parse(&bytes).unwrap();
raised.verify(b"other").unwrap();
assert!(raised.tampered());
let mut stored = raised.to_bytes();
assert_eq!(stored[72], 1, "the report must reach the file");
// Now wipe the flag, as somebody hiding their tracks would.
stored[72] = 0;
let mut hidden = AppLock::parse(&stored).unwrap();
assert!(!hidden.tampered(), "the file no longer says so");
hidden.verify(b"other").unwrap();
assert!(
hidden.tampered(),
"the flag is inside the tag, so clearing it is itself an edit"
);
}
/// The report must not be dismissible by anything except the passphrase,
/// and it must survive the process dying.
#[test]
fn a_report_outlives_a_restart_and_needs_the_passphrase_to_clear() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("applock.bin");
LockStore::create(&path, b"pw", weak()).unwrap();
let attacker = AppLock::create(b"other", weak()).unwrap();
let mut bytes = std::fs::read(&path).unwrap();
bytes[24..72].copy_from_slice(&attacker.to_bytes()[24..72]);
std::fs::write(&path, &bytes).unwrap();
let mut store = LockStore::open(&path).unwrap().unwrap();
store.unlock(b"other").unwrap();
assert!(store.tampered());
// Reopened from disk, the report is still there.
let mut store = LockStore::open(&path).unwrap().unwrap();
assert!(store.tampered(), "the report did not survive a restart");
assert!(matches!(
store.acknowledge(b"wrong"),
Err(Error::AppLockRejected)
));
assert!(store.tampered(), "a wrong password must not dismiss it");
store.acknowledge(b"other").unwrap();
assert!(!store.tampered());
let store = LockStore::open(&path).unwrap().unwrap();
assert!(!store.tampered(), "the acknowledgement must reach disk");
}
/// An honest typo is not tampering, and a test says so because getting
/// this wrong would train the user to ignore the warning.
#[test]
fn a_wrong_password_is_never_reported_as_tampering() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("applock.bin");
let mut store = LockStore::create(&path, b"pw", weak()).unwrap();
for _ in 0..3 {
assert!(matches!(store.unlock(b"typo"), Err(Error::AppLockRejected)));
}
store.unlock(b"pw").unwrap();
assert!(!store.tampered());
}
/// A lock written by an older build has no tag. It must keep working, and
/// it must gain one, and it must not be accused of anything on the way.
#[test]
fn a_version_one_lock_still_opens_and_is_upgraded_in_place() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("applock.bin");
let lock = AppLock::create(b"pw", weak()).unwrap();
let v2 = lock.to_bytes();
let mut v1 = Vec::with_capacity(LOCK_LEN_V1);
v1.extend_from_slice(&v2[..8]);
v1.push(1);
v1.extend_from_slice(&v2[9..72]);
v1.extend_from_slice(&2u32.to_le_bytes()); // failures
v1.extend_from_slice(&0i64.to_le_bytes()); // last failure
assert_eq!(v1.len(), LOCK_LEN_V1);
std::fs::write(&path, &v1).unwrap();
let mut store = LockStore::open(&path).unwrap().unwrap();
assert_eq!(store.failures(), 2, "the old counter must be read");
store.unlock(b"pw").unwrap();
assert!(!store.tampered(), "an untagged record is not an edited one");
let raw = std::fs::read(&path).unwrap();
assert_eq!(raw.len(), LOCK_LEN, "it should have been rewritten");
assert_eq!(raw[8], FORMAT_VERSION);
}
/// The nonce must move on every write, because Poly1305 under a repeated
/// nonce hands an observer of two records enough to forge a third.
#[test]
fn two_writes_never_share_a_tag_nonce() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("applock.bin");
let mut store = LockStore::create(&path, b"pw", weak()).unwrap();
let first = std::fs::read(&path).unwrap()[73..97].to_vec();
store.unlock(b"pw").unwrap();
let second = std::fs::read(&path).unwrap()[73..97].to_vec();
assert_ne!(first, second, "the tag nonce repeated across two writes");
}
/// F-91. The one file this program reads before anybody has authenticated
/// must not be able to ask for more memory than the machine has. Nobody
/// chose to open it, so nobody can choose to stop waiting for it.
#[test]
fn a_lock_file_cannot_demand_more_memory_than_an_unattended_caller_allows() {
let lock = AppLock::create(b"pw", weak()).unwrap();
let mut bytes = lock.to_bytes();
// What the campaign produced: within `checked`, far outside anything
// this program has ever written.
bytes[12..16].copy_from_slice(&(1_703_936u32).to_le_bytes());
assert!(
AppLock::parse(&bytes).is_err(),
"a lock file declaring 1.6 GiB of Argon2 memory was accepted"
);
// And the ceiling itself is above anything legitimate, so a real lock
// is not caught by it.
bytes[12..16].copy_from_slice(&kdf::KdfParams::default().m_cost.to_le_bytes());
bytes[16..20].copy_from_slice(&kdf::KdfParams::default().t_cost.to_le_bytes());
bytes[20..24].copy_from_slice(&kdf::KdfParams::default().p_cost.to_le_bytes());
assert!(
AppLock::parse(&bytes).is_ok(),
"the default cost this program writes was refused by its own ceiling"
);
}
/// F-88. Acknowledging a report must cost one key derivation, not three.
/// Counted in the source rather than timed, because a timing test on a
/// deliberately slow function is a flaky test.
#[test]
fn acknowledging_a_report_derives_the_key_once() {
let source = include_str!("lock.rs").replace("\r\n", "\n");
let start = source
.find("pub fn acknowledge(&mut self, password: &[u8]) -> Result<(), Error> {\n self.unlock")
.expect("the store's acknowledge has to exist");
let body = &source[start..];
let end = body.find("\n }").unwrap_or(body.len());
let body = &body[..end];
assert!(
!body.contains("self.lock.acknowledge"),
"the store verifies once through `unlock`; calling the record's own \
acknowledge verifies a second and a third time"
);
assert_eq!(
body.matches("self.unlock").count(),
1,
"one proof of the passphrase is enough to clear a report"
);
}
/// F-86. A password change that could not reach the spare has left the
/// previous password in a file this process cannot rewrite, so it is not a
/// finished change and must not be reported as one.
#[test]
fn a_change_that_did_not_reach_the_spare_is_not_reported_as_done() {
let source = include_str!("lock.rs").replace("\r\n", "\n");
let start = source
.find("pub fn change_password")
.expect("change_password exists");
let body = &source[start..];
let end = body.find("\n /// Remove the lock").unwrap_or(body.len());
assert!(
body[..end].contains("Error::AppLockSpareStale"),
"a change that left the spare behind reported success"
);
}
/// The user-facing claim must keep stating the limit. If someone edits this
/// into a promise, this test is what stops it shipping.
#[test]
fn the_scope_note_states_the_limit_rather_than_a_guarantee() {
let scope = SCOPE.to_lowercase();
assert!(scope.contains("not disk encryption"));
assert!(
scope.contains("not tamper-proof"),
"the tag catches edits; it does not make the file tamper-proof, and \
the difference is the whole of what this note exists to say"
);
assert!(scope.contains("delete"), "deletion must be admitted");
assert!(scope.contains("offline"), "offline attack must be admitted");
for boast in ["unbreakable", "impossible", "guarantee"] {
assert!(!scope.contains(boast), "overclaim: {boast}");
}
}
#[test]
fn the_default_path_is_under_a_config_directory_when_the_environment_says() {
if let Some(path) = default_path() {
assert!(
path.ends_with("veilvoice/applock.bin") || path.ends_with("veilvoice\\applock.bin")
);
assert!(path.parent().is_some());
}
}
}