diff --git a/Cargo.lock b/Cargo.lock index 5e18736ac..fe0e6d9e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -739,6 +739,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cmpv2" version = "0.2.0" @@ -757,7 +763,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", "spki", "x509-cert", @@ -827,6 +833,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a" + [[package]] name = "core-foundation" version = "0.9.4" @@ -948,6 +960,23 @@ dependencies = [ "writeable", ] +[[package]] +name = "craft-offcrypto" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4" +dependencies = [ + "aes", + "base64 0.23.1", + "craft-cfb", + "craft-xml", + "getrandom 0.3.4", + "hmac", + "sha1 0.11.0", + "sha2 0.11.1", + "thiserror 2.0.21", + "zeroize", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1066,6 +1095,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1146,6 +1184,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1158,7 +1205,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der_derive", "flagset", "pem-rfc7468", @@ -1223,7 +1270,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid 0.10.3", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1914,9 +1963,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2272,8 +2323,9 @@ dependencies = [ name = "gridcraft-xlsx" version = "0.19.1" dependencies = [ - "aes", + "craft-cfb", "craft-csv", + "craft-offcrypto", "craft-xls", "gridcraft-core", "gridcraft-formula", @@ -2281,7 +2333,6 @@ dependencies = [ "gridcraft-math", "gridcraft-model", "quick-xml 0.41.0", - "sha1 0.11.0", "sha2 0.11.1", "thiserror 2.0.21", "zip 3.0.0", @@ -2386,6 +2437,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.5.0" @@ -4848,7 +4908,7 @@ checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" dependencies = [ "aws-lc-rs", "base64 0.22.1", - "const-oid", + "const-oid 0.9.6", "der", "digest 0.10.7", "pem", @@ -4922,7 +4982,7 @@ dependencies = [ "chrono", "cmpv2", "cms", - "const-oid", + "const-oid 0.9.6", "der", "hex", "rand 0.10.3", @@ -4961,7 +5021,7 @@ dependencies = [ "base64 0.22.1", "chrono", "cms", - "const-oid", + "const-oid 0.9.6", "hex", "pem", "rustls-pki-types", @@ -6686,7 +6746,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", "sha1 0.10.7", "signature", diff --git a/Cargo.toml b/Cargo.toml index 11b242c25..f2c817995 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ gridcraft-math = { path = "crates/math" } # craft-libs revision. # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "=0.1.0", features = ["egui"] } +# Compound files ([MS-CFB]) and password-encrypted OOXML packages ([MS-OFFCRYPTO]). +craft-cfb = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" } +craft-offcrypto = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" } # Delimited text (CSV, TSV) and SYLK; `crates/xlsx/src/csv.rs` and `crates/engine/src/io/sylk.rs` # turn them into workbooks. craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" } @@ -50,9 +53,6 @@ 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"] } # equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index 9d09548ce..b414ec8e8 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -18,13 +18,13 @@ gridcraft-core = { workspace = true } gridcraft-formula = { workspace = true } gridcraft-math = { workspace = true } gridcraft-model = { workspace = true } +craft-cfb = { workspace = true } craft-csv = { workspace = true } +craft-offcrypto = { workspace = true } craft-xls = { workspace = true } thiserror = { workspace = true } quick-xml = { workspace = true } zip = { workspace = true } -aes = { workspace = true } -sha1 = { workspace = true } sha2 = { workspace = true } [dev-dependencies] diff --git a/crates/xlsx/src/cfb.rs b/crates/xlsx/src/cfb.rs deleted file mode 100644 index 012f2ef7b..000000000 --- a/crates/xlsx/src/cfb.rs +++ /dev/null @@ -1,450 +0,0 @@ -//! Just enough of the Compound File Binary (CFB/OLE2) container to tell what a non-zip -//! spreadsheet file is: Excel wraps password-protected workbooks in a CFB holding an -//! `EncryptedPackage` stream, while Excel 97-2003 `.xls` (and other legacy Office files) are -//! CFB files without one. Every read is bounds-checked and every loop bounded. - -/// The CFB signature at the start of the file. -pub(crate) const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; - -const END_OF_CHAIN: u32 = 0xFFFF_FFFE; -/// Sector ids at or above this are markers (free, end of chain, FAT/DIFAT sector), not sectors. -const MAX_REG_SECT: u32 = 0xFFFF_FFFA; -/// Directory sectors followed at most (4096 entries with 512-byte sectors). -const MAX_DIR_SECTORS: usize = 1024; -/// DIFAT sectors followed at most. -const MAX_DIFAT_SECTORS: usize = 1024; - -/// Stream names that mark an encrypted (password-protected) OOXML package. -const ENCRYPTED_STREAMS: [&str; 2] = ["EncryptedPackage", "EncryptionInfo"]; - -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)?])) -} - -/// Whether the compound file holds an encryption stream. Walks the directory through the FAT; -/// when the structure can't be followed (truncated or malformed), falls back to scanning the -/// bytes for the UTF-16LE stream names. -pub(crate) fn is_encrypted(bytes: &[u8]) -> bool { - match directory_names(bytes) { - Some(names) => names.iter().any(|n| ENCRYPTED_STREAMS.iter().any(|e| n.eq_ignore_ascii_case(e))), - None => ENCRYPTED_STREAMS.iter().any(|e| contains_utf16(bytes, e)), - } -} - -fn contains_utf16(hay: &[u8], needle: &str) -> bool { - let n: Vec = needle.encode_utf16().flat_map(u16::to_le_bytes).collect(); - !n.is_empty() && hay.windows(n.len()).any(|w| w == n.as_slice()) -} - -/// Names of every directory entry, or `None` when the directory can't be read. -fn directory_names(b: &[u8]) -> Option> { - if !b.starts_with(&SIGNATURE) { - return None; - } - let shift = u16_at(b, 30)?; - if shift != 9 && shift != 12 { - return None; - } - let size = 1usize << shift; - let per = size / 4; - let sector = |id: u32| -> Option<&[u8]> { - let start = (id as usize).checked_add(1)?.checked_mul(size)?; - b.get(start..start.checked_add(size)?) - }; - // The DIFAT: the FAT's sector ids, 109 in the header then chained DIFAT sectors. - let mut difat: Vec = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect(); - let mut next = u32_at(b, 68)?; - for _ in 0..MAX_DIFAT_SECTORS { - if next >= MAX_REG_SECT { - break; - } - let s = sector(next)?; - difat.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT)); - next = u32_at(s, (per - 1) * 4)?; - } - let fat_next = |id: u32| -> Option { - let idx = id as usize; - let fat_sector = *difat.get(idx / per)?; - u32_at(sector(fat_sector)?, (idx % per) * 4) - }; - let mut names = Vec::new(); - let mut id = u32_at(b, 48)?; - for _ in 0..MAX_DIR_SECTORS { - if id == END_OF_CHAIN { - return Some(names); - } - if id >= MAX_REG_SECT { - return None; - } - let s = sector(id)?; - for e in s.as_chunks::<128>().0 { - let len = usize::from(u16_at(e, 64)?); - if !(2..=64).contains(&len) { - continue; - } - let units: Vec = (0..(len - 2) / 2).filter_map(|i| u16_at(e, i * 2)).collect(); - names.push(String::from_utf16_lossy(&units)); - } - id = fat_next(id)?; - } - // A directory longer than the bound (or a cyclic chain): report what was read. - Some(names) -} - -/// Directory entry type of a stream object ([MS-CFB] 2.6.1). -const STREAM_OBJECT: u8 = 2; -/// "No entry" in the directory tree (NOSTREAM). -const NO_STREAM: u32 = 0xFFFF_FFFF; -/// Streams larger than this are refused (the format allows more, a workbook never needs it). -const MAX_STREAM: u64 = 1 << 30; - -/// One directory entry ([MS-CFB] 2.6). -struct DirEntry { - name: String, - kind: u8, - left: u32, - right: u32, - child: u32, - start: u32, - size: u64, -} - -/// A parsed compound file: the FAT, the mini FAT and the directory, all bounds-checked. -/// Implemented from Microsoft's public [MS-CFB] specification. -pub(crate) struct Compound<'a> { - bytes: &'a [u8], - sector_size: usize, - mini_sector_size: usize, - mini_cutoff: u64, - fat: Vec, - mini_fat: Vec, - entries: Vec, -} - -impl<'a> Compound<'a> { - /// Parses the header, FAT, mini FAT and directory. Errors on anything that can't be followed. - pub(crate) fn open(b: &'a [u8]) -> Result, String> { - let bad = |m: &str| format!("damaged compound file: {m}"); - if !b.starts_with(&SIGNATURE) { - return Err(bad("missing signature")); - } - let major = u16_at(b, 26).ok_or_else(|| bad("truncated header"))?; - let shift = u16_at(b, 30).ok_or_else(|| bad("truncated header"))?; - if shift != 9 && shift != 12 { - return Err(bad("unsupported sector size")); - } - let mini_shift = u16_at(b, 32).ok_or_else(|| bad("truncated header"))?; - if mini_shift != 6 { - return Err(bad("unsupported mini sector size")); - } - let sector_size = 1usize << shift; - let per = sector_size / 4; - // Sectors that physically exist after the header sector: every chain is bounded by it. - let sectors = (b.len() / sector_size).saturating_sub(1); - let sector = |id: u32| -> Option<&[u8]> { - let start = (id as usize).checked_add(1)?.checked_mul(sector_size)?; - b.get(start..start.checked_add(sector_size)?) - }; - // DIFAT: 109 FAT sector ids in the header, then chained DIFAT sectors. - let mut fat_sectors: Vec = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect(); - let mut next = u32_at(b, 68).ok_or_else(|| bad("truncated header"))?; - let mut hops = 0usize; - while next < MAX_REG_SECT { - hops += 1; - if hops > sectors.min(MAX_DIFAT_SECTORS * 64) { - return Err(bad("DIFAT chain loops")); - } - let s = sector(next).ok_or_else(|| bad("DIFAT sector out of range"))?; - fat_sectors.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT)); - next = u32_at(s, (per - 1) * 4).ok_or_else(|| bad("truncated DIFAT sector"))?; - } - if fat_sectors.len() > sectors { - return Err(bad("more FAT sectors than the file holds")); - } - let mut fat = Vec::with_capacity(fat_sectors.len().saturating_mul(per)); - for id in fat_sectors { - let s = sector(id).ok_or_else(|| bad("FAT sector out of range"))?; - fat.extend((0..per).filter_map(|i| u32_at(s, i * 4))); - } - let mut cf = Compound { bytes: b, sector_size, mini_sector_size: 64, mini_cutoff: 4096, fat, mini_fat: vec![], entries: vec![] }; - if let Some(c) = u32_at(b, 56) { - cf.mini_cutoff = u64::from(c); - } - // Directory. - let dir_start = u32_at(b, 48).ok_or_else(|| bad("truncated header"))?; - let dir = cf.read_chain(dir_start, u64::MAX)?; - for e in dir.as_chunks::<128>().0 { - let len = usize::from(u16_at(e, 64).unwrap_or(0)).min(64); - let units: Vec = (0..len.saturating_sub(2) / 2).filter_map(|i| u16_at(e, i * 2)).collect(); - let mut size = u32_at(e, 120).map(u64::from).unwrap_or(0); - if major >= 4 { - size |= u64::from(u32_at(e, 124).unwrap_or(0)) << 32; - } - cf.entries.push(DirEntry { - name: String::from_utf16_lossy(&units), - kind: e.get(66).copied().unwrap_or(0), - left: u32_at(e, 68).unwrap_or(NO_STREAM), - right: u32_at(e, 72).unwrap_or(NO_STREAM), - child: u32_at(e, 76).unwrap_or(NO_STREAM), - start: u32_at(e, 116).unwrap_or(END_OF_CHAIN), - size, - }); - } - if cf.entries.first().is_none_or(|r| r.kind != 5) { - return Err(bad("missing root entry")); - } - // Mini FAT. - let mini_start = u32_at(b, 60).ok_or_else(|| bad("truncated header"))?; - if mini_start < MAX_REG_SECT { - let raw = cf.read_chain(mini_start, u64::MAX)?; - cf.mini_fat = (0..raw.len() / 4).filter_map(|i| u32_at(&raw, i * 4)).collect(); - } - Ok(cf) - } - - /// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the - /// FAT (so a cycle) or pointing outside the file is an error. The file may end inside the - /// chain's last sector (some writers don't pad it, and Excel opens those files): that sector - /// reads short, and the caller checks the stream's declared size against what was read. - fn read_chain(&self, start: u32, limit: u64) -> Result, String> { - let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX); - let mut out = Vec::new(); - let mut id = start; - let mut seen = vec![false; self.fat.len()]; - let mut cut = false; - while id != END_OF_CHAIN && out.len() < limit { - if cut { - return Err("damaged compound file: sector out of range (the file may be truncated)".into()); - } - if id >= MAX_REG_SECT { - return Err("damaged compound file: broken sector chain".into()); - } - match seen.get_mut(id as usize) { - Some(v) if !*v => *v = true, - Some(_) => return Err("damaged compound file: sector chain loops".into()), - None => return Err("damaged compound file: sector missing from the FAT".into()), - } - let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size)); - let s = start - .and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?.min(self.bytes.len()))) - .ok_or("damaged compound file: sector out of range (the file may be truncated)")?; - cut = s.len() < self.sector_size; - out.extend_from_slice(s); - id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?; - } - Ok(out) - } - - /// Follows a mini FAT chain inside the mini stream container. Like `read_chain`, the - /// container may end inside the chain's last mini sector. - fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result, String> { - let mut out = Vec::new(); - let mut id = start; - let mut seen = vec![false; self.mini_fat.len()]; - let mut cut = false; - while id != END_OF_CHAIN && out.len() < limit { - if cut { - return Err("damaged compound file: mini sector out of range".into()); - } - if id >= MAX_REG_SECT { - return Err("damaged compound file: broken mini sector chain".into()); - } - match seen.get_mut(id as usize) { - Some(v) if !*v => *v = true, - Some(_) => return Err("damaged compound file: mini sector chain loops".into()), - None => return Err("damaged compound file: mini sector missing from the mini FAT".into()), - } - let at = (id as usize).checked_mul(self.mini_sector_size); - let s = at - .and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?.min(container.len()))) - .ok_or("damaged compound file: mini sector out of range")?; - cut = s.len() < self.mini_sector_size; - out.extend_from_slice(s); - id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?; - } - Ok(out) - } - - /// The contents of the stream called `name` (case-insensitive) directly under the root - /// storage, or `None` when there is no such stream. - /// The directory entry of the stream called `name` (case-insensitive) directly under the - /// root storage. - fn root_entry(&self, name: &str) -> Result, String> { - let Some(root) = self.entries.first() else { return Ok(None) }; - // Walk the root's children (a red-black tree through left/right siblings), bounded. - let mut stack = vec![root.child]; - let mut seen = 0usize; - while let Some(i) = stack.pop() { - if i == NO_STREAM { - continue; - } - seen += 1; - if seen > self.entries.len() { - return Err("damaged compound file: directory tree loops".into()); - } - let Some(e) = self.entries.get(i as usize) else { continue }; - if e.kind == STREAM_OBJECT && e.name.eq_ignore_ascii_case(name) { - return Ok(Some(e)); - } - stack.push(e.left); - stack.push(e.right); - } - Ok(None) - } - - /// The contents of the stream called `name` (case-insensitive) directly under the root - /// storage, or `None` when there is no such stream. - pub(crate) fn root_stream(&self, name: &str) -> Result>, String> { - let Some(root) = self.entries.first() else { return Ok(None) }; - let found = self.root_entry(name)?; - let Some(e) = found else { return Ok(None) }; - if e.size > MAX_STREAM { - return Err("the workbook stream is larger than 1 GiB".into()); - } - let size = usize::try_from(e.size).map_err(|_| "stream too large".to_string())?; - let mut data = if e.size < self.mini_cutoff { - let container = self.read_chain(root.start, root.size)?; - self.read_mini_chain(&container, e.start, size)? - } else { - self.read_chain(e.start, e.size)? - }; - if data.len() < size { - return Err("damaged compound file: a stream is shorter than its declared size (the file may be truncated)".into()); - } - data.truncate(size); - Ok(Some(data)) - } -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - - /// A minimal valid compound file (512-byte sectors: FAT at sector 0, directory at sector 1) - /// whose directory holds a root entry plus the given stream names. - pub(crate) fn build(streams: &[&str]) -> Vec { - let mut b = vec![0u8; 512 * 3]; - b[..8].copy_from_slice(&SIGNATURE); - b[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); // minor version - b[26..28].copy_from_slice(&3u16.to_le_bytes()); // major version - b[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); // byte order - b[30..32].copy_from_slice(&9u16.to_le_bytes()); // sector shift - b[32..34].copy_from_slice(&6u16.to_le_bytes()); // mini sector shift - b[44..48].copy_from_slice(&1u32.to_le_bytes()); // FAT sectors - b[48..52].copy_from_slice(&1u32.to_le_bytes()); // first directory sector - b[60..64].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no mini FAT - b[68..72].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no DIFAT sectors - for i in 0..109 { - b[76 + i * 4..80 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); - } - b[76..80].copy_from_slice(&0u32.to_le_bytes()); // FAT lives in sector 0 - // FAT (sector 0, at byte 512): sector 0 is a FAT sector, sector 1 ends its chain. - for i in 0..128 { - b[512 + i * 4..516 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); - } - b[512..516].copy_from_slice(&0xFFFF_FFFDu32.to_le_bytes()); - b[516..520].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); - // Directory (sector 1, at byte 1024). - for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).take(4).enumerate() { - let e = 1024 + k * 128; - let units: Vec = name.encode_utf16().take(31).collect(); - for (i, u) in units.iter().enumerate() { - b[e + i * 2..e + i * 2 + 2].copy_from_slice(&u.to_le_bytes()); - } - b[e + 64..e + 66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes()); - b[e + 66] = if k == 0 { 5 } else { 2 }; - } - b - } - - #[test] - fn reads_directory_names() { - let b = build(&["EncryptionInfo", "EncryptedPackage"]); - assert_eq!(directory_names(&b), Some(vec!["Root Entry".into(), "EncryptionInfo".into(), "EncryptedPackage".into()])); - assert!(is_encrypted(&b)); - assert!(!is_encrypted(&build(&["Workbook", "\u{5}SummaryInformation"]))); - } - - #[test] - fn hostile_headers_never_panic() { - let mut b = build(&["Workbook"]); - for at in [30usize, 48, 68, 76, 512, 516, 1024 + 64] { - for v in [0u32, 1, 7, 0xFFFF_FFFA, 0xFFFF_FFFF, 0x7FFF_FFFF] { - let mut c = b.clone(); - c[at..at + 4].copy_from_slice(&v.to_le_bytes()); - let _ = is_encrypted(&c); - for cut in [0usize, 8, 100, 600, 1100] { - let _ = is_encrypted(&c[..cut.min(c.len())]); - } - } - } - // A directory chain that loops back on itself stops at the bound. - b[516..520].copy_from_slice(&1u32.to_le_bytes()); - assert!(!is_encrypted(&b)); - } - - /// `compound_file` with one regular-sector stream of `len` bytes (FAT in sector 0, directory - /// in sector 1, the stream from sector 2), cut right after the stream's last byte, the way - /// some writers leave the final sector unpadded. - fn unpadded(len: usize) -> (Vec, Vec) { - let data: Vec = (0..len).map(|i| (i % 251) as u8).collect(); - let mut file = crate::testing::compound_file(&[("Workbook", &data)]); - file.truncate(512 * 3 + len); - (file, data) - } - - #[test] - fn an_unpadded_last_sector_still_reads() { - let (file, data) = unpadded(5000); - assert_ne!(file.len() % 512, 0); - let cf = Compound::open(&file).unwrap(); - assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data); - // Bytes the stream needs that the file doesn't hold are still damage. - let cf = Compound::open(&file[..file.len() - 1]).unwrap(); - assert!(cf.root_stream("Workbook").is_err()); - } - - #[test] - fn an_unpadded_last_mini_sector_still_reads() { - let data: Vec = (0..1000u32).map(|i| (i % 251) as u8).collect(); - let mut file = crate::testing::compound_file(&[("Workbook", &data)]); - // Header, FAT, directory, mini FAT, then the mini stream holding the stream's bytes. - file.truncate(512 * 4 + data.len()); - let cf = Compound::open(&file).unwrap(); - assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data); - let cf = Compound::open(&file[..file.len() - 1]).unwrap(); - assert!(cf.root_stream("Workbook").is_err()); - } - - #[test] - fn a_cut_sector_inside_a_chain_is_damage() { - let (mut file, _) = unpadded(5000); - // The cut sector (11) points on to the directory sector and the stream claims those - // bytes too: the partial sector would splice 392 bytes into the middle of the stream. - file[512 + 11 * 4..512 + 12 * 4].copy_from_slice(&1u32.to_le_bytes()); - file[1024 + 128 + 120..1024 + 128 + 124].copy_from_slice(&5512u32.to_le_bytes()); - let cf = Compound::open(&file).unwrap(); - assert!(cf.root_stream("Workbook").is_err()); - } - - #[test] - fn a_cut_mini_sector_inside_a_chain_is_damage() { - let small = [7u8; 100]; - let data: Vec = (0..1000u32).map(|i| (i % 251) as u8).collect(); - let mut file = crate::testing::compound_file(&[("Small", &small), ("Workbook", &data)]); - // "Small" holds mini sectors 0-1, "Workbook" 2-17; the mini stream starts at sector 3. - file.truncate(512 * 4 + 128 + data.len()); - assert_eq!(Compound::open(&file).unwrap().root_stream("Workbook").unwrap().unwrap(), data); - // The cut mini sector (17) points on to sector 0 and the stream claims those bytes too. - file[1536 + 17 * 4..1536 + 18 * 4].copy_from_slice(&0u32.to_le_bytes()); - file[1024 + 2 * 128 + 120..1024 + 2 * 128 + 124].copy_from_slice(&1064u32.to_le_bytes()); - let cf = Compound::open(&file).unwrap(); - assert!(cf.root_stream("Workbook").is_err()); - } -} diff --git a/crates/xlsx/src/crypto.rs b/crates/xlsx/src/crypto.rs index bf495d747..710308638 100644 --- a/crates/xlsx/src/crypto.rs +++ b/crates/xlsx/src/crypto.rs @@ -1,42 +1,7 @@ //! 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 two dataIntegrity block keys ([MS-OFFCRYPTO] 2.3.4.14), one per HMAC field. -pub(crate) const BLOCK_HMAC_KEY: [u8; 8] = [0x5F, 0xB2, 0xAD, 0x01, 0x0C, 0xB9, 0xE1, 0xF6]; -pub(crate) const BLOCK_HMAC_VALUE: [u8; 8] = [0xA0, 0x67, 0x7F, 0x02, 0xB2, 0x2C, 0x84, 0x33]; - -/// 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; +//! two streams, `EncryptionInfo` and `EncryptedPackage`. `craft-offcrypto` reads them (Agile and +//! Standard encryption; RC4, Extensible, certificate-only and rights-managed files are named as +//! unsupported); this module keeps GridCraft's error type. /// Why an encrypted workbook couldn't be decrypted. #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] @@ -50,489 +15,32 @@ pub enum DecryptError { Malformed(String), } -fn malformed(m: impl Into) -> DecryptError { - DecryptError::Malformed(m.into()) +impl From for DecryptError { + fn from(e: craft_offcrypto::Error) -> Self { + use craft_offcrypto::Error as E; + match e { + E::WrongPassword | E::PasswordRequired => DecryptError::WrongPassword, + E::Unsupported(scheme) => DecryptError::Unsupported(scheme), + E::Malformed(detail) | E::TooLarge(detail) => DecryptError::Malformed(detail), + e => DecryptError::Malformed(e.to_string()), + } + } } /// The plain OOXML package (a zip) inside an encrypted workbook. pub fn decrypt(bytes: &[u8], password: &str) -> Result, DecryptError> { - let (info, package) = streams(bytes)?; - decrypt_streams(&info, &package, password) + craft_offcrypto::decrypt(bytes, Some(password)).map_err(DecryptError::from) } /// Whether the right password could decrypt this encrypted workbook, checked without one. An /// error names a scheme no password opens (RC4, rights management…) or damage, so callers can /// say so instead of asking for a password first. pub fn check_encryption(bytes: &[u8]) -> Result<(), DecryptError> { - let (info, _) = streams(bytes)?; - match scheme(&info)? { - Scheme::Agile(xml) => agile_descriptor(xml).map(|_| ()), - Scheme::Standard(info) => standard_header(info).map(|_| ()), - } + craft_offcrypto::check(bytes).map_err(DecryptError::from) } -/// The `EncryptionInfo` and `EncryptedPackage` streams. Rights management (IRM) keeps the -/// package but describes its encryption elsewhere, with no `EncryptionInfo`. -fn streams(bytes: &[u8]) -> Result<(Vec, Vec), DecryptError> { - let cf = Compound::open(bytes).map_err(malformed)?; - let info = cf.root_stream("EncryptionInfo").map_err(malformed)?; - let package = cf.root_stream("EncryptedPackage").map_err(malformed)?.ok_or_else(|| malformed("no EncryptedPackage stream"))?; - let info = info.ok_or_else(|| DecryptError::Unsupported("rights-management".into()))?; - Ok((info, package)) +/// Whether a compound file holds an encryption stream, at any depth. A damaged directory falls +/// back to a search for the stream names, so the file is still reported as encrypted. +pub(crate) fn is_encrypted(bytes: &[u8]) -> bool { + ["EncryptedPackage", "EncryptionInfo"].iter().any(|n| craft_cfb::has_entry_named(bytes, n, &craft_cfb::Limits::default())) } - -/// Decrypts from the two streams' contents. -pub(crate) fn decrypt_streams(info: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { - match scheme(info)? { - Scheme::Agile(xml) => agile(xml, package, password), - Scheme::Standard(info) => standard(info, package, password), - } -} - -/// The two schemes read, with the part of `EncryptionInfo` each one parses. -enum Scheme<'a> { - /// The XML descriptor after the version and reserved field. - Agile(&'a [u8]), - /// Everything after the version. - Standard(&'a [u8]), -} - -fn scheme(info: &[u8]) -> Result, DecryptError> { - let short = || malformed("truncated EncryptionInfo"); - let major = u16_at(info, 0).ok_or_else(short)?; - let minor = u16_at(info, 2).ok_or_else(short)?; - match (major, minor) { - (4, 4) => Ok(Scheme::Agile(info.get(8..).ok_or_else(short)?)), - (2..=4, 2) => Ok(Scheme::Standard(info.get(4..).ok_or_else(short)?)), - (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), - } - } - - /// The digest's length in bytes. - fn digest_len(self) -> usize { - match self { - Hash::Sha1 => 20, - Hash::Sha256 => 32, - Hash::Sha384 => 48, - Hash::Sha512 => 64, - } - } - - /// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64. - fn block_size(self) -> usize { - match self { - Hash::Sha384 | Hash::Sha512 => 128, - Hash::Sha1 | Hash::Sha256 => 64, - } - } -} - -/// 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 -} - -/// HMAC-SHA-`hash` ([RFC2104]), as Agile's dataIntegrity uses. -pub(crate) fn hmac(hash: Hash, key: &[u8], message: &[u8]) -> Vec { - let bs = hash.block_size(); - let mut k = key.to_vec(); - if k.len() > bs { - k = hash.digest(&[&k]); - } - k.resize(bs, 0); - let pad = |x: u8| k.iter().map(|b| b ^ x).collect::>(); - let inner = hash.digest(&[&pad(0x36), message]); - hash.digest(&[&pad(0x5C), &inner]) -} - -/// 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 ------------------------------------------------------------------- - -/// What a Standard `EncryptionInfo` holds: the key size and the password verifier. -struct StandardHeader<'a> { - key_len: usize, - salt: &'a [u8], - verifier: &'a [u8], - verifier_hash: &'a [u8], -} - -/// `info` starts after the version: flags, header size, header, verifier. -fn standard_header(info: &[u8]) -> 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 verifier = v.get(20..36).ok_or_else(short)?; - let verifier_hash = v.get(40..72).ok_or_else(short)?; - Ok(StandardHeader { key_len, salt, verifier, verifier_hash }) -} - -fn standard(info: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { - let h = standard_header(info)?; - let (mut verifier, mut verifier_hash) = (h.verifier.to_vec(), h.verifier_hash.to_vec()); - let aes = Aes::new(&standard_key(password, h.salt, h.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 - } - } - } -} - -/// What an Agile descriptor holds: the package's cipher, the password key encryptor with its -/// three encrypted values (verifier input, verifier hash, package key), and the package's -/// integrity pair, which Excel always writes and the reader requires. -struct AgileDescriptor { - key_data: Params, - key: Params, - spin: u32, - verifier_input: Vec, - verifier_hash: Vec, - key_value: Vec, - hmac_key: Vec, - hmac_value: Vec, -} - -/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field. -fn agile_descriptor(xml: &[u8]) -> Result { - 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 field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?); - // A descriptor without dataIntegrity can't prove its ciphertext is the one the password - // protects: the verifier only authenticates the key, not the package ([MS-OFFCRYPTO] 2.3.4.14). - let integrity = root.child("dataIntegrity").ok_or_else(|| malformed("no dataIntegrity"))?; - let hmac_field = |attr: &str| { - let v = base64(integrity.attr(attr).ok_or_else(|| malformed(format!("no {attr}")))?)?; - if v.is_empty() || v.len() % BLOCK != 0 { - return Err(malformed(format!("a whole-block {attr}"))); - } - Ok(v) - }; - Ok(AgileDescriptor { - key_data, - key, - spin, - verifier_input: field("encryptedVerifierHashInput")?, - verifier_hash: field("encryptedVerifierHashValue")?, - key_value: field("encryptedKeyValue")?, - hmac_key: hmac_field("encryptedHmacKey")?, - hmac_value: hmac_field("encryptedHmacValue")?, - }) -} - -/// Decrypts an Agile package ([MS-OFFCRYPTO] 2.3.4.10): checks the password against the -/// verifier, unwraps the package key, verifies the dataIntegrity HMAC, then decrypts the segments. -fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { - let descriptor = agile_descriptor(xml)?; - let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor; - let h = iterated_hash(key.hash, password, &key.salt, spin); - let iv = key.iv(None); - let field = |data: &[u8], block_key: &[u8]| -> Result, DecryptError> { - let mut data = data.to_vec(); - Aes::new(&derive(key.hash, &h, block_key, key.key_len))?.decrypt_cbc(&iv, &mut data)?; - Ok(data) - }; - let input = field(&verifier_input, &BLOCK_VERIFIER_INPUT)?; - let expected = field(&verifier_hash, &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(&key_value, &BLOCK_KEY)?; - let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?; - // Package integrity, [MS-OFFCRYPTO] 2.3.4.14: both HMAC fields are encrypted with the - // package key (the verifier above only authenticates the password), computed over the whole - // EncryptedPackage stream, StreamSize field included. The specification keys the HMAC with a - // random salt-size array, but Excel's key is as long as the hash (64 bytes for SHA-512), so - // both lengths are tried. - let mut mac_field = hmac_key; - aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?; - let mut declared = hmac_value; - aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?; - let verified = [key_data.hash.digest_len(), key_data.salt.len()].into_iter().any(|len| { - let Some(key) = mac_field.get(..len) else { return false }; - let mac = hmac(key_data.hash, key, package); - declared.get(..mac.len()) == Some(mac.as_slice()) - }); - if !verified { - return Err(malformed("the decrypted data failed its integrity check")); - } - 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 deleted file mode 100644 index 56ac7d8a2..000000000 --- a/crates/xlsx/src/crypto/tests.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! 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_agile_with_hmac_key, encrypt_standard, with_agile_header}; - -const PASSWORD: &str = "Pass1234"; -/// 16 zero bytes (whole AES blocks), a stand-in for a real integrity field where the values -/// can't be reached anyway (scheme errors, descriptor-parse damage). -const HMAC_STUB: &str = "AAAAAAAAAAAAAAAAAAAAAA=="; - -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, HMAC_STUB, HMAC_STUB).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\""); - assert_eq!(unsupported(&with_agile_header(&des)), "3DES"); - let cert = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).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, HMAC_STUB, HMAC_STUB); - 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("| v.iter().map(|b| format!("{b:02x}")).collect::(); - // RFC 2202 test case 1 (HMAC-SHA-1) and HMAC-SHA-256 vectors. - assert_eq!(h(mac(Hash::Sha1, &[0x0b; 20], b"Hi There")), "b617318655057264e28bc0b6fb378c8ef146be00"); - assert_eq!(h(mac(Hash::Sha256, &[0x0b; 20], b"Hi There")), "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"); - // RFC 4231 test case 5 (HMAC-SHA-256): a key longer than the block size is hashed first. - assert_eq!( - h(mac(Hash::Sha256, &[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")), - "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" - ); -} - -/// Strips the `` element from an Agile descriptor. -fn without_integrity(info: &[u8]) -> Vec { - let xml = std::str::from_utf8(&info[8..]).unwrap().to_string(); - let start = xml.find("").unwrap() + 2; - let stripped = format!("{}{}", &xml[..start], &xml[end..]); - with_agile_header(&stripped) -} - -#[test] -fn integrity_is_required_and_enforced() { - let plain = payload(300); - let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); - // An attacker can't just delete the integrity element to get the package verified. - assert!(matches!(decrypt_streams(&without_integrity(&info), &package, PASSWORD), - Err(DecryptError::Malformed(m)) if m.contains("dataIntegrity"))); - // An empty or ragged field is damage, not a pass. - for bad in [("encryptedHmacKey", ""), ("encryptedHmacKey", "AA=="), ("encryptedHmacValue", "")] { - let xml = - agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace(&format!("{}=\"{HMAC_STUB}\"", bad.0), &format!("{}=\"{}\"", bad.0, bad.1)); - let r = decrypt_streams(&with_agile_header(&xml), &package, PASSWORD); - assert!(matches!(r, Err(DecryptError::Malformed(_))), "{}=\"{}\"", bad.0, bad.1); - } -} - -#[test] -fn modified_ciphertext_fails_the_integrity_check() { - let plain = payload(300); - let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); - // The correct password proves the verifier can't vouch for a modified package. - for at in [8usize, 100, package.len() - 5] { - let mut tampered = package.clone(); - tampered[at] ^= 0x80; - assert!( - matches!(decrypt_streams(&info, &tampered, PASSWORD), - Err(DecryptError::Malformed(m)) if m.contains("integrity")), - "byte {at}" - ); - } - // And neither can the descriptor: a swapped integrity value is refused. - let xml = String::from_utf8_lossy(&info[8..]).to_string(); - let at = xml.find("encryptedHmacValue=\"").unwrap() + "encryptedHmacValue=\"".len(); - let mut xml: Vec = xml.into_bytes(); - xml[at] ^= b'A' ^ b'B'; - assert!(matches!(decrypt_streams(&with_agile_header(std::str::from_utf8(&xml).unwrap()), &package, PASSWORD), Err(DecryptError::Malformed(_)))); -} - -#[test] -fn integrity_matches_the_stream_not_the_plaintext() { - // The MAC covers the whole EncryptedPackage stream, StreamSize field included: changing the - // stated size alone must fail, while the actual bytes round-trip. - let plain = payload(4200); - let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); - let mut ours = package.clone(); - ours[..8].copy_from_slice(&((plain.len() + 1) as u64).to_le_bytes()); - assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); - assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain); -} - -#[test] -fn integrity_keys_as_long_as_the_hash_or_the_salt_verify() { - // Excel keys the HMAC with 64 bytes (#2712); [MS-OFFCRYPTO] 2.3.4.14 describes a salt-size - // key. Both verify, and neither lets a modified package through. - let plain = payload(5000); - for key in [vec![7u8; 64], vec![7u8; 16]] { - let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &key); - assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain, "{}-byte key", key.len()); - let mut tampered = package.clone(); - tampered[100] ^= 1; - assert!(matches!(decrypt_streams(&info, &tampered, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); - } - // Another length (a key that matches neither) fails. - let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &[7u8; 32]); - assert!(matches!(decrypt_streams(&info, &package, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); -} diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 67694e72d..efd386164 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -12,7 +12,6 @@ #![forbid(unsafe_code)] mod array_formula; -mod cfb; mod chart; mod chartex; mod controls; @@ -272,8 +271,8 @@ pub fn sniff(bytes: &[u8]) -> Format { // CFB/OLE2 compound file: a password-protected workbook when it holds an encryption // stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another // legacy binary (.doc…). - if bytes.starts_with(&cfb::SIGNATURE) { - return if cfb::is_encrypted(bytes) { + if bytes.starts_with(&craft_cfb::SIGNATURE) { + return if crypto::is_encrypted(bytes) { Format::Encrypted } else if xls::is_xls(bytes) { Format::Xls diff --git a/crates/xlsx/src/testing.rs b/crates/xlsx/src/testing.rs index a82283644..211f635b7 100644 --- a/crates/xlsx/src/testing.rs +++ b/crates/xlsx/src/testing.rs @@ -1,282 +1,25 @@ //! Test support: builds password-encrypted workbooks in code, so tests here and in the crates //! above (engine, UI) never need an encrypted file on disk. Compiled for this crate's tests and, //! elsewhere, only with the `test-support` feature (a dev-dependency feature, never shipped). -//! -//! Salts and keys are fixed so the output is deterministic. This is not an encryptor for real -//! use: a fixed key protects nothing. +//! The encryption is `craft-offcrypto`'s and the compound file `craft-cfb`'s. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use aes::cipher::{BlockCipherEncrypt, KeyInit}; - -use crate::crypto::{ - BLOCK_HMAC_KEY, BLOCK_HMAC_VALUE, BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, - SEGMENT, derive, hmac, iterated_hash, standard_key, -}; - /// The spin count `encrypt_workbook` uses: Excel's default. pub const SPIN_COUNT: u32 = 100_000; -const PASSWORD_SALT: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; -const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]; -const SECRET_KEY: [u8; 32] = - [64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]; -const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]; -/// The dataIntegrity HMAC key, as long as the hash: Excel writes 64 bytes for SHA-512, not the -/// salt-size key [MS-OFFCRYPTO] 2.3.4.14 describes. -const HMAC_KEY: [u8; 64] = [ - 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, - 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, - 170, 171, 172, 173, 174, 175, -]; - /// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption /// (AES-256, SHA-512) inside a compound file. pub fn encrypt_workbook(package: &[u8], password: &str) -> Vec { - let (info, encrypted) = encrypt_agile(package, password, SPIN_COUNT); - compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)]) + craft_offcrypto::encrypt_with_spin_count(package, password, SPIN_COUNT).unwrap() } /// The same with Standard encryption (AES-128, SHA-1), as Excel 2007 wrote it. pub fn encrypt_workbook_standard(package: &[u8], password: &str) -> Vec { - let (info, encrypted) = encrypt_standard(package, password); - compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)]) + craft_offcrypto::encrypt_standard(package, password, 128).unwrap() } -/// A version 3 compound file (512-byte sectors) holding `streams` under the root, laid out as -/// Excel lays it out: streams under 4096 bytes in the mini stream, larger ones in regular -/// sectors. Up to 109 FAT sectors (about 7 MB of streams). +/// A version 3 compound file (512-byte sectors) holding `streams` under the root: streams under +/// 4096 bytes in the mini stream, larger ones in regular sectors. pub fn compound_file(streams: &[(&str, &[u8])]) -> Vec { - const SECTOR: usize = 512; - const MINI: usize = 64; - const CUTOFF: usize = 4096; - const FREE: u32 = 0xFFFF_FFFF; - const END: u32 = 0xFFFF_FFFE; - const FAT_SECTOR: u32 = 0xFFFF_FFFD; - let is_mini = |d: &[u8]| d.len() < CUTOFF; - - // The mini stream: small streams in 64-byte mini sectors, chained by the mini FAT. - let mut container = vec![]; - let mut mini_fat: Vec = vec![]; - let mut mini_starts = vec![]; - for (_, d) in streams { - if !is_mini(d) || d.is_empty() { - mini_starts.push(END); - continue; - } - let first = mini_fat.len(); - let n = d.len().div_ceil(MINI); - for k in 0..n { - mini_fat.push(if k + 1 == n { END } else { (first + k + 1) as u32 }); - } - mini_starts.push(first as u32); - container.extend_from_slice(d); - container.resize((first + n) * MINI, 0); - } - - // Regular-sector blobs, in order: directory, mini FAT, mini stream, then each large stream. - let dir_len = (streams.len() + 1).div_ceil(4) * SECTOR; - let mut blobs: Vec> = vec![vec![0; dir_len], mini_fat.iter().flat_map(|v| v.to_le_bytes()).collect(), container.clone()]; - blobs.extend(streams.iter().map(|(_, d)| if is_mini(d) { vec![] } else { d.to_vec() })); - let counts: Vec = blobs.iter().map(|b| b.len().div_ceil(SECTOR)).collect(); - let body: usize = counts.iter().sum(); - let mut fat_sectors = 1; - while fat_sectors * 128 < fat_sectors + body { - fat_sectors += 1; - } - assert!(fat_sectors <= 109, "the test writer has no DIFAT sectors"); - let mut fat = vec![FREE; fat_sectors * 128]; - fat[..fat_sectors].fill(FAT_SECTOR); - let mut starts = vec![]; - let mut next = fat_sectors; - for &n in &counts { - if n == 0 { - starts.push(END); - continue; - } - starts.push(next as u32); - for k in 0..n { - fat[next + k] = if k + 1 == n { END } else { (next + k + 1) as u32 }; - } - next += n; - } - - let put16 = |b: &mut [u8], at: usize, v: u16| b[at..at + 2].copy_from_slice(&v.to_le_bytes()); - let put32 = |b: &mut [u8], at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes()); - // Directory: the root (owning the mini stream), then the streams chained as right siblings. - let dir = &mut blobs[0]; - for k in 0..dir_len / 128 { - for at in [68, 72, 76] { - put32(dir, k * 128 + at, FREE); - } - } - let mut entry = |k: usize, name: &str, kind: u8, start: u32, size: usize| { - let e = k * 128; - let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); - dir[e..e + units.len()].copy_from_slice(&units); - put16(dir, e + 64, units.len() as u16 + 2); - dir[e + 66] = kind; - dir[e + 67] = 1; // black - if kind == 5 && !streams.is_empty() { - put32(dir, e + 76, 1); - } - if kind == 2 && k < streams.len() { - put32(dir, e + 72, k as u32 + 1); - } - put32(dir, e + 116, start); - put32(dir, e + 120, size as u32); - }; - entry(0, "Root Entry", 5, starts[2], container.len()); - for (k, (name, data)) in streams.iter().enumerate() { - let start = if is_mini(data) { mini_starts[k] } else { starts[3 + k] }; - entry(k + 1, name, 2, start, data.len()); - } - - let mut out = vec![0u8; SECTOR * (1 + fat_sectors + body)]; - out[..8].copy_from_slice(&crate::cfb::SIGNATURE); - put16(&mut out, 24, 0x3E); // minor version - put16(&mut out, 26, 3); // major version - put16(&mut out, 28, 0xFFFE); // byte order - put16(&mut out, 30, 9); // 512-byte sectors - put16(&mut out, 32, 6); // 64-byte mini sectors - put32(&mut out, 44, fat_sectors as u32); - put32(&mut out, 48, starts[0]); - put32(&mut out, 56, CUTOFF as u32); - put32(&mut out, 60, starts[1]); // first mini FAT sector (END when there is none) - put32(&mut out, 64, counts[1] as u32); - put32(&mut out, 68, END); // no DIFAT sectors - for i in 0..109 { - put32(&mut out, 76 + i * 4, if i < fat_sectors { i as u32 } else { FREE }); - } - for (i, v) in fat.iter().enumerate() { - put32(&mut out, SECTOR + i * 4, *v); - } - for (blob, start) in blobs.iter().zip(&starts) { - if !blob.is_empty() { - let at = SECTOR * (1 + *start as usize); - out[at..at + blob.len()].copy_from_slice(blob); - } - } - out -} - -fn encrypt_block(key: &[u8], block: &mut [u8; 16]) { - let b = aes::Block::cast_from_core_mut(block); - match key.len() { - 16 => 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, and the -/// dataIntegrity pair (`hmac_key_b64`/`hmac_value_b64`) a compliant file carries. -pub(crate) fn agile_info(password: &str, spin: u32, hmac_key_b64: &str, hmac_value_b64: &str) -> 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) - ) - }; - // The dataIntegrity pair ([MS-OFFCRYPTO] 2.3.4.14), encrypted with the package key. - format!( - "\r\n", - params(KEY_DATA_SALT.as_slice()), - 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), with the -/// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it. -pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec, Vec) { - encrypt_agile_with_hmac_key(plain, password, spin, &HMAC_KEY) -} - -/// [`encrypt_agile`] with the dataIntegrity HMAC keyed by `hmac_key`. -pub(crate) fn encrypt_agile_with_hmac_key(plain: &[u8], password: &str, spin: u32, hmac_key: &[u8]) -> (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)); - } - let stream = package(plain.len(), body); - let (hmac_key_b64, hmac_value_b64) = ( - base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), hmac_key)), - base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, hmac_key, &stream))), - ); - (with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream) -} - -/// `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))) + craft_cfb::write_streams(craft_cfb::Version::V3, streams).unwrap() } diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index c277febf4..adf083a97 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -275,8 +275,9 @@ fn sniffing_formats() { assert_eq!(sniff(b"a,b\n1,2"), Format::Csv); // An Excel password-protected file is a CFB/OLE2 compound file with an EncryptedPackage // stream; any other compound file is a legacy binary (.xls), not an encrypted workbook. - assert_eq!(sniff(&crate::cfb::tests::build(&["EncryptionInfo", "EncryptedPackage"])), Format::Encrypted); - assert_eq!(sniff(&crate::cfb::tests::build(&["Workbook"])), Format::LegacyBinary); + let compound = crate::testing::compound_file; + assert_eq!(sniff(&compound(&[("EncryptionInfo", b"x"), ("EncryptedPackage", b"x")])), Format::Encrypted); + assert_eq!(sniff(&compound(&[("WordDocument", b"x")])), Format::LegacyBinary); assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0]), Format::LegacyBinary); assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0, 0, 1, 2]), Format::Unknown); assert_eq!(sniff(b""), Format::Unknown); diff --git a/crates/xlsx/src/tests/encrypted_tests.rs b/crates/xlsx/src/tests/encrypted_tests.rs index 525ed9193..1c418c0fe 100644 --- a/crates/xlsx/src/tests/encrypted_tests.rs +++ b/crates/xlsx/src/tests/encrypted_tests.rs @@ -3,24 +3,11 @@ use crate::testing::{compound_file, encrypt_workbook, encrypt_workbook_standard}; use crate::{DecryptError, decrypt}; -#[test] -fn the_test_compound_writer_reads_back() { - let big: Vec = (0..3_000_000u32).map(|i| (i % 253) as u8).collect(); - let mid: Vec = (0..4095u32).map(|i| i as u8).collect(); - let file = compound_file(&[("Small", b"hello"), ("Big", &big), ("Empty", b""), ("Mid", &mid)]); - let cf = crate::cfb::Compound::open(&file).unwrap(); - assert_eq!(cf.root_stream("Mid").unwrap().unwrap(), mid, "just under the cutoff: the mini stream"); - assert_eq!(cf.root_stream("Small").unwrap().unwrap(), b"hello"); - assert_eq!(cf.root_stream("big").unwrap().unwrap(), big); - assert_eq!(cf.root_stream("Empty").unwrap().unwrap(), b""); - assert_eq!(cf.root_stream("Missing").unwrap(), None); -} - #[test] fn encrypted_files_decrypt_to_the_package() { let package: Vec = (0..3_000_000u32).map(|i| (i * 31 % 251) as u8).collect(); let agile = encrypt_workbook(&package, "Pass1234"); - assert!(crate::cfb::is_encrypted(&agile)); + assert!(crate::crypto::is_encrypted(&agile)); assert_eq!(decrypt(&agile, "Pass1234").unwrap(), package); assert_eq!(decrypt(&agile, "wrong"), Err(DecryptError::WrongPassword)); let standard = encrypt_workbook_standard(&package, "Pass1234"); @@ -49,16 +36,19 @@ fn a_gridcraft_workbook_survives_encryption() { #[test] fn schemes_no_password_opens_are_named_before_asking_for_one() { use crate::check_encryption; - use crate::testing::{agile_info, with_agile_header}; let unsupported = |s: &str| Err(DecryptError::Unsupported(s.into())); assert_eq!(check_encryption(&encrypt_workbook(b"package", "pw")), Ok(())); assert_eq!(check_encryption(&encrypt_workbook_standard(b"package", "pw")), Ok(())); let file = |info: &[u8]| compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); assert_eq!(check_encryption(&file(&[3, 0, 2, 0, 4, 0, 0, 0])), unsupported("RC4")); assert_eq!(check_encryption(&file(&[4, 0, 3, 0, 0, 0, 0, 0])), unsupported("Extensible")); - let cert = - agile_info("pw", 1000, "AAAAAAAAAAAAAAAAAAAAAA==", "AAAAAAAAAAAAAAAAAAAAAA==").replace("keyEncryptor/password", "keyEncryptor/certificate"); - assert_eq!(check_encryption(&file(&with_agile_header(&cert))), unsupported("certificate")); + // An Agile descriptor whose only key encryptor is a certificate. + let agile = encrypt_workbook(b"package", "pw"); + let cf = craft_cfb::CompoundFile::open(&agile).unwrap(); + let info = cf.read(cf.child(0, "EncryptionInfo").unwrap()).unwrap(); + let mut cert = info[..8].to_vec(); + cert.extend(String::from_utf8(info[8..].to_vec()).unwrap().replace("keyEncryptor/password", "keyEncryptor/certificate").into_bytes()); + assert_eq!(check_encryption(&file(&cert)), unsupported("certificate")); assert!(matches!(check_encryption(&file(&[4, 0])), Err(DecryptError::Malformed(_)))); // Rights management (IRM) keeps an EncryptedPackage but no EncryptionInfo. let irm = compound_file(&[("EncryptedPackage", &[0; 8])]);