//! Password-protected Word documents: ECMA-376 document encryption with the *agile* method //! (Word 2010 and later), written from Microsoft's public Open Specification [MS-OFFCRYPTO] //! §2.3.4.4 and §2.3.4.10–§2.3.4.15, with the data-spaces streams of §2.1 / §2.3.4.1–§2.3.4.3. //! //! An encrypted package is not a zip but an OLE compound file ([MS-CFB]) holding //! `EncryptionInfo` (how the key is derived from the password, as XML) and `EncryptedPackage` //! (the original zip, AES-CBC encrypted in 4096-byte segments). A random *intermediate key* //! encrypts the package; the password, hashed `spinCount` times with a salt, encrypts that key and //! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it //! hasn't been damaged or tampered with. //! //! Older packages (Word 2007) use the *Standard* method (§2.3.4.5–§2.3.4.9): a binary //! `EncryptionInfo`, one AES key derived from the password with SHA-1 and 50,000 spins, and the //! whole package AES-ECB encrypted. [`decrypt`] reads both; [`encrypt`] writes agile only. //! //! Files are hostile: every stream read is capped, every length is checked before slicing, and the //! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory //! when dropped ([`Zeroizing`]) and never logged. use std::io::{Cursor, Read, Write}; use aes::cipher::{BlockCipherDecrypt, BlockCipherEncrypt, KeyInit}; use base64::Engine as _; use hmac::Mac; use sha2::Digest; use zeroize::Zeroizing; use crate::DocxError; use crate::xml; /// The spin count Word uses, and WordCraft writes. pub const DEFAULT_SPIN_COUNT: u32 = 100_000; /// The largest spin count the specification allows (§2.3.4.10 `ST_SpinCount`): it bounds the /// work a hostile file can ask for. const MAX_SPIN_COUNT: u32 = 10_000_000; /// Word's limit on password length, in characters. pub const MAX_PASSWORD_CHARS: usize = 255; /// `EncryptedPackage` is encrypted in segments of this many bytes (§2.3.4.15). const SEGMENT: usize = 4096; /// Largest `EncryptionInfo` stream read (the XML is a few kilobytes). const MAX_INFO: u64 = 1 << 20; /// AES's block size. const BLOCK: usize = 16; /// Salt length written. const SALT: usize = 16; /// Block keys (§2.3.4.13, §2.3.4.14). const BK_VERIFIER_INPUT: [u8; 8] = [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79]; const BK_VERIFIER_VALUE: [u8; 8] = [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e]; const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6]; const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6]; const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33]; /// Standard encryption (§2.3.2, §2.3.4.5–§2.3.4.7): `EncryptionHeader.Flags` bits, the AES and /// SHA-1 algorithm ids, the provider type, the fixed spin count and the SHA-1 size. const F_CRYPTO_API: u32 = 0x04; const F_EXTERNAL: u32 = 0x10; const F_AES: u32 = 0x20; const STANDARD_FLAGS: u32 = F_CRYPTO_API | F_AES; const ALG_AES_128: u32 = 0x660E; const ALG_AES_192: u32 = 0x660F; const ALG_AES_256: u32 = 0x6610; const ALG_SHA1: u32 = 0x8004; const PROV_RSA_AES: u32 = 0x18; const STANDARD_SPIN_COUNT: u32 = 50_000; const SHA1_SIZE: usize = 20; /// An OLE compound file starts with these bytes ([MS-CFB] §2.2). const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; const NS_ENCRYPTION: &str = "http://schemas.microsoft.com/office/2006/encryption"; const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password"; /// Is this an encrypted Office package (a compound file with `EncryptionInfo` and /// `EncryptedPackage` streams) rather than a zip? pub fn is_encrypted(bytes: &[u8]) -> bool { if bytes.get(..8) != Some(&CFB_MAGIC[..]) || wordcraft_doc::cfb_guard::check(bytes).is_err() { return false; } match cfb::CompoundFile::open(Cursor::new(bytes)) { Ok(comp) => comp.is_stream("/EncryptionInfo") && comp.is_stream("/EncryptedPackage"), Err(_) => false, } } /// The zip package inside an encrypted Office file. Without a password (`None`) this says /// [`DocxError::PasswordRequired`] once it knows the file is one it can decrypt; a password that /// doesn't fit gives [`DocxError::WrongPassword`]. pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result, DocxError> { wordcraft_doc::cfb_guard::check(bytes).map_err(|e| bad(format!("not a readable compound file ({e})")))?; let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?; let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?; let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?; if matches!(info.get(..4), Some([2..=4, 0, 2, 0])) { return decrypt_standard(&info, &package, password); } let info = parse_info(&info)?; let Some(password) = password else { return Err(DocxError::PasswordRequired) }; let pe = &info.password; let hn = iterate(pe.hash, &pe.salt, password, pe.spin_count); // The verifier (§2.3.4.13): a random value and its hash, both encrypted with keys derived // from the password. They match only for the right password. let key_input = derive_key(pe.hash, &hn, &BK_VERIFIER_INPUT, pe.key_bytes); let key_value = derive_key(pe.hash, &hn, &BK_VERIFIER_VALUE, pe.key_bytes); let iv = make_iv(pe.hash, &pe.salt, None, pe.block_size); let input = Zeroizing::new(cbc_decrypt(&key_input, &iv, &pe.verifier_input)?); let input = input.get(..pe.salt_size).ok_or_else(|| bad("the password verifier is too short"))?; let value = Zeroizing::new(cbc_decrypt(&key_value, &iv, &pe.verifier_hash)?); let value = value.get(..pe.hash.size()).ok_or_else(|| bad("the password verifier hash is too short"))?; if !same(&pe.hash.hash(&[input]), value) { return Err(DocxError::WrongPassword); } // The intermediate key that encrypts the package. let key_key = derive_key(pe.hash, &hn, &BK_KEY_VALUE, pe.key_bytes); let key = Zeroizing::new(cbc_decrypt(&key_key, &iv, &pe.key_value)?); let key = Zeroizing::new(key.get(..info.key_bytes).ok_or_else(|| bad("the encrypted key is too short"))?.to_vec()); // Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream. It is required: // a file that could drop it would let altered ciphertext, or an altered StreamSize, through // unauthenticated (`parse_info` refuses a file without it). let (enc_key, enc_value) = &info.integrity; let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?); // The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros // anyway, so the padded key gives the same HMAC. let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]); let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?; let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?; if !same(&info.hash.hmac(hmac_key, &package)?, expected) { return Err(bad("the document is damaged (its integrity check failed)")); } // The package itself (§2.3.4.15): StreamSize, then 4096-byte segments, each with its own IV. let size = package .get(..8) .and_then(|b| <[u8; 8]>::try_from(b).ok()) .map(u64::from_le_bytes) .ok_or_else(|| bad("the encrypted package is truncated"))?; let data = package.get(8..).unwrap_or(&[]); let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?; let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK); let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?; let mut out = Vec::with_capacity(needed); for (i, seg) in data.chunks(SEGMENT).enumerate() { let i = u32::try_from(i).map_err(|_| bad("the encrypted package is too large"))?; let iv = make_iv(info.hash, &info.salt, Some(&i.to_le_bytes()), info.block_size); out.extend_from_slice(&cbc_decrypt(&key, &iv, seg)?); } out.truncate(size); Ok(out) } /// [`decrypt`] for Standard encryption (§2.3.4.5–§2.3.4.9): one AES key from the password, /// checked with the verifier, and the whole package AES-ECB encrypted after its 8-byte size. fn decrypt_standard(info: &[u8], package: &[u8], password: Option<&str>) -> Result, DocxError> { let s = parse_standard(info)?; let Some(password) = password else { return Err(DocxError::PasswordRequired) }; let key = standard_key(s.salt, password, s.key_bytes); // The verifier (§2.3.4.9): a random value and its SHA-1, both encrypted with the key. let verifier = Zeroizing::new(ecb(&key, s.verifier, false)?); let hash = Zeroizing::new(ecb(&key, s.verifier_hash, false)?); let hash = hash.get(..SHA1_SIZE).ok_or_else(|| bad("the password verifier hash is too short"))?; if !same(&Hash::Sha1.hash(&[&verifier]), hash) { return Err(DocxError::WrongPassword); } // The package (§2.3.4.4): StreamSize, then the data; bytes past the last block are ignored. let size = package .get(..8) .and_then(|b| <[u8; 8]>::try_from(b).ok()) .map(u64::from_le_bytes) .ok_or_else(|| bad("the encrypted package is truncated"))?; let data = package.get(8..).unwrap_or(&[]); let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?; let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK); let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?; let mut out = ecb(&key, data, false)?; out.truncate(size); Ok(out) } /// What a Standard `EncryptionInfo` stream says (§2.3.4.5): the key size, and the verifier's salt /// and encrypted values. struct Standard<'a> { key_bytes: usize, salt: &'a [u8], verifier: &'a [u8], verifier_hash: &'a [u8], } fn parse_standard(info: &[u8]) -> Result, DocxError> { let short = || bad("EncryptionInfo is truncated"); let u32_at = |at: usize| -> Result { let b = at.checked_add(4).and_then(|end| info.get(at..end)).ok_or_else(short)?; <[u8; 4]>::try_from(b).map(u32::from_le_bytes).map_err(|_| short()) }; let flags = u32_at(4)?; if flags & F_EXTERNAL != 0 { return Err(bad("this file uses extensible encryption, which WordCraft can't open")); } if flags & STANDARD_FLAGS != STANDARD_FLAGS { return Err(bad("this file's encryption isn't AES, which WordCraft can't open")); } // EncryptionHeader (§2.3.2): Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType, two // reserved words, then the CSP name, which isn't needed. let header_size = usize::try_from(u32_at(8)?).map_err(|_| short())?; if header_size < 32 { return Err(bad("the encryption header is too short")); } let verifier_at = header_size.checked_add(12).filter(|&v| v <= info.len()).ok_or_else(short)?; // AlgID 0 and AlgIDHash 0 mean "set by the flags": AES-128 and SHA-1. let bits = match u32_at(20)? { 0 | ALG_AES_128 => 128, ALG_AES_192 => 192, ALG_AES_256 => 256, id => return Err(bad(format!("encryption algorithm 0x{id:X} isn't supported"))), }; let hash_id = u32_at(24)?; if !matches!(hash_id, 0 | ALG_SHA1) { return Err(bad(format!("hash algorithm 0x{hash_id:X} isn't supported"))); } if u32_at(28)? != bits { return Err(bad("the key size doesn't match the encryption algorithm")); } // EncryptionVerifier (§2.3.3): SaltSize (16), Salt, EncryptedVerifier, VerifierHashSize (20 // for SHA-1), EncryptedVerifierHash (32 bytes for AES). if u32_at(verifier_at)? != SALT as u32 { return Err(bad("the salt isn't 16 bytes")); } let field = |at: usize, len: usize| verifier_at.checked_add(at).and_then(|a| info.get(a..a.checked_add(len)?)).ok_or_else(short); let salt = field(4, SALT)?; let verifier = field(4 + SALT, BLOCK)?; if u32_at(verifier_at.saturating_add(4 + SALT + BLOCK))? != SHA1_SIZE as u32 { return Err(bad("the password verifier hash isn't SHA-1")); } let verifier_hash = field(8 + SALT + BLOCK, 2 * BLOCK)?; Ok(Standard { key_bytes: bits as usize / 8, salt, verifier, verifier_hash }) } /// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin /// count), as Word's "Encrypt with Password" does. pub fn encrypt(package: &[u8], password: &str) -> Result, DocxError> { encrypt_with_spin_count(package, password, DEFAULT_SPIN_COUNT) } /// [`encrypt`] with a chosen spin count (tests use a small one; files should use the default). pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32) -> Result, DocxError> { check_password(password)?; let spin_count = spin_count.min(MAX_SPIN_COUNT); let hash = Hash::Sha512; let key_bytes = 32; let key_data_salt = random(SALT)?; let password_salt = random(SALT)?; let key = random(key_bytes)?; let verifier = random(SALT)?; // The HMAC key is a salt as long as the hash (MS-OFFCRYPTO §2.3.4.14). let hmac_salt = random(hash.size())?; // The package, segment by segment; the last segment is zero-padded to the block size. let mut stream = Vec::with_capacity(package.len().saturating_add(8 + BLOCK)); stream.extend_from_slice(&(package.len() as u64).to_le_bytes()); for (i, seg) in package.chunks(SEGMENT).enumerate() { let i = u32::try_from(i).map_err(|_| bad("the document is too large to encrypt"))?; let iv = make_iv(hash, &key_data_salt, Some(&i.to_le_bytes()), BLOCK); stream.extend_from_slice(&cbc_encrypt(&key, &iv, &padded(seg))?); } // The password key encryptor (§2.3.4.13). let hn = iterate(hash, &password_salt, password, spin_count); let iv = make_iv(hash, &password_salt, None, BLOCK); let enc_verifier_input = cbc_encrypt(&derive_key(hash, &hn, &BK_VERIFIER_INPUT, key_bytes), &iv, &padded(&verifier))?; let enc_verifier_hash = cbc_encrypt(&derive_key(hash, &hn, &BK_VERIFIER_VALUE, key_bytes), &iv, &padded(&hash.hash(&[&verifier])))?; let enc_key_value = cbc_encrypt(&derive_key(hash, &hn, &BK_KEY_VALUE, key_bytes), &iv, &padded(&key))?; // Data integrity (§2.3.4.14). let enc_hmac_key = cbc_encrypt(&key, &make_iv(hash, &key_data_salt, Some(&BK_HMAC_KEY), BLOCK), &padded(&hmac_salt))?; let hmac = hash.hmac(&hmac_salt, &stream)?; let enc_hmac_value = cbc_encrypt(&key, &make_iv(hash, &key_data_salt, Some(&BK_HMAC_VALUE), BLOCK), &padded(&hmac))?; let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b); let params = format!( r#"saltSize="{SALT}" blockSize="{BLOCK}" keyBits="{}" hashSize="{}" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="{}""#, key_bytes * 8, hash.size(), hash.name(), ); let xml = format!( "\r\n\ \ \ \ \ \ ", b64(&key_data_salt), b64(&enc_hmac_key), b64(&enc_hmac_value), b64(&password_salt), b64(&enc_verifier_input), b64(&enc_verifier_hash), b64(&enc_key_value), ); // EncryptionInfo (§2.3.4.10): version 4.4, reserved 0x40, the XML. let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0]; info.extend_from_slice(xml.as_bytes()); let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})")); let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?; for (path, data) in [ ("/\u{6}DataSpaces/Version", data_space_version()), ("/\u{6}DataSpaces/DataSpaceMap", data_space_map()), ("/\u{6}DataSpaces/DataSpaceInfo/StrongEncryptionDataSpace", data_space_definition()), ("/\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary", primary_transform()), ("/EncryptionInfo", info), ("/EncryptedPackage", stream), ] { if let Some((parent, _)) = path.rsplit_once('/') && !parent.is_empty() { comp.create_storage_all(parent).map_err(io)?; } comp.create_stream(path).map_err(io)?.write_all(&data).map_err(io)?; } comp.flush().map_err(io)?; Ok(comp.into_inner().into_inner()) } /// A package encrypted with the *Standard* method (§2.3.4.5–§2.3.4.9: AES-ECB, SHA-1, 50,000 /// spins), as Word 2007 wrote it, for tests only: WordCraft saves with the agile method /// ([`encrypt`]). `key_bits` is 128, 192 or 256. The compound file holds just the two streams /// [`decrypt`] reads. #[doc(hidden)] pub fn encrypt_standard_for_tests(package: &[u8], password: &str, key_bits: u32) -> Result, DocxError> { check_password(password)?; let alg_id = match key_bits { 128 => ALG_AES_128, 192 => ALG_AES_192, 256 => ALG_AES_256, _ => return Err(bad(format!("unsupported AES key size {key_bits}"))), }; let salt = random(SALT)?; let verifier = random(SALT)?; let key = standard_key(&salt, password, key_bits as usize / 8); let mut header = Vec::new(); for v in [STANDARD_FLAGS, 0, alg_id, ALG_SHA1, key_bits, PROV_RSA_AES, 0, 0] { header.extend_from_slice(&v.to_le_bytes()); } // CSPName: an empty null-terminated UTF-16 string. header.extend_from_slice(&[0, 0]); let mut info = vec![3, 0, 2, 0]; info.extend_from_slice(&STANDARD_FLAGS.to_le_bytes()); info.extend_from_slice(&(header.len() as u32).to_le_bytes()); info.extend_from_slice(&header); info.extend_from_slice(&(SALT as u32).to_le_bytes()); info.extend_from_slice(&salt); info.extend_from_slice(&ecb(&key, &verifier, true)?); info.extend_from_slice(&(SHA1_SIZE as u32).to_le_bytes()); info.extend_from_slice(&ecb(&key, &padded(&Hash::Sha1.hash(&[&verifier])), true)?); let mut stream = (package.len() as u64).to_le_bytes().to_vec(); stream.extend_from_slice(&ecb(&key, &padded(package), true)?); let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})")); let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?; comp.create_stream("/EncryptionInfo").map_err(io)?.write_all(&info).map_err(io)?; comp.create_stream("/EncryptedPackage").map_err(io)?.write_all(&stream).map_err(io)?; comp.flush().map_err(io)?; Ok(comp.into_inner().into_inner()) } /// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`]. pub fn check_password(password: &str) -> Result<(), DocxError> { if password.is_empty() { return Err(bad("the password is empty")); } if password.chars().count() > MAX_PASSWORD_CHARS { return Err(bad(format!("passwords are at most {MAX_PASSWORD_CHARS} characters"))); } Ok(()) } /// Restrict Editing's password (`w:documentProtection`, ECMA-376 Part 1 §17.15.1.29): the /// attributes that store its hash — SHA-512 of a random 16-byte salt and the password as UTF-16LE, /// hashed again `spin_count` times with the iteration number appended ([MS-OFFCRYPTO] §2.4.2.4, /// the ISO write-protection method). Protection isn't encryption: the document stays readable, /// the password only guards turning protection off. pub fn protection_hash(password: &str, spin_count: u32) -> Result, DocxError> { check_password(password)?; let spin_count = spin_count.min(MAX_SPIN_COUNT); let salt = random(SALT)?; let hash = iterate_appended(Hash::Sha512, &salt, password, spin_count); let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b); Ok(vec![ ("w:algorithmName".into(), "SHA-512".into()), ("w:hashValue".into(), b64(&hash)), ("w:saltValue".into(), b64(&salt)), ("w:spinCount".into(), spin_count.to_string()), ]) } /// What checking a Restrict Editing password found ([`check_protection_password`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PasswordCheck { /// The protection has no password: anyone may stop it. NoPassword, /// The password matches the stored hash. Correct, /// The password doesn't match. Incorrect, /// The hash uses an algorithm or form WordCraft can't check, or is damaged. The protection /// must stay on: guessing would let any password through. Unsupported, } /// Does `password` match a Restrict Editing hash (attributes as [`protection_hash`] makes them, or /// as read from a file)? Two forms are checked: /// /// - `w:algorithmName` / `w:hashValue` / `w:saltValue` / `w:spinCount`: the password itself is /// hashed ([`iterate_appended`]). /// - `w:cryptAlgorithmSid` / `w:hash` / `w:salt` / `w:cryptSpinCount` (ECMA-376 Part 4 §14.8.1): /// the password first becomes Word's legacy 32-bit key ([`legacy_key`]), whose bytes, reversed /// and written as an upper-case hex string, are hashed the same way. /// /// Anything else — an algorithm other than SHA-1/SHA-256/SHA-384/SHA-512, an application-defined /// algorithm (`w:algIdExt`), a class other than `hash`, a spin count above the 10,000,000 Word /// accepts, damaged values — is [`PasswordCheck::Unsupported`], never a match. pub fn check_protection_password(attrs: &[(String, String)], password: &str) -> PasswordCheck { let get = |k: &str| attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.trim()); if get("w:hashValue").is_some() { let Some(hash) = get("w:algorithmName").and_then(Hash::parse) else { return PasswordCheck::Unsupported }; return verify(hash, get("w:hashValue"), get("w:saltValue"), get("w:spinCount"), password); } if get("w:hash").is_none() { // §17.15.1.29: without a hash no password is associated with the protection. return PasswordCheck::NoPassword; } let class_ok = get("w:cryptAlgorithmClass").is_none_or(|c| c == "hash"); let type_ok = get("w:cryptAlgorithmType").is_none_or(|t| t == "typeAny"); if !class_ok || !type_ok || get("w:algIdExt").is_some() { return PasswordCheck::Unsupported; } // Part 4 §14.8.1 lists 1 MD2, 2 MD4, 3 MD5, 4 SHA-1, 12 SHA-256, 13 SHA-384, 14 SHA-512. let hash = match get("w:cryptAlgorithmSid").and_then(|v| v.parse::().ok()) { Some(4) => Hash::Sha1, Some(12) => Hash::Sha256, Some(13) => Hash::Sha384, Some(14) => Hash::Sha512, _ => return PasswordCheck::Unsupported, }; verify(hash, get("w:hash"), get("w:salt"), get("w:cryptSpinCount"), &legacy_key_hex(password)) } /// Hash `input` with the stored salt and spin count and compare with the stored hash. Damaged or /// out-of-range values are [`PasswordCheck::Unsupported`]. fn verify(hash: Hash, want: Option<&str>, salt: Option<&str>, spin_count: Option<&str>, input: &str) -> PasswordCheck { let dec = |v: &str| base64::engine::general_purpose::STANDARD.decode(v).ok(); let Some(want) = want.and_then(dec) else { return PasswordCheck::Unsupported }; // §17.15.1.29: without a salt nothing is prepended. let salt = match salt { Some(v) => match dec(v) { Some(s) => s, None => return PasswordCheck::Unsupported, }, None => Vec::new(), }; // Word accepts spin counts from 0 to 10,000,000 ([MS-OI29500] 2.1.402 d); a missing one is 0. let spin_count = match spin_count.map(str::parse::) { None => 0, Some(Ok(n)) if n <= MAX_SPIN_COUNT => n, Some(_) => return PasswordCheck::Unsupported, }; if want.len() != hash.size() || salt.len() > 1024 { return PasswordCheck::Unsupported; } if same(&iterate_appended(hash, &salt, input, spin_count), &want) { PasswordCheck::Correct } else { PasswordCheck::Incorrect } } /// The initial value of the legacy key's high-order word, by password length 1–15 (ECMA-376 /// Part 4 §14.8.1, "Initial code array"). const LEGACY_INITIAL_CODE: [u16; 15] = [0xE1F0, 0x1D0F, 0xCC9C, 0x84C0, 0x110C, 0x0E10, 0xF1CE, 0x313E, 0x1872, 0xE139, 0xD40F, 0x84F9, 0x280C, 0xA96A, 0x4EC3]; /// The legacy key's encryption matrix (Part 4 §14.8.1): one row per character position, rows /// "Last-14" to "Last", one column per bit 0–6. The password's last character uses the last row. const LEGACY_MATRIX: [[u16; 7]; 15] = [ [0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09], [0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF], [0x4563, 0x8AC6, 0x05AD, 0x0B5A, 0x16B4, 0x2D68, 0x5AD0], [0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40], [0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5], [0x6F45, 0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A], [0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9], [0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4, 0x7B68, 0xF6D0], [0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC], [0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10], [0xAA51, 0x4483, 0x8906, 0x022D, 0x045A, 0x08B4, 0x1168], [0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C], [0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6, 0x1DAD], [0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC], [0x1021, 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4], ]; /// Word's legacy 32-bit password key (ECMA-376 Part 4 §14.8.1): the password as UTF-16 without a /// leading BOM, cut to 15 characters, each character reduced to one byte (its low byte, or its /// high byte when the low byte is 0); the high-order word comes from the initial code array and /// the encryption matrix, the low-order word from rotating and XOR-ing the bytes backwards. fn legacy_key(password: &str) -> u32 { let mut units = password.encode_utf16().peekable(); units.next_if_eq(&0xFEFF); let bytes: Zeroizing> = Zeroizing::new(units.take(LEGACY_INITIAL_CODE.len()).map(|u| if u & 0xFF != 0 { (u & 0xFF) as u8 } else { (u >> 8) as u8 }).collect()); let Some(&initial) = bytes.len().checked_sub(1).and_then(|i| LEGACY_INITIAL_CODE.get(i)) else { return 0 }; let mut high = initial; let first_row = LEGACY_MATRIX.len() - bytes.len(); for (row, &c) in LEGACY_MATRIX.iter().skip(first_row).zip(bytes.iter()) { for (bit, &code) in row.iter().enumerate() { if c & (1 << bit) != 0 { high ^= code; } } } let rotate = |w: u16| ((w >> 14) & 0x0001) | ((w << 1) & 0x7FFF); let mut low = 0u16; for &c in bytes.iter().rev() { low = rotate(low) ^ u16::from(c); } // `bytes` holds at most 15 values, so its length fits a word. low = rotate(low) ^ (bytes.len() as u16) ^ 0xCE4B; (u32::from(high) << 16) | u32::from(low) } /// What the legacy form hashes: the key's bytes in reversed order (Part 4 §14.8.1: 0x64CEED7E /// becomes 7EEDCE64), as an upper-case hex string — hashed as UTF-16LE like a password, as Word /// does ([MS-OE376] 2.1.410 a). fn legacy_key_hex(password: &str) -> Zeroizing { let key = legacy_key(password); Zeroizing::new(key.to_le_bytes().iter().map(|b| format!("{b:02X}")).collect()) } /// The ISO write-protection hash ([MS-OFFCRYPTO] §2.4.2.4): H0 = H(salt + password as UTF-16LE), /// Hn = H(Hn-1 + iterator) — the iterator after the hash, unlike encryption's [`iterate`]. fn iterate_appended(hash: Hash, salt: &[u8], password: &str, spin_count: u32) -> Zeroizing> { let pw: Zeroizing> = Zeroizing::new(password.encode_utf16().flat_map(u16::to_le_bytes).collect()); let mut h = Zeroizing::new(hash.hash(&[salt, &pw])); for i in 0..spin_count.min(MAX_SPIN_COUNT) { *h = hash.hash(&[&h, &i.to_le_bytes()]); } h } fn bad(msg: impl Into) -> DocxError { DocxError::Encryption(msg.into()) } /// A root-level stream, at most `cap` bytes. fn stream(comp: &mut cfb::CompoundFile, path: &str, cap: u64) -> Result, DocxError> { let s = comp.open_stream(path).map_err(|e| bad(format!("can't read {} ({e})", path.trim_start_matches('/'))))?; let mut v = Vec::new(); s.take(cap.saturating_add(1)).read_to_end(&mut v).map_err(|e| bad(format!("can't read {} ({e})", path.trim_start_matches('/'))))?; if v.len() as u64 > cap { return Err(DocxError::Limit(format!("{} is too large", path.trim_start_matches('/')))); } Ok(v) } /// The hash algorithms agile encryption names that WordCraft supports. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Hash { Sha1, Sha256, Sha384, Sha512, } impl Hash { fn parse(s: &str) -> Option { match s.to_ascii_uppercase().replace('-', "").as_str() { "SHA1" => Some(Hash::Sha1), "SHA256" => Some(Hash::Sha256), "SHA384" => Some(Hash::Sha384), "SHA512" => Some(Hash::Sha512), _ => None, } } fn name(self) -> &'static str { match self { Hash::Sha1 => "SHA-1", Hash::Sha256 => "SHA256", Hash::Sha384 => "SHA384", Hash::Sha512 => "SHA512", } } fn size(self) -> usize { match self { Hash::Sha1 => 20, Hash::Sha256 => 32, Hash::Sha384 => 48, Hash::Sha512 => 64, } } /// The hash of the concatenated `parts`. fn hash(self, parts: &[&[u8]]) -> Vec { fn go(parts: &[&[u8]]) -> Vec { let mut d = D::new(); for p in parts { d.update(p); } d.finalize().to_vec() } match self { Hash::Sha1 => go::(parts), Hash::Sha256 => go::(parts), Hash::Sha384 => go::(parts), Hash::Sha512 => go::(parts), } } fn hmac(self, key: &[u8], msg: &[u8]) -> Result, DocxError> { fn go(key: &[u8], msg: &[u8]) -> Result, DocxError> { let mut m = ::new_from_slice(key).map_err(|_| bad("bad integrity key"))?; m.update(msg); Ok(m.finalize().into_bytes().to_vec()) } match self { Hash::Sha1 => go::>(key, msg), Hash::Sha256 => go::>(key, msg), Hash::Sha384 => go::>(key, msg), Hash::Sha512 => go::>(key, msg), } } } /// The password hash after `spin_count` iterations (§2.3.4.11): H0 = H(salt + password as /// UTF-16LE), Hn = H(iterator + Hn-1). fn iterate(hash: Hash, salt: &[u8], password: &str, spin_count: u32) -> Zeroizing> { let pw: Zeroizing> = Zeroizing::new(password.encode_utf16().flat_map(u16::to_le_bytes).collect()); let mut h = Zeroizing::new(hash.hash(&[salt, &pw])); for i in 0..spin_count.min(MAX_SPIN_COUNT) { *h = hash.hash(&[&i.to_le_bytes(), &h]); } h } /// A key from the iterated password hash and a block key (§2.3.4.11): H(Hn + blockKey), /// truncated to `len`, or padded with 0x36. fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing> { let mut k = Zeroizing::new(hash.hash(&[hn, block_key])); k.resize(len, 0x36); k } /// Standard encryption's key (§2.3.4.7): the SHA-1 password hash after 50,000 iterations, hashed /// with block number 0; then X1 = SHA-1 of that hash XOR-ed into 64 bytes of 0x36, X2 the same /// with 0x5C, and the key is the first `len` bytes of X1 + X2. fn standard_key(salt: &[u8], password: &str, len: usize) -> Zeroizing> { let hn = iterate(Hash::Sha1, salt, password, STANDARD_SPIN_COUNT); let hfinal = Zeroizing::new(Hash::Sha1.hash(&[&hn, &0u32.to_le_bytes()])); let x = |fill: u8| { let mut buf = Zeroizing::new([fill; 64]); for (b, h) in buf.iter_mut().zip(hfinal.iter()) { *b ^= h; } Zeroizing::new(Hash::Sha1.hash(&[&buf[..]])) }; let mut key = Zeroizing::new(x(0x36).to_vec()); key.extend_from_slice(&x(0x5C)); key.truncate(len); key } /// AES-ECB without padding (Standard encryption, §2.3.4.7–§2.3.4.9): `data` must be whole blocks. fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Result, DocxError> { let aes = Aes::new(key)?; let (blocks, rest) = data.as_chunks::(); if !rest.is_empty() { return Err(bad("encrypted data isn't a whole number of blocks")); } let mut out = Vec::with_capacity(data.len()); for b in blocks { let mut x = aes::Block::from(*b); if encrypt { aes.encrypt(&mut x); } else { aes.decrypt(&mut x); } out.extend_from_slice(&x); } Ok(out) } /// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to /// the block size or padded with 0x36. fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec { let mut iv = match block_key { Some(b) => hash.hash(&[salt, b]), None => salt.to_vec(), }; iv.resize(block_size, 0x36); iv } /// `data` zero-padded to a whole number of blocks. fn padded(data: &[u8]) -> Zeroizing> { let mut v = Zeroizing::new(data.to_vec()); v.resize(data.len().div_ceil(BLOCK).saturating_mul(BLOCK), 0); v } fn random(n: usize) -> Result>, DocxError> { let mut v = Zeroizing::new(vec![0u8; n]); getrandom::fill(&mut v).map_err(|e| bad(format!("no secure random numbers available ({e})")))?; Ok(v) } /// Compare without stopping at the first difference. fn same(a: &[u8], b: &[u8]) -> bool { a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } /// AES with a 128-, 192- or 256-bit key. enum Aes { A128(aes::Aes128), A192(aes::Aes192), A256(aes::Aes256), } impl Aes { fn new(key: &[u8]) -> Result { let err = |_| bad("bad AES key"); Ok(match key.len() { 16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(err)?), 24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(err)?), 32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(err)?), n => return Err(bad(format!("unsupported AES key length {} bits", n * 8))), }) } fn encrypt(&self, b: &mut aes::Block) { match self { Aes::A128(c) => c.encrypt_block(b), Aes::A192(c) => c.encrypt_block(b), Aes::A256(c) => c.encrypt_block(b), } } fn decrypt(&self, b: &mut aes::Block) { match self { Aes::A128(c) => c.decrypt_block(b), Aes::A192(c) => c.decrypt_block(b), Aes::A256(c) => c.decrypt_block(b), } } } /// AES-CBC without padding: `data` must be whole blocks. fn cbc_encrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result, DocxError> { let aes = Aes::new(key)?; let (blocks, rest) = data.as_chunks::(); let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| bad("bad initialization vector"))?; if !rest.is_empty() { return Err(bad("encrypted data isn't a whole number of blocks")); } let mut out = Vec::with_capacity(data.len()); for b in blocks { let mut x = aes::Block::default(); for ((o, p), v) in x.iter_mut().zip(b).zip(&prev) { *o = p ^ v; } aes.encrypt(&mut x); prev.copy_from_slice(&x); out.extend_from_slice(&x); } Ok(out) } /// AES-CBC decryption without padding. fn cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result, DocxError> { let aes = Aes::new(key)?; let (blocks, rest) = data.as_chunks::(); if !rest.is_empty() { return Err(bad("encrypted data isn't a whole number of blocks")); } let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| bad("bad initialization vector"))?; let mut out = Vec::with_capacity(data.len()); for b in blocks { let mut x = aes::Block::default(); x.copy_from_slice(b); aes.decrypt(&mut x); for (o, v) in x.iter_mut().zip(&prev) { *o ^= v; } prev = *b; out.extend_from_slice(&x); } Ok(out) } /// What `EncryptionInfo` says. struct Info { hash: Hash, salt: Vec, block_size: usize, key_bytes: usize, /// encryptedHmacKey, encryptedHmacValue. integrity: (Vec, Vec), password: PasswordEncryptor, } struct PasswordEncryptor { hash: Hash, salt: Vec, salt_size: usize, block_size: usize, key_bytes: usize, spin_count: u32, verifier_input: Vec, verifier_hash: Vec, key_value: Vec, } /// Cipher parameters shared by `keyData` and `encryptedKey`. struct Params { hash: Hash, salt: Vec, salt_size: usize, block_size: usize, key_bytes: usize, } fn parse_info(info: &[u8]) -> Result { let (major, minor) = match info.get(..4) { Some([a, b, c, d]) => (u16::from_le_bytes([*a, *b]), u16::from_le_bytes([*c, *d])), _ => return Err(bad("EncryptionInfo is truncated")), }; match (major, minor) { (4, 4) => {} (3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")), _ => return Err(bad(format!("unknown encryption version {major}.{minor}"))), } let root = xml::parse(info.get(8..).unwrap_or(&[]))?; if root.local() != "encryption" { return Err(bad("EncryptionInfo has no element")); } let key_data = root.els().find(|e| e.local() == "keyData").ok_or_else(|| bad("EncryptionInfo has no keyData"))?; let kd = params(key_data)?; // The integrity check is mandatory (§2.3.4.14): a file is not trusted to opt out of it. let di = root .els() .find(|e| e.local() == "dataIntegrity") .ok_or_else(|| bad("EncryptionInfo has no dataIntegrity, so the document cannot be checked for tampering"))?; let integrity = (b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?); // The password key encryptor is the `encryptedKey` with a spin count (a certificate key // encryptor has none). let encryptors = root.els().find(|e| e.local() == "keyEncryptors").ok_or_else(|| bad("EncryptionInfo has no keyEncryptors"))?; let ek = encryptors .els() .filter(|e| e.local() == "keyEncryptor") .flat_map(|e| e.els()) .find(|e| e.local() == "encryptedKey" && e.attr("spinCount").is_some()) .ok_or_else(|| bad("this file isn't protected with a password (only certificate encryption is listed)"))?; let pe = params(ek)?; let spin_count: u32 = ek.attr("spinCount").and_then(|s| s.trim().parse().ok()).ok_or_else(|| bad("bad spinCount"))?; if spin_count > MAX_SPIN_COUNT { return Err(bad(format!("spinCount {spin_count} is over the allowed maximum"))); } Ok(Info { hash: kd.hash, salt: kd.salt, block_size: kd.block_size, key_bytes: kd.key_bytes, integrity, password: PasswordEncryptor { hash: pe.hash, salt: pe.salt, salt_size: pe.salt_size, block_size: pe.block_size, key_bytes: pe.key_bytes, spin_count, verifier_input: b64_attr(ek, "encryptedVerifierHashInput")?, verifier_hash: b64_attr(ek, "encryptedVerifierHashValue")?, key_value: b64_attr(ek, "encryptedKeyValue")?, }, }) } fn params(e: &xml::El) -> Result { let num = |k: &str| -> Result { e.attr(k).and_then(|s| s.trim().parse::().ok()).ok_or_else(|| bad(format!("bad {k}"))) }; let cipher = e.attr("cipherAlgorithm").unwrap_or(""); if !cipher.eq_ignore_ascii_case("AES") { return Err(bad(format!("the {cipher:?} cipher isn't supported"))); } let chaining = e.attr("cipherChaining").unwrap_or(""); if chaining != "ChainingModeCBC" { return Err(bad(format!("the {chaining:?} chaining mode isn't supported"))); } let name = e.attr("hashAlgorithm").unwrap_or(""); let hash = Hash::parse(name).ok_or_else(|| bad(format!("the {name:?} hash algorithm isn't supported")))?; if num("hashSize")? != hash.size() { return Err(bad("hashSize doesn't match the hash algorithm")); } let block_size = num("blockSize")?; if block_size != BLOCK { return Err(bad(format!("AES needs a block size of {BLOCK}, not {block_size}"))); } let key_bits = num("keyBits")?; if !matches!(key_bits, 128 | 192 | 256) { return Err(bad(format!("unsupported AES key size {key_bits}"))); } let salt = b64_attr(e, "saltValue")?; let salt_size = num("saltSize")?; if salt.is_empty() || salt.len() != salt_size { return Err(bad("the salt doesn't match saltSize")); } Ok(Params { hash, salt, salt_size, block_size, key_bytes: key_bits / 8 }) } fn b64_attr(e: &xml::El, k: &str) -> Result, DocxError> { let s = e.attr(k).ok_or_else(|| bad(format!("{k} is missing")))?; let s: String = s.chars().filter(|c| !c.is_ascii_whitespace()).collect(); base64::engine::general_purpose::STANDARD.decode(s).map_err(|_| bad(format!("{k} isn't valid base64"))) } /// A UNICODE-LP-P4 string (§2.1.2): byte length, UTF-16LE, zero-padded to 4 bytes. fn lp_p4(out: &mut Vec, s: &str) { let units: Vec = s.encode_utf16().flat_map(u16::to_le_bytes).collect(); out.extend_from_slice(&(units.len() as u32).to_le_bytes()); out.extend_from_slice(&units); while !out.len().is_multiple_of(4) { out.push(0); } } /// `\x06DataSpaces\Version` (§2.1.5). fn data_space_version() -> Vec { let mut v = Vec::new(); lp_p4(&mut v, "Microsoft.Container.DataSpaces"); for _ in 0..3 { v.extend_from_slice(&[1, 0, 0, 0]); } v } /// `\x06DataSpaces\DataSpaceMap` (§2.1.6, §2.3.4.1): EncryptedPackage → StrongEncryptionDataSpace. fn data_space_map() -> Vec { let mut entry = Vec::new(); entry.extend_from_slice(&1u32.to_le_bytes()); entry.extend_from_slice(&0u32.to_le_bytes()); lp_p4(&mut entry, "EncryptedPackage"); lp_p4(&mut entry, "StrongEncryptionDataSpace"); let mut v = Vec::new(); v.extend_from_slice(&8u32.to_le_bytes()); v.extend_from_slice(&1u32.to_le_bytes()); v.extend_from_slice(&((entry.len() + 4) as u32).to_le_bytes()); v.extend_from_slice(&entry); v } /// `\x06DataSpaces\DataSpaceInfo\StrongEncryptionDataSpace` (§2.1.7, §2.3.4.2). fn data_space_definition() -> Vec { let mut v = Vec::new(); v.extend_from_slice(&8u32.to_le_bytes()); v.extend_from_slice(&1u32.to_le_bytes()); lp_p4(&mut v, "StrongEncryptionTransform"); v } /// `\x06DataSpaces\TransformInfo\StrongEncryptionTransform\x06Primary` (§2.1.8, §2.1.9, /// §2.3.4.3): the encryption transform, with a null EncryptionName as agile encryption requires. fn primary_transform() -> Vec { let mut id = Vec::new(); lp_p4(&mut id, "{FF9A3F03-56EF-4613-BDD5-5A41C1D07246}"); let mut v = Vec::new(); // TransformLength: the bytes before TransformName. v.extend_from_slice(&((8 + id.len()) as u32).to_le_bytes()); v.extend_from_slice(&1u32.to_le_bytes()); v.extend_from_slice(&id); lp_p4(&mut v, "Microsoft.Container.EncryptionTransform"); for _ in 0..3 { v.extend_from_slice(&[1, 0, 0, 0]); } // EncryptionTransformInfo: null name, block size, cipher mode 0, reserved 4. v.extend_from_slice(&0u32.to_le_bytes()); v.extend_from_slice(&(BLOCK as u32).to_le_bytes()); v.extend_from_slice(&0u32.to_le_bytes()); v.extend_from_slice(&4u32.to_le_bytes()); v } #[cfg(test)] mod tests;