Files
Dylan M. Taylor 100768b90d 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.
2026-11-11 18:43:46 -05:00

62 lines
3.4 KiB
Markdown

# 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.