crates/veilvoice-crypto/src/studio.rs
what this file is for · veilvoice-crypto · 1508 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 studio vault: a key that exists only when both locks have been opened.
//!
//! # The one thing this adds
//!
//! Everything else in this crate is protected by one secret. The app lock
//! guards the window; a sealed recording is opened by its own passphrase. Each
//! is a single point: whoever has that one secret has the thing it guards.
//!
//! A [`StudioKey`] is derived from **both**, and from neither alone. A laptop
//! stolen with VeilVoice already unlocked opens nothing here, because the
//! at-rest passphrase was never entered. An at-rest passphrase learned by any
//! means opens nothing here either, because it is not the app lock. Both, at
//! the same time, on the same machine, or the vault stays shut.
//!
//! # How the two are combined
//!
//! Not concatenated, and not one encrypting the other. Both secrets go into
//! HKDF-SHA256 as input keying material, under a salt that names this vault and
//! its version, and the output is the vault key:
//!
//! ```text
//! ikm = app_lock_key || at_rest_key
//! salt = "veilvoice/studio-vault/v1"
//! key = HKDF-SHA256(ikm, salt, info)
//! ```
//!
//! HKDF-Extract mixes the whole of the input, so an attacker holding one half
//! and guessing the other faces the full cost of the half they are guessing.
//! Concatenating the two *ciphertexts* instead, or encrypting once with each
//! key in turn, would let each layer be attacked separately, which is the
//! mistake this shape exists to avoid.
//!
//! The length of each half is bound into the info string. Without that,
//! `("ab", "c")` and `("a", "bc")` would produce the same input keying material
//! and therefore the same vault key, which is a collision an attacker chooses
//! rather than finds.
//!
//! # What it is worth, and what it is not
//!
//! It raises the cost of a stolen machine and of a leaked passphrase, and it
//! turns one compromise into two. Both of those are real.
//!
//! It does **not** defeat somebody who is watching this process while both
//! secrets are entered: at that moment the derived key exists in memory, and
//! this crate has never claimed to beat an attacker who is already inside the
//! process. It is page-locked and zeroized like every other secret here, which
//! narrows the window and does not close it. The vault is a second lock on the
//! door, not a guard in the room.
//!
//! # In plain words
//!
//! The recordings the studio makes are locked with a key made out of *two* of
//! your passwords at once. Somebody who learns one of them still cannot open
//! them, and neither can somebody who walks off with the computer while the app
//! is open.
//!
//! What it cannot do is protect you from something already running inside
//! VeilVoice at the moment you type both.
use crate::{Error, Secret};
use hkdf::Hkdf;
use sha2::Sha256;
use zeroize::Zeroize;
/// Bytes in a studio vault key.
pub const KEY_LEN: usize = 32;
/// Names this construction and its version in the HKDF salt.
///
/// Versioned so that changing how the two halves are combined produces a
/// different key rather than silently reinterpreting an existing vault.
const SALT: &[u8] = b"veilvoice/studio-vault/v1";
/// The HKDF info label.
const INFO: &[u8] = b"studio vault key";
/// A key that exists only while both locks are open.
///
/// No `Debug`, no `Clone`, and no way to read the bytes out except
/// [`StudioKey::expose`], which the sealing code needs. Wiped when dropped,
/// because the [`Secret`] inside it is.
pub struct StudioKey(Secret);
impl StudioKey {
/// Derive the vault key from both secrets.
///
/// `app_lock` is the key material the app lock produced when the window was
/// unlocked; `at_rest` is the key material the recording passphrase
/// produced. Both are required, and an empty one is refused rather than
/// treated as "no second factor": a vault that quietly degraded to one
/// secret when the other was missing would be the exact failure this type
/// exists to prevent, and it would do it silently.
pub fn derive(app_lock: &Secret, at_rest: &Secret) -> Result<Self, Error> {
if app_lock.is_empty() || at_rest.is_empty() {
return Err(Error::StudioNeedsBoth);
}
// Exact capacity, so the Vec never reallocates and never leaves a copy
// of half the key material behind on the heap.
let mut ikm = Vec::with_capacity(app_lock.len() + at_rest.len());
ikm.extend_from_slice(app_lock.expose());
ikm.extend_from_slice(at_rest.expose());
// The split point, bound into the info. Without it, moving one byte
// from the end of the first secret to the start of the second gives the
// same concatenation and therefore the same key.
let mut info = Vec::with_capacity(INFO.len() + 8);
info.extend_from_slice(INFO);
info.extend_from_slice(&(app_lock.len() as u64).to_le_bytes());
let hk = Hkdf::<Sha256>::new(Some(SALT), &ikm);
let mut key = Secret::zeroed(KEY_LEN);
let result = hk.expand(&info, key.expose_mut()).map_err(|_| Error::Kdf);
// Wiped whether or not the expansion succeeded: the failure path still
// had both secrets in this buffer.
ikm.zeroize();
result?;
Ok(Self(key))
}
/// Borrow the key bytes, for sealing and opening the vault.
pub fn expose(&self) -> &[u8] {
self.0.expose()
}
/// Whether the operating system agreed to keep this key out of swap.
///
/// Reported rather than assumed, exactly as [`crate::amnesia`] and
/// [`crate::tape`] report it: locking is best effort, and a caller telling
/// somebody their vault key is unswappable should be saying what actually
/// happened.
pub fn is_locked(&self) -> bool {
self.0.is_locked()
}
}
/// One recording in the vault.
///
/// Metadata only: what it is, when it was made and how large. The audio is
/// never in here, so listing a vault does not decrypt any of it.
#[derive(Clone, PartialEq, Eq)]
pub struct Entry {
/// Opaque identifier, and the name of the file on disk.
pub id: String,
/// What the person called it.
pub name: String,
/// Unix seconds when it was sealed.
pub made: i64,
/// Bytes of audio inside, before sealing.
pub bytes: usize,
}
/// Deliberately opaque: an entry names a recording somebody made, and a name
/// like "meeting with the lawyer" reaching a log is the sort of leak this
/// project is otherwise careful about.
impl std::fmt::Debug for Entry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Entry({}, {} bytes, redacted name)", self.id, self.bytes)
}
}
/// A directory of recordings, sealed under a [`StudioKey`].
///
/// # What is on disk, and what it gives away
///
/// One file per recording, named by an identifier that says nothing, plus one
/// index file. The index holds every name and date and is itself sealed under
/// the same key, so a vault sitting on a disk shows how many recordings there
/// are and roughly how large each is, and nothing else.
///
/// Those two facts are not hidden inside a vault, and the documentation says so
/// rather than implying otherwise. What hides them is the folder the vault is
/// in: [`make_decoy_in`] fills it with vaults of exactly this size holding
/// nothing, and [`find_or_make`] finds the real one by opening it rather than
/// by its name, so the count and the sizes stop identifying anything. The
/// program folder's own storage takes the other route, padding, which
/// [`crate::hoard`] is for and is a different trade.
pub struct Studio {
dir: std::path::PathBuf,
key: StudioKey,
}
/// The index file's name. Fixed rather than derived: a vault whose index cannot
/// be found is a vault nothing can open, and the directory already discloses
/// that it is a vault by existing.
const INDEX: &str = "index.veil";
impl Studio {
/// Open the vault in `dir`, creating the directory if it is not there.
pub fn open(dir: impl Into<std::path::PathBuf>, key: StudioKey) -> Result<Self, Error> {
let dir = dir.into();
std::fs::create_dir_all(&dir).map_err(|_| Error::AppLockStore)?;
Ok(Self { dir, key })
}
/// Where the vault lives.
pub fn dir(&self) -> &std::path::Path {
&self.dir
}
/// The vault key as a [`Secret`], for the AEAD.
///
/// A fresh copy each time, taken into locked memory and wiped when it goes
/// out of scope, so the working copy never outlives the call that needed
/// it. `Secret::new` wipes the intermediate `Vec` as it takes ownership.
fn secret_key(&self) -> Secret {
let mut bytes = self.key.expose().to_vec();
Secret::new(&mut bytes)
}
/// Every recording in the vault, oldest first.
///
/// An index that will not open is an error rather than an empty list. A
/// vault that quietly reports "no recordings" when the truth is "the key is
/// wrong, or this has been tampered with" would be the worst possible
/// answer: it reads as reassurance.
pub fn list(&self) -> Result<Vec<Entry>, Error> {
let path = self.dir.join(INDEX);
let sealed = match std::fs::read(&path) {
Ok(bytes) => bytes,
// Genuinely nothing here yet, which is not the same as unreadable.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(_) => return Err(Error::AppLockStore),
};
let plain = self.unseal(&sealed, b"veilvoice/studio/index")?;
let text = String::from_utf8(plain).map_err(|_| Error::BadHeader)?;
parse_index(&text)
}
/// Seal `wav` into the vault under `name`, returning its entry.
pub fn store(&self, name: &str, made: i64, wav: &[u8]) -> Result<Entry, Error> {
let id = new_id()?;
let entry = Entry {
id: id.clone(),
name: name.to_string(),
made,
bytes: wav.len(),
};
// The recording first. An index naming a file that does not exist is a
// worse state than a file no index mentions: the first looks like loss,
// the second is recoverable and is what an interrupted store leaves.
let sealed = self.seal(wav, id.as_bytes())?;
crate::privatefile::write_owner_only(&self.dir.join(&id), &sealed)
.map_err(|_| Error::AppLockStore)?;
let mut all = self.list()?;
all.push(entry.clone());
self.write_index(&all)?;
Ok(entry)
}
/// Open one recording into locked memory.
///
/// Returns a [`Secret`], not a `Vec`: this is the audio in the clear, and
/// the whole vault exists so that it is never anywhere unprotected. A
/// caller playing it back reads from here and does not write it to a
/// temporary file, because a temporary file is the thing the vault was
/// avoiding.
pub fn load(&self, id: &str) -> Result<Secret, Error> {
if !safe_id(id) {
return Err(Error::BadHeader);
}
let sealed = std::fs::read(self.dir.join(id)).map_err(|_| Error::AppLockStore)?;
self.unseal_secret(&sealed, id.as_bytes())
}
/// Change what a recording is called.
///
/// Only the index is rewritten. The audio is sealed under the recording's
/// identifier rather than its name, so renaming does not re-encrypt
/// anything and cannot lose the recording if it is interrupted: either the
/// new index lands or the old one stays.
///
/// A name that is not in the vault is an error rather than a silent
/// no-operation. Somebody renaming a recording that is not there has a
/// wrong identifier, and telling them so is more use than appearing to
/// succeed.
pub fn rename(&self, id: &str, name: &str) -> Result<(), Error> {
if !safe_id(id) {
return Err(Error::BadHeader);
}
let mut all = self.list()?;
let Some(entry) = all.iter_mut().find(|e| e.id == id) else {
return Err(Error::NoSuchTake);
};
// Stored as given. `render_index` is the one place that knows the
// index format and is where a name is made safe for it, stripping the
// tab and the newline that would otherwise forge a field or an entry.
//
// This used to clean the name here as well, and that second copy was
// **weaker**: it stripped newlines and not tabs, in a format whose
// fields are tab separated. Two rules for one job, one of them wrong,
// is worse than one rule, so this defers to the writer.
entry.name = name.to_string();
self.write_index(&all)
}
/// Remove one recording and its index entry.
pub fn remove(&self, id: &str) -> Result<(), Error> {
if !safe_id(id) {
return Err(Error::BadHeader);
}
let remaining: Vec<Entry> = self.list()?.into_iter().filter(|e| e.id != id).collect();
self.write_index(&remaining)?;
// The index is written first here, the opposite order from `store`: an
// interrupted removal must not leave the index pointing at a file that
// has gone.
let _ = std::fs::remove_file(self.dir.join(id));
Ok(())
}
/// Seal the index and replace the file on disk with it.
///
/// Replaced rather than rewritten in place, so a crash halfway through leaves
/// the old index rather than half of the new one.
fn write_index(&self, entries: &[Entry]) -> Result<(), Error> {
let text = render_index(entries);
let sealed = self.seal(text.as_bytes(), b"veilvoice/studio/index")?;
crate::privatefile::replace_owner_only(&self.dir.join(INDEX), &sealed)
.map_err(|_| Error::AppLockStore)
}
/// Seal with a fresh nonce, binding `aad` so a file cannot be moved to
/// another identity inside the same vault and still open.
fn seal(&self, plain: &[u8], aad: &[u8]) -> Result<Vec<u8>, Error> {
let nonce = crate::aead::random_nonce()?;
let key = self.secret_key();
let mut out = Vec::with_capacity(nonce.len() + plain.len() + crate::aead::TAG_LEN);
out.extend_from_slice(&nonce);
out.extend_from_slice(&crate::aead::seal(&key, &nonce, aad, plain)?);
Ok(out)
}
/// Open what [`Studio::seal`] produced, with the same associated data.
///
/// The nonce is the first bytes of the file, so a file shorter than one is
/// truncated rather than something to attempt.
fn unseal(&self, sealed: &[u8], aad: &[u8]) -> Result<Vec<u8>, Error> {
if sealed.len() < crate::aead::NONCE_LEN {
return Err(Error::Truncated);
}
let mut nonce = [0u8; crate::aead::NONCE_LEN];
nonce.copy_from_slice(&sealed[..crate::aead::NONCE_LEN]);
let key = self.secret_key();
crate::aead::open(&key, &nonce, aad, &sealed[crate::aead::NONCE_LEN..])
}
/// The same, decrypting straight into locked memory.
///
/// A recording is the whole point of this vault and it is large. Going
/// through [`Self::unseal`] would put every byte of it into an ordinary
/// heap `Vec` first, where the kernel is free to page it out, and only then
/// copy it into a [`Secret`]. That window is not brief for a file of any
/// size, and it is exactly the window this vault exists to close, so the
/// index goes through `unseal` and the audio goes through here.
fn unseal_secret(&self, sealed: &[u8], aad: &[u8]) -> Result<Secret, Error> {
if sealed.len() < crate::aead::NONCE_LEN {
return Err(Error::Truncated);
}
let mut nonce = [0u8; crate::aead::NONCE_LEN];
nonce.copy_from_slice(&sealed[..crate::aead::NONCE_LEN]);
let key = self.secret_key();
crate::aead::open_secret(&key, &nonce, aad, &sealed[crate::aead::NONCE_LEN..])
}
}
/// How long an identifier is, in characters.
///
/// Named because two things read it: the generator below, and the arithmetic
/// that works out how large a decoy will be. A second literal in either place
/// would be a fact written twice.
const ID_LEN: usize = 20;
/// A random, opaque identifier: [`ID_LEN`] lower-case letters and digits that
/// say nothing about what they name.
///
/// One byte is drawn per character and five bits of each are used, so the
/// identifier carries five bits per character: a hundred of them at the length
/// set above. That is far more than a vault will ever hold and it is not a
/// compromise for space: the alphabet has 32 letters in it, so five bits per
/// character is exactly what one character holds, and taking more would need a
/// base conversion for no benefit.
fn new_id() -> Result<String, Error> {
let mut raw = [0u8; ID_LEN];
getrandom::getrandom(&mut raw).map_err(|_| Error::Random)?;
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
Ok(raw
.iter()
.map(|b| ALPHABET[(b & 31) as usize] as char)
.collect())
}
/// Whether `id` is one this vault could have produced.
///
/// Checked before it reaches a path. An identifier read back out of an index is
/// data, and an index is a file somebody could have edited: without this, an id
/// of `../../.bashrc` would send a read or a delete somewhere else entirely.
fn safe_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
}
/// The index, as lines. Tab-separated because a name may contain almost
/// anything except a tab or a newline, and both are rejected on the way in.
fn render_index(entries: &[Entry]) -> String {
let mut out = String::new();
for e in entries {
let name = e.name.replace(['\t', '\n', '\r'], " ");
out.push_str(&format!("{}\t{}\t{}\t{}\n", e.id, e.made, e.bytes, name));
}
out
}
/// Read the index back: one entry per line, four tab-separated fields.
///
/// Every field is read from inside the sealed region, so this parses text that
/// has already been authenticated. It still refuses a line it cannot read
/// rather than filling in a default, because a record that says the wrong
/// thing is worse than one that is missing.
fn parse_index(text: &str) -> Result<Vec<Entry>, Error> {
let mut out = Vec::new();
for line in text.lines() {
if line.is_empty() {
continue;
}
let mut parts = line.splitn(4, '\t');
let id = parts.next().unwrap_or_default();
let made = parts.next().unwrap_or_default();
let bytes = parts.next().unwrap_or_default();
let name = parts.next().unwrap_or_default();
if !safe_id(id) {
return Err(Error::BadHeader);
}
out.push(Entry {
id: id.to_string(),
name: name.to_string(),
made: made.parse().map_err(|_| Error::BadHeader)?,
bytes: bytes.parse().map_err(|_| Error::BadHeader)?,
});
}
Ok(out)
}
/// What a decoy vault looks like from outside, so it looks like the real one.
///
/// Taken from a real vault rather than invented, because the whole value of a
/// decoy is that the two cannot be told apart by looking. A decoy built to a
/// guessed shape is a decoy that stands out.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Shape {
/// How many recordings the vault appears to hold.
pub recordings: usize,
/// Bytes in each, before sealing.
pub each: usize,
/// Bytes in the index before it is sealed.
///
/// Carried because the index file's size is visible on the disk and the
/// rest of the shape does not determine it: names are stored in there, and
/// a vault whose recordings are called something has a larger index than
/// one whose recordings are called nothing. A decoy that skipped this would
/// be the vault in the folder with the smallest index file.
pub index: usize,
}
impl Shape {
/// Measure a real vault, to build decoys that match it.
pub fn of(studio: &Studio) -> Result<Self, Error> {
let entries = studio.list()?;
let total: usize = entries.iter().map(|e| e.bytes).sum();
Ok(Self {
recordings: entries.len(),
// The mean, so a decoy is the same size overall. Matching every
// individual length would copy the real vault's fingerprint into
// the decoy, which is the opposite of what a decoy is for.
each: if entries.is_empty() {
0
} else {
total / entries.len()
},
index: render_index(&entries).len(),
})
}
/// What one vault of this shape occupies, in bytes, as files on a disk.
///
/// # Why this is here and not where it is asked for
///
/// The number depends on the sealed file layout: a nonce and a tag on
/// every file, and the index beside them. That layout is this module's, so
/// the arithmetic is this module's too. An interface that worked it out for
/// itself would be a second copy of the format, and the two would part
/// company the first time a field was added here.
///
/// It is exact rather than approximate, and a test builds decoys and adds
/// up the real files to prove it stays exact.
pub fn bytes_on_disk(&self) -> u64 {
// Every sealed file carries a nonce in front and a tag behind.
let overhead = (crate::aead::NONCE_LEN + crate::aead::TAG_LEN) as u64;
let recordings = (self.each as u64)
.saturating_add(overhead)
.saturating_mul(self.recordings as u64);
let index = overhead.saturating_add(self.index_len() as u64);
recordings.saturating_add(index)
}
/// The shortest index a decoy of this shape can be written with: every
/// entry present and every name empty.
fn bare_index(&self) -> usize {
// The identifier, three tabs, a `made` of zero which is one digit, the
// byte count as text, an empty name, and a newline.
self.recordings
.saturating_mul(ID_LEN + 3 + 1 + digits(self.each) + 1)
}
/// The index length a decoy of this shape will actually be written with.
///
/// The measured length, unless that is shorter than a decoy of this many
/// recordings can be, in which case the shortest one is what gets written.
/// Both this and [`make_decoy`] read it, so the size a panel promises and
/// the size the disk receives cannot part company.
fn index_len(&self) -> usize {
self.index.max(self.bare_index())
}
}
/// How many decimal digits `n` is written with.
///
/// The index stores the byte count as text, so its width is part of the size on
/// disk. Written out rather than reached for through a formatted `String`,
/// because this is asked once per decoy per redraw of a panel.
fn digits(n: usize) -> usize {
let mut d = 1;
let mut n = n;
while n >= 10 {
n /= 10;
d += 1;
}
d
}
/// Every directory under `parent` that is shaped like a vault.
///
/// Shaped like one means it holds an index. That is the only thing that can be
/// seen from outside, and it is deliberately the only thing looked at: a real
/// vault and a decoy are the same shape here, and which of them opens is a
/// question only a key can answer.
///
/// Sorted by name so the order does not depend on how the filesystem happens to
/// hand directories back, which would otherwise make a test flaky and, worse,
/// make the order a decoy is tried in vary between machines.
pub fn vault_dirs(parent: &std::path::Path) -> Result<Vec<std::path::PathBuf>, Error> {
let mut out = Vec::new();
let listing = match std::fs::read_dir(parent) {
Ok(listing) => listing,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(_) => return Err(Error::AppLockStore),
};
for entry in listing.flatten() {
let path = entry.path();
if path.is_dir() && path.join(INDEX).is_file() {
out.push(path);
}
}
out.sort();
Ok(out)
}
/// Open the one vault under `parent` that `key` unlocks, making it on a first
/// run.
///
/// # Why the vault is found rather than named
///
/// A decoy is only worth making if it cannot be told from the real thing, and a
/// real vault at a fixed, known name is told from a decoy by reading the name.
/// So every vault under `parent`, real and decoy alike, is a directory with an
/// opaque identifier for a name, and the only thing that distinguishes them is
/// that exactly one of them opens.
///
/// This tries each in turn. Trying is cheap: the expensive part of unlocking is
/// deriving the key, which happens once before this is called, and each attempt
/// after that is opening one small sealed index.
///
/// # What happens when nothing opens
///
/// The error from the last attempt is returned, and **no vault is made**.
/// Making a fresh one there would be the worst answer available: somebody who
/// mistyped a passphrase would be shown an empty vault and would reasonably
/// conclude their recordings were gone.
///
/// A vault is made only when `parent` holds none at all, which is a first run.
/// It is made with an index written immediately, so that a vault holding
/// nothing is the same shape on the disk as a decoy holding nothing, from the
/// moment it exists.
pub fn find_or_make(parent: &std::path::Path, key: StudioKey) -> Result<Studio, Error> {
std::fs::create_dir_all(parent).map_err(|_| Error::AppLockStore)?;
migrate_flat(parent)?;
let mut studio = Studio {
dir: parent.to_path_buf(),
key,
};
let mut last = None;
for dir in vault_dirs(parent)? {
studio.dir = dir;
match studio.list() {
Ok(_) => return Ok(studio),
Err(error) => last = Some(error),
}
}
if let Some(error) = last {
return Err(error);
}
studio.dir = parent.join(new_id()?);
std::fs::create_dir_all(&studio.dir).map_err(|_| Error::AppLockStore)?;
studio.write_index(&[])?;
Ok(studio)
}
/// Move a vault written straight into `parent` down into a directory of its
/// own.
///
/// # The layout this converts from
///
/// Before decoys existed there was one vault and it sat directly in `parent`,
/// because there was nothing for it to be confused with. There is now, and a
/// vault sitting where decoys are siblings would be the one directory that is
/// not a directory, which gives it away completely.
///
/// # Interrupted half way
///
/// The index moves **last**, so `parent` still holding an index means the move
/// did not finish, and this runs again on the next open. It moves into the
/// directory already made rather than a new one when there is exactly one, so
/// running again finishes the job instead of splitting the vault in two.
fn migrate_flat(parent: &std::path::Path) -> Result<(), Error> {
if !parent.join(INDEX).is_file() {
return Ok(());
}
// A directory already there is a previous run of this that did not finish.
// Anything else means a vault and a half, which is not a state this can
// reach and not one to guess at.
let existing: Vec<std::path::PathBuf> = std::fs::read_dir(parent)
.map_err(|_| Error::AppLockStore)?
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
let into = match existing.len() {
0 => parent.join(new_id()?),
1 => existing[0].clone(),
_ => return Err(Error::AppLockStore),
};
std::fs::create_dir_all(&into).map_err(|_| Error::AppLockStore)?;
for entry in std::fs::read_dir(parent)
.map_err(|_| Error::AppLockStore)?
.flatten()
{
let path = entry.path();
if path.is_dir() || path.file_name() == Some(std::ffi::OsStr::new(INDEX)) {
continue;
}
std::fs::rename(&path, into.join(entry.file_name())).map_err(|_| Error::AppLockStore)?;
}
std::fs::rename(parent.join(INDEX), into.join(INDEX)).map_err(|_| Error::AppLockStore)?;
Ok(())
}
/// Make one decoy under `parent`, named the way a real vault is named.
///
/// Returns where it went. The name is drawn from the same generator that names
/// recordings and vaults, so a decoy is not distinguishable from the real vault
/// by its name any more than by its size.
pub fn make_decoy_in(parent: &std::path::Path, shape: Shape) -> Result<std::path::PathBuf, Error> {
let dir = parent.join(new_id()?);
make_decoy(&dir, shape)?;
Ok(dir)
}
/// Fill `dir` with a vault that never held anything.
///
/// # What a decoy is, and what it deliberately is not
///
/// It is **not** a vault with weak contents, or a vault whose passphrase is
/// written down somewhere, or a vault holding harmless recordings. Any of those
/// is a vault that rewards cracking, and a decoy that rewards cracking teaches
/// an attacker that cracking works.
///
/// It is a vault whose contents never existed. The files are random bytes
/// sealed under a key generated here and dropped before this function returns,
/// so nobody holds it: not the person who made the decoy, not this code, not
/// anybody who takes the disk. Opened by brute force, it yields bytes that
/// parse as nothing, because there is nothing under them to find.
///
/// # What it buys, stated plainly
///
/// It raises the cost of a search. Somebody who takes a disk and finds nine
/// vaults must attack all nine to learn which one matters, and eight of them
/// cannot be finished at any price.
///
/// It does **not** make the real vault unfindable to somebody who watches you
/// open it, reads this process's memory, or has any other way to see which
/// directory you actually use. Decoys are cover against a search of the disk,
/// not against being observed, and anybody relying on them should know which of
/// those they are facing.
pub fn make_decoy(dir: impl AsRef<std::path::Path>, shape: Shape) -> Result<(), Error> {
let dir = dir.as_ref();
std::fs::create_dir_all(dir).map_err(|_| Error::AppLockStore)?;
// Generated, used, and never returned. Dropping it is the point: a key
// nobody holds is a key nobody can be made to give up.
let key = StudioKey(Secret::random(KEY_LEN)?);
let decoy = Studio {
dir: dir.to_path_buf(),
key,
};
let mut entries = Vec::with_capacity(shape.recordings);
for _ in 0..shape.recordings {
let id = new_id()?;
// Random bytes rather than silence or a tone. A decoy full of zeroes
// compresses differently from a real recording, and a stored size that
// does not match its entropy is exactly the tell this exists to avoid.
let mut filler = vec![0u8; shape.each];
getrandom::getrandom(&mut filler).map_err(|_| Error::Random)?;
let sealed = decoy.seal(&filler, id.as_bytes())?;
crate::privatefile::write_owner_only(&dir.join(&id), &sealed)
.map_err(|_| Error::AppLockStore)?;
entries.push(Entry {
id,
// Filled in below, once it is known how much room is left to fill.
name: String::new(),
made: 0,
bytes: shape.each,
});
}
pad_index(&mut entries, shape.index_len())?;
decoy.write_index(&entries)
}
/// Grow the names until the index is exactly the length a real one was.
///
/// # Why the names are padded rather than left empty
///
/// The index is sealed, so nobody can read a name out of it or see how the
/// length is divided between them. What anybody can see is the size of the
/// file, and that size is the length of the text plus a fixed overhead. A real
/// vault's recordings are called something and a decoy's are called nothing, so
/// without this every decoy in a folder is the one with the smallest index.
///
/// The share is even because the division is invisible: only the total is on
/// the disk. The characters are random rather than repeated for the same reason
/// the audio is, which is that a file whose size does not match its entropy is
/// itself a tell if the sealing is ever broken.
///
/// `want` shorter than the bare minimum is left alone rather than forced. It
/// means the real index was smaller than a decoy of the same count can be, and
/// the honest answer to that is a decoy a few bytes larger, not a corrupt one.
fn pad_index(entries: &mut [Entry], want: usize) -> Result<(), Error> {
if entries.is_empty() {
return Ok(());
}
let bare = render_index(entries).len();
let Some(spare) = want.checked_sub(bare) else {
return Ok(());
};
let n = entries.len();
for (i, entry) in entries.iter_mut().enumerate() {
// The last one takes the remainder, so the total is exact rather than
// short by up to one byte per entry.
let take = if i + 1 == n {
spare - (spare / n) * (n - 1)
} else {
spare / n
};
let mut raw = vec![0u8; take];
getrandom::getrandom(&mut raw).map_err(|_| Error::Random)?;
// Lower-case letters: one byte each, and neither a tab nor a newline,
// so the rendered length is the number of characters asked for.
entry.name = raw.iter().map(|b| (b'a' + b % 26) as char).collect();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn secret(bytes: &[u8]) -> Secret {
let mut copy = bytes.to_vec();
Secret::new(&mut copy)
}
/// A vault in a temporary directory, with a take already in it.
fn vault_with_a_take() -> (tempfile::TempDir, Studio, String) {
let dir = tempfile::tempdir().expect("a temporary directory");
let key = StudioKey::derive(&secret(b"app-lock"), &secret(b"at-rest")).unwrap();
let vault = Studio::open(dir.path(), key).unwrap();
let entry = vault
.store("first name", 1_700_000_000, b"RIFFfake")
.unwrap();
let id = entry.id.clone();
(dir, vault, id)
}
#[test]
fn renaming_changes_the_name_and_nothing_else() {
let (_dir, vault, id) = vault_with_a_take();
let before = vault.list().unwrap();
vault.rename(&id, "second name").unwrap();
let after = vault.list().unwrap();
assert_eq!(after.len(), 1);
assert_eq!(after[0].name, "second name");
// The identifier, the date and the size are untouched: renaming
// rewrites the index and never re-seals the audio.
assert_eq!(after[0].id, before[0].id);
assert_eq!(after[0].made, before[0].made);
assert_eq!(after[0].bytes, before[0].bytes);
// And the recording still opens, which is the thing a rename must not
// be able to break.
assert_eq!(vault.load(&id).unwrap().expose(), b"RIFFfake");
}
#[test]
fn a_newline_in_a_name_cannot_forge_a_second_entry() {
// The index is one entry per line. A name carrying a newline would be
// read back as the start of another entry, and the vault would lose a
// recording rather than refuse the name.
let (_dir, vault, id) = vault_with_a_take();
vault.rename(&id, "quiet\nname\tforged\t1\t2").unwrap();
let after = vault.list().unwrap();
assert_eq!(after.len(), 1, "a name split the index into two entries");
assert!(!after[0].name.contains('\n'), "a newline survived");
// And a tab, which is what separates the fields: a name carrying one
// would forge an identifier, a date and a size for an entry that does
// not exist.
assert!(
!after[0].name.contains('\t'),
"a tab survived: {:?}",
after[0].name
);
assert_eq!(vault.load(&id).unwrap().expose(), b"RIFFfake");
}
#[test]
fn renaming_something_that_is_not_there_is_refused() {
let (_dir, vault, _id) = vault_with_a_take();
// A well-formed identifier that is simply not in this vault.
assert!(matches!(
vault.rename("0123456789abcdef", "new"),
Err(Error::NoSuchTake)
));
// And one that is not an identifier at all: a rename must not be a
// route to writing outside the vault directory.
assert!(matches!(
vault.rename("../../etc/passwd", "new"),
Err(Error::BadHeader)
));
// Neither attempt disturbed what is there.
assert_eq!(vault.list().unwrap()[0].name, "first name");
}
#[test]
fn the_same_pair_always_gives_the_same_key() {
let a = StudioKey::derive(&secret(b"app-lock-key"), &secret(b"at-rest-key")).unwrap();
let b = StudioKey::derive(&secret(b"app-lock-key"), &secret(b"at-rest-key")).unwrap();
assert_eq!(a.expose(), b.expose());
assert_eq!(a.expose().len(), KEY_LEN);
}
#[test]
fn neither_secret_alone_produces_the_vault_key() {
// The whole point of the type. Holding one half and an empty other half
// must not derive anything, let alone the real key.
let real = StudioKey::derive(&secret(b"app-lock-key"), &secret(b"at-rest-key")).unwrap();
assert!(matches!(
StudioKey::derive(&secret(b"app-lock-key"), &secret(b"")),
Err(Error::StudioNeedsBoth)
));
assert!(matches!(
StudioKey::derive(&secret(b""), &secret(b"at-rest-key")),
Err(Error::StudioNeedsBoth)
));
assert!(matches!(
StudioKey::derive(&secret(b""), &secret(b"")),
Err(Error::StudioNeedsBoth)
));
// And a wrong half gives a key unrelated to the right one, rather than
// something close to it.
let wrong_app = StudioKey::derive(&secret(b"WRONG"), &secret(b"at-rest-key")).unwrap();
let wrong_rest = StudioKey::derive(&secret(b"app-lock-key"), &secret(b"WRONG")).unwrap();
assert_ne!(real.expose(), wrong_app.expose());
assert_ne!(real.expose(), wrong_rest.expose());
assert_ne!(wrong_app.expose(), wrong_rest.expose());
}
#[test]
fn changing_either_half_by_one_bit_changes_the_key() {
let base = StudioKey::derive(&secret(b"aaaaaaaa"), &secret(b"bbbbbbbb")).unwrap();
let first = StudioKey::derive(&secret(b"aaaaaaab"), &secret(b"bbbbbbbb")).unwrap();
let second = StudioKey::derive(&secret(b"aaaaaaaa"), &secret(b"bbbbbbbc")).unwrap();
assert_ne!(base.expose(), first.expose());
assert_ne!(base.expose(), second.expose());
}
#[test]
fn where_the_split_falls_is_part_of_the_key() {
// Without the length in the info, these two pairs concatenate to the
// same bytes and would derive the same vault key. That is a collision
// an attacker picks rather than stumbles on: it would let a vault built
// from one pair be opened by another.
let a = StudioKey::derive(&secret(b"ab"), &secret(b"c")).unwrap();
let b = StudioKey::derive(&secret(b"a"), &secret(b"bc")).unwrap();
assert_ne!(
a.expose(),
b.expose(),
"the split point is not bound into the key"
);
}
#[test]
fn swapping_the_two_halves_is_a_different_vault() {
let forward = StudioKey::derive(&secret(b"one"), &secret(b"two")).unwrap();
let backward = StudioKey::derive(&secret(b"two"), &secret(b"one")).unwrap();
assert_ne!(forward.expose(), backward.expose());
}
#[test]
fn the_key_is_key_shaped_rather_than_a_copy_of_its_inputs() {
// A construction that returned one of its inputs, or their
// concatenation, would pass every equality test above while providing
// no mixing at all.
let app = b"app-lock-key-material";
let rest = b"at-rest-key-material";
let key = StudioKey::derive(&secret(app), &secret(rest)).unwrap();
assert_ne!(key.expose(), &app[..]);
assert_ne!(key.expose(), &rest[..]);
let joined: Vec<u8> = app.iter().chain(rest.iter()).copied().collect();
assert_ne!(key.expose(), &joined[..]);
assert!(
!joined.windows(KEY_LEN).any(|w| w == key.expose()),
"the key appears verbatim inside its own inputs"
);
}
fn a_studio(dir: &std::path::Path) -> Studio {
let key = StudioKey::derive(&secret(b"app-lock"), &secret(b"at-rest")).unwrap();
Studio::open(dir, key).unwrap()
}
#[test]
fn a_recording_stored_comes_back_byte_for_byte() {
let tmp = tempfile::tempdir().unwrap();
let studio = a_studio(tmp.path());
assert!(
studio.list().unwrap().is_empty(),
"a new vault holds nothing"
);
let audio = vec![7u8; 5000];
let entry = studio.store("interview", 1_767_225_600, &audio).unwrap();
let listed = studio.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "interview");
assert_eq!(listed[0].made, 1_767_225_600);
assert_eq!(listed[0].bytes, audio.len());
let back = studio.load(&entry.id).unwrap();
assert_eq!(back.expose(), &audio[..]);
}
#[test]
fn the_other_key_opens_nothing_in_this_vault() {
// The whole promise. A vault written with one pair of secrets must be
// opaque to any other pair, including one sharing a half.
let tmp = tempfile::tempdir().unwrap();
let entry = {
let studio = a_studio(tmp.path());
studio.store("private", 1, &[3u8; 128]).unwrap()
};
for (app, rest) in [
(&b"app-lock"[..], &b"WRONG"[..]),
(&b"WRONG"[..], &b"at-rest"[..]),
(&b"WRONG"[..], &b"ALSO-WRONG"[..]),
] {
let key = StudioKey::derive(&secret(app), &secret(rest)).unwrap();
let other = Studio::open(tmp.path(), key).unwrap();
assert!(other.list().is_err(), "the index opened with the wrong key");
assert!(other.load(&entry.id).is_err(), "a recording opened");
}
}
#[test]
fn nothing_readable_is_left_on_disk() {
// The audio must not be findable by reading the vault directory, and
// neither must the name: both are what the vault is for.
let tmp = tempfile::tempdir().unwrap();
let studio = a_studio(tmp.path());
let audio: Vec<u8> = (0..4096).map(|i| (i % 251) as u8).collect();
studio.store("meeting with the lawyer", 1, &audio).unwrap();
for file in std::fs::read_dir(tmp.path()).unwrap() {
let bytes = std::fs::read(file.unwrap().path()).unwrap();
assert!(
!bytes
.windows(audio.len().min(64))
.any(|w| w == &audio[..64]),
"the audio is on disk in the clear"
);
assert!(
!bytes.windows(6).any(|w| w == b"lawyer"),
"the name is on disk in the clear"
);
}
}
#[test]
fn a_recording_cannot_be_moved_to_another_identity_and_still_open() {
// Each file is sealed with its own id as authenticated data, so
// swapping two files inside a vault, which somebody with the directory
// can do without the key, is detected rather than silently serving the
// wrong recording under the wrong name.
let tmp = tempfile::tempdir().unwrap();
let studio = a_studio(tmp.path());
let first = studio.store("one", 1, b"first recording").unwrap();
let second = studio.store("two", 2, b"second recording").unwrap();
let a = tmp.path().join(&first.id);
let b = tmp.path().join(&second.id);
let swap = tmp.path().join("swap");
std::fs::rename(&a, &swap).unwrap();
std::fs::rename(&b, &a).unwrap();
std::fs::rename(&swap, &b).unwrap();
assert!(
studio.load(&first.id).is_err(),
"a swapped file still opened"
);
assert!(studio.load(&second.id).is_err());
}
#[test]
fn an_identifier_out_of_an_index_cannot_walk_out_of_the_vault() {
// The index is a file. Somebody who can write it can put anything in
// the id column, and that value reaches a path, so it is checked.
assert!(safe_id("abcdefgh2345"));
for bad in [
"",
"../../.bashrc",
"..",
"a/b",
"a\\b",
"ABC",
"with space",
"with.dot",
] {
assert!(!safe_id(bad), "{bad:?} was accepted as an identifier");
}
let tmp = tempfile::tempdir().unwrap();
let studio = a_studio(tmp.path());
assert!(studio.load("../../.bashrc").is_err());
assert!(studio.remove("../../.bashrc").is_err());
}
#[test]
fn removing_one_leaves_the_others_openable() {
let tmp = tempfile::tempdir().unwrap();
let studio = a_studio(tmp.path());
let a = studio.store("a", 1, b"aaaa").unwrap();
let b = studio.store("b", 2, b"bbbb").unwrap();
studio.remove(&a.id).unwrap();
let left = studio.list().unwrap();
assert_eq!(left.len(), 1);
assert_eq!(left[0].id, b.id);
assert_eq!(studio.load(&b.id).unwrap().expose(), b"bbbb");
assert!(
studio.load(&a.id).is_err(),
"the removed file is still there"
);
}
#[test]
fn an_unreadable_index_is_an_error_rather_than_an_empty_vault() {
// Reporting "no recordings" when the truth is "wrong key, or tampered
// with" reads as reassurance, which is the worst possible answer.
let tmp = tempfile::tempdir().unwrap();
let studio = a_studio(tmp.path());
studio.store("one", 1, b"audio").unwrap();
std::fs::write(tmp.path().join(INDEX), b"not a sealed index at all").unwrap();
assert!(studio.list().is_err());
}
#[test]
fn a_name_with_a_tab_or_newline_cannot_forge_a_second_entry() {
// The index is line-based, so a name carrying a newline could otherwise
// inject a row, and one carrying a tab could shift every column.
let tmp = tempfile::tempdir().unwrap();
let studio = a_studio(tmp.path());
studio
.store(
"evil\tzzzzzzzzzzzz\t9\t9\nzzzzzzzzzzzz\t1\t1\tinjected",
1,
b"x",
)
.unwrap();
let listed = studio.list().unwrap();
assert_eq!(listed.len(), 1, "a name forged an extra entry");
assert!(!listed[0].name.contains('\t'));
assert!(!listed[0].name.contains('\n'));
}
#[test]
fn each_stored_recording_gets_an_identifier_of_its_own() {
let tmp = tempfile::tempdir().unwrap();
let studio = a_studio(tmp.path());
let mut seen = std::collections::HashSet::new();
for i in 0..25 {
let e = studio.store(&format!("take {i}"), i, b"audio").unwrap();
assert!(safe_id(&e.id), "{:?} is not a usable identifier", e.id);
assert!(seen.insert(e.id), "an identifier repeated");
}
assert_eq!(studio.list().unwrap().len(), 25);
}
#[test]
fn a_decoy_looks_like_the_real_thing_from_outside() {
// The whole value of a decoy is that the two cannot be told apart by
// looking at the directory, so that is what this checks: the same
// number of files, the same shape of names, an index present in both.
let real_dir = tempfile::tempdir().unwrap();
let real = a_studio(real_dir.path());
for i in 0..4 {
real.store(&format!("take {i}"), 100 + i, &vec![9u8; 2048])
.unwrap();
}
let shape = Shape::of(&real).unwrap();
assert_eq!(shape.recordings, 4);
assert_eq!(shape.each, 2048);
let decoy_dir = tempfile::tempdir().unwrap();
make_decoy(decoy_dir.path(), shape).unwrap();
let names = |d: &std::path::Path| {
let mut v: Vec<String> = std::fs::read_dir(d)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
v.sort();
v
};
let r = names(real_dir.path());
let d = names(decoy_dir.path());
assert_eq!(
r.len(),
d.len(),
"a different number of files gives it away"
);
assert!(d.contains(&INDEX.to_string()), "a decoy has an index too");
for name in &d {
assert!(
name == INDEX || safe_id(name),
"{name:?} is not shaped like a real identifier"
);
}
}
#[test]
fn a_decoy_holds_nothing_and_nobody_can_open_it() {
// Not a vault with weak contents: a vault whose contents never existed.
// The key is generated inside `make_decoy` and dropped before it
// returns, so there is no key to find, to leak, or to be compelled to
// hand over.
let dir = tempfile::tempdir().unwrap();
make_decoy(
dir.path(),
Shape {
recordings: 3,
each: 512,
index: 0,
},
)
.unwrap();
// The real vault's key opens none of it, and neither does any other.
for (app, rest) in [(&b"app-lock"[..], &b"at-rest"[..]), (&b"x"[..], &b"y"[..])] {
let key = StudioKey::derive(&secret(app), &secret(rest)).unwrap();
let attempt = Studio::open(dir.path(), key).unwrap();
assert!(attempt.list().is_err(), "a decoy index opened");
}
}
#[test]
fn a_first_run_makes_one_vault_and_the_next_run_finds_it() {
let home = tempfile::tempdir().unwrap();
let key = || StudioKey::derive(&secret(b"app"), &secret(b"rest")).unwrap();
let first = find_or_make(home.path(), key()).unwrap();
let made = first.dir().to_path_buf();
first.store("a take", 7, b"audio").unwrap();
// The vault is a directory of its own with an opaque name, not the
// parent, so a decoy beside it is not told apart by reading the name.
assert_eq!(made.parent().unwrap(), home.path());
assert!(safe_id(made.file_name().unwrap().to_str().unwrap()));
let again = find_or_make(home.path(), key()).unwrap();
assert_eq!(again.dir(), made, "the second run made a second vault");
assert_eq!(again.list().unwrap().len(), 1);
}
#[test]
fn a_new_vault_has_an_index_from_the_moment_it_exists() {
// Otherwise a vault holding nothing is an empty directory and a decoy
// holding nothing is a directory with a file in it, which is a tell
// that lasts until the first recording.
let home = tempfile::tempdir().unwrap();
let key = StudioKey::derive(&secret(b"app"), &secret(b"rest")).unwrap();
let vault = find_or_make(home.path(), key).unwrap();
assert!(vault.dir().join(INDEX).is_file());
assert_eq!(
vault_dirs(home.path()).unwrap(),
vec![vault.dir().to_path_buf()]
);
}
#[test]
fn the_wrong_pair_finds_nothing_and_makes_nothing() {
// The dangerous failure this forbids: a mistyped passphrase creating a
// second, empty vault, which would look exactly like the recordings
// having been lost.
let home = tempfile::tempdir().unwrap();
let right = StudioKey::derive(&secret(b"app"), &secret(b"rest")).unwrap();
find_or_make(home.path(), right)
.unwrap()
.store("a take", 1, b"audio")
.unwrap();
let wrong = StudioKey::derive(&secret(b"app"), &secret(b"typo")).unwrap();
assert!(find_or_make(home.path(), wrong).is_err());
assert_eq!(vault_dirs(home.path()).unwrap().len(), 1);
}
#[test]
fn the_real_vault_is_found_among_its_decoys() {
let home = tempfile::tempdir().unwrap();
let key = || StudioKey::derive(&secret(b"app"), &secret(b"rest")).unwrap();
let vault = find_or_make(home.path(), key()).unwrap();
vault.store("a take", 1, &vec![7u8; 4096]).unwrap();
let real = vault.dir().to_path_buf();
let shape = Shape::of(&vault).unwrap();
drop(vault);
for _ in 0..5 {
make_decoy_in(home.path(), shape).unwrap();
}
assert_eq!(vault_dirs(home.path()).unwrap().len(), 6);
let found = find_or_make(home.path(), key()).unwrap();
assert_eq!(found.dir(), real, "a decoy was taken for the vault");
assert_eq!(found.list().unwrap().len(), 1);
}
#[test]
fn a_decoy_is_the_same_shape_on_the_disk_as_the_vault_it_copies() {
// Same number of files, same total size, same style of name. What a
// search of the disk can see is the same for both.
let home = tempfile::tempdir().unwrap();
let key = StudioKey::derive(&secret(b"app"), &secret(b"rest")).unwrap();
let vault = find_or_make(home.path(), key).unwrap();
for i in 0..3 {
vault
.store(&format!("take {i}"), i, &vec![3u8; 8192])
.unwrap();
}
let shape = Shape::of(&vault).unwrap();
let decoy = make_decoy_in(home.path(), shape).unwrap();
let weigh = |d: &std::path::Path| -> (usize, u64) {
let files: Vec<_> = std::fs::read_dir(d).unwrap().flatten().collect();
(
files.len(),
files.iter().map(|f| f.metadata().unwrap().len()).sum(),
)
};
assert_eq!(weigh(vault.dir()), weigh(&decoy));
assert!(safe_id(decoy.file_name().unwrap().to_str().unwrap()));
}
#[test]
fn a_vault_written_the_old_way_moves_down_into_a_directory_of_its_own() {
let home = tempfile::tempdir().unwrap();
let key = || StudioKey::derive(&secret(b"app"), &secret(b"rest")).unwrap();
// The layout before decoys existed: the vault straight in the parent.
let old = Studio::open(home.path(), key()).unwrap();
old.store("kept", 42, b"the recording").unwrap();
drop(old);
assert!(home.path().join(INDEX).is_file());
let moved = find_or_make(home.path(), key()).unwrap();
assert_ne!(moved.dir(), home.path(), "it did not move");
assert!(
!home.path().join(INDEX).exists(),
"the old index is still there"
);
let entries = moved.list().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "kept");
assert_eq!(
moved.load(&entries[0].id).unwrap().expose(),
b"the recording",
"the audio did not come with it"
);
}
#[test]
fn a_move_interrupted_half_way_finishes_rather_than_splitting_the_vault() {
let home = tempfile::tempdir().unwrap();
let key = || StudioKey::derive(&secret(b"app"), &secret(b"rest")).unwrap();
let old = Studio::open(home.path(), key()).unwrap();
let kept = old.store("kept", 42, b"the recording").unwrap();
drop(old);
// What an interruption leaves: the directory made and the recording
// moved into it, and the index still in the parent because it goes
// last. The move must resume into that directory, not a second one.
let half = home.path().join(new_id().unwrap());
std::fs::create_dir(&half).unwrap();
std::fs::rename(home.path().join(&kept.id), half.join(&kept.id)).unwrap();
let moved = find_or_make(home.path(), key()).unwrap();
assert_eq!(moved.dir(), half, "it started a second vault");
assert_eq!(vault_dirs(home.path()).unwrap().len(), 1);
assert_eq!(moved.load(&kept.id).unwrap().expose(), b"the recording");
}
#[test]
fn a_decoy_takes_exactly_the_room_its_shape_says_it_will() {
// The interface offering decoys shows how much room they will take and
// how many will fit, and both come from `bytes_on_disk`. If that
// arithmetic drifts from the file format the panel is lying about the
// disk, so this builds real decoys and adds up the real files.
for shape in [
Shape {
recordings: 3,
each: 2048,
index: 0,
},
Shape {
recordings: 1,
each: 999_999,
index: 0,
},
Shape {
recordings: 0,
each: 0,
index: 0,
},
Shape {
recordings: 7,
each: 1,
index: 0,
},
] {
let dir = tempfile::tempdir().unwrap();
make_decoy(dir.path(), shape).unwrap();
let actual: u64 = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().metadata().unwrap().len())
.sum();
assert_eq!(
actual,
shape.bytes_on_disk(),
"{shape:?} was predicted at {} bytes and took {actual}",
shape.bytes_on_disk()
);
}
}
#[test]
fn a_decoy_is_not_full_of_zeroes() {
// A decoy of silence compresses differently from a real recording, and
// a file whose size does not match its entropy is the exact tell this
// is meant to avoid.
let dir = tempfile::tempdir().unwrap();
make_decoy(
dir.path(),
Shape {
recordings: 1,
each: 4096,
index: 0,
},
)
.unwrap();
for file in std::fs::read_dir(dir.path()).unwrap() {
let bytes = std::fs::read(file.unwrap().path()).unwrap();
let zeroes = bytes.iter().filter(|&&b| b == 0).count();
assert!(
zeroes * 4 < bytes.len(),
"a decoy file is mostly zeroes, which is a tell"
);
}
}
#[test]
fn decoys_differ_from_each_other() {
// Identical decoys are one decoy copied, and an attacker who notices
// that has learned they are all fake.
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
let shape = Shape {
recordings: 2,
each: 1024,
index: 0,
};
make_decoy(a.path(), shape).unwrap();
make_decoy(b.path(), shape).unwrap();
let read_all = |d: &std::path::Path| {
let mut v: Vec<Vec<u8>> = std::fs::read_dir(d)
.unwrap()
.map(|e| std::fs::read(e.unwrap().path()).unwrap())
.collect();
v.sort();
v
};
assert_ne!(read_all(a.path()), read_all(b.path()));
}
#[test]
fn the_shape_of_an_empty_vault_does_not_divide_by_zero() {
let dir = tempfile::tempdir().unwrap();
let studio = a_studio(dir.path());
let shape = Shape::of(&studio).unwrap();
assert_eq!(shape.recordings, 0);
assert_eq!(shape.each, 0);
// And a decoy of that shape is an empty vault, which is still a
// believable thing for somebody to have.
let decoy = tempfile::tempdir().unwrap();
make_decoy(decoy.path(), shape).unwrap();
assert!(decoy.path().join(INDEX).exists());
}
/// A recording never passes through an ordinary heap buffer on its way out.
///
/// `Studio::load` used to call `unseal`, which calls `aead::open`, which
/// hands back a plain `Vec<u8>`. The whole recording was decrypted into
/// pageable memory the kernel may write to swap, and only then copied into
/// a `Secret` and the vector wiped. Every test here passed: the bytes were
/// right and the wrong key still opened nothing. The defect was in where
/// the right bytes had been, which no round trip can see.
///
/// So this reads the source. It is a blunt check and it is the only kind
/// that can express "and it never went anywhere else on the way".
#[test]
fn a_recording_is_decrypted_straight_into_locked_memory() {
let source = include_str!("studio.rs").replace("\r\n", "\n");
let body = source
.split("pub fn load(&self, id: &str)")
.nth(1)
.expect("Studio::load has to be findable");
let body = body.split("\n /// ").next().unwrap_or(body);
assert!(
body.contains("unseal_secret"),
"Studio::load must decrypt into a Secret, not into a Vec: {body}"
);
assert!(
!body.contains("self.unseal("),
"Studio::load is back on the Vec path: {body}"
);
// And the two really are different routes, so the assertion above is
// about something rather than about a name.
let secret_route = source
.split("fn unseal_secret(")
.nth(1)
.expect("unseal_secret has to be findable");
assert!(secret_route.contains("open_secret("));
}
#[test]
fn the_key_reports_its_locking_rather_than_claiming_it() {
let key = StudioKey::derive(&secret(b"one"), &secret(b"two")).unwrap();
// Best effort, budget-dependent: this asserts the report exists and is
// a plain answer, not that the lock was granted.
let _: bool = key.is_locked();
}
}