chk_defaced 0.1.0

Detect 'defaced' documents: verify the coherence of the fonts used to render text (PDF, DOCX, HTML+CSS) and build a JSON index of the system fonts' cmaps.
Documentation
//! DOCX semantic-replacement detector (variant A3) on the **embedded fonts directly** — deterministic,
//! NO rendering and NO OCR, and **font-agnostic** (works for any embedded font, not a specific corpus).
//!
//! Mechanism: a defacement font maps a character to a glyph whose **outline draws a different letter**
//! (the text extracts 'D' while the glyph drawn is 'M'). The attack copies a real glyph into the wrong
//! cmap slot, so across the document's embedded fonts the *same outline* becomes reachable from two
//! different letters. We deobfuscate and parse every embedded font, hash each glyph's outline, and flag
//! any outline reachable from more than one (non-homoglyph) letter: the extracted letter disagrees with
//! the drawn one. The "true" letter is the one that maps to that outline most consistently.
//!
//! Word embeds *full* fonts (not subsets), so a single outline is legitimately reached from hundreds of
//! codepoints the document never uses (Lisu letters shaped like Latin, Arabic presentation forms, roman
//! numerals…). To avoid that noise we restrict the candidate letters to those that actually occur in the
//! document's extracted text — the only characters a reader could be misled by — keeping full Unicode
//! coverage (accented Latin, non-Latin scripts) without the false positives of the full repertoire.

use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::path::Path;

use anyhow::{Context, Result};
use regex::Regex;

use crate::finding::{Category, Finding, Severity};

/// De-obfuscate the first 32 bytes of an OOXML embedded font with the `fontKey` GUID.
fn deobfuscate(odttf: &[u8], guid: &str, reversed: bool) -> Vec<u8> {
    let hexs: String = guid.chars().filter(|c| c.is_ascii_hexdigit()).collect();
    if hexs.len() != 32 {
        return odttf.to_vec();
    }
    let mut key = [0u8; 16];
    for i in 0..16 {
        key[i] = u8::from_str_radix(&hexs[i * 2..i * 2 + 2], 16).unwrap_or(0);
    }
    let mut out = odttf.to_vec();
    let n = out.len().min(32);
    for i in 0..n {
        let kb = if reversed { key[15 - (i % 16)] } else { key[i % 16] };
        out[i] ^= kb;
    }
    out
}

/// FNV-1a hash of a glyph's outline command stream (exact font-unit coordinates — the attack copies
/// glyphs verbatim, so identical shapes hash identically; different typefaces hash differently).
struct OutlineSig {
    h: u64,
    empty: bool,
}
impl OutlineSig {
    fn new() -> Self {
        Self { h: 0xcbf29ce484222325, empty: true }
    }
    fn mix(&mut self, v: i64) {
        self.empty = false;
        self.h ^= v as u64;
        self.h = self.h.wrapping_mul(0x100000001b3);
    }
}
impl ttf_parser::OutlineBuilder for OutlineSig {
    fn move_to(&mut self, x: f32, y: f32) {
        self.mix(1);
        self.mix(x as i64);
        self.mix(y as i64);
    }
    fn line_to(&mut self, x: f32, y: f32) {
        self.mix(2);
        self.mix(x as i64);
        self.mix(y as i64);
    }
    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
        self.mix(3);
        self.mix(x1 as i64);
        self.mix(y1 as i64);
        self.mix(x as i64);
        self.mix(y as i64);
    }
    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
        self.mix(4);
        for v in [x1, y1, x2, y2, x, y] {
            self.mix(v as i64);
        }
    }
    fn close(&mut self) {
        self.mix(5);
    }
}

/// A **Latin-script** alphabetic character, normalized to lowercase for tallying. v1.0 scopes the
/// outline cross-reference to Latin (the safety target): other scripts are not analyzed.
fn letter(cp: u32) -> Option<char> {
    use unicode_script::{Script, UnicodeScript};
    let c = char::from_u32(cp)?;
    if c.is_alphabetic() && c.script() == Script::Latin {
        c.to_lowercase().next()
    } else {
        None
    }
}

/// The TR39 confusable skeleton of a single character.
fn skel(c: char) -> String {
    unicode_security::confusable_detection::skeleton(&c.to_string()).collect()
}

/// Two letters legitimately share an identical glyph (so a collision is not tampering) when they are
/// Unicode TR39 confusables (cross-script homoglyphs like Latin 'A' / Greek 'Α'), or the common
/// lowercase-'l' / uppercase-'I' pair that many fonts draw with a single glyph.
fn legitimately_identical(a: char, b: char) -> bool {
    use unicode_normalization::UnicodeNormalization;
    use unicode_script::UnicodeScript;
    if a == b {
        return true;
    }
    // Cross-script glyph-sharing (Latin 'b' / Greek 'β' / Cyrillic 'в') is legitimate, not semantic
    // replacement — the A3 attack swaps letters within one script.
    if a.script() != b.script() {
        return true;
    }
    // Compatibility-equivalent encodings (Arabic presentation forms, ligatures) are the same letter.
    if a.to_string().nfkd().eq(b.to_string().nfkd()) {
        return true;
    }
    // Same ASCII base (ð / đ / ɖ → "d") = confusable variants of one base letter, not replacement.
    if let (Some(x), Some(y)) = (deunicode::deunicode_char(a), deunicode::deunicode_char(b)) {
        if !x.is_empty() && x == y {
            return true;
        }
    }
    let mut p = [a, b];
    p.sort_unstable();
    if matches!((p[0], p[1]), ('i', 'l')) {
        return true;
    }
    skel(a) == skel(b)
}

/// Per-outline tally: how many times each letter maps to this exact outline, across all embedded fonts.
#[derive(Default)]
struct Tally {
    by_letter: HashMap<char, usize>,
}

/// Collect the lowercased alphabetic characters that actually occur in the document's extracted text
/// (`w:t` runs of document.xml, headers/footers, notes). Word embeds *full* fonts, so an outline can be
/// legitimately reached from hundreds of codepoints the document never uses (Lisu letters shaped like
/// Latin, Arabic presentation forms, roman numerals…). We only treat a mapping as suspicious when its
/// extracted character is one the reader could actually encounter — i.e. present in this set.
fn document_letters(zip: &mut zip::ZipArchive<std::fs::File>, names: &[String]) -> HashSet<char> {
    let re = Regex::new(r"(?s)<w:t[ >](.*?)</w:t>").unwrap();
    let mut set = HashSet::new();
    for name in names {
        let textpart = name == "word/document.xml"
            || (name.starts_with("word/header") && name.ends_with(".xml"))
            || (name.starts_with("word/footer") && name.ends_with(".xml"))
            || name == "word/footnotes.xml"
            || name == "word/endnotes.xml";
        if !textpart {
            continue;
        }
        let mut s = String::new();
        if zip.by_name(name).map(|mut zf| zf.read_to_string(&mut s)).is_err() {
            continue;
        }
        for cap in re.captures_iter(&s) {
            for c in cap[1].chars() {
                if c.is_alphabetic() {
                    if let Some(l) = c.to_lowercase().next() {
                        set.insert(l);
                    }
                }
            }
        }
    }
    set
}

/// Scan a DOCX's embedded fonts for semantic replacement (variant A3). Deterministic, no OCR.
pub fn docx_outline_scan(path: &Path) -> Result<Vec<Finding>> {
    let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
    let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
    let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();

    // Characters the document actually extracts — used to suppress by-design outline sharing in full fonts.
    let doc_letters = document_letters(&mut zip, &names);

    // fontKeys from the font table.
    let mut keys: Vec<String> = Vec::new();
    if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
        let mut s = String::new();
        if zf.read_to_string(&mut s).is_ok() {
            let re = Regex::new(r#"w:fontKey="(\{[0-9A-Fa-f-]+\})""#).unwrap();
            for cap in re.captures_iter(&s) {
                keys.push(cap[1].to_string());
            }
        }
    }

    // outline signature → tally of the letters that map to it.
    let mut sigs: HashMap<u64, Tally> = HashMap::new();
    let odttf: Vec<String> =
        names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();

    for name in &odttf {
        let mut raw = Vec::new();
        if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
            continue;
        }
        // try each fontKey (both byte orders) until the font parses
        let mut parsed = false;
        'keys: for guid in &keys {
            for reversed in [true, false] {
                let de = deobfuscate(&raw, guid, reversed);
                if let Ok(face) = ttf_parser::Face::parse(&de, 0) {
                    tally_face(&face, &doc_letters, &mut sigs);
                    parsed = true;
                    break 'keys;
                }
            }
        }
        let _ = parsed;
    }

    // A glyph (outline) reachable from >1 distinct letter → the extracted letter disagrees with the
    // drawn one. The most frequent letter is the true (drawn) one; the others are the lies. Aggregate
    // by (drawn, extracted) pair across all colliding glyphs.
    let mut lies: HashMap<(char, char), usize> = HashMap::new();
    for tally in sigs.values() {
        if tally.by_letter.len() < 2 {
            continue;
        }
        let mut letters: Vec<(char, usize)> = tally.by_letter.iter().map(|(c, n)| (*c, *n)).collect();
        letters.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
        let (truth, _) = letters[0];
        for (lie, n) in letters.iter().skip(1) {
            if legitimately_identical(truth, *lie) {
                continue;
            }
            *lies.entry((truth, *lie)).or_default() += *n;
        }
    }

    let mut findings: Vec<Finding> = lies
        .into_iter()
        .map(|((truth, lie), n)| {
            Finding::new(
                "DOCX.GLYPH_SEMANTIC_REPLACEMENT",
                Severity::High,
                Category::FontIntegrity,
                "embedded font",
                format!(
                    "a glyph that draws '{truth}' is mapped from the extracted character '{lie}' ({n}×): the embedded font's cmap lies (semantic replacement / variant A3)"
                ),
                0.9,
            )
        })
        .collect();
    findings.sort_by(|a, b| a.message.cmp(&b.message));
    Ok(findings)
}

/// Per embedded font, the de-obfuscated font bytes plus the `(claimed_char, glyph_id)` pairs the
/// document extracts — input for the specimen-OCR escalation ([`crate::specimen`]). Same extraction and
/// document-text scoping as [`docx_outline_scan`], but it records the claims (so a custom font with no
/// honest anchor is still checkable by OCR). Restricted to glyphs whose extracted character actually
/// occurs in the document, which also bounds the OCR cost on Word's full (non-subset) embedded fonts.
pub fn docx_font_claims(path: &Path) -> Result<Vec<crate::FontClaims>> {
    use std::collections::HashSet;
    let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
    let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
    let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
    let doc_letters = document_letters(&mut zip, &names);

    let mut keys: Vec<String> = Vec::new();
    if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
        let mut s = String::new();
        if zf.read_to_string(&mut s).is_ok() {
            let re = Regex::new(r#"w:fontKey="(\{[0-9A-Fa-f-]+\})""#).unwrap();
            for cap in re.captures_iter(&s) {
                keys.push(cap[1].to_string());
            }
        }
    }

    let odttf: Vec<String> =
        names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();
    let mut out: Vec<crate::FontClaims> = Vec::new();

    for name in &odttf {
        let mut raw = Vec::new();
        if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
            continue;
        }
        'keys: for guid in &keys {
            for reversed in [true, false] {
                let de = deobfuscate(&raw, guid, reversed);
                let Ok(face) = ttf_parser::Face::parse(&de, 0) else { continue };
                let Some(cmap) = face.tables().cmap else { break 'keys };
                let mut claims: HashSet<(char, u16)> = HashSet::new();
                for sub in cmap.subtables {
                    if !sub.is_unicode() {
                        continue;
                    }
                    sub.codepoints(|cp| {
                        let Some(l) = letter(cp) else { return };
                        if !doc_letters.contains(&l) {
                            return;
                        }
                        if let (Some(c), Some(gid)) = (char::from_u32(cp), sub.glyph_index(cp)) {
                            claims.insert((c, gid.0));
                        }
                    });
                }
                if !claims.is_empty() {
                    out.push((de, claims.into_iter().collect()));
                }
                break 'keys;
            }
        }
    }
    Ok(out)
}

fn tally_face(face: &ttf_parser::Face, doc_letters: &HashSet<char>, sigs: &mut HashMap<u64, Tally>) {
    let Some(cmap) = face.tables().cmap else { return };
    for sub in cmap.subtables {
        if !sub.is_unicode() {
            continue;
        }
        sub.codepoints(|cp| {
            let Some(letter) = letter(cp) else { return };
            // Only characters the document actually extracts can mislead a reader; ignore the rest
            // (full fonts reuse one outline across many never-used codepoints).
            if !doc_letters.contains(&letter) {
                return;
            }
            if let Some(gid) = sub.glyph_index(cp) {
                let mut b = OutlineSig::new();
                if face.outline_glyph(gid, &mut b).is_some() && !b.empty {
                    *sigs.entry(b.h).or_default().by_letter.entry(letter).or_default() += 1;
                }
            }
        });
    }
}