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.