crates/veilvoice-crypto/src/tape.rs

what this file is for · veilvoice-crypto · 391 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
//! A recording held in locked, zeroizing memory while it is still being made.
//!
//! # The problem this exists for
//!
//! [`Secret`] holds a passphrase or a key: a few dozen bytes, known in full
//! before the allocation is made. A recording is neither. It arrives a few
//! milliseconds at a time, for as long as somebody keeps talking, and nobody
//! knows at the start how long that will be.
//!
//! Accumulating it in a `Vec<u8>` would undo the thing this crate is careful
//! about everywhere else. A `Vec` is not locked, so the operating system may
//! write it to the page file; it is not zeroized, so its contents outlive it in
//! freed memory; and it reallocates as it grows, which leaves the *previous*
//! buffer, still holding the recording so far, somewhere on the heap with
//! nothing wiping it. Every doubling leaves another copy behind.
//!
//! A `Tape` is the growable equivalent of a [`Secret`]: append-only, made of
//! page-locked chunks that are never reallocated or moved, and zeroized in full
//! when it goes out of scope.
//!
//! # Why chunks, rather than one buffer that grows
//!
//! Growing means reallocating, and reallocating a secret means copying it to a
//! new address and leaving the old bytes unwiped in memory the allocator is now
//! free to hand to anybody. The whole point is that no copy is ever left
//! behind, so nothing here is ever resized. A full chunk is kept exactly where
//! it is and a new one is added beside it.
//!
//! Chunks are [`CHUNK`] bytes, which is a deliberate compromise rather than a
//! round number picked for looks. Locking is charged against a per-process
//! budget (`RLIMIT_MEMLOCK` on Linux, often a few megabytes and sometimes far
//! less), and a chunk is the unit in which that budget is spent. Small chunks
//! spend it in fine increments, so a tape that outgrows the budget locks as
//! much as the budget allowed rather than losing a large request wholesale.
//!
//! # What this buys, and what it does not
//!
//! It buys the same thing [`Secret`] buys, over a buffer that grows: the
//! recording is kept out of the page file where the operating system permits
//! it, and it is wiped rather than abandoned.
//!
//! It does not defeat somebody who can already read this process's memory, and
//! locking does not survive hibernation, which writes RAM to disk wholesale.
//! Locking can also simply fail: the budget above is small and unprivileged
//! processes cannot raise it. That is reported rather than hidden.
//! [`Tape::fully_locked`] is false the moment one chunk could not be locked,
//! and [`Tape::locked_chunks`] says how many were, so a caller can tell the
//! user what was actually obtained instead of implying a guarantee.
//!
//! Zeroization, as in [`crate::amnesia`], always happens.
//!
//! # In plain words
//!
//! Somewhere to keep a recording while it is being made, which asks the
//! operating system not to write it out to disk and wipes it when it is
//! finished with.
//!
//! It is built out of fixed-size pieces so that it never has to move what it is
//! already holding. Moving it would leave a copy of your recording behind in
//! memory, which is exactly what this is for avoiding.

use crate::{Error, Secret};


/// Bytes per chunk.
///
/// See the module documentation: this is the unit in which the operating
/// system's lock budget is spent, so it is small enough that a tape which
/// outgrows that budget still locks most of what it holds, and large enough
/// that a long recording does not accumulate an absurd number of allocations.
/// At 48 kHz 16-bit mono, one chunk is about two thirds of a second.
pub const CHUNK: usize = 64 * 1024;



/// An append-only buffer of locked, zeroizing chunks.
///
/// Deliberately has no `Debug`, no `Clone` and no way to hand out an owned copy
/// of its contents: every route out of it writes into storage the caller has
/// already made safe. See [`Tape::copy_into`].
pub struct Tape {
    /// Full chunks, then the one being filled. Never reallocated or moved.
    chunks: Vec<Secret>,
    /// Bytes used in the final chunk. Every earlier chunk is full.
    filled: usize,
}


impl Default for Tape {

    fn default() -> Self {
        Self::new()
    }

}

impl Tape {

    /// An empty tape. No allocation happens until the first byte is appended.
    pub fn new() -> Self {
        Self {
            chunks: Vec::new(),
            filled: 0,
        }
    }



    /// Append `bytes`.
    ///
    /// Fills the current chunk before adding another, so a tape of `n` bytes
    /// holds `n.div_ceil(CHUNK)` chunks and no more: the count is a function of
    /// the length alone, never of how the appends were split up. A caller
    /// pushing one sample at a time and a caller pushing a whole buffer end up
    /// with byte-for-byte the same tape.
    pub fn push(&mut self, mut bytes: &[u8]) {
        while !bytes.is_empty() {
            if self.chunks.is_empty() || self.filled == CHUNK {
                self.chunks.push(Secret::zeroed(CHUNK));
                self.filled = 0;
            }
            let room = CHUNK - self.filled;
            let take = room.min(bytes.len());
            let at = self.filled;
            // `expect` cannot fire: a chunk was just pushed if none existed.
            let last = self
                .chunks
                .last_mut()
                .expect("a chunk exists after the check above");
            last.expose_mut()[at..at + take].copy_from_slice(&bytes[..take]);
            self.filled += take;
            bytes = &bytes[take..];
        }
    }



    /// Total bytes held.
    pub fn len(&self) -> usize {
        match self.chunks.len() {
            0 => 0,
            n => (n - 1) * CHUNK + self.filled,
        }
    }



    /// Whether nothing has been appended.
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }



    /// How many chunks the operating system agreed to lock out of swap.
    pub fn locked_chunks(&self) -> usize {
        self.chunks.iter().filter(|c| c.is_locked()).count()
    }



    /// How many chunks the tape holds.
    pub fn chunk_count(&self) -> usize {
        self.chunks.len()
    }



    /// Whether every chunk is locked.
    ///
    /// An empty tape is trivially fully locked: there is nothing unlocked in
    /// it. Callers reporting this to a user should check [`Tape::is_empty`]
    /// first if "nothing to protect" and "protected" should read differently.
    pub fn fully_locked(&self) -> bool {
        self.chunks.iter().all(|c| c.is_locked())
    }



    /// Copy the whole tape into `out`, which must be exactly [`Tape::len`].
    ///
    /// Takes a destination rather than returning a `Vec` on purpose. Returning
    /// one would put the entire recording into unlocked, unzeroized memory and
    /// undo this type in a single line, and it would do it at the one moment
    /// the recording is complete and therefore at its most worth protecting.
    /// The caller allocates a [`Secret`] and passes its
    /// [`expose_mut`](Secret::expose_mut) here instead.
    ///
    /// Returns [`Error::TapeLength`] if `out` is the wrong size, rather than
    /// copying what fits: a partial recording written into a buffer sized for a
    /// whole one is a truncated file that nothing would flag.
    pub fn copy_into(&self, out: &mut [u8]) -> Result<(), Error> {
        if out.len() != self.len() {
            return Err(Error::TapeLength);
        }
        let mut at = 0;
        for (index, chunk) in self.chunks.iter().enumerate() {
            let take = if index + 1 == self.chunks.len() {
                self.filled
            } else {
                CHUNK
            };
            out[at..at + take].copy_from_slice(&chunk.expose()[..take]);
            at += take;
        }
        Ok(())
    }



    /// Wipe and release everything held, leaving an empty tape.
    ///
    /// Each chunk is a [`Secret`], so dropping it wipes it and unlocks its
    /// pages. This exists so a caller can do that at a chosen moment rather
    /// than waiting for the tape itself to go out of scope, which matters when
    /// the tape lives inside a long-running session.
    pub fn wipe(&mut self) {
        self.chunks.clear();
        self.filled = 0;
    }

}

#[cfg(test)]
mod tests {
    use super::*;

    /// A push longer than one chunk fills the room actually left in it.
    #[test]
    fn a_push_that_crosses_a_chunk_boundary_lands_where_it_should() {
        let mut tape = Tape::new();
        // A short push first, so the boundary falls inside the second push
        // rather than neatly at the start of a chunk.
        tape.push(&[7u8; 100]);
        let long: Vec<u8> = (0..CHUNK + 33).map(|i| (i % 251) as u8).collect();
        tape.push(&long);

        assert_eq!(tape.len(), 100 + CHUNK + 33);
        assert!(!tape.is_empty(), "a tape with bytes in it is not empty");
        // 65,669 bytes is one full chunk and 133 bytes of a second.
        assert_eq!(tape.chunk_count(), 2);

        let mut out = vec![0u8; tape.len()];
        tape.copy_into(&mut out).unwrap();
        assert_eq!(&out[..100], &[7u8; 100]);
        assert_eq!(&out[100..], &long[..]);
    }

    /// Stated as a relation between the counts, because whether the operating
    /// system grants a lock is a property of the machine.
    #[test]
    fn fully_locked_agrees_with_the_chunk_counts() {
        let mut tape = Tape::new();
        assert!(
            tape.fully_locked(),
            "an empty tape has nothing unlocked in it"
        );
        assert_eq!(tape.locked_chunks(), tape.chunk_count());

        tape.push(&[1u8; CHUNK * 2 + 5]);
        assert_eq!(tape.chunk_count(), 3);
        assert_eq!(
            tape.fully_locked(),
            tape.locked_chunks() == tape.chunk_count(),
            "fully_locked must say exactly what the counts say"
        );
    }

    #[test]
    fn an_empty_tape_holds_nothing_and_allocates_nothing() {
        let tape = Tape::new();
        assert_eq!(tape.len(), 0);
        assert!(tape.is_empty());
        assert_eq!(tape.chunk_count(), 0);
    }

    #[test]
    fn what_goes_in_comes_out_byte_for_byte() {
        let mut tape = Tape::new();
        let data: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
        tape.push(&data);
        assert_eq!(tape.len(), data.len());

        let mut out = vec![0u8; tape.len()];
        tape.copy_into(&mut out).unwrap();
        assert_eq!(out, data);
    }

    #[test]
    fn a_tape_spanning_many_chunks_reassembles_in_order() {
        // The failure this guards is an off-by-one at a chunk boundary, which
        // would corrupt audio in a way that still plays: the length would be
        // right and the samples would be shuffled.
        let mut tape = Tape::new();
        let data: Vec<u8> = (0..(CHUNK * 3 + 1234)).map(|i| (i % 253) as u8).collect();
        tape.push(&data);
        assert_eq!(tape.len(), data.len());
        assert_eq!(tape.chunk_count(), 4);

        let mut out = vec![0u8; tape.len()];
        tape.copy_into(&mut out).unwrap();
        assert_eq!(out, data, "the tape did not reassemble in order");
    }

    #[test]
    fn how_the_appends_were_split_up_does_not_change_the_tape() {
        // One sample at a time and one buffer at a time have to produce the
        // same bytes and the same chunk count, or an audio callback delivering
        // odd-sized buffers would produce a different file from the same sound.
        let data: Vec<u8> = (0..(CHUNK + 777)).map(|i| (i % 249) as u8).collect();

        let mut whole = Tape::new();
        whole.push(&data);

        let mut dribbled = Tape::new();
        for byte in &data {
            dribbled.push(std::slice::from_ref(byte));
        }

        let mut a = vec![0u8; whole.len()];
        let mut b = vec![0u8; dribbled.len()];
        whole.copy_into(&mut a).unwrap();
        dribbled.copy_into(&mut b).unwrap();
        assert_eq!(a, b);
        assert_eq!(whole.chunk_count(), dribbled.chunk_count());
    }

    #[test]
    fn a_chunk_boundary_lands_exactly_where_the_arithmetic_says() {
        let mut tape = Tape::new();
        tape.push(&vec![7u8; CHUNK]);
        assert_eq!(tape.len(), CHUNK);
        assert_eq!(
            tape.chunk_count(),
            1,
            "a full chunk is not yet a second one"
        );

        tape.push(&[9]);
        assert_eq!(tape.len(), CHUNK + 1);
        assert_eq!(tape.chunk_count(), 2);
    }

    #[test]
    fn pushing_nothing_changes_nothing() {
        let mut tape = Tape::new();
        tape.push(&[]);
        assert_eq!(tape.chunk_count(), 0, "an empty push must not allocate");
        tape.push(&[1, 2, 3]);
        tape.push(&[]);
        assert_eq!(tape.len(), 3);
        assert_eq!(tape.chunk_count(), 1);
    }

    #[test]
    fn a_destination_of_the_wrong_size_is_refused_rather_than_part_filled() {
        let mut tape = Tape::new();
        tape.push(&[1, 2, 3, 4]);

        let mut too_small = vec![0u8; 3];
        assert!(matches!(
            tape.copy_into(&mut too_small),
            Err(Error::TapeLength)
        ));
        let mut too_large = vec![0u8; 5];
        assert!(matches!(
            tape.copy_into(&mut too_large),
            Err(Error::TapeLength)
        ));
        // Neither attempt wrote anything.
        assert_eq!(too_small, vec![0, 0, 0]);
        assert_eq!(too_large, vec![0, 0, 0, 0, 0]);
    }

    #[test]
    fn wiping_leaves_an_empty_tape_that_still_works() {
        let mut tape = Tape::new();
        tape.push(&vec![3u8; CHUNK * 2]);
        assert_eq!(tape.chunk_count(), 2);

        tape.wipe();
        assert!(tape.is_empty());
        assert_eq!(tape.chunk_count(), 0);

        // And it is reusable rather than poisoned.
        tape.push(&[8, 8]);
        let mut out = vec![0u8; 2];
        tape.copy_into(&mut out).unwrap();
        assert_eq!(out, vec![8, 8]);
    }

    #[test]
    fn the_lock_count_never_claims_more_than_the_tape_holds() {
        // Locking is best-effort and the budget is small, so this asserts the
        // relationship rather than a number: claiming more locked chunks than
        // exist would be a report that cannot be true.
        let mut tape = Tape::new();
        tape.push(&vec![1u8; CHUNK * 2 + 10]);
        assert_eq!(tape.chunk_count(), 3);
        assert!(tape.locked_chunks() <= tape.chunk_count());
        assert_eq!(
            tape.fully_locked(),
            tape.locked_chunks() == tape.chunk_count()
        );
    }

    #[test]
    fn an_empty_tape_reports_no_unlocked_chunks() {
        let tape = Tape::new();
        assert!(tape.fully_locked());
        assert_eq!(tape.locked_chunks(), 0);
        assert_eq!(tape.chunk_count(), 0);
    }
}