Add craft-offcrypto: encrypted OOXML packages and protection password hashes (#931)

Moved from WordCraft's crates/docx/src/crypt.rs (07f97737): agile and
standard decryption, agile encryption, the ISO and legacy protection
password hashes. The compound file is read and written with craft-cfb
and the descriptor parsed with craft-xml.

From GridCraft's crates/xlsx/src/crypto.rs (5cf52c5f): check(), named
unsupported schemes, rights-managed files, the password key encryptor
chosen by its URI, and independent key vectors.
This commit is contained in:
Dylan M. Taylor
2026-11-11 18:43:46 -05:00
committed by GitHub
parent dbed0a83ea
commit 100768b90d
9 changed files with 1767 additions and 19 deletions
Generated
+164 -19
View File
@@ -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",
+1
View File
@@ -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" }
+1
View File
@@ -194,6 +194,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
header fields, MIME parts and multiparts, encoded-words, RFC 2231 parameters, base64 and
quoted-printable, character sets, address lists, dates, mbox files and the `multipart/related`
lookups of MHT web archives. What a message means (body, attachments, flags) stays in each app.
- [`craft-offcrypto`](crates/offcrypto/README.md): clean-room Office document cryptography ([MS-OFFCRYPTO]): password-encrypted OOXML packages on `craft-cfb` (agile and standard encryption read, with the integrity check enforced; agile written), unsupported schemes named, and the password hashes of document and sheet protection.
- [`craft-opc`](crates/opc/README.md): Open Packaging Conventions, the ZIP container of DOCX, XLSX and PPTX
files: bounded readers (every part up front, or on demand), part names, relationships, content
types and a deterministic package writer. What the parts hold stays with each format's crate.
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "craft-offcrypto"
description = "Clean-room Office document cryptography ([MS-OFFCRYPTO]): password-encrypted OOXML packages (agile and standard encryption) and the password hashes of document and sheet protection."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-cfb = { workspace = true }
craft-xml = { workspace = true }
thiserror = { workspace = true }
base64 = "0.23"
aes = "0.9"
sha1 = "0.11"
sha2 = "0.11"
hmac = "0.13"
getrandom = "0.3"
zeroize = "1"
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
# The browser's `crypto.getRandomValues` for the salts and keys of encrypted saves.
getrandom = { version = "0.3", features = ["wasm_js"] }
+61
View File
@@ -0,0 +1,61 @@
# craft-offcrypto
Clean-room Office document cryptography: password-encrypted OOXML packages and the password
hashes of document and sheet protection. No `unsafe`. No third-party implementation was
consulted; some expected keys in the tests were computed with msoffcrypto-tool and Python's
hashlib as independent checks.
| Document | Used for |
|---|---|
| [MS-OFFCRYPTO] Office Document Cryptography Structure (Microsoft Open Specifications) | §2.1 data spaces; §2.3.2, §2.3.3, §2.3.4.5-§2.3.4.9 standard encryption; §2.3.4.10-§2.3.4.15 agile encryption; §2.4.2.4 the ISO write-protection hash |
| ECMA-376 Part 1 §17.15.1.29, §18.2.29; Part 4 §14.8.1 | where protection hashes are stored; the legacy 32-bit password key |
| [MS-CFB] | the compound file, through `craft-cfb` |
## Encrypted packages
A password-protected `.docx`, `.xlsx` or `.pptx` is a compound file with an `EncryptionInfo`
stream and an `EncryptedPackage` stream. The crate turns that file into the zip package and
back. It does not look inside the package: `craft-opc` reads what `decrypt` returns.
```rust
if craft_offcrypto::is_encrypted(&bytes) {
craft_offcrypto::check(&bytes)?; // Unsupported("RC4"), ... before asking
let package = craft_offcrypto::decrypt(&bytes, Some(password))?; // WrongPassword, Malformed
}
let file = craft_offcrypto::encrypt(&package, password)?;
```
| | Read | Written |
|---|---|---|
| Agile (Office 2010 and later): AES-128/192/256 CBC in 4096-byte segments, SHA-1/256/384/512 | yes; the HMAC over the encrypted stream is required and checked | AES-256, SHA-512, 100,000 spins, with the data-spaces streams Office expects |
| Standard (Office 2007): AES-128/192/256 ECB, SHA-1, 50,000 spins | yes | for tests only (`encrypt_standard`) |
| RC4, extensible, certificate-only, rights management | refused by name (`Error::Unsupported`) | no |
`decrypt(bytes, None)` returns `Error::PasswordRequired` once it knows the file is one it can
decrypt; `check` is that question on its own.
## Protection passwords
`protection::hash_password` makes the `algorithmName` / `hashValue` / `saltValue` / `spinCount`
values of a new protection password; `protection::check_password_hash` and
`check_legacy_password_hash` (the `cryptAlgorithmSid` form, through `legacy_key`) check a
password against stored values. Which attributes hold them is the format's business. A hash
that cannot be checked is `PasswordCheck::Unsupported`, never a match.
## Hostile input
Stream reads are capped (`EncryptionInfo` at 1 MiB, the package at the file's size), the XML
descriptor is parsed with `craft-xml` under the same cap, every length is checked before
slicing, a stated package size larger than the data is damage and never drives an allocation,
and spin counts above 10,000,000 are refused. Verifier and integrity values are compared
without stopping at the first difference. Passwords, keys and derived hashes are wiped when
dropped (`zeroize`).
## Origin
Moved from WordCraft's `crates/docx/src/crypt.rs` (at `07f97737`), the fullest of three copies:
it reads both schemes, writes agile encryption and has the protection hashes. From GridCraft's
`crates/xlsx/src/crypto.rs` (at `5cf52c5f`) come `check`, the naming of unsupported schemes
(`Error::Unsupported`), rights-managed files, choosing the password key encryptor by its URI,
and the independent key vectors in the tests. DeckCraft's `crates/pptx/src/cfb.rs` only
detected encrypted files, which `is_encrypted` does.
+67
View File
@@ -0,0 +1,67 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Clean-room Office document cryptography, written from Microsoft's public Open Specification
//! **[MS-OFFCRYPTO]: Office Document Cryptography Structure** and ECMA-376.
//!
//! A password-encrypted `.docx`, `.xlsx` or `.pptx` is not a zip but a compound file
//! ([MS-CFB], read and written with `craft-cfb`) holding `EncryptionInfo` (how the key comes
//! from the password) and `EncryptedPackage` (the zip, AES encrypted). This crate goes between
//! the two: file bytes in, package bytes out, and back. It knows nothing about what the package
//! holds.
//!
//! - [`is_encrypted`] tells such a file from any other compound file; [`check`] says, without
//! a password, whether a password could open it.
//! - [`decrypt`] reads *agile* encryption (Office 2010 and later: AES-CBC in 4096-byte
//! segments, SHA-1 to SHA-512, a password verifier and an HMAC over the encrypted stream,
//! which is required and checked) and *standard* encryption (Office 2007: AES-ECB, SHA-1).
//! RC4, extensible, certificate-only and rights-managed files are refused by name
//! ([`Error::Unsupported`]).
//! - [`encrypt`] writes agile encryption as Office does: AES-256, SHA-512, 100,000 spins.
//! - [`protection`] has the password hashes of document, sheet and workbook protection.
//!
//! 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 (10,000,000). Passwords and keys
//! are wiped from memory when dropped and never logged.
//!
//! ```
//! # fn main() -> 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),
}
+807
View File
@@ -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<usize> {
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Standard<'_>, Error> {
let short = || bad("EncryptionInfo is truncated");
let u32_at = |at: usize| -> Result<u32, Error> {
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<Vec<u8>, 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<Vec<u8>, 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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n\
<encryption xmlns=\"{NS_ENCRYPTION}\" xmlns:p=\"{NS_PASSWORD}\">\
<keyData {params} saltValue=\"{}\"/>\
<dataIntegrity encryptedHmacKey=\"{}\" encryptedHmacValue=\"{}\"/>\
<keyEncryptors><keyEncryptor uri=\"{NS_PASSWORD}\">\
<p:encryptedKey spinCount=\"{spin_count}\" {params} saltValue=\"{}\" encryptedVerifierHashInput=\"{}\" encryptedVerifierHashValue=\"{}\" encryptedKeyValue=\"{}\"/>\
</keyEncryptor></keyEncryptors></encryption>",
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<u8>)]) -> Result<Vec<u8>, 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<Vec<u8>, 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<String>) -> 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<Vec<u8>, 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<Hash> {
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<u8> {
fn go<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
let mut d = D::new();
for p in parts {
d.update(p);
}
d.finalize().to_vec()
}
match self {
Hash::Sha1 => go::<sha1::Sha1>(parts),
Hash::Sha256 => go::<sha2::Sha256>(parts),
Hash::Sha384 => go::<sha2::Sha384>(parts),
Hash::Sha512 => go::<sha2::Sha512>(parts),
}
}
fn hmac(self, key: &[u8], msg: &[u8]) -> Result<Vec<u8>, Error> {
fn go<M: Mac + KeyInit>(key: &[u8], msg: &[u8]) -> Result<Vec<u8>, Error> {
let mut m = <M as KeyInit>::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::<hmac::Hmac<sha1::Sha1>>(key, msg),
Hash::Sha256 => go::<hmac::Hmac<sha2::Sha256>>(key, msg),
Hash::Sha384 => go::<hmac::Hmac<sha2::Sha384>>(key, msg),
Hash::Sha512 => go::<hmac::Hmac<sha2::Sha512>>(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<Vec<u8>> {
let pw: Zeroizing<Vec<u8>> = 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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>, Error> {
let aes = Aes::new(key)?;
let (blocks, rest) = data.as_chunks::<BLOCK>();
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<u8> {
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<Vec<u8>> {
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<Zeroizing<Vec<u8>>, 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<Aes, Error> {
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<Vec<u8>, Error> {
let aes = Aes::new(key)?;
let (blocks, rest) = data.as_chunks::<BLOCK>();
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<Vec<u8>, Error> {
let aes = Aes::new(key)?;
let (blocks, rest) = data.as_chunks::<BLOCK>();
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<u8>,
block_size: usize,
key_bytes: usize,
/// encryptedHmacKey, encryptedHmacValue.
integrity: (Vec<u8>, Vec<u8>),
password: PasswordEncryptor,
}
struct PasswordEncryptor {
hash: Hash,
salt: Vec<u8>,
salt_size: usize,
block_size: usize,
key_bytes: usize,
spin_count: u32,
verifier_input: Vec<u8>,
verifier_hash: Vec<u8>,
key_value: Vec<u8>,
}
/// Cipher parameters shared by `keyData` and `encryptedKey`.
struct Params {
hash: Hash,
salt: Vec<u8>,
salt_size: usize,
block_size: usize,
key_bytes: usize,
}
fn parse_info(info: &[u8]) -> Result<Info, Error> {
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 <encryption> 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<Params, Error> {
let num = |k: &str| -> Result<usize, Error> { e.attr(k).and_then(|s| s.trim().parse::<usize>().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<Vec<u8>, 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<u8>, s: &str) {
let units: Vec<u8> = 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<u8> {
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<u8> {
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<u8> {
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<u8> {
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;
+399
View File
@@ -0,0 +1,399 @@
use super::*;
/// A stand-in for a zip package: the crate never looks inside it.
fn zip_package() -> Vec<u8> {
(0..9000u32).map(|i| (i * 31 % 251) as u8).collect()
}
fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
#[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<u8> = (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("<dataIntegrity", "<nothing", 1).as_bytes());
assert!(matches!(decrypt(&cfb_with(&stripped, &package), Some("pw")), Err(Error::Malformed(m)) if m.contains("dataIntegrity")));
// An empty or ragged field is damage, not a pass.
for attr in ["encryptedHmacKey=\"", "encryptedHmacValue=\""] {
let at = xml.find(attr).unwrap() + attr.len();
let end = at + xml[at..].find('"').unwrap();
for value in ["", "AA=="] {
let mut bad = info[..8].to_vec();
bad.extend_from_slice(format!("{}{value}{}", &xml[..at], &xml[end..]).as_bytes());
assert!(matches!(decrypt_streams(&bad, &package, Some("pw")), Err(Error::Malformed(_))), "{attr}{value}");
}
}
}
#[test]
fn tampered_package_fails_the_integrity_check() {
let pkg = zip_package();
let (info, package) = streams(&encrypt_with_spin_count(&pkg, "pw", 4).unwrap());
for at in [8usize, 100, package.len() - 1] {
let mut data = package.clone();
data[at] ^= 1;
assert!(matches!(decrypt(&cfb_with(&info, &data), Some("pw")), Err(Error::Malformed(m)) if m.contains("integrity")), "byte {at}");
}
// The MAC covers the whole stream, StreamSize included: changing the stated size alone fails.
let mut resized = package.clone();
resized[..8].copy_from_slice(&((pkg.len() - 1) as u64).to_le_bytes());
assert!(matches!(decrypt_streams(&info, &resized, Some("pw")), Err(Error::Malformed(m)) if m.contains("integrity")));
// And the descriptor can't vouch for it either: a swapped integrity value is refused.
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
let at = 8 + xml.find("encryptedHmacValue=\"").unwrap() + "encryptedHmacValue=\"".len();
let mut swapped = info.clone();
swapped[at] = if swapped[at] == b'A' { b'B' } else { b'A' };
assert!(matches!(decrypt_streams(&swapped, &package, Some("pw")), Err(Error::Malformed(_))));
assert_eq!(decrypt_streams(&info, &package, Some("pw")).unwrap(), pkg);
}
/// A compound file with the given EncryptionInfo and EncryptedPackage streams.
fn cfb_with(info: &[u8], package: &[u8]) -> Vec<u8> {
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<u8>, Vec<u8>, 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("<dataIntegrity", "<nothing"), package.clone(), true));
let mut huge = package.clone();
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
for (i, p, must_fail) in cases {
let file = cfb_with(&i, &p);
let r = decrypt(&file, Some("pw"));
assert!(!must_fail || r.is_err());
let _ = check(&file);
}
// A file that only looks like a compound file.
let mut fake = craft_cfb::SIGNATURE.to_vec();
fake.extend_from_slice(&[0u8; 600]);
assert!(!is_encrypted(&fake));
assert!(decrypt(&fake, Some("pw")).is_err());
assert!(!is_encrypted(b"PK\x03\x04") && !is_encrypted(&[]));
}
/// Schemes that are not read are named, so a caller can say which, before asking for a password.
#[test]
fn unsupported_schemes_are_named() {
let (info, package) = streams(&encrypt_with_spin_count(&zip_package(), "pw", 2).unwrap());
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
let agile = |from: &str, to: &str| {
let mut v = info[..8].to_vec();
v.extend_from_slice(xml.replace(from, to).as_bytes());
v
};
let unsupported = |info: &[u8]| match check(&cfb_with(info, &package)) {
Err(Error::Unsupported(s)) => 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<u8> = (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<u8> = (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::<String>();
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<u8> = (0..16).collect();
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
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<u8> = (0..16u8).map(|i| i * 0x11).collect();
let k128: Vec<u8> = (0..16).collect();
let k256: Vec<u8> = (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<u8> = (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<u8> {
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<u8> = "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<u8> = (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<u8>, Vec<u8>) {
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<u8>, Vec<u8>)> = 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);
}
+240
View File
@@ -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<PasswordHash, Error> {
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::<u32>) {
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<Vec<u8>> =
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<String> {
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<Vec<u8>> {
let pw: Zeroizing<Vec<u8>> = 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);
}
}