crates/veilvoice-crypto/tests/timing.rs
what this file is for · veilvoice-crypto · 249 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
//! Timing measurement of the password paths.
//!
//! `docs/AUDIT.md` listed this as outstanding: Argon2id is inherently
//! constant-ish, but nobody had measured the code *around* it. The question is
//! whether the time an attempt takes leaks anything about the password,
//! classically, whether a byte-by-byte comparison returns early and turns
//! "how long did that take" into "how many characters were right".
//!
//! # These are ignored by default, and that is deliberate
//!
//! A timing test on a shared CI runner measures the neighbours, not the code.
//! Run them on a quiet machine and read the numbers:
//!
//! ```text
//! cargo test -p veilvoice-crypto --release --test timing -- --ignored --nocapture
//! ```
//!
//! The thresholds below are loose on purpose. They are there to catch a
//! *catastrophic* regression, such as someone replacing a constant-time
//! comparison with `==`, which shows up as a difference of orders of magnitude, not to
//! certify a bound in nanoseconds, which this method cannot honestly do.
//!
//! # In plain words
//!
//! Measures whether checking a password takes a different amount of time depending
//! on how wrong it is.
//!
//! If it did, somebody could work out a password one character at a time by
//! watching the clock rather than by guessing. This runs the comparison many times
//! and checks that the timing says nothing.
use std::time::{Duration, Instant};
use veilvoice_crypto::{container, kdf, lock};
/// Cheap parameters on purpose: a fast KDF makes the *comparison* a larger
/// share of the total, so a non-constant-time one is easier to see. Measuring
/// with the 256 MiB default would bury any leak under Argon2's own noise, which
/// would be a comfortable way to prove nothing.
fn params() -> kdf::KdfParams {
kdf::KdfParams {
m_cost: 64,
t_cost: 1,
p_cost: 1,
}
}
const SAMPLES: usize = 2_000;
/// What a run of samples looked like.
///
/// The headline figure is the **minimum**, not the mean or the median. Timing
/// noise on a real machine is one-sided: a scheduler, an interrupt or a cache
/// miss can only ever make a sample slower, never faster. The fastest sample is
/// therefore the closest estimate of the work the code actually does, and it is
/// far more stable across runs than any average, which on the first pass of
/// these tests moved the "ratio" by 50% purely from Windows scheduling.
struct Stats {
min: Duration,
median: Duration,
iqr: Duration,
}
fn summarise(times: Vec<Duration>) -> Stats {
let mut sorted = times;
sorted.sort_unstable();
Stats {
min: sorted[0],
median: sorted[sorted.len() / 2],
iqr: sorted[sorted.len() * 3 / 4] - sorted[sorted.len() / 4],
}
}
fn show(label: &str, s: &Stats) {
println!(
" {label:<30} min {:>9.3?} median {:>9.3?} IQR {:>9.3?}",
s.min, s.median, s.iqr
);
}
fn time_it(runs: usize, mut body: impl FnMut()) -> Stats {
// A warm-up pass, so the first sample is not measuring page faults and
// branch predictors rather than the code.
for _ in 0..50 {
body();
}
summarise(
(0..runs)
.map(|_| {
let start = Instant::now();
body();
start.elapsed()
})
.collect(),
)
}
/// Time one call each against a batch of values prepared *outside* the clock.
///
/// Needed wherever a single measurement would otherwise have to include its own
/// setup: the app lock counts failures, so timing repeated wrong guesses on one
/// lock either trips the rate limiter or has to reset it inside the timed
/// region, which is how the first version of this test ended up comparing two
/// derivations against one and reporting a meaningless 2× "leak".
fn time_each<T>(items: &mut [T], mut body: impl FnMut(&mut T)) -> Stats {
summarise(
items
.iter_mut()
.map(|item| {
let start = Instant::now();
body(item);
start.elapsed()
})
.collect(),
)
}
fn ratio(a: &Stats, b: &Stats) -> f64 {
a.min.as_secs_f64() / b.min.as_secs_f64().max(1e-12)
}
#[test]
#[ignore = "timing measurement; run by hand on a quiet machine"]
fn opening_a_container_does_not_leak_how_much_of_the_password_was_right() {
let sealed =
container::seal_with_password(b"correct horse battery staple", b"audio", params()).unwrap();
// Wrong from the first character, versus wrong only in the last. A
// comparison that gave up early would make the second markedly slower.
let early = time_it(SAMPLES, || {
let _ = container::open_with_password(b"xorrect horse battery staple", &sealed);
});
let late = time_it(SAMPLES, || {
let _ = container::open_with_password(b"correct horse battery stapl3", &sealed);
});
let right = time_it(SAMPLES, || {
let _ = container::open_with_password(b"correct horse battery staple", &sealed);
});
println!("\ncontainer open, cheap Argon2 ({SAMPLES} samples each)");
show("wrong at the first byte", &early);
show("wrong at the last byte", &late);
show("right password", &right);
let prefix = ratio(&early, &late);
let success = ratio(&early, &right);
println!(" wrong-early / wrong-late {prefix:.4}");
println!(" wrong / right {success:.4}");
// The one that matters. A comparison that returns on the first differing
// byte turns the clock into a character-by-character oracle, and shows up
// here as a large factor rather than a few percent.
assert!(
(0.8..1.25).contains(&prefix),
"prefix length changed the time by {prefix:.3}x, so an early-exit comparison?"
);
// Success versus failure is a weaker property, and is not an oracle in any
// case: an attacker holding the container learns whether a guess worked
// from the plaintext, not from the clock.
assert!(
(0.5..2.0).contains(&success),
"success and failure differ by {success:.3}x"
);
}
#[test]
#[ignore = "timing measurement; run by hand on a quiet machine"]
fn the_app_lock_takes_the_same_time_whether_or_not_the_password_is_right() {
// A batch of pristine locks, built before the clock starts. Each is used
// for exactly one attempt, so no measurement includes a reset and none of
// them trips the rate limiter.
let batch = |n: usize| -> Vec<lock::AppLock> {
(0..n)
.map(|_| lock::AppLock::create(b"the app lock password", params()).unwrap())
.collect()
};
let samples = SAMPLES / 4; // each sample costs a `create` as well
let mut for_right = batch(samples);
let mut for_wrong = batch(samples);
let mut for_length = batch(samples);
let right = time_each(&mut for_right, |l| {
let _ = l.verify(b"the app lock password");
});
let wrong = time_each(&mut for_wrong, |l| {
let _ = l.verify(b"the app lock passwerd");
});
// A wildly different length, in case anything downstream is length-sensitive.
let long = time_each(&mut for_length, |l| {
let _ = l.verify(b"z");
});
println!("\napp lock verify, cheap Argon2 ({samples} fresh locks each)");
show("right password", &right);
show("wrong password, same length", &wrong);
show("wrong password, one byte", &long);
let same_length = ratio(&wrong, &right);
let diff_length = ratio(&long, &right);
println!(" wrong / right {same_length:.4}");
println!(" one-byte / right {diff_length:.4}");
assert!(
(0.8..1.25).contains(&same_length),
"right and wrong passwords differ by {same_length:.3}x"
);
assert!(
(0.8..1.25).contains(&diff_length),
"password length changed the time by {diff_length:.3}x"
);
}
/// The rate limiter returns **before** touching the KDF, so a locked-out
/// attempt is obviously faster than a real one. That is deliberate, because the
/// point of a rate limit is to refuse to spend the CPU, and it leaks only the state
/// the UI displays on screen anyway. Measured so the trade is a number rather
/// than an assumption.
#[test]
#[ignore = "timing measurement; run by hand on a quiet machine"]
fn a_rate_limited_attempt_is_visibly_cheaper_and_that_is_intended() {
let mut locked = lock::AppLock::create(b"pw", params()).unwrap();
for _ in 0..6 {
let _ = locked.verify(b"nope");
}
assert!(locked.cooldown().is_some(), "should be rate limited by now");
let refused = time_it(SAMPLES, || {
let _ = locked.verify(b"pw");
});
let mut fresh: Vec<lock::AppLock> = (0..SAMPLES / 4)
.map(|_| lock::AppLock::create(b"pw", params()).unwrap())
.collect();
let considered = time_each(&mut fresh, |l| {
let _ = l.verify(b"pw");
});
println!("\nrate-limited refusal versus a real attempt");
show("refused, KDF never consulted", &refused);
show("a real derivation", &considered);
println!(
" the refusal is at least {:.0}x cheaper, by design",
considered.min.as_secs_f64() / refused.min.as_secs_f64().max(1e-9)
);
assert!(
refused.median < considered.median,
"a rate-limited attempt must not cost a full derivation"
);
}