mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 04:36:51 -06:00
Agile decryption checked only the password verifier, which authenticates
the key — not the package. An attacker who could replace EncryptedPackage
ciphertext could change workbook content undetected ([MS-OFFCRYPTO]
2.3.4.14, the descriptor's dataIntegrity element).
The reader now requires the dataIntegrity pair, decrypts it with the
recovered package key, and checks HMAC(hash, hmacKey) over the whole
EncryptedPackage stream (StreamSize field included) against the stored
value before any plaintext is returned. Excel always writes the element
("SHOULD" in the spec); descriptors without it are refused as damaged
rather than accepted unauthenticated.
- crypto: RFC 2104/4231 HMAC test vectors, RFC-test cases for a key
longer than the block size
- fixtures write the pair like Excel; new tests: missing/empty/ragged
integrity fields, tampered ciphertext and descriptor, a changed
StreamSize field
272 lines
12 KiB
Rust
272 lines
12 KiB
Rust
//! Test support: builds password-encrypted workbooks in code, so tests here and in the crates
|
|
//! above (engine, UI) never need an encrypted file on disk. Compiled for this crate's tests and,
|
|
//! elsewhere, only with the `test-support` feature (a dev-dependency feature, never shipped).
|
|
//!
|
|
//! Salts and keys are fixed so the output is deterministic. This is not an encryptor for real
|
|
//! use: a fixed key protects nothing.
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
|
|
|
use crate::crypto::{
|
|
BLOCK_HMAC_KEY, BLOCK_HMAC_VALUE, BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params,
|
|
SEGMENT, derive, hmac, iterated_hash, standard_key,
|
|
};
|
|
|
|
/// The spin count `encrypt_workbook` uses: Excel's default.
|
|
pub const SPIN_COUNT: u32 = 100_000;
|
|
|
|
const PASSWORD_SALT: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
|
const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
|
|
const SECRET_KEY: [u8; 32] =
|
|
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95];
|
|
const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47];
|
|
const HMAC_SALT: [u8; 16] = [112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127];
|
|
|
|
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
|
|
/// (AES-256, SHA-512) inside a compound file.
|
|
pub fn encrypt_workbook(package: &[u8], password: &str) -> Vec<u8> {
|
|
let (info, encrypted) = encrypt_agile(package, password, SPIN_COUNT);
|
|
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
|
}
|
|
|
|
/// The same with Standard encryption (AES-128, SHA-1), as Excel 2007 wrote it.
|
|
pub fn encrypt_workbook_standard(package: &[u8], password: &str) -> Vec<u8> {
|
|
let (info, encrypted) = encrypt_standard(package, password);
|
|
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
|
}
|
|
|
|
/// A version 3 compound file (512-byte sectors) holding `streams` under the root, laid out as
|
|
/// Excel lays it out: streams under 4096 bytes in the mini stream, larger ones in regular
|
|
/// sectors. Up to 109 FAT sectors (about 7 MB of streams).
|
|
pub fn compound_file(streams: &[(&str, &[u8])]) -> Vec<u8> {
|
|
const SECTOR: usize = 512;
|
|
const MINI: usize = 64;
|
|
const CUTOFF: usize = 4096;
|
|
const FREE: u32 = 0xFFFF_FFFF;
|
|
const END: u32 = 0xFFFF_FFFE;
|
|
const FAT_SECTOR: u32 = 0xFFFF_FFFD;
|
|
let is_mini = |d: &[u8]| d.len() < CUTOFF;
|
|
|
|
// The mini stream: small streams in 64-byte mini sectors, chained by the mini FAT.
|
|
let mut container = vec![];
|
|
let mut mini_fat: Vec<u32> = vec![];
|
|
let mut mini_starts = vec![];
|
|
for (_, d) in streams {
|
|
if !is_mini(d) || d.is_empty() {
|
|
mini_starts.push(END);
|
|
continue;
|
|
}
|
|
let first = mini_fat.len();
|
|
let n = d.len().div_ceil(MINI);
|
|
for k in 0..n {
|
|
mini_fat.push(if k + 1 == n { END } else { (first + k + 1) as u32 });
|
|
}
|
|
mini_starts.push(first as u32);
|
|
container.extend_from_slice(d);
|
|
container.resize((first + n) * MINI, 0);
|
|
}
|
|
|
|
// Regular-sector blobs, in order: directory, mini FAT, mini stream, then each large stream.
|
|
let dir_len = (streams.len() + 1).div_ceil(4) * SECTOR;
|
|
let mut blobs: Vec<Vec<u8>> = vec![vec![0; dir_len], mini_fat.iter().flat_map(|v| v.to_le_bytes()).collect(), container.clone()];
|
|
blobs.extend(streams.iter().map(|(_, d)| if is_mini(d) { vec![] } else { d.to_vec() }));
|
|
let counts: Vec<usize> = blobs.iter().map(|b| b.len().div_ceil(SECTOR)).collect();
|
|
let body: usize = counts.iter().sum();
|
|
let mut fat_sectors = 1;
|
|
while fat_sectors * 128 < fat_sectors + body {
|
|
fat_sectors += 1;
|
|
}
|
|
assert!(fat_sectors <= 109, "the test writer has no DIFAT sectors");
|
|
let mut fat = vec![FREE; fat_sectors * 128];
|
|
fat[..fat_sectors].fill(FAT_SECTOR);
|
|
let mut starts = vec![];
|
|
let mut next = fat_sectors;
|
|
for &n in &counts {
|
|
if n == 0 {
|
|
starts.push(END);
|
|
continue;
|
|
}
|
|
starts.push(next as u32);
|
|
for k in 0..n {
|
|
fat[next + k] = if k + 1 == n { END } else { (next + k + 1) as u32 };
|
|
}
|
|
next += n;
|
|
}
|
|
|
|
let put16 = |b: &mut [u8], at: usize, v: u16| b[at..at + 2].copy_from_slice(&v.to_le_bytes());
|
|
let put32 = |b: &mut [u8], at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
|
// Directory: the root (owning the mini stream), then the streams chained as right siblings.
|
|
let dir = &mut blobs[0];
|
|
for k in 0..dir_len / 128 {
|
|
for at in [68, 72, 76] {
|
|
put32(dir, k * 128 + at, FREE);
|
|
}
|
|
}
|
|
let mut entry = |k: usize, name: &str, kind: u8, start: u32, size: usize| {
|
|
let e = k * 128;
|
|
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
|
dir[e..e + units.len()].copy_from_slice(&units);
|
|
put16(dir, e + 64, units.len() as u16 + 2);
|
|
dir[e + 66] = kind;
|
|
dir[e + 67] = 1; // black
|
|
if kind == 5 && !streams.is_empty() {
|
|
put32(dir, e + 76, 1);
|
|
}
|
|
if kind == 2 && k < streams.len() {
|
|
put32(dir, e + 72, k as u32 + 1);
|
|
}
|
|
put32(dir, e + 116, start);
|
|
put32(dir, e + 120, size as u32);
|
|
};
|
|
entry(0, "Root Entry", 5, starts[2], container.len());
|
|
for (k, (name, data)) in streams.iter().enumerate() {
|
|
let start = if is_mini(data) { mini_starts[k] } else { starts[3 + k] };
|
|
entry(k + 1, name, 2, start, data.len());
|
|
}
|
|
|
|
let mut out = vec![0u8; SECTOR * (1 + fat_sectors + body)];
|
|
out[..8].copy_from_slice(&crate::cfb::SIGNATURE);
|
|
put16(&mut out, 24, 0x3E); // minor version
|
|
put16(&mut out, 26, 3); // major version
|
|
put16(&mut out, 28, 0xFFFE); // byte order
|
|
put16(&mut out, 30, 9); // 512-byte sectors
|
|
put16(&mut out, 32, 6); // 64-byte mini sectors
|
|
put32(&mut out, 44, fat_sectors as u32);
|
|
put32(&mut out, 48, starts[0]);
|
|
put32(&mut out, 56, CUTOFF as u32);
|
|
put32(&mut out, 60, starts[1]); // first mini FAT sector (END when there is none)
|
|
put32(&mut out, 64, counts[1] as u32);
|
|
put32(&mut out, 68, END); // no DIFAT sectors
|
|
for i in 0..109 {
|
|
put32(&mut out, 76 + i * 4, if i < fat_sectors { i as u32 } else { FREE });
|
|
}
|
|
for (i, v) in fat.iter().enumerate() {
|
|
put32(&mut out, SECTOR + i * 4, *v);
|
|
}
|
|
for (blob, start) in blobs.iter().zip(&starts) {
|
|
if !blob.is_empty() {
|
|
let at = SECTOR * (1 + *start as usize);
|
|
out[at..at + blob.len()].copy_from_slice(blob);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn encrypt_block(key: &[u8], block: &mut [u8; 16]) {
|
|
let b = aes::Block::cast_from_core_mut(block);
|
|
match key.len() {
|
|
16 => aes::Aes128::new_from_slice(key).unwrap().encrypt_block(b),
|
|
32 => aes::Aes256::new_from_slice(key).unwrap().encrypt_block(b),
|
|
n => panic!("no {n}-byte AES in the test encryptor"),
|
|
}
|
|
}
|
|
|
|
fn pad(data: &[u8]) -> Vec<u8> {
|
|
let mut v = data.to_vec();
|
|
v.resize(data.len().div_ceil(16) * 16, 0);
|
|
v
|
|
}
|
|
|
|
fn encrypt_ecb(key: &[u8], data: &[u8]) -> Vec<u8> {
|
|
let mut v = pad(data);
|
|
v.as_chunks_mut::<16>().0.iter_mut().for_each(|b| encrypt_block(key, b));
|
|
v
|
|
}
|
|
|
|
fn encrypt_cbc(key: &[u8], iv: &[u8], data: &[u8]) -> Vec<u8> {
|
|
let mut v = pad(data);
|
|
let mut prev: [u8; 16] = iv.try_into().unwrap();
|
|
for b in v.as_chunks_mut::<16>().0 {
|
|
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
|
|
encrypt_block(key, b);
|
|
prev = *b;
|
|
}
|
|
v
|
|
}
|
|
|
|
fn base64_encode(b: &[u8]) -> String {
|
|
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut s = String::new();
|
|
for c in b.chunks(3) {
|
|
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
|
for i in 0..4 {
|
|
s.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
|
|
}
|
|
}
|
|
s
|
|
}
|
|
|
|
fn package(size: usize, body: Vec<u8>) -> Vec<u8> {
|
|
let mut p = (size as u64).to_le_bytes().to_vec();
|
|
p.extend(body);
|
|
p
|
|
}
|
|
|
|
/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16, and the
|
|
/// dataIntegrity pair (`hmac_key_b64`/`hmac_value_b64`) a compliant file carries.
|
|
pub(crate) fn agile_info(password: &str, spin: u32, hmac_key_b64: &str, hmac_value_b64: &str) -> String {
|
|
let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 };
|
|
let h = iterated_hash(Hash::Sha512, password, &key.salt, spin);
|
|
let iv = key.iv(None);
|
|
let field = |block_key: &[u8], data: &[u8]| base64_encode(&encrypt_cbc(&derive(Hash::Sha512, &h, block_key, 32), &iv, data));
|
|
let input = field(&BLOCK_VERIFIER_INPUT, &VERIFIER);
|
|
let value = field(&BLOCK_VERIFIER_HASH, &Hash::Sha512.digest(&[&VERIFIER]));
|
|
let secret = field(&BLOCK_KEY, &SECRET_KEY);
|
|
let params = |salt: &[u8]| {
|
|
format!(
|
|
"saltSize=\"16\" blockSize=\"16\" keyBits=\"256\" hashSize=\"64\" cipherAlgorithm=\"AES\" cipherChaining=\"ChainingModeCBC\" hashAlgorithm=\"SHA512\" saltValue=\"{}\"",
|
|
base64_encode(salt)
|
|
)
|
|
};
|
|
// The dataIntegrity pair ([MS-OFFCRYPTO] 2.3.4.14), encrypted with the package key.
|
|
format!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><dataIntegrity encryptedHmacKey=\"{hmac_key_b64}\" encryptedHmacValue=\"{hmac_value_b64}\"/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
|
|
params(KEY_DATA_SALT.as_slice()),
|
|
params(&PASSWORD_SALT),
|
|
)
|
|
}
|
|
|
|
/// `EncryptionInfo` for an Agile descriptor: version 4.4, reserved 0x40, then the XML.
|
|
pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
|
|
let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0];
|
|
info.extend_from_slice(xml.as_bytes());
|
|
info
|
|
}
|
|
|
|
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512), with the
|
|
/// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it.
|
|
pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>, Vec<u8>) {
|
|
let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 };
|
|
let mut body = vec![];
|
|
for (i, segment) in plain.chunks(SEGMENT).enumerate() {
|
|
body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment));
|
|
}
|
|
let stream = package(plain.len(), body);
|
|
let (hmac_key_b64, hmac_value_b64) = (
|
|
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), &HMAC_SALT)),
|
|
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, &HMAC_SALT, &stream))),
|
|
);
|
|
(with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream)
|
|
}
|
|
|
|
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1).
|
|
pub(crate) fn encrypt_standard(plain: &[u8], password: &str) -> (Vec<u8>, Vec<u8>) {
|
|
let key = standard_key(password, &PASSWORD_SALT, 16);
|
|
let mut header = vec![];
|
|
for v in [F_CRYPTOAPI | F_AES, 0, 0x660E, 0x8004, 128, 0x18, 0, 0] {
|
|
header.extend_from_slice(&u32::to_le_bytes(v));
|
|
}
|
|
header.extend("\0".encode_utf16().flat_map(u16::to_le_bytes));
|
|
let mut info = vec![4, 0, 2, 0];
|
|
info.extend_from_slice(&(F_CRYPTOAPI | F_AES).to_le_bytes());
|
|
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
|
info.extend(header);
|
|
info.extend_from_slice(&16u32.to_le_bytes());
|
|
info.extend(PASSWORD_SALT);
|
|
info.extend(encrypt_ecb(&key, &VERIFIER));
|
|
info.extend_from_slice(&20u32.to_le_bytes());
|
|
info.extend(encrypt_ecb(&key, &Hash::Sha1.digest(&[&VERIFIER])));
|
|
(info, package(plain.len(), encrypt_ecb(&key, plain)))
|
|
}
|