signalscreen-checker 0.3.0

Windows code-signing hygiene checker. Reads the Authenticode signature in a PE file and grades it A-F. Pure Rust, no Windows dependency.
Documentation
//! PE entry point: parse the file and extract the signer's signature facts.
use crate::signature::{self, ImageHash, SignatureInfo};
use anyhow::Result;
use authenticode::{AttributeCertificateIterator, AuthenticodeSignature, PeTrait};
use object::read::pe::{PeFile32, PeFile64};
use sha1::Sha1;
use sha2::digest::Update;
use sha2::{Digest, Sha256, Sha384, Sha512};

/// Recompute the file's Authenticode image hash and compare it to the digest
/// embedded in the signature. A mismatch means the bytes were altered after
/// signing; a digest whose length we don't recognise, or an offset error, yields
/// `Unverified` — a fact stated without a false accusation, never a mismatch.
///
/// The hasher is chosen by the length of the embedded digest, not by an OID: that
/// length identifies the algorithm the image digest was actually computed with
/// (SHA-1/256/384/512 have distinct 20/32/48/64-byte outputs). Selecting by
/// `signer_info().digest_alg` instead would hash with the wrong algorithm — and
/// report a false mismatch — for a file whose SignerInfo digest differs from the
/// SpcIndirectDataContent digest, which the signature format does not forbid.
fn image_hash(pe: &dyn PeTrait, sig: &AuthenticodeSignature) -> ImageHash {
    let expected = sig.digest();
    let computed: Option<Vec<u8>> = match expected.len() {
        20 => hash_pe::<Sha1>(pe),
        32 => hash_pe::<Sha256>(pe),
        48 => hash_pe::<Sha384>(pe),
        64 => hash_pe::<Sha512>(pe),
        _ => return ImageHash::Unverified,
    };
    match computed {
        Some(bytes) if bytes.as_slice() == expected => ImageHash::Match,
        Some(_) => ImageHash::Mismatch,
        None => ImageHash::Unverified, // PeOffsetError: couldn't resolve the regions
    }
}

/// Run the Authenticode digest over the PE with hasher `D`, returning the hash
/// bytes, or `None` if the PE offsets couldn't be resolved.
fn hash_pe<D: Digest + Update>(pe: &dyn PeTrait) -> Option<Vec<u8>> {
    let mut hasher = D::new();
    authenticode::authenticode_digest(pe, &mut hasher).ok()?;
    Some(hasher.finalize().to_vec())
}

/// Parse PE bytes and return the signer's `SignatureInfo`, or `None` if the file
/// carries no embedded Authenticode signature. Errors only if the bytes are not a PE.
pub fn extract(data: &[u8]) -> Result<Option<SignatureInfo>> {
    // `AttributeCertificateIterator` takes `&dyn PeTrait`, so width only matters here.
    let pe: Box<dyn PeTrait + '_> = match PeFile64::parse(data) {
        Ok(pe) => Box::new(pe),
        Err(e) => match PeFile32::parse(data) {
            Ok(pe) => Box::new(pe),
            Err(_) => return Err(anyhow::anyhow!("not a PE: {e}")),
        },
    };
    let Some(mut iter) = AttributeCertificateIterator::new(pe.as_ref())
        .map_err(|e| anyhow::anyhow!("cert table: {e:?}"))?
    else {
        return Ok(None);
    };
    // The signer's signature is the first attribute certificate (if any).
    match iter.next() {
        Some(cert) => {
            let cert = cert.map_err(|e| anyhow::anyhow!("attribute certificate: {e:?}"))?;
            let sig = cert
                .get_authenticode_signature()
                .map_err(|e| anyhow::anyhow!("authenticode signature: {e:?}"))?;
            let mut info = signature::from_authenticode(&sig);
            info.image_hash = image_hash(pe.as_ref(), &sig);
            Ok(Some(info))
        }
        None => Ok(None),
    }
}

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

    #[test]
    fn unsigned_has_no_signature() {
        let data = std::fs::read("fixtures/unsigned.exe").unwrap();
        assert!(extract(&data).unwrap().is_none());
    }

    #[test]
    fn pe32_is_parsed_not_rejected() {
        // PE32 must parse, not error out as "not a PE".
        let data = std::fs::read("fixtures/unsigned-pe32.exe").unwrap();
        assert!(extract(&data).unwrap().is_none());
    }

    #[test]
    fn garbage_is_still_rejected() {
        // The fallback must not swallow genuine non-PE input.
        assert!(extract(b"not an executable at all").is_err());
    }

    #[test]
    fn signed_fixture_parses() {
        let data = std::fs::read("fixtures/selfsigned-sha256.exe").unwrap();
        let info = extract(&data).unwrap().expect("should be signed");
        assert_eq!(info.digest_algo_oid, "2.16.840.1.101.3.4.2.1"); // SHA-256
        assert!(!info.has_timestamp); // signed without a timestamp
        assert!(info.leaf_cert.is_some());
    }

    #[test]
    fn signed_fixture_image_hash_matches() {
        // The fixture is correctly signed, so its recomputed Authenticode hash
        // must equal the digest embedded in the signature.
        let data = std::fs::read("fixtures/selfsigned-sha256.exe").unwrap();
        let info = extract(&data).unwrap().expect("should be signed");
        assert_eq!(info.image_hash, ImageHash::Match);
    }

    #[test]
    fn tampered_fixture_image_hash_mismatches() {
        // Flip a byte in the DOS header (offset 2 = e_cblp): the PE loader ignores
        // it so the file still parses, but it is inside the Authenticode-hashed
        // region, so the recomputed hash no longer matches the signed digest.
        let mut data = std::fs::read("fixtures/selfsigned-sha256.exe").unwrap();
        data[2] ^= 0xff;
        let info = extract(&data).unwrap().expect("still parses as signed");
        assert_eq!(info.image_hash, ImageHash::Mismatch);
    }
}