ssign 0.1.0

Cross-platform CLI to Authenticode-sign Windows binaries with a Certum SimplySign cloud certificate — no GUI, no vendor stack.
//! MSI (and other OLE2 Compound File installers) Authenticode digest, replicating
//! osslsigncode's `msi_hash_dir`: walk the directory, sort each storage's children
//! by their raw UTF-16LE name, hash each non-empty stream's content (skipping the
//! signature streams at the root), recurse storages, then append the storage CLSID.

use anyhow::{Context, Result};
use cfb::CompoundFile;
use sha2::{Digest, Sha256};
use std::io::{Cursor, Read};
use std::path::PathBuf;

const OLE_MAGIC: [u8; 8] = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
const DIGITAL_SIGNATURE: &str = "\u{5}DigitalSignature";
const DIGITAL_SIGNATURE_EX: &str = "\u{5}MsiDigitalSignatureEx";

/// True if `bytes` is an OLE2 compound file (MSI/MSP/MSM).
pub fn is_compound_file(bytes: &[u8]) -> bool {
    bytes.starts_with(&OLE_MAGIC)
}

/// osslsigncode `dirent_cmp_hash`: memcmp on the raw UTF-16LE name bytes; if one
/// is a prefix of the other, the longer name sorts first.
fn cmp_hash(a: &str, b: &str) -> std::cmp::Ordering {
    let na: Vec<u8> = a.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
    let nb: Vec<u8> = b.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
    let m = na.len().min(nb.len());
    match na[..m].cmp(&nb[..m]) {
        std::cmp::Ordering::Equal => nb.len().cmp(&na.len()), // longer first
        other => other,
    }
}

/// The 16 CLSID bytes as stored in a CFB directory entry (Data1/2/3 little-endian,
/// Data4 as-is) from a big-endian `uuid`.
fn clsid_cfb_bytes(u: &uuid::Uuid) -> [u8; 16] {
    let b = u.as_bytes();
    [
        b[3], b[2], b[1], b[0], b[5], b[4], b[7], b[6], b[8], b[9], b[10], b[11], b[12], b[13],
        b[14], b[15],
    ]
}

/// Compute the MSI Authenticode SHA-256 digest.
pub fn msi_hash(bytes: &[u8]) -> Result<[u8; 32]> {
    let mut cf = CompoundFile::open(Cursor::new(bytes)).context("opening compound file")?;
    let mut h = Sha256::new();
    hash_storage(&mut cf, PathBuf::from("/"), true, &mut h)?;
    Ok(h.finalize().into())
}

fn hash_storage(
    cf: &mut CompoundFile<Cursor<&[u8]>>,
    path: PathBuf,
    is_root: bool,
    h: &mut Sha256,
) -> Result<()> {
    // Collect + sort this storage's children.
    let mut children: Vec<(String, PathBuf, bool)> = cf
        .read_storage(&path)
        .with_context(|| format!("reading storage {}", path.display()))?
        .map(|e| (e.name().to_string(), e.path().to_path_buf(), e.is_stream()))
        .collect();
    children.sort_by(|a, b| cmp_hash(&a.0, &b.0));

    let clsid = clsid_cfb_bytes(cf.entry(&path).context("storage entry")?.clsid());

    for (name, child_path, is_stream) in children {
        if is_root && (name == DIGITAL_SIGNATURE || name == DIGITAL_SIGNATURE_EX) {
            continue;
        }
        if is_stream {
            let mut data = Vec::new();
            cf.open_stream(&child_path)
                .with_context(|| format!("opening stream {}", child_path.display()))?
                .read_to_end(&mut data)
                .context("reading stream")?;
            if data.is_empty() {
                continue;
            }
            h.update(&data);
        } else {
            hash_storage(cf, child_path, false, h)?;
        }
    }
    h.update(clsid);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    // WIP: this faithful port of osslsigncode's `msi_hash_dir` (sort children by
    // raw UTF-16LE name, hash non-empty stream contents, append the storage
    // CLSID) does NOT yet reproduce osslsigncode's digest for the fixture — it
    // yields 472820BD… instead of the expected value below, and both `cfb` and
    // Python `olefile` agree on 472820BD, so the gap is a subtle ordering/scope
    // detail still to pin down. Ignored until it matches; MSI signing is not
    // wired into the pipeline until then.
    #[test]
    #[ignore = "MSI digest does not yet match osslsigncode — see comment"]
    fn msi_hash_matches_osslsigncode() {
        let msi = include_bytes!("../tests/fixtures/test.msi");
        assert!(is_compound_file(msi));
        assert_eq!(
            hex::encode_upper(msi_hash(msi).unwrap()),
            "CB45D9776CEE01B97E0EB14D007FB2779EBE25C0DCAB25F4A1CAE947C3B19A61"
        );
    }
}