crates/veilvoice-crypto/tests/parser_fuzz.rs

what this file is for · veilvoice-crypto · 368 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
//! Randomised robustness testing for the two parsers that read untrusted input.
//!
//! # What is being defended
//!
//! [`container::Header::parse`] reads a file somebody sent you.
//! [`lock::AppLock::parse`] reads the app-lock file, which is worse: it is
//! parsed **before anyone has authenticated anything**, so it is the first
//! bytes the program touches on a locked machine.
//!
//! For a parser in a security tool the bar is not "usually returns the right
//! answer". It is:
//!
//! 1. **Never panic.** A panic on hostile input is a denial of service, and in
//!    a `panic = "abort"` release profile it is the whole process.
//! 2. **Never hang.** Every loop must be bounded by the input, not by a length
//!    field the input controls.
//! 3. **Never report success for something it did not fully understand.** An
//!    `Ok` must come with offsets that are actually inside the buffer.
//!
//! # Why this and not `cargo fuzz`
//!
//! `cargo fuzz` needs nightly and libFuzzer, which is a poor fit for a project
//! that pins a stable toolchain and wants every check runnable by anyone who
//! cloned it. This is a deterministic campaign instead: a seeded PRNG, so a
//! failure is reproducible from its seed rather than being a story about a run
//! nobody can repeat, and structure-aware mutation, so the bytes spend their
//! time near the interesting boundaries rather than being rejected at the magic
//! number.
//!
//! It is **not** a substitute for a coverage-guided fuzzer and `docs/AUDIT.md`
//! does not claim it is. It is the campaign that can actually be run on every
//! commit, on every platform, by everybody.
//!
//! Set `VEILVOICE_FUZZ_ROUNDS` to run it longer than the default.
//!
//! # In plain words
//!
//! Throws malformed and deliberately hostile encrypted files at the code that
//! reads them, in bulk.
//!
//! Reading a file somebody else made is where most security problems live. Every
//! one of these has to be refused with a reason: never accepted, and never able to
//! bring the program down.

use veilvoice_crypto::{container, kdf, lock};


/// xorshift32. Deterministic, seeded, and twenty lines. A dependency here
/// would be a dependency in the audit surface for no benefit.
struct Rng(u32);


impl Rng {

    fn new(seed: u32) -> Self {
        Self(seed | 1)
    }


    fn next_u32(&mut self) -> u32 {
        self.0 ^= self.0 << 13;
        self.0 ^= self.0 >> 17;
        self.0 ^= self.0 << 5;
        self.0
    }


    fn below(&mut self, n: usize) -> usize {
        if n == 0 {
            0
        } else {
            self.next_u32() as usize % n
        }
    }


    fn byte(&mut self) -> u8 {
        (self.next_u32() >> 24) as u8
    }

}


fn rounds() -> u32 {
    std::env::var("VEILVOICE_FUZZ_ROUNDS")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(20_000)
}



/// Mutate `seed_bytes` in one of the ways that break parsers in practice.
///
/// Deliberately biased towards length fields and boundaries: uniformly random
/// bytes almost never get past a magic number, so a campaign made only of them
/// tests the first four bytes very thoroughly and nothing else at all.
fn mutate(rng: &mut Rng, seed_bytes: &[u8]) -> Vec<u8> {
    let mut out = seed_bytes.to_vec();
    match rng.below(8) {
        // Flip a bit.
        0 => {
            if !out.is_empty() {
                let i = rng.below(out.len());
                out[i] ^= 1 << rng.below(8);
            }
        }
        // Replace a byte outright.
        1 => {
            if !out.is_empty() {
                let i = rng.below(out.len());
                out[i] = rng.byte();
            }
        }
        // Truncate, which is the classic way a length field outruns the buffer.
        2 => {
            let n = rng.below(out.len() + 1);
            out.truncate(n);
        }
        // Extend with noise.
        3 => {
            for _ in 0..rng.below(64) {
                out.push(rng.byte());
            }
        }
        // Corrupt a 32-bit field with an extreme value.
        4 => {
            if out.len() >= 4 {
                let i = rng.below(out.len() - 3);
                let v: u32 = match rng.below(5) {
                    0 => u32::MAX,
                    1 => u32::MAX - 1,
                    2 => 0,
                    3 => i32::MAX as u32,
                    _ => rng.next_u32(),
                };
                out[i..i + 4].copy_from_slice(&v.to_le_bytes());
            }
        }
        // Splice a region onto itself.
        5 => {
            if out.len() > 8 {
                let from = rng.below(out.len());
                let len = rng.below(out.len() - from);
                let piece = out[from..from + len].to_vec();
                let at = rng.below(out.len());
                out.splice(at..at, piece);
            }
        }
        // Zero a run.
        6 => {
            if !out.is_empty() {
                let from = rng.below(out.len());
                let to = (from + rng.below(32)).min(out.len());
                for b in &mut out[from..to] {
                    *b = 0;
                }
            }
        }
        // Start from scratch: pure noise, occasionally with the right magic.
        _ => {
            let n = rng.below(200);
            out = (0..n).map(|_| rng.byte()).collect();
            if rng.below(2) == 0 && out.len() >= 8 {
                out[..8].copy_from_slice(container::MAGIC);
            }
        }
    }
    out
}



fn weak() -> kdf::KdfParams {
    kdf::KdfParams::weak_for_tests()
}



/// Whether it is worth actually running the KDF for these parameters.
///
/// Mutation cheerfully produces costs that are *valid* and enormous: a
/// 4 GiB-and-three-passes Argon2 is a perfectly legal header. Executing those
/// turns a campaign into a benchmark, so the KDF-running half of each round is
/// limited to cheap parameters and the expensive ones are covered by the unit
/// tests in `kdf.rs` instead.
///
/// Worth stating plainly, because it is a real property and not just a test
/// convenience: an attacker who hands you a container **can** make opening it
/// slow, because the cost travels with the file and that is the whole point of
/// the design. Slow is not the same as crashing, the user chose to open that
/// file, and they can stop waiting. The crash was the bug; the delay is the
/// documented trade.
fn cheap(params: kdf::KdfParams) -> bool {
    params.checked().is_ok() && params.m_cost <= 256 && params.t_cost <= 4 && params.p_cost <= 4
}



#[test]
fn the_container_header_parser_survives_hostile_input() {
    let valid = container::seal_with_password(b"pw", b"a recording", weak()).unwrap();
    let mut checked = 0u32;

    for seed in 1..=rounds() {
        let mut rng = Rng::new(seed);
        let bytes = mutate(&mut rng, &valid);

        // Property 1: it must return, and must not panic. Reaching the next
        // line at all is the assertion.
        if let Ok((header, body)) = container::Header::parse(&bytes) {
            // Property 3: a success must describe the buffer it was given.
            assert!(
                body <= bytes.len(),
                "seed {seed}: parse reported ciphertext starting at {body} \
                 in a {}-byte buffer",
                bytes.len()
            );
            assert!(
                body >= container::HEADER_LEN,
                "seed {seed}: body offset {body} is inside the fixed header"
            );
            assert_eq!(
                header.encapsulation.len(),
                body - container::HEADER_LEN,
                "seed {seed}: encapsulation length disagrees with the offset"
            );
            // Re-serialising a parsed header must reproduce the bytes it was
            // parsed from. If it does not, the parser accepted something it did
            // not fully account for.
            assert_eq!(
                header.to_bytes(),
                &bytes[..body],
                "seed {seed}: header does not round-trip"
            );

            // The guard itself must hold for every parsed header, cheap or not.
            // This is the check that stops an absurd cost reaching Argon2.
            let _ = header.kdf.checked();
            // And the full open path, which is what actually faces a hostile
            // file, only for costs cheap enough to run: see `cheap`.
            if cheap(header.kdf) {
                let _ = container::open_with_password(b"pw", &bytes);
            }
        }
        checked += 1;
    }
    assert!(checked > 0);
}



#[test]
fn the_app_lock_parser_survives_hostile_input() {
    let valid = lock::AppLock::create(b"pw", weak()).unwrap().to_bytes();

    for seed in 1..=rounds() {
        let mut rng = Rng::new(seed ^ 0x5EED);
        let bytes = mutate(&mut rng, &valid);

        if let Ok(mut parsed) = lock::AppLock::parse(&bytes) {
            // A parsed lock must re-serialise to exactly what it came from.
            // Anything else means a field was ignored, and an ignored field in
            // a file read before authentication is precisely the place not to
            // have one.
            assert_eq!(
                parsed.to_bytes(),
                bytes,
                "seed {seed}: lock file does not round-trip"
            );
            // The cooldown must be a finite answer whatever the stored counters
            // say, including a failure count of u32::MAX and a timestamp from
            // the far future, which mutation reaches often.
            let _ = parsed.cooldown();
            assert!(lock::delay_secs(parsed.failures()) <= 15 * 60);

            // And the path that actually runs on a locked machine. Parsing
            // alone missed the Argon2 parallelism overflow entirely, because
            // the corrupt cost parameters only reach the KDF here.
            if cheap(parsed.params()) {
                let _ = parsed.verify(b"whatever the user typed");
            }
            // Whatever the file said, the guard must have an opinion about it
            // and must not panic forming one.
            let _ = parsed.params().checked();
        }
    }
}



/// Pure noise, with no valid seed to start from. Cheap, and it covers the
/// "someone pointed it at an unrelated file" case that structured mutation
/// never reaches.
#[test]
fn both_parsers_survive_pure_noise() {
    for seed in 1..=rounds() {
        let mut rng = Rng::new(seed.wrapping_mul(2_654_435_761));
        let n = rng.below(300);
        let bytes: Vec<u8> = (0..n).map(|_| rng.byte()).collect();

        if let Ok((header, _)) = container::Header::parse(&bytes) {
            if cheap(header.kdf) {
                let _ = container::open_with_password(b"pw", &bytes);
            }
        }
        if let Ok(parsed) = lock::AppLock::parse(&bytes) {
            let _ = parsed.params().checked();
        }
    }
}



/// The lengths a parser gets wrong are the ones either side of a boundary, and
/// a random campaign hits them only by luck. This walks them deliberately.
#[test]
fn every_length_around_a_boundary_is_handled() {
    let container_bytes = container::seal_with_password(b"pw", b"x", weak()).unwrap();
    let lock_bytes = lock::AppLock::create(b"pw", weak()).unwrap().to_bytes();

    for len in 0..=container_bytes.len() {
        let _ = container::Header::parse(&container_bytes[..len]);
        let _ = container::open_with_password(b"pw", &container_bytes[..len]);
    }

    for len in 0..=lock_bytes.len() + 8 {
        let mut padded = lock_bytes.clone();
        padded.resize(len, 0);
        let _ = lock::AppLock::parse(&padded);
    }

    // Exactly the right length parses; one byte either side does not.
    assert!(lock::AppLock::parse(&lock_bytes).is_ok());
    assert!(lock::AppLock::parse(&lock_bytes[..lock_bytes.len() - 1]).is_err());
    let mut long = lock_bytes.clone();
    long.push(0);
    assert!(lock::AppLock::parse(&long).is_err());
}



/// **F-82.** The one input the coverage-guided campaign found, kept here.
///
/// `fuzz/README.md` says these two campaigns are different things and both are
/// kept, and this is what that means in practice: the nightly campaign found an
/// input, and the input lives here, where it is checked on every commit on
/// every platform by anybody who cloned the repository.
///
/// The bytes are a whole `.veil` header declaring `m_cost` 65535, `t_cost`
/// 4521984 and `p_cost` 1280. Nothing about them overflows, nothing allocates
/// beyond the memory ceiling, and `m_cost >= p_cost * 8` holds, so every check
/// this file's other tests make passed. The only thing wrong with them is that
/// the derivation would not finish: about 74 hours, measured in a release
/// build before `MAX_T_COST` existed.
///
/// Written out as bytes rather than built from `KdfParams`, because the point
/// is the file, and a test that constructs the parameters would keep passing if
/// the header ever stopped putting them where it puts them.
#[test]
fn the_header_the_coverage_guided_campaign_found_is_refused() {
    let mut header = vec![0u8; 69];
    header[..8].copy_from_slice(b"VEILVOX1");
    header[8] = 1; // format version
    header[9] = 1; // password mode
    header[12..16].copy_from_slice(&65_535u32.to_le_bytes()); // m_cost
    header[16..20].copy_from_slice(&4_521_984u32.to_le_bytes()); // t_cost
    header[20..24].copy_from_slice(&1280u32.to_le_bytes()); // p_cost

    // The header still parses: the numbers are structurally fine, and pretending
    // otherwise would be testing the wrong thing.
    let parsed = container::Header::parse(&header);
    assert!(
        parsed.is_ok(),
        "the header itself is well formed: {parsed:?}"
    );

    // What must not happen is that opening it runs. On a thread with a deadline,
    // because the defect this guards against is a *hang*: timing the call after
    // it returns cannot fail, it can only never finish, and a test that hangs
    // says less than one that fails and says why.
    let (done, answer) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let refused = container::open_with_password(b"not the password", &header).is_err();
        let _ = done.send(refused);
    });
    match answer.recv_timeout(std::time::Duration::from_secs(5)) {
        Ok(refused) => assert!(refused, "a header this expensive must be refused"),
        Err(_) => panic!(
            "open_with_password did not come back within five seconds, so it is \
             deriving rather than refusing. That is F-82: t_cost is unbounded."
        ),
    }
}