Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 97c169b56a M7: verify Agile encryption's dataIntegrity before returning plaintext
Agile decryption checked only the password verifier, which authenticates
the key — not the package. An attacker who could replace EncryptedPackage
ciphertext could change workbook content undetected ([MS-OFFCRYPTO]
2.3.4.14, the descriptor's dataIntegrity element).

The reader now requires the dataIntegrity pair, decrypts it with the
recovered package key, and checks HMAC(hash, hmacKey) over the whole
EncryptedPackage stream (StreamSize field included) against the stored
value before any plaintext is returned. Excel always writes the element
("SHOULD" in the spec); descriptors without it are refused as damaged
rather than accepted unauthenticated.

- crypto: RFC 2104/4231 HMAC test vectors, RFC-test cases for a key
  longer than the block size
- fixtures write the pair like Excel; new tests: missing/empty/ragged
  integrity fields, tampered ciphertext and descriptor, a changed
  StreamSize field
2026-10-24 06:32:45 -04:00
11 changed files with 189 additions and 267 deletions
-15
View File
@@ -350,21 +350,6 @@ mod tests {
assert_eq!(split_sheet("A1"), (None, "A1"));
}
/// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only
/// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it
/// never reaches a filesystem path or a shell); the body half always stays a pure cell/range
/// reference.
#[test]
fn split_sheet_leaves_device_names_inert_in_the_body() {
for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] {
let (sheet, rest) = split_sheet(s);
assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference");
let _ = sheet;
}
// A plain single-letter sheet is still split, not mistaken for a body.
assert_eq!(split_sheet("C!A1").0, Some("C".to_string()));
}
#[test]
fn letters_roundtrip() {
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
+3 -23
View File
@@ -371,16 +371,10 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>
if let Some((d, f)) = parse_date(t, sys) {
return Some((d, Some(f)));
}
// date + time: split at the last space(s) before a time-looking tail. Only splits where the
// tail could be a time are tried: a long tail can't parse, so this stays linear in the text
// instead of reparsing every prefix for every space (one long text field quadratic).
// date + time: split at the last space(s) before a time-looking tail.
for (i, _) in t.match_indices(' ') {
let Some(b) = t.get(i + 1..) else { continue };
let bytes = b.as_bytes();
if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') {
continue;
}
if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) {
let (a, b) = (t.get(..i)?, t.get(i + 1..)?);
if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) {
return Some((d + tf, Some("m/d/yyyy h:mm")));
}
}
@@ -406,20 +400,6 @@ mod tests {
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
}
#[test]
fn overlong_time_fields_stay_text() {
// #2168: all-digit field lengths are gated before allocation/derivation,
// so hostile strings never spin the time-derivation machinery.
let big = "9".repeat(500);
for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] {
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
assert_eq!(parse_number_text(&s), None, "{s}");
}
// Within the gate, times still work.
assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0));
assert_eq!(parse_number_text("13:14:15"), Some(0.5515625));
}
#[test]
fn non_digit_time_and_fraction_parts_stay_text() {
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
+55 -3
View File
@@ -26,6 +26,9 @@ const BLOCK: usize = 16;
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";
@@ -165,6 +168,14 @@ impl Hash {
Hash::Sha512 => run::<sha2::Sha512>(parts),
}
}
/// 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.
@@ -184,6 +195,19 @@ pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec<u8> {
k
}
/// HMAC-SHA-`hash` ([RFC2104]), as Agile's dataIntegrity uses.
pub(crate) fn hmac(hash: Hash, key: &[u8], message: &[u8]) -> Vec<u8> {
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::<Vec<u8>>();
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<u8> {
@@ -368,8 +392,9 @@ impl Params {
}
}
/// What an Agile descriptor holds: the package's cipher, and the password key encryptor with its
/// three encrypted values (verifier input, verifier hash, package key).
/// 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,
@@ -377,6 +402,8 @@ struct AgileDescriptor {
verifier_input: Vec<u8>,
verifier_hash: Vec<u8>,
key_value: Vec<u8>,
hmac_key: Vec<u8>,
hmac_value: Vec<u8>,
}
/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field.
@@ -395,6 +422,16 @@ fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
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,
@@ -402,11 +439,14 @@ fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
verifier_input: field("encryptedVerifierHashInput")?,
verifier_hash: field("encryptedVerifierHashValue")?,
key_value: field("encryptedKeyValue")?,
hmac_key: hmac_field("encryptedHmacKey")?,
hmac_value: hmac_field("encryptedHmacValue")?,
})
}
fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value } = agile_descriptor(xml)?;
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<Vec<u8>, DecryptError> {
@@ -423,6 +463,18 @@ fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptE
}
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), keyed by a random
// salt-size array, computed over the whole EncryptedPackage stream, StreamSize field included.
let mut mac_field = hmac_key;
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?;
mac_field.truncate(key_data.salt.len());
let mut declared = hmac_value;
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?;
let mac = hmac(key_data.hash, &mac_field, package);
if declared.get(..mac.len()) != Some(mac.as_slice()) {
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() {
+79 -3
View File
@@ -6,6 +6,9 @@ use super::*;
use crate::testing::{agile_info, encrypt_agile, 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<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
@@ -107,9 +110,9 @@ fn unsupported_schemes_are_named() {
assert_eq!(unsupported(&rc4), "RC4");
assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible");
assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1");
let des = agile_info(PASSWORD, 1000).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\"");
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).replace("keyEncryptor/password", "keyEncryptor/certificate");
let cert = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("keyEncryptor/password", "keyEncryptor/certificate");
assert_eq!(unsupported(&with_agile_header(&cert)), "certificate");
}
@@ -136,7 +139,7 @@ fn damaged_files_are_malformed_not_panics() {
ragged[..8].copy_from_slice(&len.to_le_bytes());
assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD)));
}
let xml = agile_info(PASSWORD, 1000);
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=\"*")));
@@ -155,3 +158,76 @@ fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() {
hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a")
);
}
#[test]
fn hmac_matches_the_rfc2104_test_vectors() {
use crate::crypto::hmac as mac;
let h = |v: Vec<u8>| v.iter().map(|b| format!("{b:02x}")).collect::<String>();
// 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 `<dataIntegrity …/>` element from an Agile descriptor.
fn without_integrity(info: &[u8]) -> Vec<u8> {
let xml = std::str::from_utf8(&info[8..]).unwrap().to_string();
let start = xml.find("<dataIntegrity").unwrap();
let end = start + xml[start..].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<u8> = 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);
}
+1 -27
View File
@@ -74,24 +74,14 @@ impl<'a> Package<'a> {
let remaining = MAX_TOTAL.saturating_sub(self.total);
let limit = maximum.min(remaining);
let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
let read = (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| {
// A read that fails partway has already spent decompression work and buffered data
// the package budget never sees: count the allowance as spent, so a loop over many
// broken parts can't decompress it over and over.
self.total = MAX_TOTAL;
IoError::Zip(format!("{part}: {e}"))
});
(&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
if out.len() as u64 > limit {
// The allowance was spent getting here; make the exhaustion terminal instead of
// letting later reads pay the same decompression cost again.
self.total = MAX_TOTAL;
return Err(IoError::TooLarge(if remaining < maximum {
"package expands to more than 1 GB".into()
} else {
format!("{part} expands to more than {maximum} bytes")
}));
}
read?;
self.total += out.len() as u64;
Ok(Some(out))
}
@@ -211,20 +201,4 @@ mod tests {
assert_eq!(split_dir("xl/workbook.xml"), ("xl", "workbook.xml"));
assert_eq!(split_dir("x.xml"), ("", "x.xml"));
}
/// An over-budget read must spend the package's allowance once and for all: loops over many
/// broken parts (a sheet's optional VML relationships, for one) otherwise re-decompress the
/// remaining allowance again and again.
#[test]
fn a_failed_part_ends_the_package_budget() {
let z = crate::tests::make_zip(&[("a.xml", b"0123456789"), ("b.xml", b"hello")]);
let mut pkg = Package::open(&z).unwrap();
assert!(pkg.read("a.xml").unwrap().is_some());
// Nothing left to spend within the per-part limit either.
pkg.total = MAX_TOTAL - 1;
assert!(pkg.read("b.xml").is_err());
assert_eq!(pkg.total, MAX_TOTAL, "the failed read is charged");
// Oversized repeated attempts stay cheap errors now: no allowance is left to pay for.
assert!(matches!(pkg.read("b.xml"), Err(IoError::TooLarge(_))));
}
}
+3 -66
View File
@@ -33,62 +33,12 @@ struct RowState {
/// Dynamic-array spill ranges and legacy array ranges: cached values inside them (other than
/// the anchor) are not stored, so the formula can fill them again.
dynamic: Vec<RangeRef>,
/// Cells inside the ranges above, for a cap before a pathological sheet proves it itself.
dynamic_cells: u64,
/// Row-membership checks served so far, for the work cap above.
dynamic_checks: u64,
dropped: u64,
rows: u64,
}
/// Whether `pos` lies in a known dynamic range that isn't its anchor: the cell's cached value
/// gives way to the formula that will fill it. Charges every scanned range against one work
/// budget, then gives up consulting the ranges at all: the worst kind of file may then import
/// a few cached spill values, but it never stalls the whole import.
fn in_dynamic(st: &mut RowState, pos: CellRef) -> bool {
if st.dynamic_checks > MAX_DYNAMIC_CHECKS || st.dynamic.is_empty() {
return false;
}
let ranges = st.dynamic.len();
let hit = st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos);
st.dynamic_checks = st.dynamic_checks.saturating_add(ranges as u64);
hit
}
/// Records a dynamic-array range, so its cells outside the anchor import as empty and the
/// formula fills them again. Repeated identical anchors (a damaged or hostile sheet may repeat
/// the same record) are recorded once, and count and area are capped: every later cell inside
/// known range territory pays a scan proportional to what is recorded here.
fn push_dynamic(st: &mut RowState, range: RangeRef) {
if st.dynamic.contains(&range) {
return;
}
let cells = range.count();
if st.dynamic.len() >= MAX_DYNAMIC_RANGES || st.dynamic_cells.saturating_add(cells) > MAX_DYNAMIC_CELLS {
return;
}
st.dynamic_cells = st.dynamic_cells.saturating_add(cells);
st.dynamic.push(range);
}
const MAX_HYPERLINK_CELLS: u64 = 10_000;
/// Target and tooltip bytes kept for hyperlinks of one sheet, over all cells: the same URL is
/// copied for every cell of its range, so length × cells must stay bounded whatever the file.
const MAX_HYPERLINK_BYTES: u64 = 8 * 1024 * 1024;
const MAX_COLUMN_ASSIGNMENTS: u64 = 1_000_000;
/// Most dynamic-array and legacy-array ranges of one sheet.
const MAX_DYNAMIC_RANGES: usize = 65_536;
/// Worst-case cells inside the dynamic ranges the sheet declares. Beyond it further ranges are
/// dropped: they would cost their storage in every membership lookup that follows.
const MAX_DYNAMIC_CELLS: u64 = 16_000_000;
/// Most row-membership checks the sheet's ranges may serve. Beyond it the remaining cells are
/// imported with their cached values instead of being blanked for a spill that may still come,
/// so adversarial range counts can't make each cell scan a long list (a real workbook has a
/// handful of ranges).
const MAX_DYNAMIC_CHECKS: u64 = 16_000_000;
/// Most conditional-format range cells one sheet keeps, over all rules of an element: every
/// rule copies its element's whole range list, so rules × ranges must stay bounded.
const MAX_COND_CELLS: u64 = 4_000_000;
pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoError> {
let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?;
@@ -511,7 +461,7 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
}
let dynamic = c.attr("cm").is_some();
let formula = c.child("f").and_then(|f| read_formula(cx, st, f, pos, dynamic));
let value = if formula.is_none() && in_dynamic(st, pos) { Value::Empty } else { value };
let value = if formula.is_none() && st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos) { Value::Empty } else { value };
sheet.cells.set(pos, Cell { value, formula: formula.map(Arc::new), style });
}
@@ -552,7 +502,7 @@ fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef,
let mut fm = Formula::new(text);
let range = f.attr("ref").and_then(RangeRef::parse).unwrap_or(RangeRef::cell(pos));
if !range.is_single() {
push_dynamic(st, range);
st.dynamic.push(range);
}
if !dynamic {
fm.array = Some(range);
@@ -722,12 +672,6 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
if let Some(id) = ext_id {
cx.cf_ext_ids.push((id, sheet.cond_formats.len()));
}
let rules = sheet.cond_formats.len();
let area = ranges.iter().map(RangeRef::count).sum::<u64>().saturating_mul(1 + rules as u64);
if area > MAX_COND_CELLS {
cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped"));
break;
}
sheet.cond_formats.push(CondFormat {
ranges: ranges.clone(),
rule,
@@ -790,7 +734,6 @@ fn read_validation(v: &El) -> Option<Validation> {
fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) {
let mut cells = 0u64;
let mut bytes = 0u64;
for h in e.kids("hyperlink") {
let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue };
let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default();
@@ -806,19 +749,13 @@ fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &m
if target.is_empty() {
continue;
}
let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external };
let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64;
let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string), external };
for c in range.iter() {
if cells >= MAX_HYPERLINK_CELLS {
cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped"));
return;
}
if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES {
cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped"));
return;
}
cells += 1;
bytes += link_bytes;
sheet.hyperlinks.insert(c, link.clone());
}
}
+17 -8
View File
@@ -9,8 +9,8 @@
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use crate::crypto::{
BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, SEGMENT, derive, iterated_hash,
standard_key,
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.
@@ -21,6 +21,7 @@ const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
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];
const HMAC_SALT: [u8; 16] = [112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127];
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
/// (AES-256, SHA-512) inside a compound file.
@@ -202,8 +203,9 @@ fn package(size: usize, body: Vec<u8>) -> Vec<u8> {
p
}
/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16.
pub(crate) fn agile_info(password: &str, spin: u32) -> String {
/// 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);
@@ -217,9 +219,10 @@ pub(crate) fn agile_info(password: &str, spin: u32) -> String {
base64_encode(salt)
)
};
// The dataIntegrity pair ([MS-OFFCRYPTO] 2.3.4.14), encrypted with the package key.
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
params(&KEY_DATA_SALT),
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><dataIntegrity encryptedHmacKey=\"{hmac_key_b64}\" encryptedHmacValue=\"{hmac_value_b64}\"/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
params(KEY_DATA_SALT.as_slice()),
params(&PASSWORD_SALT),
)
}
@@ -231,14 +234,20 @@ pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
info
}
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512).
/// `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<u8>, Vec<u8>) {
let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 };
let mut body = vec![];
for (i, segment) in plain.chunks(SEGMENT).enumerate() {
body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment));
}
(with_agile_header(&agile_info(password, spin)), package(plain.len(), body))
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_SALT)),
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, &HMAC_SALT, &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).
+2 -1
View File
@@ -56,7 +56,8 @@ fn schemes_no_password_opens_are_named_before_asking_for_one() {
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).replace("keyEncryptor/password", "keyEncryptor/certificate");
let cert =
agile_info("pw", 1000, "AAAAAAAAAAAAAAAAAAAAAA==", "AAAAAAAAAAAAAAAAAAAAAA==").replace("keyEncryptor/password", "keyEncryptor/certificate");
assert_eq!(check_encryption(&file(&with_agile_header(&cert))), unsupported("certificate"));
assert!(matches!(check_encryption(&file(&[4, 0])), Err(DecryptError::Malformed(_))));
// Rights management (IRM) keeps an EncryptedPackage but no EncryptionInfo.
+22 -61
View File
@@ -13,7 +13,7 @@ mod formula;
mod ftab;
mod records;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::Arc;
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
@@ -59,14 +59,6 @@ pub(crate) fn is_xls(bytes: &[u8]) -> bool {
cf.has_root_stream("Workbook") || cf.has_root_stream("Book")
}
/// Most shared formulas one sheet expands, and the longest template text one attach may
/// produce: a template shared by its followers was costing every one of them a full decode and
/// print however often that could never succeed, so each anchor is tried once and failed ones
/// are skipped for the rest of the sheet. Beyond the cap the remaining references keep their
/// cached values.
const MAX_SHARED_ATTACHES: usize = 262_144;
const MAX_SHARED_TEMPLATE: usize = 8192;
pub(crate) struct Budget {
cells: usize,
text: usize,
@@ -486,56 +478,6 @@ fn rk(v: u32) -> Value {
Value::number(if v & 1 != 0 { n / 100.0 } else { n })
}
/// Attaches every pending formula to its anchor's template, returning how many shared
/// templates were expanded. An anchor whose template can't be imported is refused once:
/// its followers re-decode the same template for nothing, so they are skipped instead (they
/// keep their cached values).
fn resolve_pending(
st: &mut SheetState<'_>,
arrays: &HashMap<CellRef, (RangeRef, Parsed)>,
shared: &HashMap<CellRef, Parsed>,
pending: &[Pending],
) -> usize {
let mut failed: HashSet<CellRef> = HashSet::new();
let mut shared_attaches = 0usize;
for p in pending {
if let Some((range, f)) = arrays.get(&p.anchor) {
if p.pos == p.anchor {
st.attach(p.pos, f, Mode::Cell, Some(*range));
}
} else if let Some(f) = shared.get(&p.anchor) {
if failed.contains(&p.anchor) || shared_attaches >= MAX_SHARED_ATTACHES {
continue;
}
shared_attaches += 1;
st.attach(p.pos, f, Mode::Shared, None);
let imported = st.sheet.cell(p.pos).and_then(|c| c.formula.as_ref()).is_some_and(|f| f.text.chars().count() <= MAX_SHARED_TEMPLATE);
if !imported {
failed.insert(p.anchor);
}
} else {
st.unsupported("a shared formula that is missing from the file");
}
}
if shared_attaches >= MAX_SHARED_ATTACHES {
st.unsupported("more shared formulas than the expansion cap; some formulas kept their values");
}
shared_attaches
}
/// The other cells of a legacy array formula hold only cached results: dropped, as when an
/// .xlsx is read, so that the formula fills its range on recalculation. If the formula itself
/// couldn't be imported, they keep their values.
fn blank_array_followers(st: &mut SheetState<'_>, pending: &[Pending]) {
for p in pending {
let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos));
if p.pos != p.anchor && filled {
let style = st.sheet.style_id(p.pos);
st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style });
}
}
}
fn worksheet(
stream: &[u8],
bs: &BoundSheet,
@@ -591,8 +533,27 @@ fn worksheet(
// Shared and array formulas: their definitions follow the first cell that uses them.
let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared));
let pending = std::mem::take(&mut st.pending);
resolve_pending(&mut st, &arrays, &shared, &pending);
blank_array_followers(&mut st, &pending);
for p in &pending {
if let Some((range, f)) = arrays.get(&p.anchor) {
if p.pos == p.anchor {
st.attach(p.pos, f, Mode::Cell, Some(*range));
}
} else if let Some(f) = shared.get(&p.anchor) {
st.attach(p.pos, f, Mode::Shared, None);
} else {
st.unsupported("a shared formula that is missing from the file");
}
}
// The other cells of a legacy array formula hold only cached results: dropped, as when an
// .xlsx is read, so that the formula fills its range on recalculation. If the formula itself
// couldn't be imported, they keep their values.
for p in &pending {
let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos));
if p.pos != p.anchor && filled {
let style = st.sheet.style_id(p.pos);
st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style });
}
}
if st.dropped > 0 {
report.warn(format!("{} cells outside the grid were skipped.", st.dropped));
}
+3 -28
View File
@@ -64,22 +64,17 @@ pub(super) struct Cursor<'a, 'b> {
parts: &'b [&'a [u8]],
part: usize,
pos: usize,
/// Bytes in every part after the current one, kept up to date as parts advance, so
/// `remaining()` is constant-time however many `Continue` records a string session crosses.
after: usize,
}
impl<'a, 'b> Cursor<'a, 'b> {
pub fn new(parts: &'b [&'a [u8]]) -> Self {
let after = parts.iter().skip(1).map(|p| p.len()).sum::<usize>();
Cursor { parts, part: 0, pos: 0, after }
Cursor { parts, part: 0, pos: 0 }
}
/// Skips to the next part when the current one is used up. Returns whether a new part began.
fn settle(&mut self) -> bool {
let mut moved = false;
while self.parts.get(self.part).is_some_and(|p| self.pos >= p.len()) && self.part + 1 < self.parts.len() {
self.after = self.after.saturating_sub(self.parts[self.part + 1].len());
self.part += 1;
self.pos = 0;
moved = true;
@@ -117,10 +112,10 @@ impl<'a, 'b> Cursor<'a, 'b> {
Ok(())
}
/// Bytes left in the record (all parts), constant-time in the part count.
/// Bytes left in the record (all parts).
pub fn remaining(&self) -> usize {
let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos));
here.saturating_add(self.after)
here + self.parts.iter().skip(self.part + 1).map(|p| p.len()).sum::<usize>()
}
/// The rest of the record as one buffer.
@@ -134,7 +129,6 @@ impl<'a, 'b> Cursor<'a, 'b> {
}
self.part = self.parts.len();
self.pos = 0;
self.after = 0;
out
}
@@ -201,23 +195,4 @@ mod tests {
let mut c = Cursor::new(&parts);
assert!(c.chars(MAX_CHARS + 1, false).is_err());
}
#[test]
fn remaining_counts_every_part_wherever_the_cursor_is() {
let parts: [&[u8]; 4] = [&[1, 2, 3], &[2, 3, 4, 5], &[3, 4, 5, 6, 7], &[4]];
let mut c = Cursor::new(&parts);
assert_eq!(c.remaining(), 13);
for b in 1..=3u64 {
assert_eq!(u64::from(c.u8().unwrap()), b, "part 0 in order");
}
assert_eq!(c.remaining(), 10, "after the first part is used up");
for _ in 0..4 {
c.u8().unwrap();
}
assert_eq!(c.remaining(), 6, "into the Continue parts");
let mut c = Cursor::new(&parts);
assert_eq!(u64::from(c.u8().unwrap()), 1);
assert_eq!(c.rest().len(), 12);
assert_eq!(c.remaining(), 0);
}
}
+4 -32
View File
@@ -13,12 +13,6 @@ use crate::IoError;
/// Maximum element nesting accepted in any part.
pub const MAX_DEPTH: usize = 256;
/// Most elements kept in one part's parsed XML tree: an emptied `El` costs well over a hundred
/// bytes, so a highly-compressible part of endless empty elements could fit the package byte
/// limit and still take gigabytes of tree before any semantic check ran. Parts whose big runs
/// matter (worksheet rows, shared strings) are streamed instead and never hold this tree.
pub const MAX_XML_NODES: usize = 1_000_000;
/// An element: local name (namespace prefix dropped), attributes by local name, children and
/// its direct text content.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -229,27 +223,13 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
let mut stack: Vec<El> = Vec::new();
let mut root: Option<El> = None;
let mut buf = Vec::new();
// Elements kept in the tree so far, so a pathological part that passes the byte limits can
// still be refused before the tree itself outgrows memory.
let mut kept = 0usize;
// Attach a finished element to its parent (or emit it / make it the root).
fn finish(
stack: &mut [El],
root: &mut Option<El>,
el: El,
split: &[&str],
cb: &mut dyn FnMut(El) -> Result<(), IoError>,
kept: &mut usize,
) -> Result<(), IoError> {
fn finish(stack: &mut [El], root: &mut Option<El>, el: El, split: &[&str], cb: &mut dyn FnMut(El) -> Result<(), IoError>) -> Result<(), IoError> {
match stack.last_mut() {
Some(parent) => {
if split.contains(&el.name.as_str()) {
cb(el)?;
} else {
*kept = kept.saturating_add(1);
if *kept > MAX_XML_NODES {
return Err(IoError::Xml(format!("the part holds more than {MAX_XML_NODES} elements")));
}
parent.children.push(el);
}
}
@@ -272,11 +252,11 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
}
Event::Empty(e) => {
let el = start_el(&e);
finish(&mut stack, &mut root, el, split, cb, &mut kept)?;
finish(&mut stack, &mut root, el, split, cb)?;
}
Event::End(_) => {
if let Some(el) = stack.pop() {
finish(&mut stack, &mut root, el, split, cb, &mut kept)?;
finish(&mut stack, &mut root, el, split, cb)?;
}
}
Event::Text(t) => {
@@ -315,7 +295,7 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
}
// Unclosed elements (truncated part): close them so we keep what we have.
while let Some(el) = stack.pop() {
finish(&mut stack, &mut root, el, split, cb, &mut kept)?;
finish(&mut stack, &mut root, el, split, cb)?;
}
root.ok_or_else(|| IoError::Xml("no root element".into()))
}
@@ -495,14 +475,6 @@ mod tests {
}
}
#[test]
fn a_part_of_endless_empty_elements_is_refused() {
assert!(parse(b"<r><x/><y t=\"1\"/><x/></r>").is_ok());
let xml = format!("<r>{}<y/></r>", "<x/>".repeat(MAX_XML_NODES + 1));
let err = parse(xml.as_bytes()).unwrap_err();
assert!(err.to_string().contains("elements"), "{err}");
}
#[test]
fn phonetic_data_validates_offsets() {
let x = r#"<si><t>課きく</t><rPh sb="0" eb="1"><t>カ</t></rPh><rPh sb="2" eb="9"><t>ク</t></rPh><rPh sb="2" eb="2"><t>x</t></rPh><rPh sb="1"/></si>"#;