craft-xls: read RC4 and RC4 CryptoAPI encrypted workbooks (ports of gridcraft#847, #3713) (#1018)

* Add craft-sheet: the cell positions, values and tables the spreadsheet formats share

craft-xls carried its own CellRef, Range, Value, CellError, Visibility, sheet-name
quoting, built-in number format table and indexed palette. craft-xlsx and craft-ods
need the same ones, so they move to a crate with no dependencies and craft-xls
re-exports them: an application gets one Value and one CellRef whichever file it read.

Added on the way: A1 parsing (CellRef::parse, Range::parse), Range::count and a1,
CellError::parse and the newer error values SpreadsheetML stores as text,
builtin_format_id for writers.

* craft-xls: read data validation (DVal and Dv records)

Each Dv after a DVal becomes a DataValidation in SpreadsheetML's terms: type,
operator, formulas as A1 text from the top left cell of all the rule's ranges (a
literal list quoted, items separated by commas), the four messages, alert style
and flags. Exactly the DVal's count of rules is read, as Excel does; a rule Excel
would refuse is skipped with a warning, and the rest are kept. A sheet keeps at
most 65,536 rules (the count was not capped on its own).

Ported from storytold/gridcraft#3636.

* craft-xls: read comments, conditional formats, pictures and the AutoFilter range

- Notes with their TxO text, author and visibility.
- CondFmt and CF records (cell value is, formula is), formulas from the bounding
  range's top left cell, and the DXFN as a DifferentialFormat whose unset parts are
  None.
- The drawing group's BLIP store (PNG, JPEG, EMF, WMF with its placeable header,
  DIB as BMP) and each sheet's picture shapes with their client anchors.
- The AutoFilter range from AutoFilterInfo and _FilterDatabase.

From the review of the source PR: picture bytes are shared by the shapes that show
them, pictures, comments and rules are capped per sheet, inflated metafiles share
one allowance, damaged object and CF records are skipped with a warning instead of
failing the read, and CF formulas are relative to the CondFmt's bounding range.
ValidationOperator is now Operator, shared with conditional formats.

Ported from storytold/gridcraft#3457.

* craft-xls: read workbooks encrypted with RC4, with their password

A FilePass record with RC4 (version 1.1) is checked against the password through
its MD5 verifier, and the record data after it is decrypted, rekeyed every 1024
bytes of the stream; record headers and the few records Excel leaves in the clear
are kept. read_with_password takes the password. Without one, Excel's default
password (VelvetSweatshop) is tried, as Excel opens a workbook protected only
against changes, and Error::Encrypted says a password is needed. XOR obfuscation
and RC4 CryptoAPI are Error::UnsupportedEncryption; a wrong password is
Error::WrongPassword.

Ported from storytold/gridcraft#847.

* craft-xls: read workbooks encrypted with RC4 CryptoAPI

What current Excel writes for an .xls saved with a password (FilePass version 2.2,
3.2 or 4.2): H0 = SHA-1(salt, UTF-16LE password), each 1024-byte block keyed by
SHA-1(H0, block number) cut to the key size, a 40-bit key padded with zeros to 128
bits, and the password checked against the verifier's SHA-1. Every size in the
FilePass record is checked before use; CryptoAPI with AES, another cipher or hash,
an external provider or a key outside 40 to 128 bits is named as unsupported.

Ported from storytold/gridcraft#3713.

---------

Co-authored-by: kiwi4boy <kiwi4boy@gmail.com>
Co-authored-by: BrianDo35 <haison.db35@icloud.com>
Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: giuseppeps <giuseppe_000@hotmail.com>
This commit is contained in:
Dylan M. Taylor
2026-11-11 20:54:41 -05:00
committed by GitHub
co-authored by kiwi4boy BrianDo35 Fabio Lussi giuseppeps
parent 45ba44f740
commit cabcf2fbf3
8 changed files with 628 additions and 13 deletions
Generated
+2
View File
@@ -2027,7 +2027,9 @@ version = "0.1.0"
dependencies = [
"craft-cfb",
"craft-sheet",
"md-5 0.10.6",
"miniz_oxide 0.9.1",
"sha1 0.10.7",
"thiserror 2.0.21",
]
+3
View File
@@ -13,6 +13,9 @@ workspace = true
[dependencies]
craft-cfb = { workspace = true }
craft-sheet = { workspace = true }
# RC4 and RC4 CryptoAPI encryption ([MS-OFFCRYPTO] 2.3.5, 2.3.6).
md5 = { workspace = true }
sha1 = { version = "0.10", default-features = false }
# zlib-wrapped metafiles in the drawing group.
miniz_oxide = "0.9"
thiserror = { workspace = true }
+15 -4
View File
@@ -3,8 +3,9 @@
A clean-room reader for Excel 97-2003 workbooks (`.xls`, BIFF8). It turns the bytes of a file
into a `Workbook` that keeps the file's own terms: cells, formulas as text, the format table,
column and row records. Mapping that onto an application's document is the caller's work.
No `unsafe`; the only dependencies are `craft-cfb` (the compound file container), `craft-sheet` (cell
positions, values and tables shared with the other spreadsheet formats) and `thiserror`.
No `unsafe`. Dependencies: `craft-cfb` (the compound file container), `craft-sheet` (cell positions,
values and tables shared with the other spreadsheet formats), `md-5` and `sha1` (RC4 keys),
`miniz_oxide` (compressed metafiles) and `thiserror`.
No third-party implementation was consulted.
| Document | Used for |
@@ -50,8 +51,18 @@ conditional formats per sheet. A damaged comment, object or conditional-format r
skipped with a warning.
Not read: charts, other shapes, newer conditional formats (CF12), hyperlinks,
PivotTables, macros. Each is named once in `Read::warnings`. Encrypted workbooks
(`Error::Encrypted`) and BIFF5 and older (`Error::Biff5`) are refused. There is no writer
PivotTables, macros. Each is named once in `Read::warnings`. BIFF5 and older is refused
(`Error::Biff5`).
## Encrypted workbooks
A workbook encrypted with RC4 (version 1.1, keyed through MD5) or RC4 CryptoAPI (versions 2.2,
3.2 and 4.2, keyed through SHA-1 with a 40- to 128-bit key, which current Excel writes) is read
with `read_with_password` ([MS-OFFCRYPTO] 2.3.5, 2.3.6). Without a password, Excel's default one
(`VelvetSweatshop`) is tried, and `Error::Encrypted` says a password is needed. A wrong password
is `Error::WrongPassword`; XOR obfuscation and CryptoAPI with AES, another cipher or hash, or an
external provider are `Error::UnsupportedEncryption`. Every size in the FilePass record is
checked before it is used. There is no writer
(`craft_xls::testing` builds small fixtures for tests).
A formula that cannot be put back into text (a link to another workbook, a macro command, an
+413
View File
@@ -0,0 +1,413 @@
//! Password-encrypted workbooks ([MS-XLS] 2.2.10, [MS-OFFCRYPTO] 2.3.5, 2.3.6). A
//! FilePass record in the workbook globals holds a salt and a password verifier; the record data
//! after it is encrypted with RC4, rekeyed every 1024 bytes of the stream. Record headers, and the
//! data of a few records, are left in the clear.
//!
//! Two schemes are read: RC4 (version 1.1), keyed from the password through MD5, and RC4
//! CryptoAPI (versions 2.2, 3.2 and 4.2, what current Excel writes for an `.xls` saved with a
//! password), keyed through SHA-1 with a 40- to 128-bit key. XOR obfuscation, and CryptoAPI with
//! another cipher or hash, are reported as unsupported. Clean-room from Microsoft's public
//! specifications.
use md5::{Digest, Md5};
use sha1::Sha1;
/// Why a Workbook stream could not be decrypted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DecryptError {
WrongPassword,
/// A scheme that is not read, named to complete "uses {0} encryption".
Unsupported(String),
Malformed(String),
}
/// The password Excel encrypts with when a workbook has none of its own (a workbook protected
/// against changes, not against opening). Excel tries it before asking.
pub(crate) const DEFAULT_PASSWORD: &str = "VelvetSweatshop";
/// FilePass: the globals record that marks an encrypted workbook.
pub(crate) const FILEPASS: u16 = 0x002F;
const EOF: u16 = 0x000A;
const BOUNDSHEET: u16 = 0x0085;
/// Records whose data is never encrypted: BOF, FilePass, UsrExcl, FileLock, InterfaceHdr,
/// RRDInfo and RRDHead.
const CLEAR: [u16; 7] = [0x0809, FILEPASS, 0x0194, 0x0195, 0x00E1, 0x0196, 0x0138];
/// The RC4 key changes every this many bytes of the stream.
const REKEY: usize = 1024;
/// CryptoAPI `AlgID` for RC4 ([MS-OFFCRYPTO] 2.3.2); 0 means the flags decide, which without
/// `fAES` is RC4 too.
const CALG_RC4: u32 = 0x6801;
/// CryptoAPI `AlgIDHash` for SHA-1; 0 means SHA-1 too.
const CALG_SHA1: u32 = 0x8004;
/// EncryptionHeader flags: `fExternal` (an external provider does the encryption) and `fAES`.
const F_EXTERNAL: u32 = 0x10;
const F_AES: u32 = 0x20;
/// A CryptoAPI salt is 16 bytes, the verifier too, and the verifier's SHA-1 hash 20.
const SALT_LEN: usize = 16;
const SHA1_LEN: usize = 20;
fn malformed(m: &str) -> DecryptError {
DecryptError::Malformed(m.into())
}
/// The record header at `pos`: its type and data length.
fn header(stream: &[u8], pos: usize) -> Option<(u16, usize)> {
let h = stream.get(pos..pos.checked_add(4)?)?;
let id = u16::from_le_bytes([*h.first()?, *h.get(1)?]);
Some((id, usize::from(u16::from_le_bytes([*h.get(2)?, *h.get(3)?]))))
}
/// The FilePass record's data, when the workbook globals hold one.
pub(crate) fn filepass(stream: &[u8]) -> Option<&[u8]> {
let mut pos = 0usize;
while let Some((id, len)) = header(stream, pos) {
let start = pos + 4;
match id {
EOF => return None,
FILEPASS => return stream.get(start..start.saturating_add(len)),
_ => pos = start + len,
}
}
None
}
/// Decrypts a Workbook stream in place with `password`. The FilePass record stays where it is
/// (removing it would move the substreams the BoundSheet records point at).
pub(crate) fn decrypt(stream: &mut [u8], password: &str) -> Result<(), DecryptError> {
let key = password_key(filepass(stream).ok_or_else(|| malformed("no FilePass record"))?, password)?;
let mut keystream = Keystream::new(key);
let mut pos = 0usize;
while let Some((id, len)) = header(stream, pos) {
let start = pos + 4;
let end = start.saturating_add(len).min(stream.len());
// BoundSheet8's stream position (its first 4 bytes) stays in the clear.
let clear = if CLEAR.contains(&id) {
len
} else if id == BOUNDSHEET {
4
} else {
0
};
let from = start.saturating_add(clear).min(end);
if let Some(data) = stream.get_mut(from..end) {
keystream.apply(from, data);
}
pos = start + len;
}
Ok(())
}
/// What the block keys are made from.
enum Key {
/// RC4 1.1: the 5 bytes every block key is the MD5 of, with the block number.
Rc4([u8; 5]),
/// RC4 CryptoAPI: H0, which every block key is the SHA-1 of, with the block number, cut to
/// `bits`.
CryptoApi { h0: [u8; SHA1_LEN], bits: u32 },
}
impl Key {
/// The cipher for 1024-byte block `block`, at its start.
fn block(&self, block: usize) -> Rc4 {
let block = u32::try_from(block).unwrap_or(u32::MAX).to_le_bytes();
match self {
// MD5(key ‖ LE32(block)) ([MS-OFFCRYPTO] 2.3.6.2).
Key::Rc4(key) => Rc4::new(&md5(&[key, &block])),
// SHA-1(H0 ‖ LE32(block)), its first bits / 8 bytes; a 40-bit key is padded with
// zeros to 128 bits ([MS-OFFCRYPTO] 2.3.5.2).
Key::CryptoApi { h0, bits } => {
let hash = sha1(&[h0, &block]);
let mut key = [0u8; 16];
let len = usize::try_from(*bits / 8).unwrap_or(0).min(key.len());
key.iter_mut().zip(hash.iter()).take(len).for_each(|(k, h)| *k = *h);
Rc4::new(if *bits == 40 { &key } else { key.get(..len).unwrap_or(&key) })
}
}
}
}
/// The key every block key is derived from, after checking `password` against the FilePass
/// verifier.
fn password_key(filepass: &[u8], password: &str) -> Result<Key, DecryptError> {
let short = || malformed("truncated FilePass record");
match u16_at(filepass, 0).ok_or_else(short)? {
0 => return Err(DecryptError::Unsupported("XOR obfuscation".into())),
1 => {}
other => return Err(DecryptError::Malformed(format!("unknown encryption type {other}"))),
}
match (u16_at(filepass, 2).ok_or_else(short)?, u16_at(filepass, 4).ok_or_else(short)?) {
(1, 1) => rc4_key(filepass, password),
(2..=4, 2) => cryptoapi_key(filepass, password),
(major, minor) => Err(DecryptError::Unsupported(format!("version {major}.{minor}"))),
}
}
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
let v = b.get(at..at.checked_add(2)?)?;
Some(u16::from_le_bytes([*v.first()?, *v.get(1)?]))
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
let v = b.get(at..at.checked_add(4)?)?;
Some(u32::from_le_bytes([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?]))
}
/// RC4 1.1 ([MS-OFFCRYPTO] 2.3.6.1, 2.3.6.2): the key, after checking the verifier.
fn rc4_key(filepass: &[u8], password: &str) -> Result<Key, DecryptError> {
let short = || malformed("truncated FilePass record");
let salt = filepass.get(6..22).ok_or_else(short)?;
let verifier = filepass.get(22..38).ok_or_else(short)?;
let verifier_hash = filepass.get(38..54).ok_or_else(short)?;
// H0 = MD5(UTF-16LE password); H1 = MD5(16 × (first 5 bytes of H0 ‖ salt)); the key is the
// first 5 bytes of H1.
let pw: Vec<u8> = password.encode_utf16().flat_map(u16::to_le_bytes).collect();
let h0 = md5(&[&pw]);
let truncated = h0.get(..5).unwrap_or_default();
let mut m = Md5::new();
for _ in 0..16 {
m.update(truncated);
m.update(salt);
}
let mut key = [0u8; 5];
key.iter_mut().zip(m.finalize().iter()).for_each(|(k, h)| *k = *h);
let key = Key::Rc4(key);
// The verifier and its hash are encrypted one after the other with the block-0 key.
let mut rc4 = key.block(0);
let verifier: Vec<u8> = verifier.iter().map(|b| b ^ rc4.next_byte()).collect();
let verifier_hash: Vec<u8> = verifier_hash.iter().map(|b| b ^ rc4.next_byte()).collect();
if md5(&[&verifier]).as_slice() != verifier_hash.as_slice() {
return Err(DecryptError::WrongPassword);
}
Ok(key)
}
/// RC4 CryptoAPI ([MS-OFFCRYPTO] 2.3.5.1, 2.3.5.2, 2.3.5.6): after the version, the flags, the
/// EncryptionHeader's size and the header itself, then the EncryptionVerifier (salt size, salt,
/// encrypted verifier, verifier hash size, encrypted verifier hash). Every size is the file's
/// word, so each is checked before it is used.
fn cryptoapi_key(filepass: &[u8], password: &str) -> Result<Key, DecryptError> {
let short = || malformed("truncated FilePass record");
let unsupported = |scheme: String| Err(DecryptError::Unsupported(scheme));
let flags = u32_at(filepass, 6).ok_or_else(short)?;
let header_size = usize::try_from(u32_at(filepass, 10).ok_or_else(short)?).unwrap_or(usize::MAX);
let header_end = 14usize.saturating_add(header_size);
let header = filepass.get(14..header_end).ok_or_else(short)?;
// Eight words (Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType and two reserved),
// then the provider's name, which says nothing the reader needs.
let (true, Some(alg), Some(alg_hash), Some(bits)) = (header.len() >= 32, u32_at(header, 8), u32_at(header, 12), u32_at(header, 16)) else {
return Err(malformed("EncryptionHeader too short"));
};
if flags & F_EXTERNAL != 0 {
return unsupported("CryptoAPI with an external provider".into());
}
if flags & F_AES != 0 {
return unsupported("CryptoAPI AES".into());
}
if !matches!(alg, 0 | CALG_RC4) {
return unsupported(format!("CryptoAPI with cipher 0x{alg:04X}"));
}
if !matches!(alg_hash, 0 | CALG_SHA1) {
return unsupported(format!("RC4 CryptoAPI with hash 0x{alg_hash:04X}"));
}
// 0 means 40 bits; otherwise 40 to 128 bits in steps of 8.
let bits = if bits == 0 { 40 } else { bits };
if !(40..=128).contains(&bits) || bits % 8 != 0 {
return unsupported(format!("RC4 CryptoAPI with a {bits}-bit key"));
}
let verifier_at = header_end;
let field = |at: usize, len: usize| filepass.get(verifier_at.saturating_add(at)..verifier_at.saturating_add(at + len));
if u32_at(filepass, verifier_at) != Some(SALT_LEN as u32) {
return Err(field(0, 4).map_or_else(short, |_| malformed("unexpected salt size")));
}
let salt = field(4, SALT_LEN).ok_or_else(short)?;
let verifier = field(4 + SALT_LEN, SALT_LEN).ok_or_else(short)?;
if u32_at(filepass, verifier_at.saturating_add(4 + 2 * SALT_LEN)) != Some(SHA1_LEN as u32) {
return Err(field(4 + 2 * SALT_LEN, 4).map_or_else(short, |_| malformed("unexpected verifier hash size")));
}
let verifier_hash = field(8 + 2 * SALT_LEN, SHA1_LEN).ok_or_else(short)?;
// H0 = SHA-1(salt ‖ UTF-16LE password).
let pw: Vec<u8> = password.encode_utf16().flat_map(u16::to_le_bytes).collect();
let key = Key::CryptoApi { h0: sha1(&[salt, &pw]), bits };
// As with RC4 1.1, the verifier and its hash run on through one block-0 cipher.
let mut rc4 = key.block(0);
let verifier: Vec<u8> = verifier.iter().map(|b| b ^ rc4.next_byte()).collect();
let verifier_hash: Vec<u8> = verifier_hash.iter().map(|b| b ^ rc4.next_byte()).collect();
if sha1(&[&verifier]).as_slice() != verifier_hash.as_slice() {
return Err(DecryptError::WrongPassword);
}
Ok(key)
}
fn md5(parts: &[&[u8]]) -> [u8; 16] {
let mut m = Md5::new();
for p in parts {
m.update(p);
}
let mut out = [0u8; 16];
out.iter_mut().zip(m.finalize().iter()).for_each(|(o, h)| *o = *h);
out
}
fn sha1(parts: &[&[u8]]) -> [u8; SHA1_LEN] {
let mut m = Sha1::new();
for p in parts {
m.update(p);
}
let mut out = [0u8; SHA1_LEN];
out.iter_mut().zip(m.finalize().iter()).for_each(|(o, h)| *o = *h);
out
}
/// The keystream by stream offset: byte `n` comes from block `n / 1024`'s key, at position
/// `n % 1024`, whether or not the bytes before it were encrypted.
struct Keystream {
key: Key,
block: usize,
rc4: Rc4,
/// The stream offset of the next byte `rc4` gives.
at: usize,
}
impl Keystream {
fn new(key: Key) -> Keystream {
Keystream { rc4: key.block(0), key, block: 0, at: 0 }
}
/// XORs `data`, found at stream offset `offset`, with the keystream there. Offsets only
/// move forward between calls in the normal case; going back rekeys.
fn apply(&mut self, offset: usize, data: &mut [u8]) {
for (n, b) in data.iter_mut().enumerate() {
*b ^= self.byte_at(offset.saturating_add(n));
}
}
fn byte_at(&mut self, offset: usize) -> u8 {
let block = offset / REKEY;
if block != self.block || offset < self.at {
self.block = block;
self.rc4 = self.key.block(block);
self.at = block * REKEY;
}
while self.at < offset {
self.rc4.next_byte();
self.at += 1;
}
self.at += 1;
self.rc4.next_byte()
}
}
/// The RC4 stream cipher.
struct Rc4 {
s: [u8; 256],
i: u8,
j: u8,
}
impl Rc4 {
/// RC4 keyed with `key` (5 to 16 bytes here; an empty key acts as one zero byte).
fn new(key: &[u8]) -> Rc4 {
let mut s = [0u8; 256];
for (n, x) in (0u8..=255).zip(s.iter_mut()) {
*x = n;
}
let mut j = 0u8;
for n in 0..256 {
let k = key.get(n % key.len().max(1)).copied().unwrap_or(0);
j = j.wrapping_add(s[n]).wrapping_add(k);
s.swap(n, usize::from(j));
}
Rc4 { s, i: 0, j: 0 }
}
fn next_byte(&mut self) -> u8 {
self.i = self.i.wrapping_add(1);
self.j = self.j.wrapping_add(self.s[usize::from(self.i)]);
self.s.swap(usize::from(self.i), usize::from(self.j));
let t = self.s[usize::from(self.i)].wrapping_add(self.s[usize::from(self.j)]);
self.s[usize::from(t)]
}
}
/// Encrypts a Workbook stream the way [`decrypt`] reads it, for tests: the FilePass record
/// (RC4 1.1 with a fixed salt and verifier) goes right after the globals' BOF. `None` when the
/// stream does not start with a record.
pub(crate) fn encrypt(stream: &[u8], password: &str) -> Option<Vec<u8>> {
let salt: [u8; 16] = std::array::from_fn(|n| n as u8 + 0x40);
let verifier: [u8; 16] = std::array::from_fn(|n| n as u8 + 0x60);
let pw: Vec<u8> = password.encode_utf16().flat_map(u16::to_le_bytes).collect();
let h0 = md5(&[&pw]);
let mut m = Md5::new();
for _ in 0..16 {
m.update(h0.get(..5)?);
m.update(salt);
}
let mut key = [0u8; 5];
key.iter_mut().zip(m.finalize().iter()).for_each(|(k, h)| *k = *h);
let mut rc4 = Key::Rc4(key).block(0);
let mut filepass = vec![1, 0, 1, 0, 1, 0];
filepass.extend(salt);
filepass.extend(verifier.iter().map(|b| b ^ rc4.next_byte()));
filepass.extend(md5(&[&verifier]).iter().map(|b| b ^ rc4.next_byte()));
with_filepass(stream, &filepass, password)
}
/// The FilePass data [`encrypt_cryptoapi`] writes: RC4 CryptoAPI version 4.2, as current Excel
/// writes it, with a `bits`-bit key (40 to 128) and a fixed salt and verifier.
pub(crate) fn cryptoapi_filepass(password: &str, bits: u32) -> Vec<u8> {
let salt: [u8; SALT_LEN] = std::array::from_fn(|n| (n as u8).wrapping_mul(7).wrapping_add(0x11));
let verifier: [u8; SALT_LEN] = std::array::from_fn(|n| n as u8 + 0x80);
let pw: Vec<u8> = password.encode_utf16().flat_map(u16::to_le_bytes).collect();
let mut rc4 = Key::CryptoApi { h0: sha1(&[&salt, &pw]), bits }.block(0);
// fCryptoAPI and fDocProps, as Excel sets them.
let flags: u32 = 0x0C;
let provider = "Test provider\0".encode_utf16().flat_map(u16::to_le_bytes);
let header: Vec<u8> = [flags, 0, CALG_RC4, CALG_SHA1, bits, 1, 0, 0].iter().flat_map(|w| w.to_le_bytes()).chain(provider).collect();
let mut filepass = vec![1, 0, 4, 0, 2, 0];
filepass.extend(flags.to_le_bytes());
filepass.extend((header.len() as u32).to_le_bytes());
filepass.extend(header);
filepass.extend((SALT_LEN as u32).to_le_bytes());
filepass.extend(salt);
filepass.extend(verifier.iter().map(|b| b ^ rc4.next_byte()));
filepass.extend((SHA1_LEN as u32).to_le_bytes());
filepass.extend(sha1(&[&verifier]).iter().map(|b| b ^ rc4.next_byte()));
filepass
}
/// [`encrypt`] with RC4 CryptoAPI ([`cryptoapi_filepass`]).
pub(crate) fn encrypt_cryptoapi(stream: &[u8], password: &str, bits: u32) -> Option<Vec<u8>> {
with_filepass(stream, &cryptoapi_filepass(password, bits), password)
}
/// `stream` with a FilePass record holding `filepass` right after its first BOF, encrypted with
/// `password`.
fn with_filepass(stream: &[u8], filepass: &[u8], password: &str) -> Option<Vec<u8>> {
let (_, bof) = header(stream, 0)?;
let bof_len = 4 + bof;
let mut out = stream.get(..bof_len)?.to_vec();
out.extend(FILEPASS.to_le_bytes());
out.extend(u16::try_from(filepass.len()).ok()?.to_le_bytes());
out.extend(filepass);
let shift = u32::try_from(out.len() - bof_len).ok()?;
let mut rest = stream.get(bof_len..)?.to_vec();
// BoundSheet8 positions move by the FilePass record's size.
let mut pos = 0;
while let Some((id, len)) = header(&rest, pos) {
if id == BOUNDSHEET {
let at = rest.get_mut(pos + 4..pos + 8)?;
let p = u32::from_le_bytes([*at.first()?, *at.get(1)?, *at.get(2)?, *at.get(3)?]).checked_add(shift)?;
at.copy_from_slice(&p.to_le_bytes());
}
pos += 4 + len;
}
out.extend(rest);
// The cipher is its own inverse.
decrypt(&mut out, password).ok()?;
Some(out)
}
+15 -4
View File
@@ -13,8 +13,10 @@
//! stay in the file's units.
//!
//! Everything else (charts, other shapes, newer conditional formats, hyperlinks,
//! PivotTables, macros) is skipped and named in [`Read::warnings`]. Encrypted workbooks and
//! versions before BIFF8 are refused ([`Error::Encrypted`], [`Error::Biff5`]). A formula that
//! PivotTables, macros) is skipped and named in [`Read::warnings`]. A workbook encrypted with
//! RC4 or RC4 CryptoAPI is read with its password ([`read_with_password`]; without one, Excel's
//! default password is tried and [`Error::Encrypted`] says one is needed). Other encryption and
//! versions before BIFF8 are refused ([`Error::UnsupportedEncryption`], [`Error::Biff5`]). A formula that
//! cannot be put back into text (links to other workbooks, macro commands, unknown tokens)
//! leaves its cell with the saved result and a [`Warning::FormulaKept`].
//!
@@ -33,6 +35,7 @@
//! ```
mod cond;
mod crypt;
mod drawing;
mod dv;
mod expr;
@@ -52,7 +55,7 @@ pub use model::{
ConditionalFormat, DataValidation, DefinedName, DifferentialFormat, DxfFill, ErrorStyle, Fill, Font, Formula, HAlign, Note, Operator, Pattern,
Picture, Range, Row, Script, Sheet, Underline, VAlign, ValidationType, Value, View, Visibility, Workbook,
};
pub use read::{is_xls, read, read_with};
pub use read::{is_encrypted, is_xls, read, read_with, read_with_password};
/// Why a workbook could not be read.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
@@ -64,9 +67,17 @@ pub enum Error {
/// More than a [`Limits`] value or a fixed cap allows.
#[error("the workbook is too large: {0}")]
TooLarge(String),
/// The workbook is password-protected (it has a FilePass record).
/// The workbook is encrypted with a password (it has a FilePass record): read it with
/// [`read_with_password`].
#[error("the workbook is password-protected (encrypted)")]
Encrypted,
/// The password does not open the workbook.
#[error("the password is not the workbook's")]
WrongPassword,
/// The workbook is encrypted with a scheme that is not read (XOR obfuscation, CryptoAPI with
/// AES or another cipher or hash, an external provider), named.
#[error("the workbook uses {0} encryption, which is not read")]
UnsupportedEncryption(String),
/// An Excel 5.0/95 workbook (BIFF5), which is not read.
#[error("Excel 5.0/95 workbooks (BIFF5) are not supported")]
Biff5,
+34 -2
View File
@@ -187,7 +187,38 @@ pub fn read(bytes: &[u8]) -> Result<Read, Error> {
/// [`read`] with the caller's [`Limits`].
pub fn read_with(bytes: &[u8], limits: Limits) -> Result<Read, Error> {
let stream = workbook_stream(bytes)?;
read_stream(workbook_stream(bytes)?, limits, None)
}
/// [`read_with`] for a workbook encrypted with a password (RC4 or RC4 CryptoAPI). A workbook
/// without one reads as with [`read_with`], whatever the password.
pub fn read_with_password(bytes: &[u8], limits: Limits, password: &str) -> Result<Read, Error> {
read_stream(workbook_stream(bytes)?, limits, Some(password))
}
/// Whether the bytes are a workbook encrypted with a password (its globals hold a FilePass
/// record).
pub fn is_encrypted(bytes: &[u8]) -> bool {
workbook_stream(bytes).is_ok_and(|s| crate::crypt::filepass(&s).is_some())
}
/// Decrypts an encrypted stream: with `password`, or without one with Excel's default password,
/// as Excel opens a workbook protected only against changes.
fn decrypt(stream: &mut [u8], password: Option<&str>) -> Result<(), Error> {
use crate::crypt::DecryptError;
match crate::crypt::decrypt(stream, password.unwrap_or(crate::crypt::DEFAULT_PASSWORD)) {
Ok(()) => Ok(()),
Err(DecryptError::WrongPassword) if password.is_none() => Err(Error::Encrypted),
Err(DecryptError::WrongPassword) => Err(Error::WrongPassword),
Err(DecryptError::Unsupported(s)) => Err(Error::UnsupportedEncryption(s)),
Err(DecryptError::Malformed(s)) => Err(invalid(format!("the encryption record is damaged: {s}"))),
}
}
fn read_stream(mut stream: Vec<u8>, limits: Limits, password: Option<&str>) -> Result<Read, Error> {
if crate::crypt::filepass(&stream).is_some() {
decrypt(&mut stream, password)?;
}
let mut budget = Budget::new(limits);
let mut report = Report::default();
let g = globals(&stream, &mut budget, &mut report)?;
@@ -267,7 +298,8 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut Report) -> Result<Gl
let mut c = Cursor::new(&rec.parts);
match rec.id {
EOF => break,
0x002F => return Err(Error::Encrypted),
// FilePass: only reached once the stream was decrypted.
0x002F => {}
0x0085 => {
if g.sheets.len() >= MAX_SHEETS {
return Err(Error::TooLarge("too many sheets".into()));
+16
View File
@@ -128,3 +128,19 @@ fn write_sst(out: &mut Vec<u8>, parts: &[Vec<u8>]) {
}
rec(out, kind, &cur);
}
/// `stream` (a Workbook stream starting with the globals' BOF) encrypted with RC4 1.1 and
/// `password`, a FilePass record put after the BOF. `None` for a stream that is not one.
pub fn encrypt_rc4(stream: &[u8], password: &str) -> Option<Vec<u8>> {
crate::crypt::encrypt(stream, password)
}
/// [`encrypt_rc4`] with RC4 CryptoAPI version 4.2 and a `bits`-bit key, as current Excel writes it.
pub fn encrypt_cryptoapi(stream: &[u8], password: &str, bits: u32) -> Option<Vec<u8>> {
crate::crypt::encrypt_cryptoapi(stream, password, bits)
}
/// The FilePass record data [`encrypt_cryptoapi`] writes, for tests that damage it.
pub fn cryptoapi_filepass(password: &str, bits: u32) -> Vec<u8> {
crate::crypt::cryptoapi_filepass(password, bits)
}
+130 -3
View File
@@ -6,7 +6,7 @@ use common::{
at, bof, cat, cell, compound, eof, exp, fmt, formula, ftext, num, ptg_int, ptg_ref, put32, read_limited, read_xls, rec, special, text, u16s, v,
xlstr,
};
use craft_xls::{CalcMode, CellError, Column, Error, Limits, Range, Row, Value, View, Workbook, is_xls};
use craft_xls::{CalcMode, CellError, CellRef, Column, Error, Limits, Range, Row, Value, View, Workbook, is_xls};
fn range(a1: &str) -> Range {
let (a, b) = a1.split_once(':').unwrap_or((a1, a1));
@@ -201,11 +201,12 @@ fn text_result_of_a_formula_kept_as_its_value() {
#[test]
fn refuses_encrypted_old_and_foreign_files() {
// FILEPASS in the globals: an encrypted workbook.
// FILEPASS in the globals: an encrypted workbook (encryption.rs reads them); this one is cut
// short, so it is a damaged file.
let mut stream = bof(0x0005);
stream.extend(rec(0x002F, &[1, 0, 1, 0]));
stream.extend(eof());
assert_eq!(read_xls(&compound("Workbook", &stream)).err(), Some(Error::Encrypted));
assert!(matches!(read_xls(&compound("Workbook", &stream)), Err(Error::Invalid(_))));
// A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream.
let book = compound("Book", &stream);
assert!(is_xls(&book));
@@ -701,3 +702,129 @@ fn reports_the_sheet_it_was_saved_on() {
// Only the first window counts.
assert_eq!(book(&cat(&[&window1(1), &window1(2)])).as_deref(), Some("Sheet1"));
}
// Encrypted workbooks. Ported from storytold/gridcraft#847 (RC4) and #3713 (RC4 CryptoAPI).
fn read_pw(file: &[u8], password: &str) -> Result<craft_xls::Read, Error> {
craft_xls::read_with_password(file, Limits::default(), password)
}
/// A sheet over several 1024-byte blocks, with records straddling the boundaries.
fn long_sheet() -> Vec<u8> {
let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 4, 0], b"Long"]));
let globals = |pos: u32| cat(&[&bof(0x0005), &boundsheet(pos), &eof()]);
let mut stream = globals(globals(0).len() as u32);
stream.extend(bof(0x0010));
for r in 0..500u16 {
stream.extend(rec(0x0203, &cat(&[&cell(r, 0, 0), &f64::from(r).to_le_bytes()])));
}
stream.extend(eof());
stream
}
fn with_filepass(payload: &[u8]) -> Vec<u8> {
compound("Workbook", &cat(&[&bof(0x0005), &rec(0x002F, payload), &eof()]))
}
#[test]
fn rc4_encrypted_workbooks_read_with_their_password() {
use craft_xls::testing::encrypt_rc4;
let plain = compound("Workbook", &sample(false));
let file = compound("Workbook", &encrypt_rc4(&sample(false), "a password").unwrap());
assert!(is_xls(&file) && craft_xls::is_encrypted(&file) && !craft_xls::is_encrypted(&plain));
assert_eq!(read_xls(&file).err(), Some(Error::Encrypted));
assert_eq!(read_pw(&file, "A password").err(), Some(Error::WrongPassword));
let wb = read_pw(&file, "a password").unwrap().workbook;
let (expected, _) = read_xls(&plain).unwrap();
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Sheet1", "Data"]);
assert_eq!(v(&wb, 0, "D1"), text("Wörld"));
assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)"));
assert_eq!(wb.sheets[0].cells, expected.sheets[0].cells);
// A workbook without a password ignores the one given.
assert!(read_pw(&plain, "x").is_ok());
let file = compound("Workbook", &encrypt_rc4(&long_sheet(), "a longer pass").unwrap());
let wb = read_pw(&file, "a longer pass").unwrap().workbook;
for r in [0u32, 56, 57, 499] {
assert_eq!(wb.sheets[0].cells[&CellRef::new(r, 0)].value, Value::Number(f64::from(r)), "row {r}");
}
// Schemes no password opens are named.
assert_eq!(read_pw(&with_filepass(&[0, 0, 0x34, 0x12, 0x78, 0x56]), "x").err(), Some(Error::UnsupportedEncryption("XOR obfuscation".into())));
assert_eq!(read_pw(&with_filepass(&[1, 0, 3, 0, 3, 0]), "x").err(), Some(Error::UnsupportedEncryption("version 3.3".into())));
assert!(matches!(read_pw(&with_filepass(&[1, 0, 1, 0, 1, 0, 9]), "x"), Err(Error::Invalid(_))));
}
#[test]
fn excels_default_password_opens_without_asking() {
// A workbook protected only against changes is encrypted with "VelvetSweatshop".
let file = compound("Workbook", &craft_xls::testing::encrypt_rc4(&sample(false), "VelvetSweatshop").unwrap());
assert_eq!(read_xls(&file).unwrap().0.sheets.len(), 2);
let file = compound("Workbook", &craft_xls::testing::encrypt_cryptoapi(&sample(false), "VelvetSweatshop", 128).unwrap());
assert_eq!(read_xls(&file).unwrap().0.sheets.len(), 2);
}
#[test]
fn rc4_cryptoapi_encrypted_workbooks_read_with_their_password() {
use craft_xls::testing::encrypt_cryptoapi;
let (expected, _) = read_xls(&compound("Workbook", &sample(false))).unwrap();
// Current Excel's 128-bit key, the oldest 40-bit one (padded to 128 bits) and one between.
for bits in [128, 40, 56] {
let file = compound("Workbook", &encrypt_cryptoapi(&sample(false), "Synthetic pw 9", bits).unwrap());
assert!(craft_xls::is_encrypted(&file), "{bits}");
assert_eq!(read_xls(&file).err(), Some(Error::Encrypted), "{bits}");
assert_eq!(read_pw(&file, "synthetic pw 9").err(), Some(Error::WrongPassword), "{bits}");
let wb = read_pw(&file, "Synthetic pw 9").unwrap().workbook;
assert_eq!(v(&wb, 0, "D1"), text("Wörld"));
assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)"));
assert_eq!(wb.sheets[0].cells, expected.sheets[0].cells, "{bits}");
assert_eq!(wb.sheets[1].cells, expected.sheets[1].cells, "{bits}");
}
let file = compound("Workbook", &encrypt_cryptoapi(&long_sheet(), "pw", 128).unwrap());
let wb = read_pw(&file, "pw").unwrap().workbook;
for r in [0u32, 56, 57, 120, 499] {
assert_eq!(wb.sheets[0].cells[&CellRef::new(r, 0)].value, Value::Number(f64::from(r)), "row {r}");
}
}
/// The RC4 CryptoAPI FilePass fields are the file's word: other ciphers, hashes and key sizes are
/// named, and sizes that don't fit are a damaged file, never a panic.
#[test]
fn rc4_cryptoapi_filepass_fields_are_checked() {
let good = craft_xls::testing::cryptoapi_filepass("pw", 128);
// The EncryptionHeader starts at byte 14.
let edited = |at: usize, word: u32| {
let mut f = good.clone();
f[at..at + 4].copy_from_slice(&word.to_le_bytes());
read_pw(&with_filepass(&f), "pw")
};
let unsupported = |r: Result<craft_xls::Read, Error>| match r {
Err(Error::UnsupportedEncryption(s)) => s,
other => panic!("{other:?}"),
};
let damaged = |r: Result<craft_xls::Read, Error>| matches!(&r, Err(Error::Invalid(m)) if m.contains("encryption record"));
// Decrypted, the record alone is a workbook with no sheets: refused for that, not for its key.
let decrypts = |r: Result<craft_xls::Read, Error>| matches!(&r, Err(Error::Invalid(m)) if m.contains("no worksheets"));
assert!(decrypts(read_pw(&with_filepass(&good), "pw")));
assert_eq!(unsupported(edited(14 + 8, 0x660E)), "CryptoAPI with cipher 0x660E");
assert_eq!(unsupported(edited(6, 0x24)), "CryptoAPI AES");
assert_eq!(unsupported(edited(6, 0x14)), "CryptoAPI with an external provider");
assert_eq!(unsupported(edited(14 + 12, 0x800C)), "RC4 CryptoAPI with hash 0x800C");
for bits in [32, 136, 256, 44, u32::MAX] {
assert_eq!(unsupported(edited(14 + 16, bits)), format!("RC4 CryptoAPI with a {bits}-bit key"));
}
// A key size of 0 means 40 bits: a valid header, so only the verifier tells.
assert_eq!(edited(14 + 16, 0).err(), Some(Error::WrongPassword));
// An algorithm of 0 means RC4 and SHA-1.
assert!(decrypts(edited(14 + 8, 0)) && decrypts(edited(14 + 12, 0)));
let header_size = u32::from_le_bytes(good[10..14].try_into().unwrap()) as usize;
for size in [0, 31, u32::MAX, header_size as u32 + 1000] {
assert!(damaged(edited(10, size)), "header size {size}");
}
let verifier_at = 14 + header_size;
assert!(damaged(edited(verifier_at, 20)), "salt size");
assert!(damaged(edited(verifier_at, u32::MAX)), "salt size");
assert!(damaged(edited(verifier_at + 36, 16)), "verifier hash size");
// Cut anywhere, the record is a damaged file, never a panic.
for cut in 6..good.len() {
assert!(damaged(read_pw(&with_filepass(&good[..cut]), "pw")), "cut at {cut}");
}
}