From 97c169b56ae5c45af59d1bd86fcdbe041d93bb57 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 06:32:45 -0400 Subject: [PATCH] M7: verify Agile encryption's dataIntegrity before returning plaintext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/xlsx/src/crypto.rs | 58 ++++++++++++++++- crates/xlsx/src/crypto/tests.rs | 82 +++++++++++++++++++++++- crates/xlsx/src/testing.rs | 25 +++++--- crates/xlsx/src/tests/encrypted_tests.rs | 3 +- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/crates/xlsx/src/crypto.rs b/crates/xlsx/src/crypto.rs index 46a19d046..0a63044a0 100644 --- a/crates/xlsx/src/crypto.rs +++ b/crates/xlsx/src/crypto.rs @@ -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::(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 { 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 { @@ -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, 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. @@ -395,6 +422,16 @@ fn agile_descriptor(xml: &[u8]) -> Result { 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 { 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, 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, DecryptError> { @@ -423,6 +463,18 @@ fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, 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() { diff --git a/crates/xlsx/src/crypto/tests.rs b/crates/xlsx/src/crypto/tests.rs index 0fb0b921b..5f14686e9 100644 --- a/crates/xlsx/src/crypto/tests.rs +++ b/crates/xlsx/src/crypto/tests.rs @@ -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 { (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| 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); +} diff --git a/crates/xlsx/src/testing.rs b/crates/xlsx/src/testing.rs index 348c08af5..5729250bb 100644 --- a/crates/xlsx/src/testing.rs +++ b/crates/xlsx/src/testing.rs @@ -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) -> Vec { 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!( - "\r\n", - params(&KEY_DATA_SALT), + "\r\n", + params(KEY_DATA_SALT.as_slice()), params(&PASSWORD_SALT), ) } @@ -231,14 +234,20 @@ pub(crate) fn with_agile_header(xml: &str) -> Vec { 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, Vec) { let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 }; let mut body = vec![]; for (i, segment) in plain.chunks(SEGMENT).enumerate() { body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment)); } - (with_agile_header(&agile_info(password, spin)), package(plain.len(), body)) + 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). diff --git a/crates/xlsx/src/tests/encrypted_tests.rs b/crates/xlsx/src/tests/encrypted_tests.rs index 7a1a9bb82..525ed9193 100644 --- a/crates/xlsx/src/tests/encrypted_tests.rs +++ b/crates/xlsx/src/tests/encrypted_tests.rs @@ -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.