mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-01 22:37:10 -06:00
docbin decrypts XOR-obfuscated, RC4 and RC4 CryptoAPI files after verifying the password against the stored verifier; the engine reuses the .docx password prompt errors. Written from [MS-DOC] and [MS-OFFCRYPTO]; synthetic tests only. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
186 lines
8.0 KiB
Rust
186 lines
8.0 KiB
Rust
//! The File Information Block ([MS-DOC] §2.5): `FibBase` plus the variable sections
|
|
//! (`FibRgW97`, `FibRgLw97`, the `RgFcLcb` blob, `FibRgCswNew`) at the start of the
|
|
//! WordDocument stream.
|
|
|
|
use crate::DocbinError;
|
|
|
|
/// `wIdent` of every Word 97-2003 file (Word 6/95 wrote 0xA5DC).
|
|
const W_IDENT: u16 = 0xA5EC;
|
|
/// Offsets within the FIB, in bytes (see the spec's Fib layout).
|
|
const OFF_FLAGS: usize = 0x0A;
|
|
const OFF_CSW: usize = 0x20;
|
|
const OFF_CSLW: usize = 0x3E;
|
|
const OFF_RGLW: usize = 0x40;
|
|
const OFF_CB_RG_FCLCB: usize = 0x98;
|
|
/// `csw` (count of 16-bit values in `FibRgW97`) — fixed by the spec.
|
|
const CSW: u16 = 0x000E;
|
|
/// `cslw` (count of 32-bit values in `FibRgLw97`) — fixed by the spec.
|
|
const CSLW: u16 = 0x0016;
|
|
/// `cbRgFcLcb` for each known `nFib` ([MS-DOC] Fib.cbRgFcLcb table).
|
|
const CB_RG_FCLCB: &[(u16, u16)] = &[(0x00C1, 0x005D), (0x00D9, 0x006C), (0x0101, 0x0088), (0x010C, 0x00A4), (0x0112, 0x00B7)];
|
|
|
|
/// Indices of `fc`/`lcb` pairs inside `FibRgFcLcb97` (each pair is 8 bytes: fc then lcb).
|
|
pub(crate) mod pair {
|
|
pub(crate) const STSHF: usize = 1;
|
|
pub(crate) const PLCF_FND_REF: usize = 2;
|
|
pub(crate) const PLCF_FND_TXT: usize = 3;
|
|
pub(crate) const PLCF_SED: usize = 6;
|
|
pub(crate) const PLCF_HDD: usize = 11;
|
|
pub(crate) const PLCF_BTE_CHPX: usize = 12;
|
|
pub(crate) const PLCF_BTE_PAPX: usize = 13;
|
|
pub(crate) const STTBF_FFN: usize = 15;
|
|
pub(crate) const STTBF_BKMK: usize = 21;
|
|
pub(crate) const PLCF_BKF: usize = 22;
|
|
pub(crate) const PLCF_BKL: usize = 23;
|
|
pub(crate) const CLX: usize = 33;
|
|
/// `fcPlcSpaMom` / `fcPlcSpaHdr`: floating shape anchors of the main document / headers.
|
|
pub(crate) const PLC_SPA_MOM: usize = 40;
|
|
pub(crate) const PLC_SPA_HDR: usize = 41;
|
|
pub(crate) const PLCF_END_REF: usize = 46;
|
|
pub(crate) const PLCF_END_TXT: usize = 47;
|
|
/// `fcDggInfo`: the OfficeArt drawing content (BLIP store, shapes).
|
|
pub(crate) const DGG_INFO: usize = 50;
|
|
/// `fcPlcftxbxTxt` / `fcPlcfHdrtxbxTxt`: text box stories of the main document / headers.
|
|
pub(crate) const PLCF_TXBX_TXT: usize = 56;
|
|
pub(crate) const PLCF_HDR_TXBX_TXT: usize = 58;
|
|
/// `fcPlfLst`: the list definitions (LSTF array, with the LVLs appended after the blob).
|
|
pub(crate) const PLF_LST: usize = 73;
|
|
/// `fcPlfLfo`: the list format overrides (LFO array with LFOData appended).
|
|
pub(crate) const PLF_LFO: usize = 74;
|
|
}
|
|
|
|
/// Character-position ranges of the subdocuments, from `FibRgLw97`.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
pub(crate) struct SubDocs {
|
|
pub(crate) text: u32,
|
|
pub(crate) ftn: u32,
|
|
pub(crate) hdd: u32,
|
|
pub(crate) atn: u32,
|
|
pub(crate) edn: u32,
|
|
pub(crate) txbx: u32,
|
|
pub(crate) hdr_txbx: u32,
|
|
}
|
|
|
|
impl SubDocs {
|
|
/// Total CPs across all subdocuments, checked against a cap.
|
|
pub(crate) fn total(&self) -> Result<u64, DocbinError> {
|
|
let t = u64::from(self.text)
|
|
+ u64::from(self.ftn)
|
|
+ u64::from(self.hdd)
|
|
+ u64::from(self.atn)
|
|
+ u64::from(self.edn)
|
|
+ u64::from(self.txbx)
|
|
+ u64::from(self.hdr_txbx);
|
|
// Word's own documents stay far below this; a larger total means a corrupt FIB
|
|
// (the ccp fields are signed and "MUST be 0, 1, or greater").
|
|
if t > 100_000_000 {
|
|
return Err(DocbinError::Limit(format!("document claims {t} characters")));
|
|
}
|
|
Ok(t)
|
|
}
|
|
}
|
|
|
|
/// What the unencrypted head of a password-protected file says (see [`peek_encryption`]).
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub(crate) struct Protection {
|
|
pub(crate) flags: u16,
|
|
/// `FibBase.lKey`: the XOR verifier, or the size of the RC4 header in the Table stream.
|
|
pub(crate) lkey: u32,
|
|
pub(crate) which_tbl_stm: bool,
|
|
}
|
|
|
|
/// Is the WordDocument stream that of an encrypted Word 97-2003 file? Only the first 68 bytes are
|
|
/// stored in the clear; this reads nothing else. `None` for anything else, including streams that
|
|
/// are too short or have a foreign magic (the full parse reports those).
|
|
pub(crate) fn peek_encryption(word: &[u8]) -> Option<Protection> {
|
|
if word.len() < crate::crypt::FIB_PLAIN || word.get(0..2)? != W_IDENT.to_le_bytes() {
|
|
return None;
|
|
}
|
|
let flags = u16::from_le_bytes([*word.get(OFF_FLAGS)?, *word.get(OFF_FLAGS + 1)?]);
|
|
if flags & crate::crypt::F_ENCRYPTED == 0 {
|
|
return None;
|
|
}
|
|
let lkey = u32::from_le_bytes([*word.get(0x0E)?, *word.get(0x0F)?, *word.get(0x10)?, *word.get(0x11)?]);
|
|
Some(Protection { flags, lkey, which_tbl_stm: flags & 0x0200 != 0 })
|
|
}
|
|
|
|
/// The parsed FIB.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct Fib {
|
|
/// Selects 1Table (true) or 0Table (false).
|
|
pub(crate) which_tbl_stm: bool,
|
|
pub(crate) ccp: SubDocs,
|
|
/// The `RgFcLcb` pairs flattened as fc₀, lcb₀, fc₁, lcb₁, …
|
|
pub(crate) pairs: Vec<u32>,
|
|
}
|
|
|
|
impl Fib {
|
|
/// The (fc, lcb) pair at `FibRgFcLcb97` index `i` (later blobs follow contiguously).
|
|
pub(crate) fn pair(&self, i: usize) -> Option<(u32, u32)> {
|
|
let fc = *self.pairs.get(i * 2)?;
|
|
let lcb = *self.pairs.get(i * 2 + 1)?;
|
|
Some((fc, lcb))
|
|
}
|
|
|
|
/// Parse the FIB from the head of the WordDocument stream.
|
|
pub(crate) fn parse(word: &[u8]) -> Result<Fib, DocbinError> {
|
|
let u16le = |at: usize| -> u16 { u16::from_le_bytes([word.get(at).copied().unwrap_or(0), word.get(at + 1).copied().unwrap_or(0)]) };
|
|
let u32le = |at: usize| -> u32 {
|
|
u32::from_le_bytes([
|
|
word.get(at).copied().unwrap_or(0),
|
|
word.get(at + 1).copied().unwrap_or(0),
|
|
word.get(at + 2).copied().unwrap_or(0),
|
|
word.get(at + 3).copied().unwrap_or(0),
|
|
])
|
|
};
|
|
if word.len() < OFF_RGLW + 88 {
|
|
return Err(DocbinError::NotWord(format!("WordDocument stream is {} bytes", word.len())));
|
|
}
|
|
let w_ident = u16le(0);
|
|
if w_ident == 0xA5DC {
|
|
return Err(DocbinError::NotWord("Word 6.0/95 format, only Word 97-2003 is supported".into()));
|
|
}
|
|
if w_ident != W_IDENT {
|
|
return Err(DocbinError::NotWord(format!("magic 0x{w_ident:04X}, expected 0x{W_IDENT:04X}")));
|
|
}
|
|
let flags = u16le(OFF_FLAGS);
|
|
let nfib = u16le(2);
|
|
if u16le(OFF_CSW) != CSW {
|
|
return Err(DocbinError::NotWord(format!("csw is 0x{:04X}", u16le(OFF_CSW))));
|
|
}
|
|
if u16le(OFF_CSLW) != CSLW {
|
|
return Err(DocbinError::NotWord(format!("cslw is 0x{:04X}", u16le(OFF_CSLW))));
|
|
}
|
|
// rglw u32 indices per FibRgLw97: cbMac, r1, r2, ccpText, ccpFtn, ccpHdd, r3, ccpAtn,
|
|
// ccpEdn, ccpTxbx, ccpHdrTxbx, …
|
|
let lw = |i: usize| -> u32 { u32le(OFF_RGLW + i * 4) };
|
|
// The ccp fields are signed in the spec; negative values are corrupt input, clamped.
|
|
let ccp = |i: usize| -> u32 { (lw(i) as i32).max(0) as u32 };
|
|
let sub = SubDocs { text: ccp(3), ftn: ccp(4), hdd: ccp(5), atn: ccp(7), edn: ccp(8), txbx: ccp(9), hdr_txbx: ccp(10) };
|
|
sub.total()?;
|
|
|
|
let cb_rg_fclcb = u16le(OFF_CB_RG_FCLCB) as usize;
|
|
if cb_rg_fclcb == 0 {
|
|
return Err(DocbinError::NotWord("empty RgFcLcb".into()));
|
|
}
|
|
if let Some(&(_, expected)) = CB_RG_FCLCB.iter().find(|(n, _)| *n == nfib)
|
|
&& cb_rg_fclcb < expected as usize
|
|
{
|
|
// Word itself always writes the full blob for its version; anything smaller is
|
|
// corrupt, but a *larger* blob (newer writer) is fine — we just read what's there.
|
|
return Err(DocbinError::NotWord(format!("cbRgFcLcb is 0x{cb_rg_fclcb:04X} for nFib 0x{nfib:04X}")));
|
|
}
|
|
let blob_off = OFF_CB_RG_FCLCB + 2;
|
|
let blob_len = cb_rg_fclcb.saturating_mul(8);
|
|
if word.len() < blob_off + blob_len {
|
|
return Err(DocbinError::NotWord("truncated RgFcLcb".into()));
|
|
}
|
|
let mut pairs = Vec::with_capacity(cb_rg_fclcb * 2);
|
|
for i in 0..cb_rg_fclcb * 2 {
|
|
pairs.push(u32le(blob_off + i * 4));
|
|
}
|
|
|
|
Ok(Fib { which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
|
|
}
|
|
}
|