zesven 3.1.0

A pure Rust implementation of the 7z archive format
Documentation
//! Encrypted header encoding.
//!
//! This module provides functions for encoding encrypted archive headers
//! using LZMA2 compression and AES-256 encryption.

#[cfg(feature = "aes")]
use std::io::{Seek, Write};

#[cfg(feature = "aes")]
use crate::format::property_id;
#[cfg(feature = "aes")]
use crate::format::reader::write_variable_u64;
#[cfg(feature = "aes")]
use crate::{Error, Result};

#[cfg(feature = "aes")]
use super::Writer;

/// Returns the smallest LZMA2 dictionary property that covers `size`.
///
/// The property encodes `(2 | (p & 1)) << (p / 2 + 11)`, so property 0 is 4 KiB
/// and each step up is the next half-power of two. A dictionary at least as
/// large as the data behaves exactly like a larger one, since no match can
/// reach further back than the data itself.
#[cfg(any(feature = "aes", feature = "lzma2"))]
pub(crate) fn lzma2_dictionary_property(size: u64) -> u8 {
    (0u8..=40)
        .find(|&p| {
            let dictionary = u64::from(2 | (p & 1)) << (p / 2 + 11);
            dictionary >= size
        })
        .unwrap_or(40)
}

#[cfg(feature = "aes")]
impl<W: Write + Seek> Writer<W> {
    /// Returns this archive's salt and a fresh IV for one stream.
    ///
    /// The salt is generated once and kept, so the key is derived once no matter
    /// how many streams the archive has; the IV is new every time, which is what
    /// CBC actually requires.
    pub(crate) fn nonce_for_stream(&mut self) -> Result<(Vec<u8>, [u8; 16])> {
        let policy = self.options.nonce_policy.clone();
        self.nonce_for_stream_under(&policy)
    }

    /// Draws a nonce from the given policy rather than the live options.
    ///
    /// A buffered entry is encrypted after the fact, and the policy is part of
    /// what it was accepted under: reading the current one lost a deterministic
    /// policy set for that entry, so two runs of the same programme produced
    /// different archives.
    pub(crate) fn nonce_for_stream_under(
        &mut self,
        policy: &crate::crypto::NoncePolicy,
    ) -> Result<(Vec<u8>, [u8; 16])> {
        let salt = match &self.archive_salt {
            Some(salt) => salt.clone(),
            None => {
                let (salt, _) = policy.generate()?;
                self.archive_salt = Some(salt.clone());
                salt
            }
        };

        Ok((salt, policy.next_iv()?))
    }

    /// Encodes an encrypted header.
    ///
    /// The header is compressed with LZMA2, encrypted with AES-256, and described
    /// by an ENCODED_HEADER structure. The two are returned separately as
    /// `(payload, structure)` because they occupy different places in the file:
    /// the payload is an ordinary packed stream living in the data area, and the
    /// structure is the archive's next header, which points back at it.
    ///
    /// `pack_pos` is the payload's offset relative to the end of the signature
    /// header, which is the base every `PackInfo` position is measured from.
    /// Writing the payload inline after the structure and recording a position of
    /// zero instead makes the archive unreadable by every other 7z implementation.
    pub(crate) fn encode_encrypted_header(
        &mut self,
        plain_header: &[u8],
        pack_pos: u64,
        nonce: (Vec<u8>, [u8; 16]),
    ) -> Result<(Vec<u8>, Vec<u8>)> {
        use crate::codec::method;
        use crate::crypto::{Aes256Encoder, AesProperties, derive_key_cached};

        let password =
            self.options.password.clone().ok_or_else(|| {
                Error::InvalidFormat("header encryption requires a password".into())
            })?;

        // Step 1: Compress the header with LZMA2
        let compressed = {
            use lzma_rust2::{Lzma2Options, Lzma2Writer};

            let mut compressed = Vec::new();
            let options = Lzma2Options::with_preset(5);
            let mut encoder = Lzma2Writer::new(&mut compressed, options);
            encoder.write_all(plain_header).map_err(Error::Io)?;
            encoder.finish().map_err(Error::Io)?;
            compressed
        };

        // Step 2: Encrypt the compressed data with AES-256
        let (salt, iv) = nonce;
        // Fixed here, beside the derivation, like the one for entry data: the
        // header used to be encrypted with whatever password was set at
        // `finish` while a buffered entry had already keyed the archive on
        // another, producing an archive no single password opens.
        self.hold_password(&password)?;
        let cycles = self.options.nonce_policy.num_cycles_power();
        let key = derive_key_cached(&password, &salt, cycles)?;

        let encrypted = {
            let mut output = Vec::new();
            let mut encoder = Aes256Encoder::with_key_iv(&mut output, key, iv);
            encoder.write_all(&compressed).map_err(Error::Io)?;
            encoder.finish().map_err(Error::Io)?;
            output
        };

        // Step 3: Build the ENCODED_HEADER structure
        let mut encoded = Vec::new();

        // ENCODED_HEADER marker
        encoded.push(property_id::ENCODED_HEADER);

        // PackInfo
        encoded.push(property_id::PACK_INFO);
        write_variable_u64(&mut encoded, pack_pos)?;
        write_variable_u64(&mut encoded, 1)?; // num_pack_streams = 1

        // Pack size
        encoded.push(property_id::SIZE);
        write_variable_u64(&mut encoded, encrypted.len() as u64)?;
        encoded.push(property_id::END);

        // UnpackInfo
        encoded.push(property_id::UNPACK_INFO);
        encoded.push(property_id::FOLDER);
        write_variable_u64(&mut encoded, 1)?; // num_folders = 1
        encoded.push(0); // external = 0 (inline)

        // Folder with 2 coders: AES-256 (decryption) -> LZMA2 (decompression)
        // 7z coder chain order: coders[1] applied first, coders[0] applied second
        // For encrypted headers: packed -> AES decrypt (coder 1) -> LZMA2 decompress (coder 0) -> plain
        //
        // Number of coders
        encoded.push(0x02);

        // Coder 0: LZMA2 (outer - decompression, applied SECOND when reading)
        //
        // The declared dictionary is what a reader allocates before it can decode
        // anything. A header is a few hundred bytes, and declaring a flat 16 MB
        // made every reader of our encrypted archives reserve 16 MB to decode it.
        let lzma2_props = [lzma2_dictionary_property(plain_header.len() as u64)];
        // LZMA2 method ID: 0x21
        let lzma2_flags = (method::LZMA2.len() as u8) | 0x20; // 1 byte + has properties
        encoded.push(lzma2_flags);
        encoded.extend_from_slice(method::LZMA2);
        write_variable_u64(&mut encoded, lzma2_props.len() as u64)?;
        encoded.extend_from_slice(&lzma2_props);

        // Coder 1: AES-256 (inner - decryption, applied FIRST when reading)
        let aes_props =
            AesProperties::encode(self.options.nonce_policy.num_cycles_power(), &salt, &iv)?;
        // AES method ID: 0x06, 0xF1, 0x07, 0x01
        let aes_flags = (method::AES.len() as u8) | 0x20; // 4 bytes + has properties
        encoded.push(aes_flags);
        encoded.extend_from_slice(method::AES);
        write_variable_u64(&mut encoded, aes_props.len() as u64)?;
        encoded.extend_from_slice(&aes_props);

        // BindPair: connect AES output (stream 1) to LZMA2 input (stream 0)
        // For a 2-coder chain, we have 1 bind pair
        // The inner coder (AES) output goes to the outer coder (LZMA2) input
        write_variable_u64(&mut encoded, 0)?; // in_index (LZMA2's input, stream 0)
        write_variable_u64(&mut encoded, 1)?; // out_index (AES's output, stream 1)

        // Unpack sizes (for both coders' outputs)
        // Coder 0 (LZMA2) output = final uncompressed size
        // Coder 1 (AES) output = compressed size (LZMA2 output)
        encoded.push(property_id::CODERS_UNPACK_SIZE);
        write_variable_u64(&mut encoded, plain_header.len() as u64)?; // LZMA2 output = final size
        write_variable_u64(&mut encoded, compressed.len() as u64)?; // AES output = compressed size

        // CRC of the decoded header. 7-Zip writes it, and it is what lets a
        // reader reject a wrong password before trying to parse the garbage that
        // decrypting with it produces.
        encoded.push(property_id::CRC);
        encoded.push(1); // all defined
        encoded.extend_from_slice(&crc32fast::hash(plain_header).to_le_bytes());

        encoded.push(property_id::END); // End UnpackInfo
        encoded.push(property_id::END); // End streams (no SubStreamsInfo needed)

        Ok((encrypted, encoded))
    }
}