crates/veilvoice-crypto/src/kdf.rs
what this file is for · veilvoice-crypto · 633 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
//! Password-based key derivation with Argon2id.
//!
//! Argon2id is the memory-hard KDF recommended by RFC 9106 and the OWASP
//! password-storage guidance; the `id` variant resists both GPU/ASIC
//! parallelism and the side-channel exposure of pure Argon2i.
//!
//! Parameters travel *with* the ciphertext rather than being compiled in, so a
//! file encrypted today still opens after the defaults are raised, and a user
//! on a small machine can lower the memory cost without forking the format.
//!
//! # Cost parameters arrive from a file, so they are hostile input
//!
//! That flexibility has a sharp edge, and two shipped defects came from it.
//! `m_cost` and `p_cost` are read verbatim from a `.veil` header -- and from the
//! app-lock file, **which is parsed before anyone has authenticated**.
//!
//! * `argon2` 0.5.3 evaluates `m_cost < p_cost * 8` *before* it checks whether
//! `p_cost` is within range, so a large `p_cost` overflows the multiplication.
//! With overflow checks on -- every debug build, and any project consuming
//! this crate as a library -- that is a panic on attacker-controlled input
//! (F-2).
//! * `m_cost` is allocated before anything else happens, so a header claiming
//! `u32::MAX` asks for **4 TiB**. The allocation fails, and a failed
//! allocation aborts the process. Merely *opening* a hostile container killed
//! the program (F-3).
//!
//! Both are bounded in [`KdfParams::checked`], in arithmetic that cannot
//! overflow. **Never bypass that funnel.** It is the single place every
//! derivation passes through, and it exists because the alternative -- checks
//! scattered across the call sites -- is how one of them gets missed.
//!
//! A residual is stated rather than fixed: a container may still declare a
//! legitimate-but-expensive cost, so an attacker can make opening *their* file
//! slow. That is inherent to shipping the cost with the file, which is what lets
//! old files open after defaults rise. Slow is not crashing, and the user chose
//! to open that file.
//!
//! # Domain separation
//!
//! The app-lock password and the recording passphrase are different secrets and
//! are kept different: they are domain-separated in the derivation, so
//! unlocking the application does not unseal recordings and one cannot be
//! derived from the other.
//!
//! # In plain words
//!
//! This turns a passphrase into a key.
//!
//! A passphrase somebody can remember is far too short and too predictable to use
//! directly, so it is put through a process designed to be slow and to need a
//! large amount of memory. That does not slow you down noticeably once, but it
//! makes guessing millions of passphrases enormously expensive for anybody trying.
//!
//! The settings are stored with each file, so an old recording still opens after
//! the defaults are made stronger.
use crate::{Error, Secret};
use argon2::{Algorithm, Argon2, Params, Version};
/// Argon2id cost parameters.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KdfParams {
/// Memory cost in kibibytes.
pub m_cost: u32,
/// Number of passes.
pub t_cost: u32,
/// Degree of parallelism.
pub p_cost: u32,
}
impl Default for KdfParams {
/// RFC 9106's "first recommended" profile: 2 GiB is the second option, but
/// 256 MiB with three passes is the sweet spot for an interactive desktop
/// unlock: strong against offline cracking while still opening a file in
/// well under a second on ordinary hardware.
fn default() -> Self {
Self {
m_cost: 256 * 1024,
t_cost: 3,
p_cost: 4,
}
}
}
impl KdfParams {
/// A deliberately cheap profile for tests and low-memory devices.
///
/// Do not use this to protect real data.
pub fn weak_for_tests() -> Self {
Self {
m_cost: 8,
t_cost: 1,
p_cost: 1,
}
}
/// Argon2's own documented ceiling on parallelism: 2^24 - 1.
const MAX_P_COST: u32 = 0x00ff_ffff;
/// The largest memory cost this build will attempt, in KiB, which is 4 GiB.
///
/// A ceiling is necessary because `m_cost` arrives from the file. Argon2
/// allocates that much memory before it does anything else, so a header
/// claiming `u32::MAX` asks for four *terabytes*: the allocation fails, and
/// a failed allocation in Rust aborts the process. Merely *attempting to
/// open* a hostile `.veil` would kill the program, and for the app lock it
/// is worse, because that file is read before anyone has authenticated, so
/// anything that can write it can stop VeilVoice from starting at all.
///
/// 4 GiB is chosen to sit well above every parameter set anyone would
/// deliberately pick: RFC 9106's *first* recommended profile is 2 GiB and
/// this crate's default is 256 MiB. A file declaring more than this is
/// refused with [`Error::KdfParams`] rather than obeyed.
///
/// The honest residual: a file whose declared cost is legitimate but larger
/// than *this machine's* memory still cannot be opened, and that failure
/// comes from the allocator rather than from here. A cap cannot fix a
/// small machine; it can stop an absurd number from being taken seriously.
pub const MAX_M_COST: u32 = 4 * 1024 * 1024;
/// The largest number of passes this build will attempt.
///
/// **F-82.** `m_cost` had a ceiling and `t_cost` had only a test for zero,
/// so a header could declare `u32::MAX` passes: four billion of them, over
/// however much memory it also asked for. Nothing overflows and nothing
/// allocates, so every check above passed and the derivation simply did not
/// finish.
///
/// Found by the coverage-guided campaign, which produced a header
/// declaring `m_cost` 65535, `t_cost` 4521984 and `p_cost` 1280. Measured
/// on the machine that found it, in a release build: **about 74 hours**,
/// and that input is not the worst one, only the one the fuzzer happened
/// to reach. `u32::MAX` passes at the same memory is roughly eight years.
///
/// It matters in two places and the second is worse. A `.veil` file is
/// something somebody sent you, and merely attempting to open it would hang
/// the program. The app-lock file carries the same three numbers and is
/// read **before anyone has authenticated**, so anything able to write it
/// could stop VeilVoice from starting, for ever, with no error and nothing
/// to see. That is the argument [`MAX_M_COST`](Self::MAX_M_COST) already
/// makes about memory; nobody had made it about time.
///
/// 16 is chosen the way the memory ceiling was, and then tighter, because
/// time has no allocator to fail on its behalf. RFC 9106's two recommended
/// profiles use one pass and three, libsodium's most expensive preset uses
/// four, and this crate's default is three; 16 is four times the highest of
/// those. Measured in a release build: 16 passes at
/// [`MAX_M_COST`](Self::MAX_M_COST) is 75 seconds, and at
/// [`UNATTENDED_MAX_M_COST`](Self::UNATTENDED_MAX_M_COST) it is 18. So the
/// most expensive header this build will accept is a wait somebody can sit
/// through, rather than one they will never see the end of.
///
/// This is one ceiling and it is enforced in [`checked`](Self::checked),
/// the single funnel every derivation passes through, so it holds for the
/// container, for the app lock, and for anything built against this crate.
/// The honest residual is the same one the memory ceiling states: a cap
/// cannot make a hostile file cheap, it can stop an absurd number from
/// being taken seriously.
pub const MAX_T_COST: u32 = 16;
/// A ceiling for a caller with nobody watching.
///
/// [`MAX_M_COST`](Self::MAX_M_COST) exists to stop an *absurd* value; it is
/// deliberately generous, so a container may still declare a legitimate but
/// expensive cost and make itself slow to open. That is fine when a person
/// chose to open that file and can decide to stop waiting. It is not fine
/// for a service processing whatever arrives, which is why
/// [`KdfParams::within`] exists and this is the value to pass it: 1 GiB is
/// four times this crate's default and still opens in a few seconds, while
/// refusing a header that asks for four gigabytes of someone else's memory.
///
/// This is a *policy*, not a security boundary. The honest framing is that
/// it bounds the cost of being handed a hostile file, not that it makes one
/// safe.
pub const UNATTENDED_MAX_M_COST: u32 = 1024 * 1024;
/// Check the costs against a caller-chosen memory ceiling as well as the
/// built-in one.
///
/// Opening a container whose declared cost is legitimate but large is slow
/// by design, and that is the price of shipping the cost with the file so
/// old files keep opening. A caller running without a human present, such
/// as a batch job, a service or anything processing files it did not choose, can
/// use this to decline instead of spending the memory. Pass
/// [`UNATTENDED_MAX_M_COST`](Self::UNATTENDED_MAX_M_COST) unless there is a
/// reason for something else.
///
/// There is no time ceiling here and there does not need to be: unlike
/// memory, time is bounded for every caller by
/// [`MAX_T_COST`](Self::MAX_T_COST), which is tight enough that the worst
/// header this build will accept is a wait rather than a hang. A ceiling
/// here as well would sit on the *attended* path too, since that is
/// [`super::container::open_with_password`] with a larger number, and would
/// refuse a container somebody had deliberately chosen to open.
pub fn within(&self, max_m_cost: u32) -> Result<(), Error> {
self.checked()?;
if self.m_cost > max_m_cost {
return Err(Error::KdfCostRefused {
requested: self.m_cost,
ceiling: max_m_cost,
});
}
Ok(())
}
/// Check the costs are ones Argon2 can accept, **before** handing them to
/// it.
///
/// This is not belt-and-braces, it is a fix. `argon2` 0.5.3 validates in
/// the wrong order: `Params::new` evaluates `m_cost < p_cost * 8` before it
/// checks `p_cost > MAX_P_COST`, so a `p_cost` above `u32::MAX / 8`
/// overflows the multiplication. With overflow checks on, which is every
/// debug build and any consumer of this crate as a library, that is a **panic
/// on attacker-controlled input**, since `p_cost` is read verbatim from a
/// `.veil` header or an app-lock file. Found by the campaign in
/// `tests/parser_fuzz.rs`.
///
/// VeilVoice's own release profile disables overflow checks, where the
/// multiplication wraps and the `MAX_P_COST` test then rejects it anyway,
/// but "our release profile happens to make the panic unreachable" is not a
/// property to rely on, and it is not true for anyone building against
/// these crates. So the bound is enforced here, in the one place every
/// derivation passes through, in arithmetic that cannot overflow.
///
/// # Why mutating the parallelism ceiling changes nothing
///
/// Worth saying, because a reader who tries it will find it out and
/// wonder. `p_cost > MAX_P_COST` can never be the *only* test that fires:
/// the lane relation below requires `m_cost >= p_cost * 8`, and `m_cost`
/// is itself capped at [`MAX_M_COST`](Self::MAX_M_COST), so nothing above
/// `MAX_M_COST / 8` can get past this function whatever this line says.
/// It stays because it is the bound `argon2` itself documents and because
/// the argument above is about the order the checks run in: a later change
/// that moved the lane relation would leave this as the thing standing
/// between a header and the overflow, and a guard that is only redundant
/// today is not a guard to delete.
pub fn checked(&self) -> Result<(), Error> {
if self.p_cost == 0 || self.p_cost > Self::MAX_P_COST {
return Err(Error::KdfParams);
}
// F-82. Zero is not a number of passes, and neither is four billion:
// a header declaring `u32::MAX` passes nothing overflows, nothing
// allocates, and nothing finishes.
if self.t_cost == 0 || self.t_cost > Self::MAX_T_COST {
return Err(Error::KdfParams);
}
if self.m_cost > Self::MAX_M_COST {
return Err(Error::KdfParams);
}
// Widened to u64 deliberately: this is the multiplication that
// overflows upstream.
if u64::from(self.m_cost) < u64::from(self.p_cost) * 8 {
return Err(Error::KdfParams);
}
Ok(())
}
/// Reject values Argon2 cannot accept, so a corrupt header fails loudly
/// rather than panicking deep inside the KDF.
fn build(&self, out_len: usize) -> Result<Argon2<'static>, Error> {
self.checked()?;
let params = Params::new(self.m_cost, self.t_cost, self.p_cost, Some(out_len))
.map_err(|_| Error::KdfParams)?;
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
}
/// Length of the salt stored in an encrypted container.
pub const SALT_LEN: usize = 16;
/// Length of a derived symmetric key.
pub const KEY_LEN: usize = 32;
/// Derive a 32-byte key from `password` and `salt`.
///
/// The result lands directly in page-locked, zeroizing storage; it is never
/// held in an ordinary `Vec` along the way.
pub fn derive_key(password: &[u8], salt: &[u8], params: KdfParams) -> Result<Secret, Error> {
if salt.len() < 8 {
return Err(Error::KdfParams);
}
let mut key = Secret::zeroed(KEY_LEN);
params
.build(KEY_LEN)?
.hash_password_into(password, salt, key.expose_mut())
.map_err(|_| Error::Kdf)?;
Ok(key)
}
/// Draw a fresh random salt from the OS CSPRNG.
pub fn random_salt() -> Result<[u8; SALT_LEN], Error> {
let mut salt = [0u8; SALT_LEN];
getrandom::getrandom(&mut salt).map_err(|_| Error::Random)?;
Ok(salt)
}
#[cfg(test)]
mod tests {
use super::*;
const P: &[u8] = b"correct horse battery staple";
fn weak() -> KdfParams {
KdfParams::weak_for_tests()
}
/// Every ceiling in this module, from both sides of it.
///
/// **Round thirty-three.** Mutation testing turned `>` into `>=` and into
/// `==` in the cost tests and `||` into `&&` in the parallelism test, and
/// all four mutants survived: the suite tested values that are obviously
/// wrong and values that are obviously right, and never the one at the
/// edge. A ceiling nobody tests at the edge is a ceiling that can move by
/// one without anybody noticing, and these ceilings are what stop a header
/// somebody sends you from choosing how much memory this program
/// allocates.
#[test]
fn every_ceiling_accepts_its_own_value_and_refuses_one_past_it() {
let at = |m_cost, t_cost, p_cost| KdfParams {
m_cost,
t_cost,
p_cost,
};
// Memory. `MAX_M_COST` itself is allowed; one more is not.
assert!(at(KdfParams::MAX_M_COST, 3, 1).checked().is_ok());
assert!(at(KdfParams::MAX_M_COST + 1, 3, 1).checked().is_err());
// Passes. Zero is not a number of passes and neither is one past the
// ceiling; the ceiling itself is fine.
assert!(at(8, KdfParams::MAX_T_COST, 1).checked().is_ok());
assert!(at(8, KdfParams::MAX_T_COST + 1, 1).checked().is_err());
assert!(
at(8, 0, 1).checked().is_err(),
"zero passes finishes nothing"
);
// Parallelism, which is the one the `||` mutant was hiding in. Zero
// must be refused on its own rather than only in company, which is
// what `&&` in place of `||` would have allowed.
assert!(
at(8, 3, 0).checked().is_err(),
"zero lanes is not a degree of parallelism"
);
// The most lanes that can actually get through, which is set by the
// lane relation and not by `MAX_P_COST`: Argon2 wants eight KiB per
// lane, and memory is capped, so the real ceiling on lanes is the
// memory ceiling divided by eight. One more lane than the memory
// allows is refused.
let most = KdfParams::MAX_M_COST / 8;
assert!(at(KdfParams::MAX_M_COST, 3, most).checked().is_ok());
assert!(at(KdfParams::MAX_M_COST, 3, most + 1).checked().is_err());
assert!(
most < KdfParams::MAX_P_COST,
"the memory ceiling binds before Argon2's own lane ceiling, which \
is why mutating `MAX_P_COST` changes no behaviour; see `checked`"
);
}
/// The unattended ceiling lets through exactly what it names.
///
/// `within` is what the desktop application opens a container with when
/// nobody has asked for it, so the value it is given is the largest wait
/// this build will sit through unattended. Off by one here is either a
/// container refused that should open, or a wait somebody did not choose.
#[test]
fn the_unattended_ceiling_is_inclusive() {
let params = KdfParams {
m_cost: KdfParams::UNATTENDED_MAX_M_COST,
t_cost: 3,
p_cost: 1,
};
assert!(
params.within(KdfParams::UNATTENDED_MAX_M_COST).is_ok(),
"the ceiling must accept its own value"
);
let over = KdfParams {
m_cost: KdfParams::UNATTENDED_MAX_M_COST + 1,
..params
};
assert!(matches!(
over.within(KdfParams::UNATTENDED_MAX_M_COST),
Err(Error::KdfCostRefused { .. })
));
}
/// The shortest salt this accepts is the one it documents.
///
/// Argon2 requires eight bytes. `derive_key` refuses anything shorter, and
/// a mutant that made the test `<=` would refuse a salt of exactly eight,
/// which is legal and which nothing in the suite was using.
#[test]
fn a_salt_of_exactly_eight_bytes_is_accepted_and_seven_is_not() {
assert!(derive_key(P, &[0u8; 8], weak()).is_ok());
assert!(matches!(
derive_key(P, &[0u8; 7], weak()),
Err(Error::KdfParams)
));
}
#[test]
fn derivation_is_deterministic() {
let salt = [9u8; SALT_LEN];
let a = derive_key(P, &salt, weak()).unwrap();
let b = derive_key(P, &salt, weak()).unwrap();
assert_eq!(a, b);
assert_eq!(a.len(), KEY_LEN);
}
#[test]
fn different_password_or_salt_diverges() {
let base = derive_key(P, &[9u8; SALT_LEN], weak()).unwrap();
assert_ne!(
base,
derive_key(b"wrong", &[9u8; SALT_LEN], weak()).unwrap()
);
assert_ne!(base, derive_key(P, &[10u8; SALT_LEN], weak()).unwrap());
}
#[test]
fn cost_parameters_change_the_key() {
// Parameters are part of the derivation, so a header that lies about
// them cannot yield the right key.
let salt = [3u8; SALT_LEN];
let a = derive_key(
P,
&salt,
KdfParams {
t_cost: 1,
..weak()
},
)
.unwrap();
let b = derive_key(
P,
&salt,
KdfParams {
t_cost: 2,
..weak()
},
)
.unwrap();
assert_ne!(a, b);
}
/// **F-82.** The exact parameters the coverage-guided campaign found, and
/// the worst case it did not reach.
///
/// These pass every other check: nothing overflows, nothing allocates
/// beyond the memory ceiling, and `m_cost >= p_cost * 8` holds. The only
/// thing wrong with them is that the derivation does not finish. Measured
/// in a release build before the ceiling existed: about 74 hours for the
/// first set, and roughly eight years for `u32::MAX`.
///
/// The numbers are written out rather than referred to, because that is
/// what makes this a regression test for the input rather than a
/// restatement of the rule.
#[test]
fn a_number_of_passes_that_would_not_finish_is_refused() {
let found = KdfParams {
m_cost: 65535,
t_cost: 4_521_984,
p_cost: 1280,
};
assert!(
matches!(found.checked(), Err(Error::KdfParams)),
"{found:?}"
);
assert!(
matches!(
derive_key(P, &[0u8; SALT_LEN], found),
Err(Error::KdfParams)
),
"the derivation must refuse, not run"
);
for t_cost in [u32::MAX, u32::MAX / 2, KdfParams::MAX_T_COST + 1] {
let bad = KdfParams { t_cost, ..weak() };
assert!(
matches!(bad.checked(), Err(Error::KdfParams)),
"t_cost {t_cost} was accepted"
);
}
// And the ceiling itself still works, so this refuses absurd values
// rather than everything.
let at_the_line = KdfParams {
t_cost: KdfParams::MAX_T_COST,
..weak()
};
assert!(at_the_line.checked().is_ok(), "{at_the_line:?}");
}
/// Every parameter set this project actually writes stays acceptable, and
/// the ceiling sits well clear of them.
///
/// A ceiling that refused the default would be a much louder bug than the
/// one it was added for, and "we chose a number" is not evidence that the
/// number is above the ones in use.
#[test]
fn the_time_ceiling_sits_above_every_cost_this_project_uses() {
for params in [KdfParams::default(), KdfParams::weak_for_tests()] {
assert!(params.checked().is_ok(), "{params:?}");
assert!(
params.t_cost * 4 <= KdfParams::MAX_T_COST,
"{params:?} leaves no room under the ceiling"
);
}
// The attended path must still reach the ceiling: a bound that nothing
// can get to is a bound on nothing.
let expensive = KdfParams {
t_cost: KdfParams::MAX_T_COST,
..KdfParams::default()
};
assert!(
expensive.within(KdfParams::MAX_M_COST).is_ok(),
"{expensive:?}"
);
}
#[test]
fn key_material_is_page_locked_storage() {
let k = derive_key(P, &[1u8; SALT_LEN], weak()).unwrap();
assert!(format!("{k:?}").contains("redacted"));
}
#[test]
fn impossible_parameters_are_rejected_not_panicked() {
let bad = KdfParams {
m_cost: 0,
t_cost: 0,
p_cost: 0,
};
assert!(matches!(
derive_key(P, &[0u8; SALT_LEN], bad),
Err(Error::KdfParams)
));
}
/// Regression for the panic the parser campaign found: `argon2` 0.5.3
/// computes `p_cost * 8` before checking `p_cost`'s ceiling, so a `p_cost`
/// above `u32::MAX / 8` overflows. `p_cost` comes straight out of a `.veil`
/// header or an app-lock file, so this is attacker-controlled.
#[test]
fn an_absurd_parallelism_is_rejected_rather_than_overflowing() {
for p_cost in [
u32::MAX,
u32::MAX / 8 + 1,
0x2000_0000,
KdfParams::MAX_P_COST + 1,
] {
let bad = KdfParams {
m_cost: 1 << 20,
t_cost: 3,
p_cost,
};
assert!(bad.checked().is_err(), "p_cost {p_cost} accepted");
assert!(
matches!(derive_key(P, &[0u8; SALT_LEN], bad), Err(Error::KdfParams)),
"p_cost {p_cost} reached Argon2"
);
}
}
/// The other half of the same finding: `m_cost` is the number of KiB
/// Argon2 allocates up front, so `u32::MAX` asks for 4 TiB. The allocation
/// fails, and a failed allocation aborts the process, so simply *trying to
/// open* a hostile file would kill the program.
#[test]
fn an_absurd_memory_cost_is_rejected_rather_than_allocated() {
for m_cost in [u32::MAX, u32::MAX - 1, KdfParams::MAX_M_COST + 1] {
let bad = KdfParams {
m_cost,
t_cost: 3,
p_cost: 4,
};
assert!(bad.checked().is_err(), "m_cost {m_cost} accepted");
assert!(
matches!(derive_key(P, &[0u8; SALT_LEN], bad), Err(Error::KdfParams)),
"m_cost {m_cost} reached Argon2"
);
}
// The ceiling itself is allowed, and comfortably exceeds both RFC
// 9106 recommendations.
assert!(KdfParams {
m_cost: KdfParams::MAX_M_COST,
t_cost: 1,
p_cost: 4
}
.checked()
.is_ok());
}
/// Checked at compile time: the ceiling must never be tightened below the
/// largest profile RFC 9106 actually recommends, or the cap would start
/// refusing legitimate files rather than absurd ones.
const _: () = assert!(KdfParams::MAX_M_COST >= 2 * 1024 * 1024);
#[test]
fn sane_parameters_still_pass_the_check() {
KdfParams::default().checked().unwrap();
KdfParams::weak_for_tests().checked().unwrap();
assert!(KdfParams {
m_cost: 8,
t_cost: 1,
p_cost: 1
}
.checked()
.is_ok());
// m_cost must still cover 8 blocks per lane.
assert!(KdfParams {
m_cost: 8,
t_cost: 1,
p_cost: 2
}
.checked()
.is_err());
}
#[test]
fn short_salt_is_rejected() {
assert!(matches!(
derive_key(P, &[0u8; 4], weak()),
Err(Error::KdfParams)
));
}
#[test]
fn random_salts_differ() {
assert_ne!(random_salt().unwrap(), random_salt().unwrap());
}
}