crates/veilvoice-crypto/src/container.rs

what this file is for · veilvoice-crypto · 618 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 `.veil` encrypted container format.
//!
//! A single self-describing blob: everything needed to decrypt except the
//! password or private key travels with the ciphertext, so a file stays
//! readable after the default KDF costs are raised.
//!
//! ```text
//!  offset  size  field
//!       0     8  magic "VEILVOX1"
//!       8     1  format version (1)
//!       9     1  mode: 1 = password, 2 = hybrid public key
//!      10     2  reserved, must be zero
//!      12     4  Argon2id m_cost (KiB, little-endian)     password mode only
//!      16     4  Argon2id t_cost                          password mode only
//!      20     4  Argon2id p_cost                          password mode only
//!      24    16  Argon2id salt                            password mode only
//!      40    24  XChaCha20 nonce
//!      64     4  encapsulation length (little-endian)
//!      68     N  encapsulation                            hybrid mode only
//!    68+N     …  ciphertext ‖ Poly1305 tag
//! ```
//!
//! **The entire header is the AEAD's associated data.** Editing any byte of it
//! by downgrading the KDF cost, swapping the mode or corrupting the salt, makes
//! decryption fail rather than silently changing behaviour. Unused fields are
//! written as zero and are still authenticated, so they cannot be used as a
//! covert channel or a downgrade vector.
//!
//! # In plain words
//!
//! The shape of an encrypted `.veil` file.
//!
//! Everything needed to open it travels inside it, apart from the passphrase or
//! the key. That means a file made today still opens years later, even after
//! VeilVoice has changed how hard it makes the encryption by default: the file
//! remembers what it was made with.
//!
//! The part at the front that describes the file is itself covered by the tamper
//! check, so it cannot be edited to make the rest open more easily.

use crate::{aead, hybrid, kdf, Error, Secret};


/// Magic bytes at the start of every container.
pub const MAGIC: &[u8; 8] = b"VEILVOX1";


/// Format version this build writes.
pub const FORMAT_VERSION: u8 = 1;


/// Fixed header length in bytes, before any encapsulation.
pub const HEADER_LEN: usize = 68;



const MODE_PASSWORD: u8 = 1;


const MODE_HYBRID: u8 = 2;



/// How a container is locked.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
    /// Argon2id over a user password.
    Password,
    /// X25519 + ML-KEM-768 to a recipient's public key.
    Hybrid,
}



/// A parsed container header.
#[derive(Clone, Debug)]
pub struct Header {
    /// How the container is locked.
    pub mode: Mode,
    /// Argon2id costs (meaningful in password mode).
    pub kdf: kdf::KdfParams,
    /// Argon2id salt (meaningful in password mode).
    pub salt: [u8; kdf::SALT_LEN],
    /// AEAD nonce.
    pub nonce: [u8; aead::NONCE_LEN],
    /// Hybrid KEM encapsulation (empty in password mode).
    pub encapsulation: Vec<u8>,
}


impl Header {

    /// Serialise exactly as it appears on disk. This byte string is also the
    /// AEAD associated data.
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(HEADER_LEN + self.encapsulation.len());
        out.extend_from_slice(MAGIC);
        out.push(FORMAT_VERSION);
        out.push(match self.mode {
            Mode::Password => MODE_PASSWORD,
            Mode::Hybrid => MODE_HYBRID,
        });
        out.extend_from_slice(&[0u8; 2]); // reserved
        out.extend_from_slice(&self.kdf.m_cost.to_le_bytes());
        out.extend_from_slice(&self.kdf.t_cost.to_le_bytes());
        out.extend_from_slice(&self.kdf.p_cost.to_le_bytes());
        out.extend_from_slice(&self.salt);
        out.extend_from_slice(&self.nonce);
        out.extend_from_slice(&(self.encapsulation.len() as u32).to_le_bytes());
        out.extend_from_slice(&self.encapsulation);
        out
    }



    /// Parse a header, returning it with the offset at which ciphertext starts.
    pub fn parse(bytes: &[u8]) -> Result<(Self, usize), Error> {
        if bytes.len() < HEADER_LEN {
            return Err(Error::Truncated);
        }
        if &bytes[..8] != MAGIC {
            return Err(Error::BadMagic);
        }
        if bytes[8] != FORMAT_VERSION {
            return Err(Error::UnsupportedVersion(bytes[8]));
        }
        let mode = match bytes[9] {
            MODE_PASSWORD => Mode::Password,
            MODE_HYBRID => Mode::Hybrid,
            other => return Err(Error::UnsupportedMode(other)),
        };
        // Reserved bytes are authenticated; refuse anything non-zero rather
        // than let a future flag be silently ignored by an old build.
        if bytes[10] != 0 || bytes[11] != 0 {
            return Err(Error::BadHeader);
        }

        let u32_at =
            |o: usize| u32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]);
        let kdf = kdf::KdfParams {
            m_cost: u32_at(12),
            t_cost: u32_at(16),
            p_cost: u32_at(20),
        };
        let mut salt = [0u8; kdf::SALT_LEN];
        salt.copy_from_slice(&bytes[24..40]);
        let mut nonce = [0u8; aead::NONCE_LEN];
        nonce.copy_from_slice(&bytes[40..64]);

        let enc_len = u32_at(64) as usize;
        match mode {
            Mode::Password if enc_len != 0 => return Err(Error::BadHeader),
            Mode::Hybrid if enc_len != hybrid::ENCAPSULATION_LEN => return Err(Error::BadHeader),
            _ => {}
        }
        let end = HEADER_LEN.checked_add(enc_len).ok_or(Error::BadHeader)?;
        if bytes.len() < end {
            return Err(Error::Truncated);
        }
        let encapsulation = bytes[HEADER_LEN..end].to_vec();

        Ok((
            Self {
                mode,
                kdf,
                salt,
                nonce,
                encapsulation,
            },
            end,
        ))
    }

}


/// The conventional path of the sealed form of `path`.
///
/// `.veil` is *appended* rather than substituted, so `recording.veiled.wav`
/// becomes `recording.veiled.wav.veil` and the original name, including what
/// kind of file it is, survives decryption without being guessed at.
///
/// # Except when it is already there
///
/// A path that already ends in `.veil` is returned unchanged. This is not
/// tidiness: `-o` is a name the user chose, and
///
/// ```text
/// veilvoice anonymise interview.wav -o veiled.veil --encrypt-to key.pub
/// ```
///
/// wrote `veiled.veil.veil`, reported that name back, and left the person with
/// a file that is not the one they asked for. Found by recording the command
/// for the website's demonstration and reading what it printed, which is the
/// only way this was ever going to be noticed: every test of this function
/// passed a path that does not end in `.veil`, because that is the case the
/// rule above describes.
pub fn veil_path(path: &std::path::Path) -> std::path::PathBuf {
    if path
        .extension()
        .is_some_and(|ext| ext.eq_ignore_ascii_case("veil"))
    {
        return path.to_path_buf();
    }
    let mut name = path.as_os_str().to_os_string();
    name.push(".veil");
    std::path::PathBuf::from(name)
}



/// Encrypt `plaintext` under a password.
pub fn seal_with_password(
    password: &[u8],
    plaintext: &[u8],
    params: kdf::KdfParams,
) -> Result<Vec<u8>, Error> {
    let header = Header {
        mode: Mode::Password,
        kdf: params,
        salt: kdf::random_salt()?,
        nonce: aead::random_nonce()?,
        encapsulation: Vec::new(),
    };
    let key = kdf::derive_key(password, &header.salt, params)?;
    finish(header, &key, plaintext)
}



/// Encrypt `plaintext` to a recipient's hybrid public key.
pub fn seal_to_public_key(
    recipient: &hybrid::PublicKey,
    plaintext: &[u8],
) -> Result<Vec<u8>, Error> {
    let (key, enc) = recipient.encapsulate()?;
    let header = Header {
        mode: Mode::Hybrid,
        kdf: kdf::KdfParams {
            m_cost: 0,
            t_cost: 0,
            p_cost: 0,
        },
        salt: [0u8; kdf::SALT_LEN],
        nonce: aead::random_nonce()?,
        encapsulation: enc.to_bytes(),
    };
    finish(header, &key, plaintext)
}



/// Seal `plaintext` under `header` and return the whole file.
///
/// The header is authenticated as associated data and then written in front of
/// the ciphertext, so the parameters a reader needs in order to open the file
/// are the same bytes the tag was computed over. Editing any of them breaks the
/// tag rather than changing how the file is opened.
fn finish(header: Header, key: &Secret, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
    let aad = header.to_bytes();
    let ciphertext = aead::seal(key, &header.nonce, &aad, plaintext)?;
    let mut out = aad;
    out.extend_from_slice(&ciphertext);
    Ok(out)
}



/// Decrypt a password-locked container.
pub fn open_with_password(password: &[u8], container: &[u8]) -> Result<Vec<u8>, Error> {
    open_with_password_within(password, container, kdf::KdfParams::MAX_M_COST)
}



/// Decrypt a password-locked container, refusing one that declares a memory
/// cost above `max_m_cost`.
///
/// The cost travels with the file so that a container written years ago still
/// opens after the defaults are raised. The price is that a *hostile* file can
/// declare a legitimate-but-large cost and make itself slow and expensive to
/// open. See F-3's residual in `docs/AUDIT.md`. When a person chose the file
/// and can stop waiting, that is an acceptable price and
/// [`open_with_password`] pays it. When nothing human is present, such as a
/// batch job, a service or anything handed files it did not choose, pass
/// [`kdf::KdfParams::UNATTENDED_MAX_M_COST`] here and get
/// [`Error::KdfCostRefused`] instead of the memory.
pub fn open_with_password_within(
    password: &[u8],
    container: &[u8],
    max_m_cost: u32,
) -> Result<Vec<u8>, Error> {
    let (header, body) = Header::parse(container)?;
    if header.mode != Mode::Password {
        return Err(Error::WrongMode);
    }
    // Checked before the derivation, so the memory is never asked for.
    header.kdf.within(max_m_cost)?;
    let key = kdf::derive_key(password, &header.salt, header.kdf)?;
    aead::open(&key, &header.nonce, &container[..body], &container[body..])
}



/// Decrypt a container addressed to `recipient`.
pub fn open_with_secret_key(
    recipient: &hybrid::SecretKey,
    container: &[u8],
) -> Result<Vec<u8>, Error> {
    let (header, body) = Header::parse(container)?;
    if header.mode != Mode::Hybrid {
        return Err(Error::WrongMode);
    }
    let enc = hybrid::Encapsulation::from_bytes(&header.encapsulation)?;
    let key = recipient.decapsulate(&enc)?;
    aead::open(&key, &header.nonce, &container[..body], &container[body..])
}


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

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

    const MSG: &[u8] = b"a recording that must never leak";

    /// A header built byte by byte, so a test can say exactly what is wrong
    /// with it.
    ///
    /// `Header::to_bytes` cannot express the malformed cases: it takes the
    /// encapsulation's length from the vector, so it can never produce a
    /// password-mode header that claims one. These bytes can.
    fn raw_header(mode: u8, enc_len: u32) -> Vec<u8> {
        let mut out = Vec::with_capacity(HEADER_LEN);
        out.extend_from_slice(MAGIC);
        out.push(FORMAT_VERSION);
        out.push(mode);
        out.extend_from_slice(&[0, 0]); // reserved
        out.extend_from_slice(&weak().m_cost.to_le_bytes());
        out.extend_from_slice(&weak().t_cost.to_le_bytes());
        out.extend_from_slice(&weak().p_cost.to_le_bytes());
        out.extend_from_slice(&[7u8; kdf::SALT_LEN]);
        out.extend_from_slice(&[9u8; aead::NONCE_LEN]);
        out.extend_from_slice(&enc_len.to_le_bytes());
        assert_eq!(out.len(), HEADER_LEN);
        out
    }

    /// The boundaries of every length test in `parse`, each from both sides.
    ///
    /// **Round thirty-three.** Mutation testing replaced `<` with `<=` in both
    /// length tests, and `!=` with `false` in both mode guards, and every one
    /// of those five mutants survived the suite: the parser was tested for
    /// what it accepts and for input that is wildly wrong, and not at the one
    /// byte where an off-by-one lives. These are the five cases that kill
    /// them.
    #[test]
    fn the_length_tests_in_parse_are_exact_at_the_boundary() {
        // Exactly a header and nothing more is a whole header: `<=` here
        // would refuse the shortest valid container this format can hold.
        let exact = raw_header(MODE_PASSWORD, 0);
        let (header, body) = Header::parse(&exact).expect("a header of exactly HEADER_LEN parses");
        assert_eq!(body, HEADER_LEN);
        assert_eq!(header.mode, Mode::Password);

        // One byte short is truncated, and stays truncated: `==` in the second
        // test would let a short buffer through to a slice that is not there.
        let short = &exact[..exact.len() - 1];
        assert!(matches!(Header::parse(short), Err(Error::Truncated)));

        // A hybrid header whose encapsulation is exactly as long as it says
        // parses, and one byte short of it does not.
        let mut hybrid_exact = raw_header(MODE_HYBRID, hybrid::ENCAPSULATION_LEN as u32);
        hybrid_exact.extend(std::iter::repeat_n(0x5a, hybrid::ENCAPSULATION_LEN));
        let (_, body) = Header::parse(&hybrid_exact).expect("a whole hybrid header parses");
        assert_eq!(body, HEADER_LEN + hybrid::ENCAPSULATION_LEN);
        assert!(matches!(
            Header::parse(&hybrid_exact[..hybrid_exact.len() - 1]),
            Err(Error::Truncated)
        ));

        // A password header that claims an encapsulation is malformed. There
        // is nothing to encapsulate to: the key came from a passphrase.
        let mut lying = raw_header(MODE_PASSWORD, 1);
        lying.push(0);
        assert!(matches!(Header::parse(&lying), Err(Error::BadHeader)));

        // And a hybrid header whose encapsulation is the wrong length is
        // malformed whichever side of the right length it falls.
        for wrong in [
            hybrid::ENCAPSULATION_LEN - 1,
            hybrid::ENCAPSULATION_LEN + 1,
            0,
        ] {
            let mut odd = raw_header(MODE_HYBRID, wrong as u32);
            odd.extend(std::iter::repeat_n(0x5a, wrong));
            assert!(
                matches!(Header::parse(&odd), Err(Error::BadHeader)),
                "a hybrid header declaring {wrong} bytes of encapsulation was accepted"
            );
        }
    }

    #[test]
    fn password_round_trip() {
        let ct = seal_with_password(b"hunter2", MSG, weak()).unwrap();
        assert_eq!(&ct[..8], MAGIC);
        assert!(
            !ct.windows(MSG.len()).any(|w| w == MSG),
            "plaintext visible"
        );
        assert_eq!(open_with_password(b"hunter2", &ct).unwrap(), MSG);
    }

    #[test]
    fn wrong_password_fails() {
        let ct = seal_with_password(b"hunter2", MSG, weak()).unwrap();
        assert!(matches!(
            open_with_password(b"hunter3", &ct),
            Err(Error::Decrypt)
        ));
    }

    #[test]
    fn hybrid_round_trip() {
        let (sk, pk) = hybrid::SecretKey::generate().unwrap();
        let ct = seal_to_public_key(&pk, MSG).unwrap();
        assert_eq!(open_with_secret_key(&sk, &ct).unwrap(), MSG);
    }

    #[test]
    fn a_different_key_cannot_open_it() {
        let (_, pk) = hybrid::SecretKey::generate().unwrap();
        let (other, _) = hybrid::SecretKey::generate().unwrap();
        let ct = seal_to_public_key(&pk, MSG).unwrap();
        assert!(open_with_secret_key(&other, &ct).is_err());
    }

    /// The central property of the format: the header is authenticated, so an
    /// attacker cannot downgrade the KDF cost to make cracking cheap.
    ///
    /// Both downgrade routes are covered. A cost Argon2 still accepts derives a
    /// different key and fails the AEAD; a cost below Argon2's own minimum is
    /// refused outright. Either way the file does not open, which is the
    /// property that matters.
    #[test]
    fn downgrading_the_kdf_cost_is_detected() {
        let strong = kdf::KdfParams {
            m_cost: 64,
            t_cost: 2,
            p_cost: 1,
        };

        let mut valid_downgrade = seal_with_password(b"pw", MSG, strong).unwrap();
        valid_downgrade[12..16].copy_from_slice(&8u32.to_le_bytes());
        assert!(matches!(
            open_with_password(b"pw", &valid_downgrade),
            Err(Error::Decrypt)
        ));

        let mut absurd_downgrade = seal_with_password(b"pw", MSG, strong).unwrap();
        absurd_downgrade[12..16].copy_from_slice(&1u32.to_le_bytes());
        assert!(matches!(
            open_with_password(b"pw", &absurd_downgrade),
            Err(Error::KdfParams)
        ));
    }

    #[test]
    fn tampering_with_the_salt_or_nonce_is_detected() {
        for offset in [24usize, 40] {
            let mut ct = seal_with_password(b"pw", MSG, weak()).unwrap();
            ct[offset] ^= 1;
            assert!(open_with_password(b"pw", &ct).is_err(), "offset {offset}");
        }
    }

    #[test]
    fn tampering_with_the_ciphertext_is_detected() {
        let mut ct = seal_with_password(b"pw", MSG, weak()).unwrap();
        let last = ct.len() - 1;
        ct[last] ^= 1;
        assert!(matches!(
            open_with_password(b"pw", &ct),
            Err(Error::Decrypt)
        ));
    }

    #[test]
    fn modes_are_not_interchangeable() {
        let (sk, pk) = hybrid::SecretKey::generate().unwrap();
        let pw_ct = seal_with_password(b"pw", MSG, weak()).unwrap();
        let hy_ct = seal_to_public_key(&pk, MSG).unwrap();
        assert!(matches!(
            open_with_secret_key(&sk, &pw_ct),
            Err(Error::WrongMode)
        ));
        assert!(matches!(
            open_with_password(b"pw", &hy_ct),
            Err(Error::WrongMode)
        ));
    }

    #[test]
    fn malformed_containers_are_rejected_cleanly() {
        assert!(matches!(
            open_with_password(b"pw", b"short"),
            Err(Error::Truncated)
        ));

        let mut bad_magic = seal_with_password(b"pw", MSG, weak()).unwrap();
        bad_magic[0] = b'X';
        assert!(matches!(
            open_with_password(b"pw", &bad_magic),
            Err(Error::BadMagic)
        ));

        let mut bad_ver = seal_with_password(b"pw", MSG, weak()).unwrap();
        bad_ver[8] = 99;
        assert!(matches!(
            open_with_password(b"pw", &bad_ver),
            Err(Error::UnsupportedVersion(99))
        ));

        let mut reserved = seal_with_password(b"pw", MSG, weak()).unwrap();
        reserved[10] = 1;
        assert!(matches!(
            open_with_password(b"pw", &reserved),
            Err(Error::BadHeader)
        ));
    }

    #[test]
    fn header_round_trips_exactly() {
        let ct = seal_with_password(b"pw", MSG, weak()).unwrap();
        let (h, body) = Header::parse(&ct).unwrap();
        assert_eq!(body, HEADER_LEN);
        assert_eq!(h.to_bytes(), &ct[..body]);

        let (sk, pk) = hybrid::SecretKey::generate().unwrap();
        let hct = seal_to_public_key(&pk, MSG).unwrap();
        let (h2, body2) = Header::parse(&hct).unwrap();
        assert_eq!(body2, HEADER_LEN + hybrid::ENCAPSULATION_LEN);
        assert_eq!(h2.to_bytes(), &hct[..body2]);
        assert_eq!(open_with_secret_key(&sk, &hct).unwrap(), MSG);
    }

    #[test]
    fn empty_payload_round_trips() {
        let ct = seal_with_password(b"pw", b"", weak()).unwrap();
        assert_eq!(open_with_password(b"pw", &ct).unwrap(), b"");
    }

    /// Appending rather than replacing keeps the original extension, so an
    /// opened container is still recognisably a WAV.
    #[test]
    fn veil_paths_append_and_keep_the_original_extension() {
        use std::path::Path;
        assert_eq!(
            veil_path(Path::new("clip.veiled.wav")),
            Path::new("clip.veiled.wav.veil")
        );
        assert_eq!(veil_path(Path::new("notes")), Path::new("notes.veil"));
        assert_eq!(
            veil_path(Path::new("a.b/c.wav")),
            Path::new("a.b/c.wav.veil")
        );
    }

    #[test]
    fn a_path_that_is_already_sealed_is_left_alone() {
        use std::path::Path;
        // `-o veiled.veil` used to write `veiled.veil.veil` and report that
        // name back, so the file the person asked for did not exist and the
        // one that did had a name they never typed.
        assert_eq!(
            veil_path(Path::new("veiled.veil")),
            Path::new("veiled.veil")
        );
        assert_eq!(
            veil_path(Path::new("/tmp/out/clip.veil")),
            Path::new("/tmp/out/clip.veil")
        );
        // Case-insensitively, because Windows and macOS are, and a path
        // typed as `.VEIL` there names the same file.
        assert_eq!(veil_path(Path::new("Clip.VEIL")), Path::new("Clip.VEIL"));
        // And a name that merely contains the word is not a sealed file.
        assert_eq!(veil_path(Path::new("veil.wav")), Path::new("veil.wav.veil"));
        assert_eq!(veil_path(Path::new("myveil")), Path::new("myveil.veil"));
    }

    /// The cost ceiling for a caller with nobody watching. A hostile container
    /// can declare a legal-but-expensive cost; an unattended caller must be
    /// able to decline before the memory is asked for, not after.
    #[test]
    fn an_unattended_caller_can_refuse_an_expensive_container() {
        let expensive = kdf::KdfParams {
            m_cost: 64 * 1024, // 64 MiB, legal and more than we will allow
            t_cost: 1,
            p_cost: 1,
        };
        let ct = seal_with_password(b"pw", MSG, expensive).unwrap();

        // The default path still opens it: a person chose this file.
        assert_eq!(open_with_password(b"pw", &ct).unwrap(), MSG);

        // A caller that set a ceiling gets told, with both numbers.
        match open_with_password_within(b"pw", &ct, 8 * 1024) {
            Err(Error::KdfCostRefused { requested, ceiling }) => {
                assert_eq!(requested, 64 * 1024);
                assert_eq!(ceiling, 8 * 1024);
            }
            other => panic!("expected a refusal, got {other:?}"),
        }

        // And a ceiling above the file's cost is not in the way.
        assert_eq!(
            open_with_password_within(b"pw", &ct, kdf::KdfParams::UNATTENDED_MAX_M_COST).unwrap(),
            MSG
        );
    }

    /// The published unattended ceiling has to be usable: comfortably above
    /// this crate's own default, comfortably below the absurd-value cap.
    /// Checked at compile time, so tightening either constant past the other
    /// fails the build rather than a test run.
    const _: () = assert!(
        kdf::KdfParams::UNATTENDED_MAX_M_COST < kdf::KdfParams::MAX_M_COST,
        "the unattended ceiling must sit below the absurd-value cap"
    );

    #[test]
    fn the_unattended_ceiling_admits_this_crates_own_default() {
        assert!(kdf::KdfParams::UNATTENDED_MAX_M_COST > kdf::KdfParams::default().m_cost);
        assert!(kdf::KdfParams::default()
            .within(kdf::KdfParams::UNATTENDED_MAX_M_COST)
            .is_ok());
    }

    #[test]
    fn two_seals_of_the_same_input_differ() {
        let a = seal_with_password(b"pw", MSG, weak()).unwrap();
        let b = seal_with_password(b"pw", MSG, weak()).unwrap();
        assert_ne!(a, b, "fresh salt and nonce must decorrelate containers");
    }
}