diff --git a/Cargo.toml b/Cargo.toml index 34af3ea29..c953b6c93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,11 +11,16 @@ Library to support the reading and writing of zip files. edition = "2018" [dependencies] -flate2 = { version = "1.0.0", default-features = false, optional = true } -time = { version = "0.3", features = ["formatting", "macros" ], optional = true } +aes = { version = "0.7.5", optional = true } byteorder = "1.3" bzip2 = { version = "0.4", optional = true } +constant_time_eq = { version = "0.1.5", optional = true } crc32fast = "1.1.1" +flate2 = { version = "1.0.0", default-features = false, optional = true } +hmac = { version = "0.12.0", optional = true, features = ["reset"] } +pbkdf2 = {version = "0.10.0", optional = true } +sha1 = {version = "0.10.0", optional = true } +time = { version = "0.3", features = ["formatting", "macros" ], optional = true } zstd = { version = "0.10", optional = true } [dev-dependencies] @@ -24,11 +29,12 @@ getrandom = "0.2" walkdir = "2" [features] +aes-crypto = [ "aes", "constant_time_eq", "hmac", "pbkdf2", "sha1" ] deflate = ["flate2/rust_backend"] deflate-miniz = ["flate2/default"] deflate-zlib = ["flate2/zlib"] unreserved = [] -default = ["bzip2", "deflate", "time", "zstd"] +default = ["aes-crypto", "bzip2", "deflate", "time", "zstd"] [[bench]] name = "read_entry" diff --git a/README.md b/README.md index be471f6d4..0fbd7a86c 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ zip = { version = "0.5", default-features = false } The features available are: +* `aes-crypto`: Enables decryption of files which were encrypted with AES. Supports AE-1 and AE-2 methods. * `deflate`: Enables the deflate compression algorithm, which is the default for zip files. * `bzip2`: Enables the BZip2 compression algorithm. * `time`: Enables features using the [time](https://github.com/rust-lang-deprecated/time) crate. diff --git a/examples/extract.rs b/examples/extract.rs index b02eb4cdc..7b8860ca0 100644 --- a/examples/extract.rs +++ b/examples/extract.rs @@ -30,7 +30,7 @@ fn real_main() -> i32 { } } - if (&*file.name()).ends_with('/') { + if (*file.name()).ends_with('/') { println!("File {} extracted to \"{}\"", i, outpath.display()); fs::create_dir_all(&outpath).unwrap(); } else { diff --git a/examples/file_info.rs b/examples/file_info.rs index 824278dfd..64969b66c 100644 --- a/examples/file_info.rs +++ b/examples/file_info.rs @@ -34,7 +34,7 @@ fn real_main() -> i32 { } } - if (&*file.name()).ends_with('/') { + if (*file.name()).ends_with('/') { println!( "Entry {} is a directory with name \"{}\"", i, diff --git a/src/aes.rs b/src/aes.rs new file mode 100644 index 000000000..8997705c6 --- /dev/null +++ b/src/aes.rs @@ -0,0 +1,185 @@ +//! Implementation of the AES decryption for zip files. +//! +//! This was implemented according to the [WinZip specification](https://www.winzip.com/win/en/aes_info.html). +//! Note that using CRC with AES depends on the used encryption specification, AE-1 or AE-2. +//! If the file is marked as encrypted with AE-2 the CRC field is ignored, even if it isn't set to 0. + +use crate::aes_ctr; +use crate::types::AesMode; +use constant_time_eq::constant_time_eq; +use hmac::{Hmac, Mac}; +use sha1::Sha1; +use std::io::{self, Read}; + +/// The length of the password verifcation value in bytes +const PWD_VERIFY_LENGTH: usize = 2; +/// The length of the authentication code in bytes +const AUTH_CODE_LENGTH: usize = 10; +/// The number of iterations used with PBKDF2 +const ITERATION_COUNT: u32 = 1000; + +/// Create a AesCipher depending on the used `AesMode` and the given `key`. +/// +/// # Panics +/// +/// This panics if `key` doesn't have the correct size for the chosen aes mode. +fn cipher_from_mode(aes_mode: AesMode, key: &[u8]) -> Box { + match aes_mode { + AesMode::Aes128 => Box::new(aes_ctr::AesCtrZipKeyStream::::new(key)) + as Box, + AesMode::Aes192 => Box::new(aes_ctr::AesCtrZipKeyStream::::new(key)) + as Box, + AesMode::Aes256 => Box::new(aes_ctr::AesCtrZipKeyStream::::new(key)) + as Box, + } +} + +// An aes encrypted file starts with a salt, whose length depends on the used aes mode +// followed by a 2 byte password verification value +// then the variable length encrypted data +// and lastly a 10 byte authentication code +pub struct AesReader { + reader: R, + aes_mode: AesMode, + data_length: u64, +} + +impl AesReader { + pub fn new(reader: R, aes_mode: AesMode, compressed_size: u64) -> AesReader { + let data_length = compressed_size + - (PWD_VERIFY_LENGTH + AUTH_CODE_LENGTH + aes_mode.salt_length()) as u64; + + Self { + reader, + aes_mode, + data_length, + } + } + + /// Read the AES header bytes and validate the password. + /// + /// Even if the validation succeeds, there is still a 1 in 65536 chance that an incorrect + /// password was provided. + /// It isn't possible to check the authentication code in this step. This will be done after + /// reading and decrypting the file. + /// + /// # Returns + /// + /// If the password verification failed `Ok(None)` will be returned to match the validate + /// method of ZipCryptoReader. + pub fn validate(mut self, password: &[u8]) -> io::Result>> { + let salt_length = self.aes_mode.salt_length(); + let key_length = self.aes_mode.key_length(); + + let mut salt = vec![0; salt_length]; + self.reader.read_exact(&mut salt)?; + + // next are 2 bytes used for password verification + let mut pwd_verification_value = vec![0; PWD_VERIFY_LENGTH]; + self.reader.read_exact(&mut pwd_verification_value)?; + + // derive a key from the password and salt + // the length depends on the aes key length + let derived_key_len = 2 * key_length + PWD_VERIFY_LENGTH; + let mut derived_key: Vec = vec![0; derived_key_len]; + + // use PBKDF2 with HMAC-Sha1 to derive the key + pbkdf2::pbkdf2::>(password, &salt, ITERATION_COUNT, &mut derived_key); + let decrypt_key = &derived_key[0..key_length]; + let hmac_key = &derived_key[key_length..key_length * 2]; + let pwd_verify = &derived_key[derived_key_len - 2..]; + + // the last 2 bytes should equal the password verification value + if pwd_verification_value != pwd_verify { + // wrong password + return Ok(None); + } + + let cipher = cipher_from_mode(self.aes_mode, decrypt_key); + let hmac = Hmac::::new_from_slice(hmac_key).unwrap(); + + Ok(Some(AesReaderValid { + reader: self.reader, + data_remaining: self.data_length, + cipher, + hmac, + finalized: false, + })) + } +} + +/// A reader for aes encrypted files, which has already passed the first password check. +/// +/// There is a 1 in 65536 chance that an invalid password passes that check. +/// After the data has been read and decrypted an HMAC will be checked and provide a final means +/// to check if either the password is invalid or if the data has been changed. +pub struct AesReaderValid { + reader: R, + data_remaining: u64, + cipher: Box, + hmac: Hmac, + finalized: bool, +} + +impl Read for AesReaderValid { + /// This implementation does not fulfill all requirements set in the trait documentation. + /// + /// ```txt + /// "If an error is returned then it must be guaranteed that no bytes were read." + /// ``` + /// + /// Whether this applies to errors that occur while reading the encrypted data depends on the + /// underlying reader. If the error occurs while verifying the HMAC, the reader might become + /// practically unusable, since its position after the error is not known. + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.data_remaining == 0 { + return Ok(0); + } + + // get the number of bytes to read, compare as u64 to make sure we can read more than + // 2^32 bytes even on 32 bit systems. + let bytes_to_read = self.data_remaining.min(buf.len() as u64) as usize; + let read = self.reader.read(&mut buf[0..bytes_to_read])?; + self.data_remaining -= read as u64; + + // Update the hmac with the encrypted data + self.hmac.update(&buf[0..read]); + + // decrypt the data + self.cipher.crypt_in_place(&mut buf[0..read]); + + // if there is no data left to read, check the integrity of the data + if self.data_remaining == 0 { + assert!( + !self.finalized, + "Tried to use an already finalized HMAC. This is a bug!" + ); + self.finalized = true; + + // Zip uses HMAC-Sha1-80, which only uses the first half of the hash + // see https://www.winzip.com/win/en/aes_info.html#auth-faq + let mut read_auth_code = [0; AUTH_CODE_LENGTH]; + self.reader.read_exact(&mut read_auth_code)?; + let computed_auth_code = &self.hmac.finalize_reset().into_bytes()[0..AUTH_CODE_LENGTH]; + + // use constant time comparison to mitigate timing attacks + if !constant_time_eq(computed_auth_code, &read_auth_code) { + return Err( + io::Error::new( + io::ErrorKind::InvalidData, + "Invalid authentication code, this could be due to an invalid password or errors in the data" + ) + ); + } + } + + Ok(read) + } +} + +impl AesReaderValid { + /// Consumes this decoder, returning the underlying reader. + pub fn into_inner(self) -> R { + self.reader + } +} diff --git a/src/aes_ctr.rs b/src/aes_ctr.rs new file mode 100644 index 000000000..0f34335cb --- /dev/null +++ b/src/aes_ctr.rs @@ -0,0 +1,281 @@ +//! A counter mode (CTR) for AES to work with the encryption used in zip files. +//! +//! This was implemented since the zip specification requires the mode to not use a nonce and uses a +//! different byte order (little endian) than NIST (big endian). +//! See [AesCtrZipKeyStream](./struct.AesCtrZipKeyStream.html) for more information. + +use aes::cipher::generic_array::GenericArray; +use aes::{BlockEncrypt, NewBlockCipher}; +use byteorder::WriteBytesExt; +use std::{any, fmt}; + +/// Internal block size of an AES cipher. +const AES_BLOCK_SIZE: usize = 16; + +/// AES-128. +#[derive(Debug)] +pub struct Aes128; +/// AES-192 +#[derive(Debug)] +pub struct Aes192; +/// AES-256. +#[derive(Debug)] +pub struct Aes256; + +/// An AES cipher kind. +pub trait AesKind { + /// Key type. + type Key: AsRef<[u8]>; + /// Cipher used to decrypt. + type Cipher; +} + +impl AesKind for Aes128 { + type Key = [u8; 16]; + type Cipher = aes::Aes128; +} + +impl AesKind for Aes192 { + type Key = [u8; 24]; + type Cipher = aes::Aes192; +} + +impl AesKind for Aes256 { + type Key = [u8; 32]; + type Cipher = aes::Aes256; +} + +/// An AES-CTR key stream generator. +/// +/// Implements the slightly non-standard AES-CTR variant used by WinZip AES encryption. +/// +/// Typical AES-CTR implementations combine a nonce with a 64 bit counter. WinZIP AES instead uses +/// no nonce and also uses a different byte order (little endian) than NIST (big endian). +/// +/// The stream implements the `Read` trait; encryption or decryption is performed by XOR-ing the +/// bytes from the key stream with the ciphertext/plaintext. +pub struct AesCtrZipKeyStream { + /// Current AES counter. + counter: u128, + /// AES cipher instance. + cipher: C::Cipher, + /// Stores the currently available keystream bytes. + buffer: [u8; AES_BLOCK_SIZE], + /// Number of bytes already used up from `buffer`. + pos: usize, +} + +impl fmt::Debug for AesCtrZipKeyStream +where + C: AesKind, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "AesCtrZipKeyStream<{}>(counter: {})", + any::type_name::(), + self.counter + ) + } +} + +impl AesCtrZipKeyStream +where + C: AesKind, + C::Cipher: NewBlockCipher, +{ + /// Creates a new zip variant AES-CTR key stream. + /// + /// # Panics + /// + /// This panics if `key` doesn't have the correct size for cipher `C`. + pub fn new(key: &[u8]) -> AesCtrZipKeyStream { + AesCtrZipKeyStream { + counter: 1, + cipher: C::Cipher::new(GenericArray::from_slice(key)), + buffer: [0u8; AES_BLOCK_SIZE], + pos: AES_BLOCK_SIZE, + } + } +} + +impl AesCipher for AesCtrZipKeyStream +where + C: AesKind, + C::Cipher: BlockEncrypt, +{ + /// Decrypt or encrypt `target`. + #[inline] + fn crypt_in_place(&mut self, mut target: &mut [u8]) { + while !target.is_empty() { + if self.pos == AES_BLOCK_SIZE { + // Note: AES block size is always 16 bytes, same as u128. + self.buffer + .as_mut() + .write_u128::(self.counter) + .expect("did not expect u128 le conversion to fail"); + self.cipher + .encrypt_block(GenericArray::from_mut_slice(&mut self.buffer)); + self.counter += 1; + self.pos = 0; + } + + let target_len = target.len().min(AES_BLOCK_SIZE - self.pos); + + xor( + &mut target[0..target_len], + &self.buffer[self.pos..(self.pos + target_len)], + ); + target = &mut target[target_len..]; + self.pos += target_len; + } + } +} + +/// This trait allows using generic AES ciphers with different key sizes. +pub trait AesCipher { + fn crypt_in_place(&mut self, target: &mut [u8]); +} + +/// XORs a slice in place with another slice. +#[inline] +fn xor(dest: &mut [u8], src: &[u8]) { + assert_eq!(dest.len(), src.len()); + + for (lhs, rhs) in dest.iter_mut().zip(src.iter()) { + *lhs ^= *rhs; + } +} + +#[cfg(test)] +mod tests { + use super::{Aes128, Aes192, Aes256, AesCipher, AesCtrZipKeyStream, AesKind}; + use aes::{BlockEncrypt, NewBlockCipher}; + + /// Checks whether `crypt_in_place` produces the correct plaintext after one use and yields the + /// cipertext again after applying it again. + fn roundtrip(key: &[u8], ciphertext: &mut [u8], expected_plaintext: &[u8]) + where + Aes: AesKind, + Aes::Cipher: NewBlockCipher + BlockEncrypt, + { + let mut key_stream = AesCtrZipKeyStream::::new(key); + + let mut plaintext: Vec = ciphertext.to_vec(); + key_stream.crypt_in_place(plaintext.as_mut_slice()); + assert_eq!(plaintext, expected_plaintext.to_vec()); + + // Round-tripping should yield the ciphertext again. + let mut key_stream = AesCtrZipKeyStream::::new(key); + key_stream.crypt_in_place(&mut plaintext); + assert_eq!(plaintext, ciphertext.to_vec()); + } + + #[test] + #[should_panic] + fn new_with_wrong_key_size() { + AesCtrZipKeyStream::::new(&[1, 2, 3, 4, 5]); + } + + // The data used in these tests was generated with p7zip without any compression. + // It's not possible to recreate the exact same data, since a random salt is used for encryption. + // `7z a -phelloworld -mem=AES256 -mx=0 aes256_40byte.zip 40byte_data.txt` + #[test] + fn crypt_aes_256_0_byte() { + let mut ciphertext = []; + let expected_plaintext = &[]; + let key = [ + 0x0b, 0xec, 0x2e, 0xf2, 0x46, 0xf0, 0x7e, 0x35, 0x16, 0x54, 0xe0, 0x98, 0x10, 0xb3, + 0x18, 0x55, 0x24, 0xa3, 0x9e, 0x0e, 0x40, 0xe7, 0x92, 0xad, 0xb2, 0x8a, 0x48, 0xf4, + 0x5c, 0xd0, 0xc0, 0x54, + ]; + + roundtrip::(&key, &mut ciphertext, expected_plaintext); + } + + #[test] + fn crypt_aes_128_5_byte() { + let mut ciphertext = [0x98, 0xa9, 0x8c, 0x26, 0x0e]; + let expected_plaintext = b"asdf\n"; + let key = [ + 0xe0, 0x25, 0x7b, 0x57, 0x97, 0x6a, 0xa4, 0x23, 0xab, 0x94, 0xaa, 0x44, 0xfd, 0x47, + 0x4f, 0xa5, + ]; + + roundtrip::(&key, &mut ciphertext, expected_plaintext); + } + + #[test] + fn crypt_aes_192_5_byte() { + let mut ciphertext = [0x36, 0x55, 0x5c, 0x61, 0x3c]; + let expected_plaintext = b"asdf\n"; + let key = [ + 0xe4, 0x4a, 0x88, 0x52, 0x8f, 0xf7, 0x0b, 0x81, 0x7b, 0x75, 0xf1, 0x74, 0x21, 0x37, + 0x8c, 0x90, 0xad, 0xbe, 0x4a, 0x65, 0xa8, 0x96, 0x0e, 0xcc, + ]; + + roundtrip::(&key, &mut ciphertext, expected_plaintext); + } + + #[test] + fn crypt_aes_256_5_byte() { + let mut ciphertext = [0xc2, 0x47, 0xc0, 0xdc, 0x56]; + let expected_plaintext = b"asdf\n"; + let key = [ + 0x79, 0x5e, 0x17, 0xf2, 0xc6, 0x3d, 0x28, 0x9b, 0x4b, 0x4b, 0xbb, 0xa9, 0xba, 0xc9, + 0xa5, 0xee, 0x3a, 0x4f, 0x0f, 0x4b, 0x29, 0xbd, 0xe9, 0xb8, 0x41, 0x9c, 0x41, 0xa5, + 0x15, 0xb2, 0x86, 0xab, + ]; + + roundtrip::(&key, &mut ciphertext, expected_plaintext); + } + + #[test] + fn crypt_aes_128_40_byte() { + let mut ciphertext = [ + 0xcf, 0x72, 0x6b, 0xa1, 0xb2, 0x0f, 0xdf, 0xaa, 0x10, 0xad, 0x9c, 0x7f, 0x6d, 0x1c, + 0x8d, 0xb5, 0x16, 0x7e, 0xbb, 0x11, 0x69, 0x52, 0x8c, 0x89, 0x80, 0x32, 0xaa, 0x76, + 0xa6, 0x18, 0x31, 0x98, 0xee, 0xdd, 0x22, 0x68, 0xb7, 0xe6, 0x77, 0xd2, + ]; + let expected_plaintext = b"Lorem ipsum dolor sit amet, consectetur\n"; + let key = [ + 0x43, 0x2b, 0x6d, 0xbe, 0x05, 0x76, 0x6c, 0x9e, 0xde, 0xca, 0x3b, 0xf8, 0xaf, 0x5d, + 0x81, 0xb6, + ]; + + roundtrip::(&key, &mut ciphertext, expected_plaintext); + } + + #[test] + fn crypt_aes_192_40_byte() { + let mut ciphertext = [ + 0xa6, 0xfc, 0x52, 0x79, 0x2c, 0x6c, 0xfe, 0x68, 0xb1, 0xa8, 0xb3, 0x07, 0x52, 0x8b, + 0x82, 0xa6, 0x87, 0x9c, 0x72, 0x42, 0x3a, 0xf8, 0xc6, 0xa9, 0xc9, 0xfb, 0x61, 0x19, + 0x37, 0xb9, 0x56, 0x62, 0xf4, 0xfc, 0x5e, 0x7a, 0xdd, 0x55, 0x0a, 0x48, + ]; + let expected_plaintext = b"Lorem ipsum dolor sit amet, consectetur\n"; + let key = [ + 0xac, 0x92, 0x41, 0xba, 0xde, 0xd9, 0x02, 0xfe, 0x40, 0x92, 0x20, 0xf6, 0x56, 0x03, + 0xfe, 0xae, 0x1b, 0xba, 0x01, 0x97, 0x97, 0x79, 0xbb, 0xa6, + ]; + + roundtrip::(&key, &mut ciphertext, expected_plaintext); + } + + #[test] + fn crypt_aes_256_40_byte() { + let mut ciphertext = [ + 0xa9, 0x99, 0xbd, 0xea, 0x82, 0x9b, 0x8f, 0x2f, 0xb7, 0x52, 0x2f, 0x6b, 0xd8, 0xf6, + 0xab, 0x0e, 0x24, 0x51, 0x9e, 0x18, 0x0f, 0xc0, 0x8f, 0x54, 0x15, 0x80, 0xae, 0xbc, + 0xa0, 0x5c, 0x8a, 0x11, 0x8d, 0x14, 0x7e, 0xc5, 0xb4, 0xae, 0xd3, 0x37, + ]; + let expected_plaintext = b"Lorem ipsum dolor sit amet, consectetur\n"; + let key = [ + 0x64, 0x7c, 0x7a, 0xde, 0xf0, 0xf2, 0x61, 0x49, 0x1c, 0xf1, 0xf1, 0xe3, 0x37, 0xfc, + 0xe1, 0x4d, 0x4a, 0x77, 0xd4, 0xeb, 0x9e, 0x3d, 0x75, 0xce, 0x9a, 0x3e, 0x10, 0x50, + 0xc2, 0x07, 0x36, 0xb6, + ]; + + roundtrip::(&key, &mut ciphertext, expected_plaintext); + } +} diff --git a/src/compression.rs b/src/compression.rs index e4546e279..603e6d8c2 100644 --- a/src/compression.rs +++ b/src/compression.rs @@ -24,6 +24,12 @@ pub enum CompressionMethod { /// Compress the file using BZIP2 #[cfg(feature = "bzip2")] Bzip2, + /// Encrypted using AES. + /// + /// The actual compression method has to be taken from the AES extra data field + /// or from `ZipFileData`. + #[cfg(feature = "aes-crypto")] + Aes, /// Compress the file using ZStandard #[cfg(feature = "zstd")] Zstd, @@ -72,6 +78,10 @@ impl CompressionMethod { pub const JPEG: Self = CompressionMethod::Unsupported(96); pub const WAVPACK: Self = CompressionMethod::Unsupported(97); pub const PPMD: Self = CompressionMethod::Unsupported(98); + #[cfg(feature = "aes-crypto")] + pub const AES: Self = CompressionMethod::Aes; + #[cfg(not(feature = "aes-crypto"))] + pub const AES: Self = CompressionMethod::Unsupported(99); } impl CompressionMethod { /// Converts an u16 to its corresponding CompressionMethod @@ -93,6 +103,7 @@ impl CompressionMethod { 12 => CompressionMethod::Bzip2, #[cfg(feature = "zstd")] 93 => CompressionMethod::Zstd, + 99 => CompressionMethod::AES, v => CompressionMethod::Unsupported(v), } @@ -115,6 +126,7 @@ impl CompressionMethod { CompressionMethod::Deflated => 8, #[cfg(feature = "bzip2")] CompressionMethod::Bzip2 => 12, + CompressionMethod::AES => 99, #[cfg(feature = "zstd")] CompressionMethod::Zstd => 93, diff --git a/src/crc32.rs b/src/crc32.rs index b351aa01b..ebace898d 100644 --- a/src/crc32.rs +++ b/src/crc32.rs @@ -10,15 +10,20 @@ pub struct Crc32Reader { inner: R, hasher: Hasher, check: u32, + /// Signals if `inner` stores aes encrypted data. + /// AE-2 encrypted data doesn't use crc and sets the value to 0. + ae2_encrypted: bool, } impl Crc32Reader { - /// Get a new Crc32Reader which check the inner reader against checksum. - pub fn new(inner: R, checksum: u32) -> Crc32Reader { + /// Get a new Crc32Reader which checks the inner reader against checksum. + /// The check is disabled if `ae2_encrypted == true`. + pub(crate) fn new(inner: R, checksum: u32, ae2_encrypted: bool) -> Crc32Reader { Crc32Reader { inner, hasher: Hasher::new(), check: checksum, + ae2_encrypted, } } @@ -33,8 +38,10 @@ impl Crc32Reader { impl Read for Crc32Reader { fn read(&mut self, buf: &mut [u8]) -> io::Result { + let invalid_check = !buf.is_empty() && !self.check_matches() && !self.ae2_encrypted; + let count = match self.inner.read(buf) { - Ok(0) if !buf.is_empty() && !self.check_matches() => { + Ok(0) if invalid_check => { return Err(io::Error::new(io::ErrorKind::Other, "Invalid checksum")) } Ok(n) => n, @@ -55,10 +62,10 @@ mod test { let data: &[u8] = b""; let mut buf = [0; 1]; - let mut reader = Crc32Reader::new(data, 0); + let mut reader = Crc32Reader::new(data, 0, false); assert_eq!(reader.read(&mut buf).unwrap(), 0); - let mut reader = Crc32Reader::new(data, 1); + let mut reader = Crc32Reader::new(data, 1, false); assert!(reader .read(&mut buf) .unwrap_err() @@ -71,7 +78,7 @@ mod test { let data: &[u8] = b"1234"; let mut buf = [0; 1]; - let mut reader = Crc32Reader::new(data, 0x9be3e0a3); + let mut reader = Crc32Reader::new(data, 0x9be3e0a3, false); assert_eq!(reader.read(&mut buf).unwrap(), 1); assert_eq!(reader.read(&mut buf).unwrap(), 1); assert_eq!(reader.read(&mut buf).unwrap(), 1); @@ -86,7 +93,7 @@ mod test { let data: &[u8] = b"1234"; let mut buf = [0; 5]; - let mut reader = Crc32Reader::new(data, 0x9be3e0a3); + let mut reader = Crc32Reader::new(data, 0x9be3e0a3, false); assert_eq!(reader.read(&mut buf[..0]).unwrap(), 0); assert_eq!(reader.read(&mut buf).unwrap(), 4); } diff --git a/src/lib.rs b/src/lib.rs index 3b39ab4f9..1abf42ec9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,10 @@ pub use crate::read::ZipArchive; pub use crate::types::DateTime; pub use crate::write::ZipWriter; +#[cfg(feature = "aes-crypto")] +mod aes; +#[cfg(feature = "aes-crypto")] +mod aes_ctr; mod compression; mod cp437; mod crc32; diff --git a/src/read.rs b/src/read.rs index a9f173fd2..2463a5633 100644 --- a/src/read.rs +++ b/src/read.rs @@ -1,19 +1,20 @@ //! Types for reading ZIP archives +#[cfg(feature = "aes-crypto")] +use crate::aes::{AesReader, AesReaderValid}; use crate::compression::CompressionMethod; +use crate::cp437::FromCp437; use crate::crc32::Crc32Reader; use crate::result::{InvalidPassword, ZipError, ZipResult}; use crate::spec; +use crate::types::{AesMode, AesVendorVersion, DateTime, System, ZipFileData}; use crate::zipcrypto::{ZipCryptoReader, ZipCryptoReaderValid, ZipCryptoValidator}; +use byteorder::{LittleEndian, ReadBytesExt}; use std::borrow::Cow; use std::collections::HashMap; use std::io::{self, prelude::*}; use std::path::{Component, Path}; -use crate::cp437::FromCp437; -use crate::types::{DateTime, System, ZipFileData}; -use byteorder::{LittleEndian, ReadBytesExt}; - #[cfg(any( feature = "deflate", feature = "deflate-miniz", @@ -57,9 +58,15 @@ pub struct ZipArchive { comment: Vec, } +#[allow(clippy::large_enum_variant)] enum CryptoReader<'a> { Plaintext(io::Take<&'a mut dyn Read>), ZipCrypto(ZipCryptoReaderValid>), + #[cfg(feature = "aes-crypto")] + Aes { + reader: AesReaderValid>, + vendor_version: AesVendorVersion, + }, } impl<'a> Read for CryptoReader<'a> { @@ -67,6 +74,8 @@ impl<'a> Read for CryptoReader<'a> { match self { CryptoReader::Plaintext(r) => r.read(buf), CryptoReader::ZipCrypto(r) => r.read(buf), + #[cfg(feature = "aes-crypto")] + CryptoReader::Aes { reader: r, .. } => r.read(buf), } } } @@ -77,8 +86,24 @@ impl<'a> CryptoReader<'a> { match self { CryptoReader::Plaintext(r) => r, CryptoReader::ZipCrypto(r) => r.into_inner(), + #[cfg(feature = "aes-crypto")] + CryptoReader::Aes { reader: r, .. } => r.into_inner(), } } + + /// Returns `true` if the data is encrypted using AE2. + pub fn is_ae2_encrypted(&self) -> bool { + #[cfg(feature = "aes-crypto")] + return matches!( + self, + CryptoReader::Aes { + vendor_version: AesVendorVersion::Ae2, + .. + } + ); + #[cfg(not(feature = "aes-crypto"))] + false + } } enum ZipFileReader<'a> { @@ -166,6 +191,7 @@ fn find_content<'a>( Ok((reader as &mut dyn Read).take(data.compressed_size)) } +#[allow(clippy::too_many_arguments)] fn make_crypto_reader<'a>( compression_method: crate::compression::CompressionMethod, crc32: u32, @@ -173,6 +199,8 @@ fn make_crypto_reader<'a>( using_data_descriptor: bool, reader: io::Take<&'a mut dyn io::Read>, password: Option<&[u8]>, + aes_info: Option<(AesMode, AesVendorVersion)>, + #[cfg(feature = "aes-crypto")] compressed_size: u64, ) -> ZipResult, InvalidPassword>> { #[allow(deprecated)] { @@ -181,9 +209,24 @@ fn make_crypto_reader<'a>( } } - let reader = match password { - None => CryptoReader::Plaintext(reader), - Some(password) => { + let reader = match (password, aes_info) { + #[cfg(not(feature = "aes-crypto"))] + (Some(_), Some(_)) => { + return Err(ZipError::UnsupportedArchive( + "AES encrypted files cannot be decrypted without the aes-crypto feature.", + )) + } + #[cfg(feature = "aes-crypto")] + (Some(password), Some((aes_mode, vendor_version))) => { + match AesReader::new(reader, aes_mode, compressed_size).validate(password)? { + None => return Ok(Err(InvalidPassword)), + Some(r) => CryptoReader::Aes { + reader: r, + vendor_version, + }, + } + } + (Some(password), None) => { let validator = if using_data_descriptor { ZipCryptoValidator::InfoZipMsdosTime(last_modified_time.timepart()) } else { @@ -194,6 +237,8 @@ fn make_crypto_reader<'a>( Some(r) => CryptoReader::ZipCrypto(r), } } + (None, Some(_)) => return Ok(Err(InvalidPassword)), + (None, None) => CryptoReader::Plaintext(reader), }; Ok(Ok(reader)) } @@ -203,8 +248,12 @@ fn make_reader( crc32: u32, reader: CryptoReader, ) -> ZipFileReader { + let ae2_encrypted = reader.is_ae2_encrypted(); + match compression_method { - CompressionMethod::Stored => ZipFileReader::Stored(Crc32Reader::new(reader, crc32)), + CompressionMethod::Stored => { + ZipFileReader::Stored(Crc32Reader::new(reader, crc32, ae2_encrypted)) + } #[cfg(any( feature = "deflate", feature = "deflate-miniz", @@ -212,17 +261,17 @@ fn make_reader( ))] CompressionMethod::Deflated => { let deflate_reader = DeflateDecoder::new(reader); - ZipFileReader::Deflated(Crc32Reader::new(deflate_reader, crc32)) + ZipFileReader::Deflated(Crc32Reader::new(deflate_reader, crc32, ae2_encrypted)) } #[cfg(feature = "bzip2")] CompressionMethod::Bzip2 => { let bzip2_reader = BzDecoder::new(reader); - ZipFileReader::Bzip2(Crc32Reader::new(bzip2_reader, crc32)) + ZipFileReader::Bzip2(Crc32Reader::new(bzip2_reader, crc32, ae2_encrypted)) } #[cfg(feature = "zstd")] CompressionMethod::Zstd => { let zstd_reader = ZstdDecoder::new(reader).unwrap(); - ZipFileReader::Zstd(Crc32Reader::new(zstd_reader, crc32)) + ZipFileReader::Zstd(Crc32Reader::new(zstd_reader, crc32, ae2_encrypted)) } _ => panic!("Compression method not supported"), } @@ -516,6 +565,9 @@ impl ZipArchive { data.using_data_descriptor, limit_reader, password, + data.aes_mode, + #[cfg(feature = "aes-crypto")] + data.compressed_size, ) { Ok(Ok(crypto_reader)) => Ok(Ok(ZipFile { crypto_reader: Some(crypto_reader), @@ -609,6 +661,7 @@ pub(crate) fn central_header_to_zip_file( data_start: 0, external_attributes: external_file_attributes, large_file: false, + aes_mode: None, }; match parse_extra_field(&mut result) { @@ -616,6 +669,13 @@ pub(crate) fn central_header_to_zip_file( Err(e) => return Err(e), } + let aes_enabled = result.compression_method == CompressionMethod::AES; + if aes_enabled && result.aes_mode.is_none() { + return Err(ZipError::InvalidArchive( + "AES encryption without AES extra data field", + )); + } + // Account for shifted zip offsets. result.header_start = result .header_start @@ -632,24 +692,58 @@ fn parse_extra_field(file: &mut ZipFileData) -> ZipResult<()> { let kind = reader.read_u16::()?; let len = reader.read_u16::()?; let mut len_left = len as i64; - // Zip64 extended information extra field - if kind == 0x0001 { - if file.uncompressed_size == 0xFFFFFFFF { - file.large_file = true; - file.uncompressed_size = reader.read_u64::()?; - len_left -= 8; + match kind { + // Zip64 extended information extra field + 0x0001 => { + if file.uncompressed_size == 0xFFFFFFFF { + file.large_file = true; + file.uncompressed_size = reader.read_u64::()?; + len_left -= 8; + } + if file.compressed_size == 0xFFFFFFFF { + file.large_file = true; + file.compressed_size = reader.read_u64::()?; + len_left -= 8; + } + if file.header_start == 0xFFFFFFFF { + file.header_start = reader.read_u64::()?; + len_left -= 8; + } } - if file.compressed_size == 0xFFFFFFFF { - file.large_file = true; - file.compressed_size = reader.read_u64::()?; - len_left -= 8; + 0x9901 => { + // AES + if len != 7 { + return Err(ZipError::UnsupportedArchive( + "AES extra data field has an unsupported length", + )); + } + let vendor_version = reader.read_u16::()?; + let vendor_id = reader.read_u16::()?; + let aes_mode = reader.read_u8()?; + let compression_method = reader.read_u16::()?; + + if vendor_id != 0x4541 { + return Err(ZipError::InvalidArchive("Invalid AES vendor")); + } + let vendor_version = match vendor_version { + 0x0001 => AesVendorVersion::Ae1, + 0x0002 => AesVendorVersion::Ae2, + _ => return Err(ZipError::InvalidArchive("Invalid AES vendor version")), + }; + match aes_mode { + 0x01 => file.aes_mode = Some((AesMode::Aes128, vendor_version)), + 0x02 => file.aes_mode = Some((AesMode::Aes192, vendor_version)), + 0x03 => file.aes_mode = Some((AesMode::Aes256, vendor_version)), + _ => return Err(ZipError::InvalidArchive("Invalid AES encryption strength")), + }; + file.compression_method = { + #[allow(deprecated)] + CompressionMethod::from_u16(compression_method) + }; } - if file.header_start == 0xFFFFFFFF { - file.header_start = reader.read_u64::()?; - len_left -= 8; + _ => { + // Other fields are ignored } - // Unparsed fields: - // u32: disk start number } // We could also check for < 0 to check for errors @@ -967,6 +1061,7 @@ pub fn read_zipfile_from_stream<'a, R: io::Read>( // from standard input, this field is set to zero.' external_attributes: 0, large_file: false, + aes_mode: None, }; match parse_extra_field(&mut result) { @@ -992,6 +1087,9 @@ pub fn read_zipfile_from_stream<'a, R: io::Read>( result.using_data_descriptor, limit_reader, None, + None, + #[cfg(feature = "aes-crypto")] + result.compressed_size, )? .unwrap(); diff --git a/src/types.rs b/src/types.rs index 88434e3f6..c029ea98c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -234,6 +234,8 @@ pub struct ZipFileData { pub external_attributes: u32, /// Reserve local ZIP64 extra field pub large_file: bool, + /// AES mode if applicable + pub aes_mode: Option<(AesMode, AesVendorVersion)>, } impl ZipFileData { @@ -281,6 +283,39 @@ impl ZipFileData { } } +/// The encryption specification used to encrypt a file with AES. +/// +/// According to the [specification](https://www.winzip.com/win/en/aes_info.html#winzip11) AE-2 +/// does not make use of the CRC check. +#[derive(Copy, Clone, Debug)] +pub enum AesVendorVersion { + Ae1, + Ae2, +} + +/// AES variant used. +#[derive(Copy, Clone, Debug)] +pub enum AesMode { + Aes128, + Aes192, + Aes256, +} + +#[cfg(feature = "aes-crypto")] +impl AesMode { + pub fn salt_length(&self) -> usize { + self.key_length() / 2 + } + + pub fn key_length(&self) -> usize { + match self { + Self::Aes128 => 16, + Self::Aes192 => 24, + Self::Aes256 => 32, + } + } +} + #[cfg(test)] mod test { #[test] @@ -315,6 +350,7 @@ mod test { central_header_start: 0, external_attributes: 0, large_file: false, + aes_mode: None, }; assert_eq!( data.file_name_sanitized(), diff --git a/src/write.rs b/src/write.rs index 17fd33447..a3aa0e0ae 100644 --- a/src/write.rs +++ b/src/write.rs @@ -349,6 +349,7 @@ impl ZipWriter { central_header_start: 0, external_attributes: permissions << 16, large_file: options.large_file, + aes_mode: None, }; write_local_file_header(writer, &file)?; @@ -789,7 +790,7 @@ impl Drop for ZipWriter { fn drop(&mut self) { if !self.inner.is_closed() { if let Err(e) = self.finalize() { - let _ = write!(&mut io::stderr(), "ZipWriter drop failed: {:?}", e); + let _ = write!(io::stderr(), "ZipWriter drop failed: {:?}", e); } } } @@ -847,6 +848,11 @@ impl GenericZipWriter { CompressionMethod::Bzip2 => { GenericZipWriter::Bzip2(BzEncoder::new(bare, bzip2::Compression::default())) } + CompressionMethod::AES => { + return Err(ZipError::UnsupportedArchive( + "AES compression is not supported for writing", + )) + } #[cfg(feature = "zstd")] CompressionMethod::Zstd => { GenericZipWriter::Zstd(ZstdEncoder::new(bare, 0).unwrap()) diff --git a/tests/aes_encryption.rs b/tests/aes_encryption.rs new file mode 100644 index 000000000..4b393ebf9 --- /dev/null +++ b/tests/aes_encryption.rs @@ -0,0 +1,80 @@ +#![cfg(feature = "aes-crypto")] + +use std::io::{self, Read}; +use zip::ZipArchive; + +const SECRET_CONTENT: &str = "Lorem ipsum dolor sit amet"; + +const PASSWORD: &[u8] = b"helloworld"; + +#[test] +fn aes256_encrypted_uncompressed_file() { + let mut v = Vec::new(); + v.extend_from_slice(include_bytes!("data/aes_archive.zip")); + let mut archive = ZipArchive::new(io::Cursor::new(v)).expect("couldn't open test zip file"); + + let mut file = archive + .by_name_decrypt("secret_data_256_uncompressed", PASSWORD) + .expect("couldn't find file in archive") + .expect("invalid password"); + assert_eq!("secret_data_256_uncompressed", file.name()); + + let mut content = String::new(); + file.read_to_string(&mut content) + .expect("couldn't read encrypted file"); + assert_eq!(SECRET_CONTENT, content); +} + +#[test] +fn aes256_encrypted_file() { + let mut v = Vec::new(); + v.extend_from_slice(include_bytes!("data/aes_archive.zip")); + let mut archive = ZipArchive::new(io::Cursor::new(v)).expect("couldn't open test zip file"); + + let mut file = archive + .by_name_decrypt("secret_data_256", PASSWORD) + .expect("couldn't find file in archive") + .expect("invalid password"); + assert_eq!("secret_data_256", file.name()); + + let mut content = String::new(); + file.read_to_string(&mut content) + .expect("couldn't read encrypted and compressed file"); + assert_eq!(SECRET_CONTENT, content); +} + +#[test] +fn aes192_encrypted_file() { + let mut v = Vec::new(); + v.extend_from_slice(include_bytes!("data/aes_archive.zip")); + let mut archive = ZipArchive::new(io::Cursor::new(v)).expect("couldn't open test zip file"); + + let mut file = archive + .by_name_decrypt("secret_data_192", PASSWORD) + .expect("couldn't find file in archive") + .expect("invalid password"); + assert_eq!("secret_data_192", file.name()); + + let mut content = String::new(); + file.read_to_string(&mut content) + .expect("couldn't read encrypted file"); + assert_eq!(SECRET_CONTENT, content); +} + +#[test] +fn aes128_encrypted_file() { + let mut v = Vec::new(); + v.extend_from_slice(include_bytes!("data/aes_archive.zip")); + let mut archive = ZipArchive::new(io::Cursor::new(v)).expect("couldn't open test zip file"); + + let mut file = archive + .by_name_decrypt("secret_data_128", PASSWORD) + .expect("couldn't find file in archive") + .expect("invalid password"); + assert_eq!("secret_data_128", file.name()); + + let mut content = String::new(); + file.read_to_string(&mut content) + .expect("couldn't read encrypted file"); + assert_eq!(SECRET_CONTENT, content); +} diff --git a/tests/data/aes_archive.zip b/tests/data/aes_archive.zip new file mode 100644 index 000000000..4cf1fd21e Binary files /dev/null and b/tests/data/aes_archive.zip differ