diff --git a/Cargo.lock b/Cargo.lock index 610e7fe1..4e387ec5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2027,7 +2027,9 @@ version = "0.1.0" dependencies = [ "craft-cfb", "craft-sheet", + "md-5 0.10.6", "miniz_oxide 0.9.1", + "sha1 0.10.7", "thiserror 2.0.21", ] diff --git a/crates/xls/Cargo.toml b/crates/xls/Cargo.toml index 3c6fa0ec..a9735458 100644 --- a/crates/xls/Cargo.toml +++ b/crates/xls/Cargo.toml @@ -13,6 +13,9 @@ workspace = true [dependencies] craft-cfb = { workspace = true } craft-sheet = { workspace = true } +# RC4 and RC4 CryptoAPI encryption ([MS-OFFCRYPTO] 2.3.5, 2.3.6). +md5 = { workspace = true } +sha1 = { version = "0.10", default-features = false } # zlib-wrapped metafiles in the drawing group. miniz_oxide = "0.9" thiserror = { workspace = true } diff --git a/crates/xls/README.md b/crates/xls/README.md index bfcce672..d656e079 100644 --- a/crates/xls/README.md +++ b/crates/xls/README.md @@ -3,8 +3,9 @@ A clean-room reader for Excel 97-2003 workbooks (`.xls`, BIFF8). It turns the bytes of a file into a `Workbook` that keeps the file's own terms: cells, formulas as text, the format table, column and row records. Mapping that onto an application's document is the caller's work. -No `unsafe`; the only dependencies are `craft-cfb` (the compound file container), `craft-sheet` (cell -positions, values and tables shared with the other spreadsheet formats) and `thiserror`. +No `unsafe`. Dependencies: `craft-cfb` (the compound file container), `craft-sheet` (cell positions, +values and tables shared with the other spreadsheet formats), `md-5` and `sha1` (RC4 keys), +`miniz_oxide` (compressed metafiles) and `thiserror`. No third-party implementation was consulted. | Document | Used for | @@ -50,8 +51,18 @@ conditional formats per sheet. A damaged comment, object or conditional-format r skipped with a warning. Not read: charts, other shapes, newer conditional formats (CF12), hyperlinks, -PivotTables, macros. Each is named once in `Read::warnings`. Encrypted workbooks -(`Error::Encrypted`) and BIFF5 and older (`Error::Biff5`) are refused. There is no writer +PivotTables, macros. Each is named once in `Read::warnings`. BIFF5 and older is refused +(`Error::Biff5`). + +## Encrypted workbooks + +A workbook encrypted with RC4 (version 1.1, keyed through MD5) or RC4 CryptoAPI (versions 2.2, +3.2 and 4.2, keyed through SHA-1 with a 40- to 128-bit key, which current Excel writes) is read +with `read_with_password` ([MS-OFFCRYPTO] 2.3.5, 2.3.6). Without a password, Excel's default one +(`VelvetSweatshop`) is tried, and `Error::Encrypted` says a password is needed. A wrong password +is `Error::WrongPassword`; XOR obfuscation and CryptoAPI with AES, another cipher or hash, or an +external provider are `Error::UnsupportedEncryption`. Every size in the FilePass record is +checked before it is used. There is no writer (`craft_xls::testing` builds small fixtures for tests). A formula that cannot be put back into text (a link to another workbook, a macro command, an diff --git a/crates/xls/src/crypt.rs b/crates/xls/src/crypt.rs new file mode 100644 index 00000000..c723341d --- /dev/null +++ b/crates/xls/src/crypt.rs @@ -0,0 +1,413 @@ +//! Password-encrypted workbooks ([MS-XLS] 2.2.10, [MS-OFFCRYPTO] 2.3.5, 2.3.6). A +//! FilePass record in the workbook globals holds a salt and a password verifier; the record data +//! after it is encrypted with RC4, rekeyed every 1024 bytes of the stream. Record headers, and the +//! data of a few records, are left in the clear. +//! +//! Two schemes are read: RC4 (version 1.1), keyed from the password through MD5, and RC4 +//! CryptoAPI (versions 2.2, 3.2 and 4.2, what current Excel writes for an `.xls` saved with a +//! password), keyed through SHA-1 with a 40- to 128-bit key. XOR obfuscation, and CryptoAPI with +//! another cipher or hash, are reported as unsupported. Clean-room from Microsoft's public +//! specifications. + +use md5::{Digest, Md5}; +use sha1::Sha1; + +/// Why a Workbook stream could not be decrypted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DecryptError { + WrongPassword, + /// A scheme that is not read, named to complete "uses {0} encryption". + Unsupported(String), + Malformed(String), +} + +/// The password Excel encrypts with when a workbook has none of its own (a workbook protected +/// against changes, not against opening). Excel tries it before asking. +pub(crate) const DEFAULT_PASSWORD: &str = "VelvetSweatshop"; + +/// FilePass: the globals record that marks an encrypted workbook. +pub(crate) const FILEPASS: u16 = 0x002F; +const EOF: u16 = 0x000A; +const BOUNDSHEET: u16 = 0x0085; +/// Records whose data is never encrypted: BOF, FilePass, UsrExcl, FileLock, InterfaceHdr, +/// RRDInfo and RRDHead. +const CLEAR: [u16; 7] = [0x0809, FILEPASS, 0x0194, 0x0195, 0x00E1, 0x0196, 0x0138]; +/// The RC4 key changes every this many bytes of the stream. +const REKEY: usize = 1024; +/// CryptoAPI `AlgID` for RC4 ([MS-OFFCRYPTO] 2.3.2); 0 means the flags decide, which without +/// `fAES` is RC4 too. +const CALG_RC4: u32 = 0x6801; +/// CryptoAPI `AlgIDHash` for SHA-1; 0 means SHA-1 too. +const CALG_SHA1: u32 = 0x8004; +/// EncryptionHeader flags: `fExternal` (an external provider does the encryption) and `fAES`. +const F_EXTERNAL: u32 = 0x10; +const F_AES: u32 = 0x20; +/// A CryptoAPI salt is 16 bytes, the verifier too, and the verifier's SHA-1 hash 20. +const SALT_LEN: usize = 16; +const SHA1_LEN: usize = 20; + +fn malformed(m: &str) -> DecryptError { + DecryptError::Malformed(m.into()) +} + +/// The record header at `pos`: its type and data length. +fn header(stream: &[u8], pos: usize) -> Option<(u16, usize)> { + let h = stream.get(pos..pos.checked_add(4)?)?; + let id = u16::from_le_bytes([*h.first()?, *h.get(1)?]); + Some((id, usize::from(u16::from_le_bytes([*h.get(2)?, *h.get(3)?])))) +} + +/// The FilePass record's data, when the workbook globals hold one. +pub(crate) fn filepass(stream: &[u8]) -> Option<&[u8]> { + let mut pos = 0usize; + while let Some((id, len)) = header(stream, pos) { + let start = pos + 4; + match id { + EOF => return None, + FILEPASS => return stream.get(start..start.saturating_add(len)), + _ => pos = start + len, + } + } + None +} + +/// Decrypts a Workbook stream in place with `password`. The FilePass record stays where it is +/// (removing it would move the substreams the BoundSheet records point at). +pub(crate) fn decrypt(stream: &mut [u8], password: &str) -> Result<(), DecryptError> { + let key = password_key(filepass(stream).ok_or_else(|| malformed("no FilePass record"))?, password)?; + let mut keystream = Keystream::new(key); + let mut pos = 0usize; + while let Some((id, len)) = header(stream, pos) { + let start = pos + 4; + let end = start.saturating_add(len).min(stream.len()); + // BoundSheet8's stream position (its first 4 bytes) stays in the clear. + let clear = if CLEAR.contains(&id) { + len + } else if id == BOUNDSHEET { + 4 + } else { + 0 + }; + let from = start.saturating_add(clear).min(end); + if let Some(data) = stream.get_mut(from..end) { + keystream.apply(from, data); + } + pos = start + len; + } + Ok(()) +} + +/// What the block keys are made from. +enum Key { + /// RC4 1.1: the 5 bytes every block key is the MD5 of, with the block number. + Rc4([u8; 5]), + /// RC4 CryptoAPI: H0, which every block key is the SHA-1 of, with the block number, cut to + /// `bits`. + CryptoApi { h0: [u8; SHA1_LEN], bits: u32 }, +} + +impl Key { + /// The cipher for 1024-byte block `block`, at its start. + fn block(&self, block: usize) -> Rc4 { + let block = u32::try_from(block).unwrap_or(u32::MAX).to_le_bytes(); + match self { + // MD5(key ‖ LE32(block)) ([MS-OFFCRYPTO] 2.3.6.2). + Key::Rc4(key) => Rc4::new(&md5(&[key, &block])), + // SHA-1(H0 ‖ LE32(block)), its first bits / 8 bytes; a 40-bit key is padded with + // zeros to 128 bits ([MS-OFFCRYPTO] 2.3.5.2). + Key::CryptoApi { h0, bits } => { + let hash = sha1(&[h0, &block]); + let mut key = [0u8; 16]; + let len = usize::try_from(*bits / 8).unwrap_or(0).min(key.len()); + key.iter_mut().zip(hash.iter()).take(len).for_each(|(k, h)| *k = *h); + Rc4::new(if *bits == 40 { &key } else { key.get(..len).unwrap_or(&key) }) + } + } + } +} + +/// The key every block key is derived from, after checking `password` against the FilePass +/// verifier. +fn password_key(filepass: &[u8], password: &str) -> Result { + let short = || malformed("truncated FilePass record"); + match u16_at(filepass, 0).ok_or_else(short)? { + 0 => return Err(DecryptError::Unsupported("XOR obfuscation".into())), + 1 => {} + other => return Err(DecryptError::Malformed(format!("unknown encryption type {other}"))), + } + match (u16_at(filepass, 2).ok_or_else(short)?, u16_at(filepass, 4).ok_or_else(short)?) { + (1, 1) => rc4_key(filepass, password), + (2..=4, 2) => cryptoapi_key(filepass, password), + (major, minor) => Err(DecryptError::Unsupported(format!("version {major}.{minor}"))), + } +} + +fn u16_at(b: &[u8], at: usize) -> Option { + let v = b.get(at..at.checked_add(2)?)?; + Some(u16::from_le_bytes([*v.first()?, *v.get(1)?])) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + let v = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?])) +} + +/// RC4 1.1 ([MS-OFFCRYPTO] 2.3.6.1, 2.3.6.2): the key, after checking the verifier. +fn rc4_key(filepass: &[u8], password: &str) -> Result { + let short = || malformed("truncated FilePass record"); + let salt = filepass.get(6..22).ok_or_else(short)?; + let verifier = filepass.get(22..38).ok_or_else(short)?; + let verifier_hash = filepass.get(38..54).ok_or_else(short)?; + + // H0 = MD5(UTF-16LE password); H1 = MD5(16 × (first 5 bytes of H0 ‖ salt)); the key is the + // first 5 bytes of H1. + let pw: Vec = password.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let h0 = md5(&[&pw]); + let truncated = h0.get(..5).unwrap_or_default(); + let mut m = Md5::new(); + for _ in 0..16 { + m.update(truncated); + m.update(salt); + } + let mut key = [0u8; 5]; + key.iter_mut().zip(m.finalize().iter()).for_each(|(k, h)| *k = *h); + let key = Key::Rc4(key); + + // The verifier and its hash are encrypted one after the other with the block-0 key. + let mut rc4 = key.block(0); + let verifier: Vec = verifier.iter().map(|b| b ^ rc4.next_byte()).collect(); + let verifier_hash: Vec = verifier_hash.iter().map(|b| b ^ rc4.next_byte()).collect(); + if md5(&[&verifier]).as_slice() != verifier_hash.as_slice() { + return Err(DecryptError::WrongPassword); + } + Ok(key) +} + +/// RC4 CryptoAPI ([MS-OFFCRYPTO] 2.3.5.1, 2.3.5.2, 2.3.5.6): after the version, the flags, the +/// EncryptionHeader's size and the header itself, then the EncryptionVerifier (salt size, salt, +/// encrypted verifier, verifier hash size, encrypted verifier hash). Every size is the file's +/// word, so each is checked before it is used. +fn cryptoapi_key(filepass: &[u8], password: &str) -> Result { + let short = || malformed("truncated FilePass record"); + let unsupported = |scheme: String| Err(DecryptError::Unsupported(scheme)); + let flags = u32_at(filepass, 6).ok_or_else(short)?; + let header_size = usize::try_from(u32_at(filepass, 10).ok_or_else(short)?).unwrap_or(usize::MAX); + let header_end = 14usize.saturating_add(header_size); + let header = filepass.get(14..header_end).ok_or_else(short)?; + // Eight words (Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType and two reserved), + // then the provider's name, which says nothing the reader needs. + let (true, Some(alg), Some(alg_hash), Some(bits)) = (header.len() >= 32, u32_at(header, 8), u32_at(header, 12), u32_at(header, 16)) else { + return Err(malformed("EncryptionHeader too short")); + }; + if flags & F_EXTERNAL != 0 { + return unsupported("CryptoAPI with an external provider".into()); + } + if flags & F_AES != 0 { + return unsupported("CryptoAPI AES".into()); + } + if !matches!(alg, 0 | CALG_RC4) { + return unsupported(format!("CryptoAPI with cipher 0x{alg:04X}")); + } + if !matches!(alg_hash, 0 | CALG_SHA1) { + return unsupported(format!("RC4 CryptoAPI with hash 0x{alg_hash:04X}")); + } + // 0 means 40 bits; otherwise 40 to 128 bits in steps of 8. + let bits = if bits == 0 { 40 } else { bits }; + if !(40..=128).contains(&bits) || bits % 8 != 0 { + return unsupported(format!("RC4 CryptoAPI with a {bits}-bit key")); + } + + let verifier_at = header_end; + let field = |at: usize, len: usize| filepass.get(verifier_at.saturating_add(at)..verifier_at.saturating_add(at + len)); + if u32_at(filepass, verifier_at) != Some(SALT_LEN as u32) { + return Err(field(0, 4).map_or_else(short, |_| malformed("unexpected salt size"))); + } + let salt = field(4, SALT_LEN).ok_or_else(short)?; + let verifier = field(4 + SALT_LEN, SALT_LEN).ok_or_else(short)?; + if u32_at(filepass, verifier_at.saturating_add(4 + 2 * SALT_LEN)) != Some(SHA1_LEN as u32) { + return Err(field(4 + 2 * SALT_LEN, 4).map_or_else(short, |_| malformed("unexpected verifier hash size"))); + } + let verifier_hash = field(8 + 2 * SALT_LEN, SHA1_LEN).ok_or_else(short)?; + + // H0 = SHA-1(salt ‖ UTF-16LE password). + let pw: Vec = password.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let key = Key::CryptoApi { h0: sha1(&[salt, &pw]), bits }; + + // As with RC4 1.1, the verifier and its hash run on through one block-0 cipher. + let mut rc4 = key.block(0); + let verifier: Vec = verifier.iter().map(|b| b ^ rc4.next_byte()).collect(); + let verifier_hash: Vec = verifier_hash.iter().map(|b| b ^ rc4.next_byte()).collect(); + if sha1(&[&verifier]).as_slice() != verifier_hash.as_slice() { + return Err(DecryptError::WrongPassword); + } + Ok(key) +} + +fn md5(parts: &[&[u8]]) -> [u8; 16] { + let mut m = Md5::new(); + for p in parts { + m.update(p); + } + let mut out = [0u8; 16]; + out.iter_mut().zip(m.finalize().iter()).for_each(|(o, h)| *o = *h); + out +} + +fn sha1(parts: &[&[u8]]) -> [u8; SHA1_LEN] { + let mut m = Sha1::new(); + for p in parts { + m.update(p); + } + let mut out = [0u8; SHA1_LEN]; + out.iter_mut().zip(m.finalize().iter()).for_each(|(o, h)| *o = *h); + out +} + +/// The keystream by stream offset: byte `n` comes from block `n / 1024`'s key, at position +/// `n % 1024`, whether or not the bytes before it were encrypted. +struct Keystream { + key: Key, + block: usize, + rc4: Rc4, + /// The stream offset of the next byte `rc4` gives. + at: usize, +} + +impl Keystream { + fn new(key: Key) -> Keystream { + Keystream { rc4: key.block(0), key, block: 0, at: 0 } + } + + /// XORs `data`, found at stream offset `offset`, with the keystream there. Offsets only + /// move forward between calls in the normal case; going back rekeys. + fn apply(&mut self, offset: usize, data: &mut [u8]) { + for (n, b) in data.iter_mut().enumerate() { + *b ^= self.byte_at(offset.saturating_add(n)); + } + } + + fn byte_at(&mut self, offset: usize) -> u8 { + let block = offset / REKEY; + if block != self.block || offset < self.at { + self.block = block; + self.rc4 = self.key.block(block); + self.at = block * REKEY; + } + while self.at < offset { + self.rc4.next_byte(); + self.at += 1; + } + self.at += 1; + self.rc4.next_byte() + } +} + +/// The RC4 stream cipher. +struct Rc4 { + s: [u8; 256], + i: u8, + j: u8, +} + +impl Rc4 { + /// RC4 keyed with `key` (5 to 16 bytes here; an empty key acts as one zero byte). + fn new(key: &[u8]) -> Rc4 { + let mut s = [0u8; 256]; + for (n, x) in (0u8..=255).zip(s.iter_mut()) { + *x = n; + } + let mut j = 0u8; + for n in 0..256 { + let k = key.get(n % key.len().max(1)).copied().unwrap_or(0); + j = j.wrapping_add(s[n]).wrapping_add(k); + s.swap(n, usize::from(j)); + } + Rc4 { s, i: 0, j: 0 } + } + + fn next_byte(&mut self) -> u8 { + self.i = self.i.wrapping_add(1); + self.j = self.j.wrapping_add(self.s[usize::from(self.i)]); + self.s.swap(usize::from(self.i), usize::from(self.j)); + let t = self.s[usize::from(self.i)].wrapping_add(self.s[usize::from(self.j)]); + self.s[usize::from(t)] + } +} + +/// Encrypts a Workbook stream the way [`decrypt`] reads it, for tests: the FilePass record +/// (RC4 1.1 with a fixed salt and verifier) goes right after the globals' BOF. `None` when the +/// stream does not start with a record. +pub(crate) fn encrypt(stream: &[u8], password: &str) -> Option> { + let salt: [u8; 16] = std::array::from_fn(|n| n as u8 + 0x40); + let verifier: [u8; 16] = std::array::from_fn(|n| n as u8 + 0x60); + let pw: Vec = password.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let h0 = md5(&[&pw]); + let mut m = Md5::new(); + for _ in 0..16 { + m.update(h0.get(..5)?); + m.update(salt); + } + let mut key = [0u8; 5]; + key.iter_mut().zip(m.finalize().iter()).for_each(|(k, h)| *k = *h); + let mut rc4 = Key::Rc4(key).block(0); + let mut filepass = vec![1, 0, 1, 0, 1, 0]; + filepass.extend(salt); + filepass.extend(verifier.iter().map(|b| b ^ rc4.next_byte())); + filepass.extend(md5(&[&verifier]).iter().map(|b| b ^ rc4.next_byte())); + with_filepass(stream, &filepass, password) +} + +/// The FilePass data [`encrypt_cryptoapi`] writes: RC4 CryptoAPI version 4.2, as current Excel +/// writes it, with a `bits`-bit key (40 to 128) and a fixed salt and verifier. +pub(crate) fn cryptoapi_filepass(password: &str, bits: u32) -> Vec { + let salt: [u8; SALT_LEN] = std::array::from_fn(|n| (n as u8).wrapping_mul(7).wrapping_add(0x11)); + let verifier: [u8; SALT_LEN] = std::array::from_fn(|n| n as u8 + 0x80); + let pw: Vec = password.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let mut rc4 = Key::CryptoApi { h0: sha1(&[&salt, &pw]), bits }.block(0); + // fCryptoAPI and fDocProps, as Excel sets them. + let flags: u32 = 0x0C; + let provider = "Test provider\0".encode_utf16().flat_map(u16::to_le_bytes); + let header: Vec = [flags, 0, CALG_RC4, CALG_SHA1, bits, 1, 0, 0].iter().flat_map(|w| w.to_le_bytes()).chain(provider).collect(); + let mut filepass = vec![1, 0, 4, 0, 2, 0]; + filepass.extend(flags.to_le_bytes()); + filepass.extend((header.len() as u32).to_le_bytes()); + filepass.extend(header); + filepass.extend((SALT_LEN as u32).to_le_bytes()); + filepass.extend(salt); + filepass.extend(verifier.iter().map(|b| b ^ rc4.next_byte())); + filepass.extend((SHA1_LEN as u32).to_le_bytes()); + filepass.extend(sha1(&[&verifier]).iter().map(|b| b ^ rc4.next_byte())); + filepass +} + +/// [`encrypt`] with RC4 CryptoAPI ([`cryptoapi_filepass`]). +pub(crate) fn encrypt_cryptoapi(stream: &[u8], password: &str, bits: u32) -> Option> { + with_filepass(stream, &cryptoapi_filepass(password, bits), password) +} + +/// `stream` with a FilePass record holding `filepass` right after its first BOF, encrypted with +/// `password`. +fn with_filepass(stream: &[u8], filepass: &[u8], password: &str) -> Option> { + let (_, bof) = header(stream, 0)?; + let bof_len = 4 + bof; + let mut out = stream.get(..bof_len)?.to_vec(); + out.extend(FILEPASS.to_le_bytes()); + out.extend(u16::try_from(filepass.len()).ok()?.to_le_bytes()); + out.extend(filepass); + let shift = u32::try_from(out.len() - bof_len).ok()?; + let mut rest = stream.get(bof_len..)?.to_vec(); + // BoundSheet8 positions move by the FilePass record's size. + let mut pos = 0; + while let Some((id, len)) = header(&rest, pos) { + if id == BOUNDSHEET { + let at = rest.get_mut(pos + 4..pos + 8)?; + let p = u32::from_le_bytes([*at.first()?, *at.get(1)?, *at.get(2)?, *at.get(3)?]).checked_add(shift)?; + at.copy_from_slice(&p.to_le_bytes()); + } + pos += 4 + len; + } + out.extend(rest); + // The cipher is its own inverse. + decrypt(&mut out, password).ok()?; + Some(out) +} diff --git a/crates/xls/src/lib.rs b/crates/xls/src/lib.rs index 28a936db..9ddfa00a 100644 --- a/crates/xls/src/lib.rs +++ b/crates/xls/src/lib.rs @@ -13,8 +13,10 @@ //! stay in the file's units. //! //! Everything else (charts, other shapes, newer conditional formats, hyperlinks, -//! PivotTables, macros) is skipped and named in [`Read::warnings`]. Encrypted workbooks and -//! versions before BIFF8 are refused ([`Error::Encrypted`], [`Error::Biff5`]). A formula that +//! PivotTables, macros) is skipped and named in [`Read::warnings`]. A workbook encrypted with +//! RC4 or RC4 CryptoAPI is read with its password ([`read_with_password`]; without one, Excel's +//! default password is tried and [`Error::Encrypted`] says one is needed). Other encryption and +//! versions before BIFF8 are refused ([`Error::UnsupportedEncryption`], [`Error::Biff5`]). A formula that //! cannot be put back into text (links to other workbooks, macro commands, unknown tokens) //! leaves its cell with the saved result and a [`Warning::FormulaKept`]. //! @@ -33,6 +35,7 @@ //! ``` mod cond; +mod crypt; mod drawing; mod dv; mod expr; @@ -52,7 +55,7 @@ pub use model::{ ConditionalFormat, DataValidation, DefinedName, DifferentialFormat, DxfFill, ErrorStyle, Fill, Font, Formula, HAlign, Note, Operator, Pattern, Picture, Range, Row, Script, Sheet, Underline, VAlign, ValidationType, Value, View, Visibility, Workbook, }; -pub use read::{is_xls, read, read_with}; +pub use read::{is_encrypted, is_xls, read, read_with, read_with_password}; /// Why a workbook could not be read. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -64,9 +67,17 @@ pub enum Error { /// More than a [`Limits`] value or a fixed cap allows. #[error("the workbook is too large: {0}")] TooLarge(String), - /// The workbook is password-protected (it has a FilePass record). + /// The workbook is encrypted with a password (it has a FilePass record): read it with + /// [`read_with_password`]. #[error("the workbook is password-protected (encrypted)")] Encrypted, + /// The password does not open the workbook. + #[error("the password is not the workbook's")] + WrongPassword, + /// The workbook is encrypted with a scheme that is not read (XOR obfuscation, CryptoAPI with + /// AES or another cipher or hash, an external provider), named. + #[error("the workbook uses {0} encryption, which is not read")] + UnsupportedEncryption(String), /// An Excel 5.0/95 workbook (BIFF5), which is not read. #[error("Excel 5.0/95 workbooks (BIFF5) are not supported")] Biff5, diff --git a/crates/xls/src/read.rs b/crates/xls/src/read.rs index a72c3db4..529a36e1 100644 --- a/crates/xls/src/read.rs +++ b/crates/xls/src/read.rs @@ -187,7 +187,38 @@ pub fn read(bytes: &[u8]) -> Result { /// [`read`] with the caller's [`Limits`]. pub fn read_with(bytes: &[u8], limits: Limits) -> Result { - let stream = workbook_stream(bytes)?; + read_stream(workbook_stream(bytes)?, limits, None) +} + +/// [`read_with`] for a workbook encrypted with a password (RC4 or RC4 CryptoAPI). A workbook +/// without one reads as with [`read_with`], whatever the password. +pub fn read_with_password(bytes: &[u8], limits: Limits, password: &str) -> Result { + read_stream(workbook_stream(bytes)?, limits, Some(password)) +} + +/// Whether the bytes are a workbook encrypted with a password (its globals hold a FilePass +/// record). +pub fn is_encrypted(bytes: &[u8]) -> bool { + workbook_stream(bytes).is_ok_and(|s| crate::crypt::filepass(&s).is_some()) +} + +/// Decrypts an encrypted stream: with `password`, or without one with Excel's default password, +/// as Excel opens a workbook protected only against changes. +fn decrypt(stream: &mut [u8], password: Option<&str>) -> Result<(), Error> { + use crate::crypt::DecryptError; + match crate::crypt::decrypt(stream, password.unwrap_or(crate::crypt::DEFAULT_PASSWORD)) { + Ok(()) => Ok(()), + Err(DecryptError::WrongPassword) if password.is_none() => Err(Error::Encrypted), + Err(DecryptError::WrongPassword) => Err(Error::WrongPassword), + Err(DecryptError::Unsupported(s)) => Err(Error::UnsupportedEncryption(s)), + Err(DecryptError::Malformed(s)) => Err(invalid(format!("the encryption record is damaged: {s}"))), + } +} + +fn read_stream(mut stream: Vec, limits: Limits, password: Option<&str>) -> Result { + if crate::crypt::filepass(&stream).is_some() { + decrypt(&mut stream, password)?; + } let mut budget = Budget::new(limits); let mut report = Report::default(); let g = globals(&stream, &mut budget, &mut report)?; @@ -267,7 +298,8 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut Report) -> Result break, - 0x002F => return Err(Error::Encrypted), + // FilePass: only reached once the stream was decrypted. + 0x002F => {} 0x0085 => { if g.sheets.len() >= MAX_SHEETS { return Err(Error::TooLarge("too many sheets".into())); diff --git a/crates/xls/src/testing.rs b/crates/xls/src/testing.rs index dcb29c82..16667d4a 100644 --- a/crates/xls/src/testing.rs +++ b/crates/xls/src/testing.rs @@ -128,3 +128,19 @@ fn write_sst(out: &mut Vec, parts: &[Vec]) { } rec(out, kind, &cur); } + +/// `stream` (a Workbook stream starting with the globals' BOF) encrypted with RC4 1.1 and +/// `password`, a FilePass record put after the BOF. `None` for a stream that is not one. +pub fn encrypt_rc4(stream: &[u8], password: &str) -> Option> { + crate::crypt::encrypt(stream, password) +} + +/// [`encrypt_rc4`] with RC4 CryptoAPI version 4.2 and a `bits`-bit key, as current Excel writes it. +pub fn encrypt_cryptoapi(stream: &[u8], password: &str, bits: u32) -> Option> { + crate::crypt::encrypt_cryptoapi(stream, password, bits) +} + +/// The FilePass record data [`encrypt_cryptoapi`] writes, for tests that damage it. +pub fn cryptoapi_filepass(password: &str, bits: u32) -> Vec { + crate::crypt::cryptoapi_filepass(password, bits) +} diff --git a/crates/xls/tests/read.rs b/crates/xls/tests/read.rs index 24540b1f..f7ea37fb 100644 --- a/crates/xls/tests/read.rs +++ b/crates/xls/tests/read.rs @@ -6,7 +6,7 @@ use common::{ at, bof, cat, cell, compound, eof, exp, fmt, formula, ftext, num, ptg_int, ptg_ref, put32, read_limited, read_xls, rec, special, text, u16s, v, xlstr, }; -use craft_xls::{CalcMode, CellError, Column, Error, Limits, Range, Row, Value, View, Workbook, is_xls}; +use craft_xls::{CalcMode, CellError, CellRef, Column, Error, Limits, Range, Row, Value, View, Workbook, is_xls}; fn range(a1: &str) -> Range { let (a, b) = a1.split_once(':').unwrap_or((a1, a1)); @@ -201,11 +201,12 @@ fn text_result_of_a_formula_kept_as_its_value() { #[test] fn refuses_encrypted_old_and_foreign_files() { - // FILEPASS in the globals: an encrypted workbook. + // FILEPASS in the globals: an encrypted workbook (encryption.rs reads them); this one is cut + // short, so it is a damaged file. let mut stream = bof(0x0005); stream.extend(rec(0x002F, &[1, 0, 1, 0])); stream.extend(eof()); - assert_eq!(read_xls(&compound("Workbook", &stream)).err(), Some(Error::Encrypted)); + assert!(matches!(read_xls(&compound("Workbook", &stream)), Err(Error::Invalid(_)))); // A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream. let book = compound("Book", &stream); assert!(is_xls(&book)); @@ -701,3 +702,129 @@ fn reports_the_sheet_it_was_saved_on() { // Only the first window counts. assert_eq!(book(&cat(&[&window1(1), &window1(2)])).as_deref(), Some("Sheet1")); } + +// Encrypted workbooks. Ported from storytold/gridcraft#847 (RC4) and #3713 (RC4 CryptoAPI). + +fn read_pw(file: &[u8], password: &str) -> Result { + craft_xls::read_with_password(file, Limits::default(), password) +} + +/// A sheet over several 1024-byte blocks, with records straddling the boundaries. +fn long_sheet() -> Vec { + let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 4, 0], b"Long"])); + let globals = |pos: u32| cat(&[&bof(0x0005), &boundsheet(pos), &eof()]); + let mut stream = globals(globals(0).len() as u32); + stream.extend(bof(0x0010)); + for r in 0..500u16 { + stream.extend(rec(0x0203, &cat(&[&cell(r, 0, 0), &f64::from(r).to_le_bytes()]))); + } + stream.extend(eof()); + stream +} + +fn with_filepass(payload: &[u8]) -> Vec { + compound("Workbook", &cat(&[&bof(0x0005), &rec(0x002F, payload), &eof()])) +} + +#[test] +fn rc4_encrypted_workbooks_read_with_their_password() { + use craft_xls::testing::encrypt_rc4; + let plain = compound("Workbook", &sample(false)); + let file = compound("Workbook", &encrypt_rc4(&sample(false), "a password").unwrap()); + assert!(is_xls(&file) && craft_xls::is_encrypted(&file) && !craft_xls::is_encrypted(&plain)); + assert_eq!(read_xls(&file).err(), Some(Error::Encrypted)); + assert_eq!(read_pw(&file, "A password").err(), Some(Error::WrongPassword)); + let wb = read_pw(&file, "a password").unwrap().workbook; + let (expected, _) = read_xls(&plain).unwrap(); + assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["Sheet1", "Data"]); + assert_eq!(v(&wb, 0, "D1"), text("Wörld")); + assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)")); + assert_eq!(wb.sheets[0].cells, expected.sheets[0].cells); + // A workbook without a password ignores the one given. + assert!(read_pw(&plain, "x").is_ok()); + let file = compound("Workbook", &encrypt_rc4(&long_sheet(), "a longer pass").unwrap()); + let wb = read_pw(&file, "a longer pass").unwrap().workbook; + for r in [0u32, 56, 57, 499] { + assert_eq!(wb.sheets[0].cells[&CellRef::new(r, 0)].value, Value::Number(f64::from(r)), "row {r}"); + } + // Schemes no password opens are named. + assert_eq!(read_pw(&with_filepass(&[0, 0, 0x34, 0x12, 0x78, 0x56]), "x").err(), Some(Error::UnsupportedEncryption("XOR obfuscation".into()))); + assert_eq!(read_pw(&with_filepass(&[1, 0, 3, 0, 3, 0]), "x").err(), Some(Error::UnsupportedEncryption("version 3.3".into()))); + assert!(matches!(read_pw(&with_filepass(&[1, 0, 1, 0, 1, 0, 9]), "x"), Err(Error::Invalid(_)))); +} + +#[test] +fn excels_default_password_opens_without_asking() { + // A workbook protected only against changes is encrypted with "VelvetSweatshop". + let file = compound("Workbook", &craft_xls::testing::encrypt_rc4(&sample(false), "VelvetSweatshop").unwrap()); + assert_eq!(read_xls(&file).unwrap().0.sheets.len(), 2); + let file = compound("Workbook", &craft_xls::testing::encrypt_cryptoapi(&sample(false), "VelvetSweatshop", 128).unwrap()); + assert_eq!(read_xls(&file).unwrap().0.sheets.len(), 2); +} + +#[test] +fn rc4_cryptoapi_encrypted_workbooks_read_with_their_password() { + use craft_xls::testing::encrypt_cryptoapi; + let (expected, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); + // Current Excel's 128-bit key, the oldest 40-bit one (padded to 128 bits) and one between. + for bits in [128, 40, 56] { + let file = compound("Workbook", &encrypt_cryptoapi(&sample(false), "Synthetic pw 9", bits).unwrap()); + assert!(craft_xls::is_encrypted(&file), "{bits}"); + assert_eq!(read_xls(&file).err(), Some(Error::Encrypted), "{bits}"); + assert_eq!(read_pw(&file, "synthetic pw 9").err(), Some(Error::WrongPassword), "{bits}"); + let wb = read_pw(&file, "Synthetic pw 9").unwrap().workbook; + assert_eq!(v(&wb, 0, "D1"), text("Wörld")); + assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)")); + assert_eq!(wb.sheets[0].cells, expected.sheets[0].cells, "{bits}"); + assert_eq!(wb.sheets[1].cells, expected.sheets[1].cells, "{bits}"); + } + let file = compound("Workbook", &encrypt_cryptoapi(&long_sheet(), "pw", 128).unwrap()); + let wb = read_pw(&file, "pw").unwrap().workbook; + for r in [0u32, 56, 57, 120, 499] { + assert_eq!(wb.sheets[0].cells[&CellRef::new(r, 0)].value, Value::Number(f64::from(r)), "row {r}"); + } +} + +/// The RC4 CryptoAPI FilePass fields are the file's word: other ciphers, hashes and key sizes are +/// named, and sizes that don't fit are a damaged file, never a panic. +#[test] +fn rc4_cryptoapi_filepass_fields_are_checked() { + let good = craft_xls::testing::cryptoapi_filepass("pw", 128); + // The EncryptionHeader starts at byte 14. + let edited = |at: usize, word: u32| { + let mut f = good.clone(); + f[at..at + 4].copy_from_slice(&word.to_le_bytes()); + read_pw(&with_filepass(&f), "pw") + }; + let unsupported = |r: Result| match r { + Err(Error::UnsupportedEncryption(s)) => s, + other => panic!("{other:?}"), + }; + let damaged = |r: Result| matches!(&r, Err(Error::Invalid(m)) if m.contains("encryption record")); + // Decrypted, the record alone is a workbook with no sheets: refused for that, not for its key. + let decrypts = |r: Result| matches!(&r, Err(Error::Invalid(m)) if m.contains("no worksheets")); + assert!(decrypts(read_pw(&with_filepass(&good), "pw"))); + assert_eq!(unsupported(edited(14 + 8, 0x660E)), "CryptoAPI with cipher 0x660E"); + assert_eq!(unsupported(edited(6, 0x24)), "CryptoAPI AES"); + assert_eq!(unsupported(edited(6, 0x14)), "CryptoAPI with an external provider"); + assert_eq!(unsupported(edited(14 + 12, 0x800C)), "RC4 CryptoAPI with hash 0x800C"); + for bits in [32, 136, 256, 44, u32::MAX] { + assert_eq!(unsupported(edited(14 + 16, bits)), format!("RC4 CryptoAPI with a {bits}-bit key")); + } + // A key size of 0 means 40 bits: a valid header, so only the verifier tells. + assert_eq!(edited(14 + 16, 0).err(), Some(Error::WrongPassword)); + // An algorithm of 0 means RC4 and SHA-1. + assert!(decrypts(edited(14 + 8, 0)) && decrypts(edited(14 + 12, 0))); + let header_size = u32::from_le_bytes(good[10..14].try_into().unwrap()) as usize; + for size in [0, 31, u32::MAX, header_size as u32 + 1000] { + assert!(damaged(edited(10, size)), "header size {size}"); + } + let verifier_at = 14 + header_size; + assert!(damaged(edited(verifier_at, 20)), "salt size"); + assert!(damaged(edited(verifier_at, u32::MAX)), "salt size"); + assert!(damaged(edited(verifier_at + 36, 16)), "verifier hash size"); + // Cut anywhere, the record is a damaged file, never a panic. + for cut in 6..good.len() { + assert!(damaged(read_pw(&with_filepass(&good[..cut]), "pw")), "cut at {cut}"); + } +}