diff --git a/Cargo.lock b/Cargo.lock index e31edd94..460fdfd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + [[package]] name = "ahash" version = "0.8.12" @@ -392,6 +403,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -602,6 +622,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -611,6 +641,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cmpv2" version = "0.2.0" @@ -629,7 +665,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", "spki", "x509-cert", @@ -705,6 +741,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -776,6 +818,12 @@ dependencies = [ "libm", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -807,7 +855,7 @@ dependencies = [ "image", "proptest", "ruzstd", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -878,7 +926,7 @@ dependencies = [ "proptest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 2.0.21", ] @@ -1028,7 +1076,7 @@ dependencies = [ "ruzstd", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "skrifa 0.47.0", "tar", "thiserror 2.0.21", @@ -1136,7 +1184,7 @@ dependencies = [ "image", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", ] @@ -1229,6 +1277,22 @@ dependencies = [ "proptest", ] +[[package]] +name = "craft-offcrypto" +version = "0.1.0" +dependencies = [ + "aes", + "base64 0.23.1", + "craft-cfb", + "craft-xml", + "getrandom 0.3.4", + "hmac", + "sha1 0.11.0", + "sha2 0.11.1", + "thiserror 2.0.21", + "zeroize", +] + [[package]] name = "craft-opc" version = "0.1.0" @@ -1546,7 +1610,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tar", "tempfile", "ureq", @@ -1709,6 +1773,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1727,7 +1809,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der_derive", "flagset", "pem-rfc7468", @@ -1770,8 +1852,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.3", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2526,9 +2620,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2881,6 +2977,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.5.0" @@ -2920,6 +3025,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.12.0" @@ -3305,6 +3419,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9" +dependencies = [ + "hybrid-array", +] + [[package]] name = "io-extras" version = "0.19.0" @@ -3840,7 +3963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -5635,7 +5758,18 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -5646,7 +5780,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -5700,12 +5845,12 @@ checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" dependencies = [ "aws-lc-rs", "base64 0.22.1", - "const-oid", + "const-oid 0.9.6", "der", - "digest", + "digest 0.10.7", "pem", "rand_core 0.10.1", - "sha2", + "sha2 0.10.9", "signature", "sigstore-types", "spki", @@ -5774,7 +5919,7 @@ dependencies = [ "chrono", "cmpv2", "cms", - "const-oid", + "const-oid 0.9.6", "der", "hex", "rand", @@ -5813,7 +5958,7 @@ dependencies = [ "base64 0.22.1", "chrono", "cms", - "const-oid", + "const-oid 0.9.6", "hex", "pem", "rustls-pki-types", @@ -7846,9 +7991,9 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", - "sha1", + "sha1 0.10.7", "signature", "spki", "tls_codec", diff --git a/Cargo.toml b/Cargo.toml index fb8ef521..301515be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ craft-mime = { path = "crates/mime" } craft-lottie = { path = "crates/lottie" } craft-menu = { path = "crates/menu" } craft-metafile = { path = "crates/metafile" } +craft-offcrypto = { path = "crates/offcrypto" } craft-opc = { path = "crates/opc" } craft-penpot = { path = "crates/penpot" } craft-pcraft-format = { path = "crates/pcraft-format" } diff --git a/README.md b/README.md index c95f5ca5..0cf34dac 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Result<(), craft_offcrypto::Error> { +//! let package = b"PK\x03\x04 a zip package"; +//! let file = craft_offcrypto::encrypt_with_spin_count(package, "secret", 1000)?; +//! assert!(craft_offcrypto::is_encrypted(&file)); +//! assert_eq!(craft_offcrypto::decrypt(&file, None), Err(craft_offcrypto::Error::PasswordRequired)); +//! assert_eq!(craft_offcrypto::decrypt(&file, Some("guess")), Err(craft_offcrypto::Error::WrongPassword)); +//! assert_eq!(craft_offcrypto::decrypt(&file, Some("secret"))?, package); +//! # Ok(()) +//! # } +//! ``` + +mod package; +pub mod protection; + +pub use package::{ + DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, check, check_password, decrypt, decrypt_streams, encrypt, encrypt_standard, encrypt_with_spin_count, + is_encrypted, +}; + +/// Why a file could not be decrypted or encrypted. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The file can be decrypted, and no password was given. + #[error("a password is required")] + PasswordRequired, + #[error("the password is incorrect")] + WrongPassword, + /// A scheme that is not read, named to complete "uses {0} encryption": `RC4`, `Extensible`, + /// `external`, `certificate`, `rights-management`, a cipher or hash name, `version 1.1`. + #[error("{0} encryption isn't supported")] + Unsupported(String), + /// The file is damaged, or fails its integrity check. + #[error("the encrypted file is damaged ({0})")] + Malformed(String), + /// A stream is larger than its cap. + #[error("the encrypted file is too large ({0})")] + TooLarge(String), + /// A password that can't be used to encrypt: empty, or longer than [`MAX_PASSWORD_CHARS`]. + #[error("{0}")] + BadPassword(String), +} diff --git a/crates/offcrypto/src/package.rs b/crates/offcrypto/src/package.rs new file mode 100644 index 00000000..4470e5b5 --- /dev/null +++ b/crates/offcrypto/src/package.rs @@ -0,0 +1,807 @@ +//! Encrypted packages: ECMA-376 document encryption, from [MS-OFFCRYPTO] §2.3.4.4 and +//! §2.3.4.10–§2.3.4.15 (agile), §2.3.4.5–§2.3.4.9 (standard), with the data-spaces streams of +//! §2.1 / §2.3.4.1–§2.3.4.3. + +use aes::cipher::{BlockCipherDecrypt, BlockCipherEncrypt, KeyInit}; +use base64::Engine as _; +use hmac::Mac; +use sha2::Digest; +use zeroize::Zeroizing; + +use craft_xml::El; + +use crate::Error; + +/// The spin count Word uses, and [`encrypt`] 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. +pub(crate) 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. +pub(crate) 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; + +const NS_ENCRYPTION: &str = "http://schemas.microsoft.com/office/2006/encryption"; +const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password"; + +/// The root-level stream called `name`. +fn root_stream(cf: &craft_cfb::CompoundFile<'_>, name: &str) -> Option { + cf.child(0, name).filter(|&id| cf.entry(id).is_some_and(|e| e.kind == craft_cfb::EntryKind::Stream)) +} + +/// Is this an encrypted Office package (a compound file with an `EncryptedPackage` stream) +/// rather than a zip? A rights-managed file counts: it has the stream but no `EncryptionInfo`, +/// and [`decrypt`] names it as unsupported. +pub fn is_encrypted(bytes: &[u8]) -> bool { + craft_cfb::CompoundFile::open(bytes).is_ok_and(|cf| root_stream(&cf, "EncryptedPackage").is_some()) +} + +/// Whether the right password could decrypt this file, checked without one: an error names a +/// scheme no password opens here ([`Error::Unsupported`]) or damage, so a caller can say so +/// instead of asking for a password first. +pub fn check(bytes: &[u8]) -> Result<(), Error> { + match decrypt(bytes, None) { + Ok(_) | Err(Error::PasswordRequired) => Ok(()), + Err(e) => Err(e), + } +} + +/// The zip package inside an encrypted Office file. Without a password (`None`) this says +/// [`Error::PasswordRequired`] once it knows the file is one it can decrypt; a password that +/// doesn't fit gives [`Error::WrongPassword`]. +pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result, Error> { + let cf = craft_cfb::CompoundFile::open(bytes).map_err(|e| bad(format!("not a readable compound file ({e})")))?; + let package = root_stream(&cf, "EncryptedPackage").ok_or_else(|| bad("no EncryptedPackage stream"))?; + // Rights management (IRM) keeps the package but describes its encryption elsewhere. + let info = root_stream(&cf, "EncryptionInfo").ok_or_else(|| Error::Unsupported("rights-management".into()))?; + let info = stream(&cf, info, "EncryptionInfo", MAX_INFO)?; + let package = stream(&cf, package, "EncryptedPackage", bytes.len() as u64)?; + decrypt_streams(&info, &package, password) +} + +/// [`decrypt`] from the contents of the two streams. +pub fn decrypt_streams(info: &[u8], package: &[u8], password: Option<&str>) -> Result, Error> { + 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(Error::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(Error::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, Error> { + let s = parse_standard(info)?; + let Some(password) = password else { return Err(Error::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(Error::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, Error> { + 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(Error::Unsupported("external".into())); + } + if flags & STANDARD_FLAGS != STANDARD_FLAGS { + return Err(Error::Unsupported("RC4".into())); + } + // 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(Error::Unsupported(format!("cipher 0x{id:04X}"))), + }; + let hash_id = u32_at(24)?; + if !matches!(hash_id, 0 | ALG_SHA1) { + return Err(Error::Unsupported(format!("hash 0x{hash_id:04X}"))); + } + 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, Error> { + 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, Error> { + 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()); + + compound(&[ + ("\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), + ]) +} + +/// A version 3 compound file of streams at `/`-separated paths. +fn compound(streams: &[(&str, Vec)]) -> Result, Error> { + let refs: Vec<(&str, &[u8])> = streams.iter().map(|(path, data)| (*path, data.as_slice())).collect(); + craft_cfb::write_streams(craft_cfb::Version::V3, &refs).map_err(|e| bad(format!("couldn't write the compound file ({e})"))) +} + +/// 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 and fixtures: files are saved 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(package: &[u8], password: &str, key_bits: u32) -> Result, Error> { + 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)?); + compound(&[("EncryptionInfo", info), ("EncryptedPackage", stream)]) +} + +/// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`]. +pub fn check_password(password: &str) -> Result<(), Error> { + if password.is_empty() { + return Err(Error::BadPassword("the password is empty".into())); + } + if password.chars().count() > MAX_PASSWORD_CHARS { + return Err(Error::BadPassword(format!("passwords are at most {MAX_PASSWORD_CHARS} characters"))); + } + Ok(()) +} + +pub(crate) fn bad(msg: impl Into) -> Error { + Error::Malformed(msg.into()) +} + +/// A stream's contents, at most `cap` bytes. +fn stream(cf: &craft_cfb::CompoundFile<'_>, id: usize, name: &str, cap: u64) -> Result, Error> { + cf.read_capped(id, cap).map_err(|e| match e { + craft_cfb::Error::LimitExceeded(_) => Error::TooLarge(format!("{name} is too large")), + e => bad(format!("can't read {name} ({e})")), + }) +} + +/// The hash algorithms agile encryption names that are supported. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Hash { + Sha1, + Sha256, + Sha384, + Sha512, +} + +impl Hash { + pub(crate) 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", + } + } + + pub(crate) fn size(self) -> usize { + match self { + Hash::Sha1 => 20, + Hash::Sha256 => 32, + Hash::Sha384 => 48, + Hash::Sha512 => 64, + } + } + + /// The hash of the concatenated `parts`. + pub(crate) 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, Error> { + fn go(key: &[u8], msg: &[u8]) -> Result, Error> { + 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, Error> { + 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 +} + +pub(crate) fn random(n: usize) -> Result>, Error> { + 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. +pub(crate) 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, Error> { + 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, Error> { + 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(Error::Unsupported("Extensible".into())), + _ => return Err(Error::Unsupported(format!("version {major}.{minor}"))), + } + let options = craft_xml::Options { + limits: craft_xml::Limits { max_input: MAX_INFO as usize, ..craft_xml::Limits::default() }, + ..craft_xml::Options::default() + }; + let root = options.parse(info.get(8..).unwrap_or(&[])).map_err(|e| bad(format!("EncryptionInfo isn't readable XML ({e})")))?; + if root.local() != "encryption" { + return Err(bad("EncryptionInfo has no element")); + } + let key_data = root.elements().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 + .elements() + .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: the one with the password URI, whose `encryptedKey` has a + // spin count (a certificate key encryptor has neither). + let encryptors = root.elements().find(|e| e.local() == "keyEncryptors").ok_or_else(|| bad("EncryptionInfo has no keyEncryptors"))?; + let ek = encryptors + .elements() + .filter(|e| e.local() == "keyEncryptor" && e.attr("uri").is_none_or(|uri| uri == NS_PASSWORD)) + .flat_map(|e| e.elements()) + .find(|e| e.local() == "encryptedKey" && e.attr("spinCount").is_some()) + .ok_or_else(|| Error::Unsupported("certificate".into()))?; + 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: &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(Error::Unsupported(cipher.to_string())); + } + let chaining = e.attr("cipherChaining").unwrap_or(""); + if chaining != "ChainingModeCBC" { + return Err(Error::Unsupported(format!("{chaining} chaining"))); + } + let name = e.attr("hashAlgorithm").unwrap_or(""); + let hash = Hash::parse(name).ok_or_else(|| Error::Unsupported(format!("{name} hash")))?; + 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: &El, k: &str) -> Result, Error> { + 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; diff --git a/crates/offcrypto/src/package/tests.rs b/crates/offcrypto/src/package/tests.rs new file mode 100644 index 00000000..9be008d1 --- /dev/null +++ b/crates/offcrypto/src/package/tests.rs @@ -0,0 +1,399 @@ +use super::*; + +/// A stand-in for a zip package: the crate never looks inside it. +fn zip_package() -> Vec { + (0..9000u32).map(|i| (i * 31 % 251) as u8).collect() +} + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +#[test] +fn round_trip_with_a_password() { + let pkg = zip_package(); + let enc = encrypt_with_spin_count(&pkg, "pässwörd 🔑", 16).unwrap(); + assert!(is_encrypted(&enc)); + assert!(!is_encrypted(&pkg)); + // The compound file carries the data spaces Word expects. + let comp = craft_cfb::CompoundFile::open(&enc).unwrap(); + for stream in [ + "\u{6}DataSpaces/Version", + "\u{6}DataSpaces/DataSpaceMap", + "\u{6}DataSpaces/DataSpaceInfo/StrongEncryptionDataSpace", + "\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary", + ] { + assert!(comp.has_stream(stream), "{stream}"); + } + assert_eq!(decrypt(&enc, Some("pässwörd 🔑")).unwrap(), pkg); + // Without a password, or with the wrong one, it says so. + assert_eq!(decrypt(&enc, None), Err(Error::PasswordRequired)); + assert_eq!(check(&enc), Ok(())); + assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(Error::WrongPassword)); + // A package that is a whole number of segments, and an empty one. + for n in [0usize, SEGMENT, SEGMENT * 2 + 1] { + let data: Vec = (0..n).map(|i| (i * 7) as u8).collect(); + let enc = encrypt_with_spin_count(&data, "x", 1).unwrap(); + assert_eq!(decrypt(&enc, Some("x")).unwrap(), data); + } + assert!(matches!(encrypt(&pkg, ""), Err(Error::BadPassword(_)))); + assert!(matches!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)), Err(Error::BadPassword(_)))); + assert!(encrypt_with_spin_count(&pkg, &"x".repeat(MAX_PASSWORD_CHARS), 1).is_ok()); +} + +#[test] +fn a_file_without_data_integrity_is_refused() { + let (info, package) = streams(&encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap()); + let xml = String::from_utf8(info[8..].to_vec()).unwrap(); + let mut stripped = info[..8].to_vec(); + stripped.extend_from_slice(xml.replacen(" Vec { + craft_cfb::write_streams(craft_cfb::Version::V3, &[("EncryptionInfo", info), ("EncryptedPackage", package)]).unwrap() +} + +#[test] +fn hostile_encryption_info_errors_without_panicking() { + let good = encrypt_with_spin_count(&zip_package(), "pw", 2).unwrap(); + let (info, package) = streams(&good); + let xml = String::from_utf8(info[8..].to_vec()).unwrap(); + // (EncryptionInfo, EncryptedPackage, must fail). Truncations only must not panic: the + // XML reader is lenient, so cutting the closing tags still reads. + let mut cases: Vec<(Vec, Vec, bool)> = Vec::new(); + for n in (0..info.len()).step_by(7) { + cases.push((info[..n].to_vec(), package.clone(), n < info.len() / 2)); + } + for n in [0, 4, 8, 9, 100, package.len() - 1] { + cases.push((info.clone(), package[..n].to_vec(), true)); + } + // Standard and unknown versions, absurd numbers, bad base64, a huge StreamSize. + let with = |from: &str, to: &str| { + let mut v = info[..8].to_vec(); + v.extend_from_slice(xml.replacen(from, to, 2).as_bytes()); + v + }; + cases.push(([3u8, 0, 2, 0, 0x24, 0, 0, 0].to_vec(), package.clone(), true)); + cases.push(([9u8, 0, 9, 0].to_vec(), package.clone(), true)); + cases.push((with("spinCount=\"2\"", "spinCount=\"4294967295\""), package.clone(), true)); + cases.push((with("keyBits=\"256\"", "keyBits=\"8\""), package.clone(), true)); + cases.push((with("blockSize=\"16\"", "blockSize=\"4096\""), package.clone(), true)); + cases.push((with("hashSize=\"64\"", "hashSize=\"0\""), package.clone(), true)); + cases.push((with("saltSize=\"16\"", "saltSize=\"99999999999999999999\""), package.clone(), true)); + cases.push((with("SHA512", "MD5"), package.clone(), true)); + cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true)); + cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true)); + cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true)); + // Data integrity is mandatory: a file without it is refused, never opened unchecked. + cases.push((with(" s, + other => panic!("expected Unsupported, got {other:?}"), + }; + let (mut rc4, _) = streams(&encrypt_standard(b"x", "pw", 128).unwrap()); + rc4[4..8].copy_from_slice(&F_CRYPTO_API.to_le_bytes()); // fCryptoAPI without fAES + assert_eq!(unsupported(&rc4), "RC4"); + let (mut external, _) = streams(&encrypt_standard(b"x", "pw", 128).unwrap()); + external[4..8].copy_from_slice(&(STANDARD_FLAGS | F_EXTERNAL).to_le_bytes()); + assert_eq!(unsupported(&external), "external"); + 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"); + assert_eq!(unsupported(&agile("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\"")), "3DES"); + assert_eq!(unsupported(&agile("SHA512", "MD5")), "MD5 hash"); + assert_eq!(unsupported(&agile("ChainingModeCBC", "ChainingModeCFB")), "ChainingModeCFB chaining"); + assert_eq!( + unsupported(&agile( + "keyEncryptor uri=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"", + "keyEncryptor uri=\"http://schemas.microsoft.com/office/2006/keyEncryptor/certificate\"" + )), + "certificate" + ); + // Rights management keeps the package but has no EncryptionInfo. + let irm = + craft_cfb::write_streams(craft_cfb::Version::V3, &[("EncryptedPackage", &package[..]), ("\u{6}DataSpaces/Version", &[0u8; 8][..])]).unwrap(); + assert!(is_encrypted(&irm)); + assert_eq!(check(&irm), Err(Error::Unsupported("rights-management".into()))); + assert_eq!(decrypt(&irm, Some("pw")), Err(Error::Unsupported("rights-management".into()))); + // Another compound file is not an encrypted package at all. + let other = craft_cfb::write_streams(craft_cfb::Version::V3, &[("Workbook", &[0u8; 8][..])]).unwrap(); + assert!(!is_encrypted(&other)); + assert!(matches!(check(&other), Err(Error::Malformed(_)))); +} + +/// Keys and IVs computed independently with msoffcrypto-tool 6.0.0 (MIT) for a synthetic +/// password and salts. +#[test] +fn agile_keys_match_an_independent_implementation() { + let salt: Vec = (0u8..16).collect(); + let h = iterate(Hash::Sha512, &salt, "Pass1234", 1000); + assert_eq!( + derive_key(Hash::Sha512, &h, &BK_VERIFIER_INPUT, 32).to_vec(), + hex("c57def04938e1e83497271f6ec099e890bfd2bd9cf05ff208f8b2405df361ae6") + ); + assert_eq!(derive_key(Hash::Sha512, &h, &BK_KEY_VALUE, 32).to_vec(), hex("4ab08cf2ed95199d58d04f19331049a8a07cc1ff508431a1dd0785c84e261d03")); + let h = iterate(Hash::Sha1, &salt, "Pass1234", 1000); + assert_eq!(derive_key(Hash::Sha1, &h, &BK_VERIFIER_INPUT, 16).to_vec(), hex("88c7567d028eae33a9a55f65729dcd2b")); + let key_data_salt: Vec = (16u8..32).collect(); + assert_eq!(make_iv(Hash::Sha512, &key_data_salt, Some(&1u32.to_le_bytes()), 16), hex("da22ccba062aff577d207b2dee4e165b")); + // SHA-1 gives 20 bytes; an AES-256 key needs 32, so 12 bytes of 0x36 follow. + let k = derive_key(Hash::Sha1, b"a", b"b", 32); + assert_eq!((&k[..20], &k[20..]), (&Hash::Sha1.hash(&[b"ab"])[..], &[0x36u8; 12][..])); + // Passwords hash as UTF-16LE, surrogate pairs included: "Pässwörd€" and a key emoji. + assert_eq!( + iterate(Hash::Sha512, &salt, "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}", 1000).to_vec(), + hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a") + ); + assert_eq!(standard_key(&salt, "Pass1234", 16).to_vec(), hex("ae0b402aeb23a779471a130843e431d4")); + assert_eq!(standard_key(&salt, "Pass1234", 32).to_vec(), hex("ae0b402aeb23a779471a130843e431d47b416314b5146b491cb815283663d26f")); +} + +/// The key a real-world Standard-encrypted package derives: Apache POI's test file +/// `protected_passtika.xlsx` (Apache-2.0 test data), password "tika", written by Excel +/// itself. The value was computed from [MS-OFFCRYPTO] §2.3.4.7 independently of this code — +/// the file decrypts to a valid package under it and nothing else opens that file — so real +/// Word 2007 documents stay pinned, not just spec-derived ones. +#[test] +fn standard_key_matches_a_real_world_package() { + let salt: [u8; 16] = [0x19, 0xd0, 0xc7, 0xf3, 0x95, 0x28, 0x78, 0x43, 0x6a, 0x49, 0x49, 0xd7, 0xf0, 0xa2, 0xcf, 0x1e]; + const KEY: &str = "8d4d00143a5f2e4918af7e6e7410d46c"; + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + assert_eq!(hex(&standard_key(&salt, "tika", 16)), KEY); + // A different password or salt gives a different key; the key is exactly the length + // asked for, for each AES width. + assert_ne!(hex(&standard_key(&salt, "tika2", 16)), KEY); + assert_ne!(hex(&standard_key(&[0; 16], "tika", 16)), KEY); + assert_eq!(standard_key(&salt, "tika", 24).len(), 24); + assert_eq!(standard_key(&salt, "tika", 32).len(), 32); +} + +/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the +/// salt 00 01 … 0F. The expected keys were computed outside this crate (Python's hashlib) +/// following the specification's steps, so the encryptor and decryptor can't share a mistake. +#[test] +fn standard_key_matches_an_independent_derivation() { + let salt: Vec = (0..16).collect(); + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + assert_eq!(hex(&standard_key(&salt, "Password1234_", 16)), "340fc93a8e2154db8304d055df81cebc"); + assert_eq!(hex(&standard_key(&salt, "Password1234_", 32)), "340fc93a8e2154db8304d055df81cebcef5e89fb8e5eb520efdca76f3330e032"); + assert_eq!(standard_key(&salt, "Password1234_", 24).len(), 24); + // AES-ECB against FIPS-197 Appendix C.1 and C.3. + let pt: Vec = (0..16u8).map(|i| i * 0x11).collect(); + let k128: Vec = (0..16).collect(); + let k256: Vec = (0..32).collect(); + assert_eq!(hex(&ecb(&k128, &pt, true).unwrap()), "69c4e0d86a7b0430d8cdb78070b4c55a"); + assert_eq!(hex(&ecb(&k256, &pt, true).unwrap()), "8ea2b7ca516745bfeafc49904b496089"); + assert_eq!(ecb(&k256, &ecb(&k256, &pt, true).unwrap(), false).unwrap(), pt); + assert!(ecb(&k128, &pt[..15], false).is_err()); +} + +/// Standard encryption (Word 2007's AES-ECB method) opens with the right password, for each +/// AES key size, and says when the password is missing or wrong — as agile files do. +#[test] +fn standard_encryption_round_trips() { + let pkg = zip_package(); + for bits in [128, 192, 256] { + let enc = encrypt_standard(&pkg, "pässwörd 🔑", bits).unwrap(); + assert!(is_encrypted(&enc)); + assert_eq!(decrypt(&enc, Some("pässwörd 🔑")), Ok(pkg.clone()), "AES-{bits}"); + assert_eq!(decrypt(&enc, None), Err(Error::PasswordRequired)); + assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(Error::WrongPassword)); + assert_eq!(check(&enc), Ok(())); + } + // Sizes on and off the block boundary, and empty. + for n in [0usize, 1, 15, 16, 17, SEGMENT + 3] { + let data: Vec = (0..n).map(|i| (i * 7) as u8).collect(); + assert_eq!(decrypt(&encrypt_standard(&data, "x", 256).unwrap(), Some("x")).unwrap(), data); + } + assert!(encrypt_standard(&pkg, "pw", 64).is_err()); + assert!(encrypt_standard(&pkg, "", 128).is_err()); +} + +/// A Standard-encrypted file assembled byte by byte from [MS-OFFCRYPTO] §2.3.4.5, §2.3.2, §2.3.3 +/// and §2.3.4.4 with literal field values, without the test encryptor, so a layout mistake +/// shared by the encryptor and the reader can't pass. Only the key derivation and AES-ECB +/// (pinned by `standard_key_matches_an_independent_derivation`) are reused. +fn hand_built_standard(payload: &[u8], password: &str) -> Vec { + let salt: [u8; 16] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF]; + let verifier: [u8; 16] = *b"verifier-16bytes"; + let key = standard_key(&salt, password, 16); + let mut hash = sha1::Sha1::digest(verifier).to_vec(); + hash.resize(32, 0); // the hash, zero-padded to two AES blocks + let csp: Vec = "Test provider".encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect(); + + let mut header = Vec::new(); + header.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES + header.extend_from_slice(&0u32.to_le_bytes()); // SizeExtra + header.extend_from_slice(&0x660Eu32.to_le_bytes()); // AlgID: AES-128 + header.extend_from_slice(&0x8004u32.to_le_bytes()); // AlgIDHash: SHA-1 + header.extend_from_slice(&128u32.to_le_bytes()); // KeySize, in bits + header.extend_from_slice(&0x18u32.to_le_bytes()); // ProviderType: AES + header.extend_from_slice(&0u32.to_le_bytes()); // Reserved1 + header.extend_from_slice(&0u32.to_le_bytes()); // Reserved2 + header.extend_from_slice(&csp); // CSPName: UTF-16LE, NUL-terminated + + let mut info = Vec::new(); + info.extend_from_slice(&3u16.to_le_bytes()); // Version major + info.extend_from_slice(&2u16.to_le_bytes()); // Version minor + info.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES + info.extend_from_slice(&(header.len() as u32).to_le_bytes()); // HeaderSize + info.extend_from_slice(&header); + info.extend_from_slice(&16u32.to_le_bytes()); // SaltSize + info.extend_from_slice(&salt); // Salt + info.extend_from_slice(&ecb(&key, &verifier, true).unwrap()); // EncryptedVerifier + info.extend_from_slice(&20u32.to_le_bytes()); // VerifierHashSize (SHA-1) + info.extend_from_slice(&ecb(&key, &hash, true).unwrap()); // EncryptedVerifierHash + + let mut padded = payload.to_vec(); + padded.resize(payload.len().next_multiple_of(16), 0); + let mut package = (payload.len() as u64).to_le_bytes().to_vec(); // StreamSize + package.extend_from_slice(&ecb(&key, &padded, true).unwrap()); + package.extend_from_slice(&[0xEE, 0xEE, 0xEE]); // trailing padding the spec allows + cfb_with(&info, &package) +} + +#[test] +fn standard_package_built_by_hand_from_the_spec_opens() { + let payload: Vec = (0..37u8).map(|i| i.wrapping_mul(5)).collect(); + let file = hand_built_standard(&payload, "pw"); + assert!(is_encrypted(&file)); + assert_eq!(decrypt(&file, Some("pw")), Ok(payload.clone())); + assert_eq!(decrypt(&file, Some("PW")), Err(Error::WrongPassword)); + assert_eq!(decrypt(&file, None), Err(Error::PasswordRequired)); + // The test encryptor writes the same fixed fields: version and flags (bytes 0..8) and the + // header's eight words (12..44); HeaderSize differs with the CSP name, the rest is random. + let (hand, _) = streams(&file); + let (made, _) = streams(&encrypt_standard(&payload, "pw", 128).unwrap()); + assert_eq!(hand[..8], made[..8]); + assert_eq!(hand[12..44], made[12..44]); +} + +/// The two streams of an encrypted package. +fn streams(file: &[u8]) -> (Vec, Vec) { + let comp = craft_cfb::CompoundFile::open(file).unwrap(); + (comp.read_path("EncryptionInfo").unwrap(), comp.read_path("EncryptedPackage").unwrap()) +} + +#[test] +fn hostile_standard_encryption_info_errors_without_panicking() { + let data = b"a small package".to_vec(); + let (info, package) = streams(&encrypt_standard(&data, "pw", 128).unwrap()); + // Layout: version 0, flags 4, header size 8, header 12 (flags, sizeExtra, algId 20, + // algIdHash 24, keySize 28, provider, 2 reserved, an empty CSP name), then the verifier at + // 46: saltSize, salt 50, encrypted verifier 66, verifierHashSize 82, its hash 86..118. + assert_eq!(info.len(), 118); + let put = |at: usize, v: u32| { + let mut i = info.clone(); + i[at..at + 4].copy_from_slice(&v.to_le_bytes()); + i + }; + let mut cases: Vec<(Vec, Vec)> = Vec::new(); + for n in 0..info.len() { + cases.push((info[..n].to_vec(), package.clone())); + } + for v in [0, 4, 31, 0x1000, u32::MAX] { + cases.push((put(8, v), package.clone())); + } + // No AES, no CryptoAPI, an external (extensible) provider. + for v in [F_CRYPTO_API, F_AES, STANDARD_FLAGS | F_EXTERNAL, 0] { + cases.push((put(4, v), package.clone())); + } + // RC4, MD5, key sizes that don't fit the algorithm. + cases.push((put(20, 0x6801), package.clone())); + cases.push((put(24, 0x8003), package.clone())); + for v in [0, 64, 192, 256, u32::MAX] { + cases.push((put(28, v), package.clone())); + } + for v in [0, 15, 17, u32::MAX] { + cases.push((put(46, v), package.clone())); + } + for v in [0, 16, 32, u32::MAX] { + cases.push((put(82, v), package.clone())); + } + // The package: truncated, an absurd StreamSize, a StreamSize past the data, data that + // isn't whole blocks. + for n in [0, 4, 8, 9, package.len() - 1] { + cases.push((info.clone(), package[..n].to_vec())); + } + for size in [u64::MAX, 1 << 40, package.len() as u64] { + let mut p = package.clone(); + p[..8].copy_from_slice(&size.to_le_bytes()); + cases.push((info.clone(), p)); + } + for (i, p) in cases { + let file = cfb_with(&i, &p); + assert!(decrypt(&file, Some("pw")).is_err()); + } + // Version 2.2 and 4.2 are Standard too; trailing bytes after the last block are ignored. + for v in [2u8, 4] { + let mut i = info.clone(); + i[0] = v; + assert_eq!(decrypt(&cfb_with(&i, &package), Some("pw")).unwrap(), data); + } + let mut p = package.clone(); + p.extend_from_slice(&[1, 2, 3]); + assert_eq!(decrypt(&cfb_with(&info, &p), Some("pw")).unwrap(), data); + // AlgID and AlgIDHash 0 mean "from the flags": AES-128 and SHA-1. + assert_eq!(decrypt(&cfb_with(&put(20, 0), &package), Some("pw")).unwrap(), data); + assert_eq!(decrypt(&cfb_with(&put(24, 0), &package), Some("pw")).unwrap(), data); +} diff --git a/crates/offcrypto/src/protection.rs b/crates/offcrypto/src/protection.rs new file mode 100644 index 00000000..0f5b7431 --- /dev/null +++ b/crates/offcrypto/src/protection.rs @@ -0,0 +1,240 @@ +//! Protection passwords: the hashes that guard turning document, sheet or workbook protection +//! off ([MS-OFFCRYPTO] §2.4.2.4, the ISO write-protection method; ECMA-376 Part 1 §17.15.1.29 +//! and §18.2.29; Part 4 §14.8.1 for the legacy form). Protection isn't encryption: the file +//! stays readable, the password only guards the setting. +//! +//! The values are the attribute values as files hold them (base64 text, decimal spin count); +//! which attributes carry them (`w:hashValue`, `hashValue`, `w:hash`, ...) is the format's. + +use base64::Engine as _; +use zeroize::Zeroizing; + +use crate::Error; +use crate::package::{Hash, MAX_SPIN_COUNT, SALT, check_password, random, same}; + +/// A new protection password 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. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PasswordHash { + /// `algorithmName`: always `SHA-512`. + pub algorithm_name: &'static str, + /// `hashValue`, base64. + pub hash_value: String, + /// `saltValue`, base64. + pub salt_value: String, + /// `spinCount`. + pub spin_count: u32, +} + +/// Hashes a new protection password. The spin count is capped at 10,000,000; an empty or +/// over-long password is refused ([`check_password`]). +pub fn hash_password(password: &str, spin_count: u32) -> Result { + 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(PasswordHash { algorithm_name: "SHA-512", hash_value: b64(&hash), salt_value: b64(&salt), spin_count }) +} + +/// What checking a protection password found. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PasswordCheck { + /// The password matches the stored hash. + Correct, + /// The password doesn't match. + Incorrect, + /// The hash uses an algorithm or form that can't be checked, or is damaged. The protection + /// must stay on: guessing would let any password through. + Unsupported, +} + +/// Does `password` match a stored hash of the current form (`algorithmName`, `hashValue`, +/// `saltValue`, `spinCount`)? The algorithm is SHA-1, SHA-256, SHA-384 or SHA-512 (with or +/// without the dash); a missing salt is empty and a missing spin count is 0. Anything else, a +/// spin count above 10,000,000 or damaged values are [`PasswordCheck::Unsupported`], never a +/// match. +pub fn check_password_hash( + algorithm_name: &str, + hash_value: &str, + salt_value: Option<&str>, + spin_count: Option<&str>, + password: &str, +) -> PasswordCheck { + let Some(hash) = Hash::parse(algorithm_name.trim()) else { return PasswordCheck::Unsupported }; + verify(hash, Some(hash_value.trim()), salt_value.map(str::trim), spin_count.map(str::trim), password) +} + +/// Does `password` match a stored hash of the legacy form (`cryptAlgorithmSid`, `hash`, `salt`, +/// `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 like a password. `algorithm_sid` is 4 (SHA-1), 12 (SHA-256), 13 (SHA-384) or 14 +/// (SHA-512); the older MD2, MD4 and MD5 are [`PasswordCheck::Unsupported`]. +pub fn check_legacy_password_hash(algorithm_sid: u32, hash: &str, salt: Option<&str>, spin_count: Option<&str>, password: &str) -> PasswordCheck { + // 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 algorithm = match algorithm_sid { + 4 => Hash::Sha1, + 12 => Hash::Sha256, + 13 => Hash::Sha384, + 14 => Hash::Sha512, + _ => return PasswordCheck::Unsupported, + }; + verify(algorithm, Some(hash.trim()), salt.map(str::trim), spin_count.map(str::trim), &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. +pub 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 +} + +#[cfg(test)] +mod tests { + use super::PasswordCheck::{Correct, Incorrect, Unsupported}; + use super::*; + + #[test] + fn password_hash_round_trip() { + let h = hash_password("s3cret", 1000).unwrap(); + assert_eq!((h.algorithm_name, h.spin_count), ("SHA-512", 1000)); + let spin = h.spin_count.to_string(); + let check = |algorithm: &str, hash: &str, password: &str| check_password_hash(algorithm, hash, Some(&h.salt_value), Some(&spin), password); + assert_eq!(check(h.algorithm_name, &h.hash_value, "s3cret"), Correct); + assert_eq!(check("SHA512", &format!(" {} ", h.hash_value), "s3cret"), Correct, "as SpreadsheetML spells it, with stray spaces"); + assert_eq!(check(h.algorithm_name, &h.hash_value, "S3cret"), Incorrect); + // Damaged values and unknown algorithms are refused, never accepted. + assert_eq!(check(h.algorithm_name, "not base64!", "s3cret"), Unsupported); + assert_eq!(check("WHIRLPOOL", &h.hash_value, "s3cret"), Unsupported); + assert_eq!(check("SHA-256", &h.hash_value, "s3cret"), Unsupported, "a hash of the wrong length"); + assert_eq!(check_password_hash("SHA-512", &h.hash_value, Some("!"), Some(&spin), "s3cret"), Unsupported); + assert_eq!(check_password_hash("SHA-512", &h.hash_value, Some(&h.salt_value), Some("10000001"), "s3cret"), Unsupported); + assert_eq!(check_password_hash("SHA-512", &h.hash_value, Some(&h.salt_value), Some("-1"), "s3cret"), Unsupported); + // A missing salt is empty and a missing spin count is 0: a different hash, not a match. + assert_eq!(check_password_hash("SHA-512", &h.hash_value, None, None, "s3cret"), Incorrect); + // The spin count is capped, and unusable passwords are refused. + assert!(matches!(hash_password("", 1), Err(Error::BadPassword(_)))); + } + + /// The worked example of ECMA-376 Part 4 §14.8.1: "Example" gives the key 0x64CEED7E, hashed + /// as "7EEDCE64". + #[test] + fn legacy_key_matches_the_specification_example() { + assert_eq!(legacy_key("Example"), 0x64CE_ED7E); + assert_eq!(legacy_key_hex("Example").as_str(), "7EEDCE64"); + assert_eq!(legacy_key(""), 0); + // A leading BOM is dropped; only the first 15 characters count. + assert_eq!(legacy_key("\u{FEFF}Example"), 0x64CE_ED7E); + assert_eq!(legacy_key("abcdefghijklmnopq"), legacy_key("abcdefghijklmno")); + assert_ne!(legacy_key("abcdefghijklmno"), legacy_key("abcdefghijklmn")); + } + + /// SHA-1 of the salt 00 01 … 0F and "7EEDCE64" as UTF-16LE, iterated 100,000 times, + /// computed independently from the specification's stages. + const KNOWN_LEGACY_SHA1: &str = "pEWelKoH6SVpvMmyPAJ8bHA/g/s="; + + #[test] + fn legacy_hash_is_verified() { + let check = |sid: u32, spin: &str, password: &str| { + check_legacy_password_hash(sid, KNOWN_LEGACY_SHA1, Some("AAECAwQFBgcICQoLDA0ODw=="), Some(spin), password) + }; + assert_eq!(check(4, "100000", "Example"), Correct); + assert_eq!(check(4, "100000", "example"), Incorrect); + assert_eq!(check(4, "100000", ""), Incorrect); + // MD5 and the other algorithms that are not implemented, and hostile spin counts: refused. + assert_eq!(check(3, "100000", "Example"), Unsupported); + assert_eq!(check(4, "4294967295", "Example"), Unsupported); + assert_eq!(check(4, "-1", "Example"), Unsupported); + } +}