From 70d857a6998e767933df5435948388a72d0de465 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 22:59:50 +0200 Subject: [PATCH] XLSX: decrypt Agile and Standard encrypted packages (#11) Clean-room from [MS-OFFCRYPTO]: EncryptionInfo parsing, SHA-1/SHA-2 key derivation with a capped spin count, AES-ECB/CBC. RC4, Extensible and certificate-only encryption are reported as unsupported. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 50 ++++ Cargo.toml | 3 + crates/xlsx/Cargo.toml | 3 + crates/xlsx/src/crypto.rs | 396 ++++++++++++++++++++++++++++++++ crates/xlsx/src/crypto/tests.rs | 147 ++++++++++++ crates/xlsx/src/lib.rs | 4 + crates/xlsx/src/testing.rs | 129 +++++++++++ 7 files changed, 732 insertions(+) create mode 100644 crates/xlsx/src/crypto.rs create mode 100644 crates/xlsx/src/crypto/tests.rs create mode 100644 crates/xlsx/src/testing.rs diff --git a/Cargo.lock b/Cargo.lock index 319b0be76..4d675538e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -608,6 +619,16 @@ dependencies = [ "libc", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -706,6 +727,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1624,10 +1651,13 @@ dependencies = [ name = "gridcraft-xlsx" version = "0.5.0" dependencies = [ + "aes", "gridcraft-core", "gridcraft-formula", "gridcraft-model", "quick-xml", + "sha1", + "sha2", "thiserror 2.0.21", "zip", ] @@ -1741,6 +1771,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -3114,6 +3153,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index 169330821..11e4edf89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,9 @@ regex = { version = "1", default-features = false, features = ["std", "unicode", quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } sha2 = { version = "0.11", default-features = false } +# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe). +aes = { version = "0.9", default-features = false } +sha1 = { version = "0.11", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # ui diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index 8f794478e..3262bd06d 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -15,3 +15,6 @@ gridcraft-model = { workspace = true } thiserror = { workspace = true } quick-xml = { workspace = true } zip = { workspace = true } +aes = { workspace = true } +sha1 = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/xlsx/src/crypto.rs b/crates/xlsx/src/crypto.rs new file mode 100644 index 000000000..615b15102 --- /dev/null +++ b/crates/xlsx/src/crypto.rs @@ -0,0 +1,396 @@ +//! Password-encrypted workbooks. Excel wraps the ordinary OOXML package in a compound file with +//! two streams: `EncryptionInfo` (how the key comes from the password, and a verifier that tells +//! a wrong password) and `EncryptedPackage` (the package, encrypted with AES). Agile encryption +//! (Excel 2010 onward) and Standard encryption (Excel 2007) are read; RC4, Extensible and +//! certificate-only encryption are reported as unsupported. +//! +//! Clean-room from Microsoft's public [MS-OFFCRYPTO] specification. Every read is +//! bounds-checked, and the costly inputs (spin count, stated sizes) are capped. + +use aes::cipher::{BlockCipherDecrypt, KeyInit}; +use sha2::Digest; + +use crate::cfb::Compound; +use crate::xml::El; + +/// Spin counts above this are refused (the cap sheet-protection hashes use too). +const MAX_SPIN_COUNT: u32 = 10_000_000; +/// Standard encryption always iterates this many times. +const STANDARD_SPIN_COUNT: u32 = 50_000; +/// Agile packages are encrypted in segments of this many bytes, each with its own IV. +pub(crate) const SEGMENT: usize = 4096; +/// AES block size. +const BLOCK: usize = 16; + +/// Agile block keys ([MS-OFFCRYPTO] 2.3.4.13): one per value derived from the password. +pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79]; +pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E]; +pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6]; + +/// The password key encryptor's URI (the other kind is certificate-based). +pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password"; + +/// Standard encryption header flags ([MS-OFFCRYPTO] 2.3.1). +pub(crate) const F_CRYPTOAPI: u32 = 0x04; +const F_EXTERNAL: u32 = 0x10; +pub(crate) const F_AES: u32 = 0x20; + +/// Why an encrypted workbook couldn't be decrypted. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum DecryptError { + #[error("the password is incorrect")] + WrongPassword, + /// The scheme, e.g. "RC4" or "Extensible", to complete "uses {0} encryption". + #[error("{0} encryption isn't supported")] + Unsupported(String), + #[error("the encrypted file is damaged ({0})")] + Malformed(String), +} + +fn malformed(m: impl Into) -> DecryptError { + DecryptError::Malformed(m.into()) +} + +/// The plain OOXML package (a zip) inside an encrypted workbook. +pub fn decrypt(bytes: &[u8], password: &str) -> Result, DecryptError> { + let cf = Compound::open(bytes).map_err(malformed)?; + let info = cf.root_stream("EncryptionInfo").map_err(malformed)?.ok_or_else(|| malformed("no EncryptionInfo stream"))?; + let package = cf.root_stream("EncryptedPackage").map_err(malformed)?.ok_or_else(|| malformed("no EncryptedPackage stream"))?; + decrypt_streams(&info, &package, password) +} + +/// Decrypts from the two streams' contents. +pub(crate) fn decrypt_streams(info: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { + let major = u16_at(info, 0).ok_or_else(|| malformed("truncated EncryptionInfo"))?; + let minor = u16_at(info, 2).ok_or_else(|| malformed("truncated EncryptionInfo"))?; + match (major, minor) { + (4, 4) => agile(info.get(8..).ok_or_else(|| malformed("truncated EncryptionInfo"))?, package, password), + (2..=4, 2) => standard(info.get(4..).ok_or_else(|| malformed("truncated EncryptionInfo"))?, package, password), + (3 | 4, 3) => Err(DecryptError::Unsupported("Extensible".into())), + _ => Err(DecryptError::Unsupported(format!("version {major}.{minor}"))), + } +} + +fn u16_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?; + Some(u16::from_le_bytes([*s.first()?, *s.get(1)?])) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?])) +} + +/// The plaintext size an `EncryptedPackage` states, and the ciphertext after it. A size larger +/// than the ciphertext is damage, so it never drives an allocation. +fn package_parts(package: &[u8]) -> Result<(usize, &[u8]), DecryptError> { + let head: [u8; 8] = package.get(..8).and_then(|h| h.try_into().ok()).ok_or_else(|| malformed("truncated EncryptedPackage"))?; + let data = package.get(8..).unwrap_or_default(); + let size = usize::try_from(u64::from_le_bytes(head)).unwrap_or(usize::MAX); + if size > data.len() { + return Err(malformed("the package is shorter than its stated size")); + } + Ok((size, data)) +} + +// ---- hashing ------------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Hash { + Sha1, + Sha256, + Sha384, + Sha512, +} + +impl Hash { + fn from_name(name: &str) -> Result { + Ok(match name { + "SHA1" | "SHA-1" => Hash::Sha1, + "SHA256" | "SHA-256" => Hash::Sha256, + "SHA384" | "SHA-384" => Hash::Sha384, + "SHA512" | "SHA-512" => Hash::Sha512, + other => return Err(DecryptError::Unsupported(format!("{other} hash"))), + }) + } + + /// The digest of the concatenated `parts`. + pub(crate) fn digest(self, parts: &[&[u8]]) -> Vec { + fn run(parts: &[&[u8]]) -> Vec { + let mut d = D::new(); + for p in parts { + d.update(p); + } + d.finalize().to_vec() + } + match self { + Hash::Sha1 => run::(parts), + Hash::Sha256 => run::(parts), + Hash::Sha384 => run::(parts), + Hash::Sha512 => run::(parts), + } + } +} + +/// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds. +pub(crate) fn iterated_hash(hash: Hash, password: &str, salt: &[u8], spin: u32) -> Vec { + let pw: Vec = password.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let mut h = hash.digest(&[salt, &pw]); + for i in 0..spin { + h = hash.digest(&[&i.to_le_bytes(), &h]); + } + h +} + +/// H(a ‖ b), truncated or padded with 0x36 to `len`: Agile's derived keys and IVs. +pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec { + let mut k = hash.digest(&[a, b]); + k.resize(len, 0x36); + k +} + +/// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the +/// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`. +pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec { + let h = iterated_hash(Hash::Sha1, password, salt, STANDARD_SPIN_COUNT); + let h = Hash::Sha1.digest(&[&h, &0u32.to_le_bytes()]); + let expand = |pad: u8| { + let mut buf = [pad; 64]; + for (b, x) in buf.iter_mut().zip(&h) { + *b ^= x; + } + Hash::Sha1.digest(&[&buf]) + }; + let mut key = expand(0x36); + key.extend(expand(0x5C)); + key.truncate(key_len); + key +} + +// ---- AES ----------------------------------------------------------------------------------- + +enum Aes { + A128(aes::Aes128), + A192(aes::Aes192), + A256(aes::Aes256), +} + +impl Aes { + fn new(key: &[u8]) -> Result { + let bad = |_| malformed("bad AES key"); + Ok(match key.len() { + 16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(bad)?), + 24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(bad)?), + 32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(bad)?), + n => return Err(malformed(format!("a {n}-byte AES key"))), + }) + } + + fn decrypt_block(&self, block: &mut [u8; BLOCK]) { + let b = aes::Block::cast_from_core_mut(block); + match self { + Aes::A128(c) => c.decrypt_block(b), + Aes::A192(c) => c.decrypt_block(b), + Aes::A256(c) => c.decrypt_block(b), + } + } + + /// ECB: each block on its own. `data` must be whole blocks. + fn decrypt_ecb(&self, data: &mut [u8]) -> Result<(), DecryptError> { + let (blocks, rest) = data.as_chunks_mut::(); + if !rest.is_empty() { + return Err(malformed("ciphertext isn't whole AES blocks")); + } + blocks.iter_mut().for_each(|b| self.decrypt_block(b)); + Ok(()) + } + + /// CBC: each block XORed with the previous ciphertext block (the IV for the first). + fn decrypt_cbc(&self, iv: &[u8], data: &mut [u8]) -> Result<(), DecryptError> { + let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| malformed("an IV that isn't one AES block"))?; + let (blocks, rest) = data.as_chunks_mut::(); + if !rest.is_empty() { + return Err(malformed("ciphertext isn't whole AES blocks")); + } + for b in blocks { + let cipher = *b; + self.decrypt_block(b); + b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p); + prev = cipher; + } + Ok(()) + } +} + +// ---- Standard encryption ------------------------------------------------------------------- + +/// `info` starts after the version: flags, header size, header, verifier. +fn standard(info: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { + let short = || malformed("truncated Standard encryption header"); + let flags = u32_at(info, 0).ok_or_else(short)?; + if flags & F_EXTERNAL != 0 { + return Err(DecryptError::Unsupported("external".into())); + } + if flags & F_AES == 0 || flags & F_CRYPTOAPI == 0 { + return Err(DecryptError::Unsupported("RC4".into())); + } + let header_size = usize::try_from(u32_at(info, 4).ok_or_else(short)?).map_err(|_| short())?; + let header = info.get(8..8usize.checked_add(header_size).ok_or_else(short)?).ok_or_else(short)?; + let alg_id = u32_at(header, 8).ok_or_else(short)?; + let alg_hash = u32_at(header, 12).ok_or_else(short)?; + let key_bits = u32_at(header, 16).ok_or_else(short)?; + let key_len = match (alg_id, key_bits) { + (0x660E | 0, 128) => 16, + (0x660F, 192) => 24, + (0x6610, 256) => 32, + (0x660E..=0x6610, _) => return Err(malformed(format!("AES with a {key_bits}-bit key"))), + _ => return Err(DecryptError::Unsupported(format!("cipher 0x{alg_id:04X}"))), + }; + if alg_hash != 0x8004 && alg_hash != 0 { + return Err(DecryptError::Unsupported(format!("hash 0x{alg_hash:04X}"))); + } + // Verifier: salt size, salt, encrypted verifier, verifier hash size, encrypted hash. + let v = info.get(8 + header_size..).ok_or_else(short)?; + if u32_at(v, 0) != Some(16) { + return Err(malformed("a Standard salt that isn't 16 bytes")); + } + let salt = v.get(4..20).ok_or_else(short)?; + let mut verifier = v.get(20..36).ok_or_else(short)?.to_vec(); + let mut verifier_hash = v.get(40..72).ok_or_else(short)?.to_vec(); + let aes = Aes::new(&standard_key(password, salt, key_len))?; + aes.decrypt_ecb(&mut verifier)?; + aes.decrypt_ecb(&mut verifier_hash)?; + if verifier_hash.get(..20) != Some(Hash::Sha1.digest(&[&verifier]).as_slice()) { + return Err(DecryptError::WrongPassword); + } + let (size, data) = package_parts(package)?; + let mut out = data.get(..size.div_ceil(BLOCK) * BLOCK).ok_or_else(|| malformed("the package isn't whole AES blocks"))?.to_vec(); + aes.decrypt_ecb(&mut out)?; + out.truncate(size); + Ok(out) +} + +// ---- Agile encryption ---------------------------------------------------------------------- + +/// The cipher parameters shared by `keyData` and a password `encryptedKey` element. +pub(crate) struct Params { + pub(crate) hash: Hash, + pub(crate) salt: Vec, + pub(crate) key_len: usize, +} + +impl Params { + fn read(e: &El) -> Result { + let cipher = e.attr("cipherAlgorithm").unwrap_or("AES"); + if cipher != "AES" { + return Err(DecryptError::Unsupported(cipher.into())); + } + let chaining = e.attr("cipherChaining").unwrap_or("ChainingModeCBC"); + if chaining != "ChainingModeCBC" { + return Err(DecryptError::Unsupported(format!("AES {chaining}"))); + } + if e.attr_u32("blockSize") != Some(16) { + return Err(malformed("an AES block size that isn't 16")); + } + let key_len = match e.attr_u32("keyBits") { + Some(128) => 16, + Some(192) => 24, + Some(256) => 32, + other => return Err(malformed(format!("AES with keyBits {}", other.unwrap_or(0)))), + }; + let hash = Hash::from_name(e.attr("hashAlgorithm").unwrap_or("SHA1"))?; + let salt = base64(e.attr("saltValue").ok_or_else(|| malformed("no saltValue"))?)?; + if salt.is_empty() || salt.len() > 64 { + return Err(malformed("a salt that isn't 1 to 64 bytes")); + } + Ok(Params { hash, salt, key_len }) + } + + /// An IV: H(salt ‖ block key) for a block key, else the salt, sized to one AES block. + pub(crate) fn iv(&self, block_key: Option<&[u8]>) -> Vec { + match block_key { + Some(k) => derive(self.hash, &self.salt, k, BLOCK), + None => { + let mut iv = self.salt.clone(); + iv.resize(BLOCK, 0x36); + iv + } + } + } +} + +/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field. +fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { + let root = crate::xml::parse(xml).map_err(|e| malformed(e.to_string()))?; + let key_data = Params::read(root.child("keyData").ok_or_else(|| malformed("no keyData"))?)?; + let encryptor = root + .child("keyEncryptors") + .and_then(|k| k.kids("keyEncryptor").find(|e| e.attr("uri") == Some(PASSWORD_ENCRYPTOR))) + .ok_or_else(|| DecryptError::Unsupported("certificate".into()))? + .child("encryptedKey") + .ok_or_else(|| malformed("no encryptedKey"))?; + let key = Params::read(encryptor)?; + let spin = encryptor.attr_u32("spinCount").ok_or_else(|| malformed("no spinCount"))?; + if spin > MAX_SPIN_COUNT { + return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed"))); + } + let h = iterated_hash(key.hash, password, &key.salt, spin); + let iv = key.iv(None); + let field = |name: &str, block_key: &[u8]| -> Result, DecryptError> { + let mut data = base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?)?; + Aes::new(&derive(key.hash, &h, block_key, key.key_len))?.decrypt_cbc(&iv, &mut data)?; + Ok(data) + }; + let input = field("encryptedVerifierHashInput", &BLOCK_VERIFIER_INPUT)?; + let expected = field("encryptedVerifierHashValue", &BLOCK_VERIFIER_HASH)?; + let input = input.get(..key.salt.len()).unwrap_or(&input); + let actual = key.hash.digest(&[input]); + if expected.get(..actual.len()) != Some(actual.as_slice()) { + return Err(DecryptError::WrongPassword); + } + let secret = field("encryptedKeyValue", &BLOCK_KEY)?; + let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?; + let (size, data) = package_parts(package)?; + let mut out = Vec::with_capacity(size); + for (i, segment) in data.chunks(SEGMENT).enumerate() { + if out.len() >= size { + break; + } + let index = u32::try_from(i).map_err(|_| malformed("too many segments"))?; + let start = out.len(); + out.extend_from_slice(segment); + let block = out.get_mut(start..).unwrap_or_default(); + aes.decrypt_cbc(&key_data.iv(Some(&index.to_le_bytes())), block)?; + } + out.truncate(size); + Ok(out) +} + +// ---- base64 -------------------------------------------------------------------------------- + +/// Standard base64 (whitespace ignored), as Agile's XML uses for every binary value. +fn base64(s: &str) -> Result, DecryptError> { + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let (mut buf, mut bits) = (0u32, 0u32); + for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return Err(malformed("invalid base64")), + }; + buf = (buf << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests; diff --git a/crates/xlsx/src/crypto/tests.rs b/crates/xlsx/src/crypto/tests.rs new file mode 100644 index 000000000..eb7d32272 --- /dev/null +++ b/crates/xlsx/src/crypto/tests.rs @@ -0,0 +1,147 @@ +//! Decryption tests. The expected keys and IVs below were computed independently with +//! msoffcrypto-tool 6.0.0 (MIT) for the synthetic password and salts used here; the encrypted +//! files are built in code by `crate::testing`. No Excel-produced file is used. + +use super::*; +use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header}; + +const PASSWORD: &str = "Pass1234"; + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn salt() -> Vec { + (0u8..16).collect() +} + +#[test] +fn aes_decrypts_the_fips_197_vectors() { + let mut b: [u8; 16] = hex("69c4e0d86a7b0430d8cdb78070b4c55a").try_into().unwrap(); + Aes::new(&hex("000102030405060708090a0b0c0d0e0f")).unwrap().decrypt_block(&mut b); + assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff")); + let mut b: [u8; 16] = hex("8ea2b7ca516745bfeafc49904b496089").try_into().unwrap(); + Aes::new(&hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")).unwrap().decrypt_block(&mut b); + assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff")); +} + +#[test] +fn agile_keys_match_msoffcrypto() { + let h = iterated_hash(Hash::Sha512, PASSWORD, &salt(), 1000); + assert_eq!(derive(Hash::Sha512, &h, &BLOCK_VERIFIER_INPUT, 32), hex("c57def04938e1e83497271f6ec099e890bfd2bd9cf05ff208f8b2405df361ae6")); + assert_eq!(derive(Hash::Sha512, &h, &BLOCK_KEY, 32), hex("4ab08cf2ed95199d58d04f19331049a8a07cc1ff508431a1dd0785c84e261d03")); + let h = iterated_hash(Hash::Sha1, PASSWORD, &salt(), 1000); + assert_eq!(derive(Hash::Sha1, &h, &BLOCK_VERIFIER_INPUT, 16), hex("88c7567d028eae33a9a55f65729dcd2b")); + let key_data = Params { hash: Hash::Sha512, salt: (16u8..32).collect(), key_len: 32 }; + assert_eq!(key_data.iv(Some(&1u32.to_le_bytes())), hex("da22ccba062aff577d207b2dee4e165b")); +} + +#[test] +fn agile_pads_short_hashes_with_0x36() { + // SHA-1 gives 20 bytes; an AES-256 key needs 32, so 12 bytes of 0x36 follow. + let k = derive(Hash::Sha1, b"a", b"b", 32); + assert_eq!(k[..20], Hash::Sha1.digest(&[b"ab"])[..]); + assert_eq!(k[20..], [0x36; 12]); +} + +#[test] +fn standard_keys_match_msoffcrypto() { + assert_eq!(standard_key(PASSWORD, &salt(), 16), hex("ae0b402aeb23a779471a130843e431d4")); + assert_eq!(standard_key(PASSWORD, &salt(), 32), hex("ae0b402aeb23a779471a130843e431d47b416314b5146b491cb815283663d26f")); +} + +#[test] +fn base64_decodes_and_rejects_junk() { + assert_eq!(base64("AAECAwQFBgcICQoLDA0ODw==").unwrap(), salt()); + assert_eq!(base64(" AAEC\nAw== ").unwrap(), vec![0, 1, 2, 3]); + assert!(matches!(base64("AA*C"), Err(DecryptError::Malformed(_)))); +} + +/// A payload that isn't a whole number of segments or blocks. +fn payload(len: usize) -> Vec { + (0..len).map(|i| (i * 7 + i / 251) as u8).collect() +} + +#[test] +fn agile_round_trips() { + let plain = payload(10_000); + let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); + assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain); +} + +#[test] +fn standard_round_trips() { + let plain = payload(10_000); + let (info, package) = encrypt_standard(&plain, PASSWORD); + assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain); +} + +#[test] +fn non_ascii_passwords_round_trip() { + // UTF-16LE, surrogate pairs included: "Pässwörd€" plus an emoji outside the BMP. + let pw = "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}"; + let (info, package) = encrypt_agile(&payload(100), pw, 1000); + assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100)); + assert_eq!(decrypt_streams(&info, &package, "Passwrd"), Err(DecryptError::WrongPassword)); + let (info, package) = encrypt_standard(&payload(100), pw); + assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100)); +} + +#[test] +fn a_wrong_password_is_reported_as_such() { + let (info, package) = encrypt_agile(&payload(100), PASSWORD, 1000); + assert_eq!(decrypt_streams(&info, &package, "pass1234"), Err(DecryptError::WrongPassword)); + assert_eq!(decrypt_streams(&info, &package, ""), Err(DecryptError::WrongPassword)); + let (info, package) = encrypt_standard(&payload(100), PASSWORD); + assert_eq!(decrypt_streams(&info, &package, "Pass12345"), Err(DecryptError::WrongPassword)); +} + +#[test] +fn unsupported_schemes_are_named() { + let unsupported = |info: &[u8]| match decrypt_streams(info, &[0; 8], PASSWORD) { + Err(DecryptError::Unsupported(s)) => s, + other => panic!("expected Unsupported, got {other:?}"), + }; + let (mut rc4, _) = encrypt_standard(&payload(10), PASSWORD); + rc4[4..8].copy_from_slice(&F_CRYPTOAPI.to_le_bytes()); // fCryptoAPI without fAES + assert_eq!(unsupported(&rc4), "RC4"); + assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible"); + assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1"); + let des = agile_info(PASSWORD, 1000).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\""); + assert_eq!(unsupported(&with_agile_header(&des)), "3DES"); + let cert = agile_info(PASSWORD, 1000).replace("keyEncryptor/password", "keyEncryptor/certificate"); + assert_eq!(unsupported(&with_agile_header(&cert)), "certificate"); +} + +#[test] +fn damaged_files_are_malformed_not_panics() { + let is_malformed = |r: Result, DecryptError>| matches!(r, Err(DecryptError::Malformed(_))); + let plain = payload(5000); + for (info, package) in [encrypt_agile(&plain, PASSWORD, 1000), encrypt_standard(&plain, PASSWORD)] { + // Every truncation of either stream. + for cut in (0..info.len()).step_by(7) { + let _ = decrypt_streams(&info[..cut], &package, PASSWORD); + } + for cut in (0..package.len()).step_by(97) { + let _ = decrypt_streams(&info, &package[..cut], PASSWORD); + } + // A stated size past the ciphertext never drives an allocation. + let mut huge = package.clone(); + huge[..8].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(is_malformed(decrypt_streams(&info, &huge, PASSWORD))); + // Ciphertext that isn't whole AES blocks. + let mut ragged = package.clone(); + ragged.truncate(ragged.len() - 3); + let len = (ragged.len() - 8) as u64; + ragged[..8].copy_from_slice(&len.to_le_bytes()); + assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD))); + } + let xml = agile_info(PASSWORD, 1000); + let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD)); + assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\""))); + assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*"))); + assert!(bad(xml.replace("keyBits=\"256\"", "keyBits=\"100\""))); + assert!(bad(xml.replace("blockSize=\"16\"", "blockSize=\"8\""))); + assert!(bad(xml.replace(" aes::Aes128::new_from_slice(key).unwrap().encrypt_block(b), + 32 => aes::Aes256::new_from_slice(key).unwrap().encrypt_block(b), + n => panic!("no {n}-byte AES in the test encryptor"), + } +} + +fn pad(data: &[u8]) -> Vec { + let mut v = data.to_vec(); + v.resize(data.len().div_ceil(16) * 16, 0); + v +} + +fn encrypt_ecb(key: &[u8], data: &[u8]) -> Vec { + let mut v = pad(data); + v.as_chunks_mut::<16>().0.iter_mut().for_each(|b| encrypt_block(key, b)); + v +} + +fn encrypt_cbc(key: &[u8], iv: &[u8], data: &[u8]) -> Vec { + let mut v = pad(data); + let mut prev: [u8; 16] = iv.try_into().unwrap(); + for b in v.as_chunks_mut::<16>().0 { + b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p); + encrypt_block(key, b); + prev = *b; + } + v +} + +fn base64_encode(b: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::new(); + for c in b.chunks(3) { + let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0)); + for i in 0..4 { + s.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' }); + } + } + s +} + +fn package(size: usize, body: Vec) -> Vec { + let mut p = (size as u64).to_le_bytes().to_vec(); + p.extend(body); + p +} + +/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16. +pub(crate) fn agile_info(password: &str, spin: u32) -> String { + let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 }; + let h = iterated_hash(Hash::Sha512, password, &key.salt, spin); + let iv = key.iv(None); + let field = |block_key: &[u8], data: &[u8]| base64_encode(&encrypt_cbc(&derive(Hash::Sha512, &h, block_key, 32), &iv, data)); + let input = field(&BLOCK_VERIFIER_INPUT, &VERIFIER); + let value = field(&BLOCK_VERIFIER_HASH, &Hash::Sha512.digest(&[&VERIFIER])); + let secret = field(&BLOCK_KEY, &SECRET_KEY); + let params = |salt: &[u8]| { + format!( + "saltSize=\"16\" blockSize=\"16\" keyBits=\"256\" hashSize=\"64\" cipherAlgorithm=\"AES\" cipherChaining=\"ChainingModeCBC\" hashAlgorithm=\"SHA512\" saltValue=\"{}\"", + base64_encode(salt) + ) + }; + format!( + "\r\n", + params(&KEY_DATA_SALT), + params(&PASSWORD_SALT), + ) +} + +/// `EncryptionInfo` for an Agile descriptor: version 4.4, reserved 0x40, then the XML. +pub(crate) fn with_agile_header(xml: &str) -> Vec { + let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0]; + info.extend_from_slice(xml.as_bytes()); + info +} + +/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512). +pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec, Vec) { + let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 }; + let mut body = vec![]; + for (i, segment) in plain.chunks(SEGMENT).enumerate() { + body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment)); + } + (with_agile_header(&agile_info(password, spin)), package(plain.len(), body)) +} + +/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1). +pub(crate) fn encrypt_standard(plain: &[u8], password: &str) -> (Vec, Vec) { + let key = standard_key(password, &PASSWORD_SALT, 16); + let mut header = vec![]; + for v in [F_CRYPTOAPI | F_AES, 0, 0x660E, 0x8004, 128, 0x18, 0, 0] { + header.extend_from_slice(&u32::to_le_bytes(v)); + } + header.extend("\0".encode_utf16().flat_map(u16::to_le_bytes)); + let mut info = vec![4, 0, 2, 0]; + info.extend_from_slice(&(F_CRYPTOAPI | F_AES).to_le_bytes()); + info.extend_from_slice(&(header.len() as u32).to_le_bytes()); + info.extend(header); + info.extend_from_slice(&16u32.to_le_bytes()); + info.extend(PASSWORD_SALT); + info.extend(encrypt_ecb(&key, &VERIFIER)); + info.extend_from_slice(&20u32.to_le_bytes()); + info.extend(encrypt_ecb(&key, &Hash::Sha1.digest(&[&VERIFIER]))); + (info, package(plain.len(), encrypt_ecb(&key, plain))) +}