mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 07:46:08 +00:00
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:
Generated
+164
-19
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user