//! Layer 10: Windows Authenticode signature verification on PE binaries. //! //! Positive trust signal companion to `signing_macos`. A `Pass` here with //! detail like `"Signed by CN=GoingsOn Software, ..."` is *evidence* of //! a legitimate signing identity — not just an absence of malware. //! //! Powered by Google's `authenticode` crate + the `object` crate's PE //! parser. Pure-Rust, no `osslsigncode` / `signtool` / WinTrust shell-out. //! See `docs/scan-pipeline-audit.md` § 4.1. //! //! **Scope of v1**: //! - Detect PE32 / PE32+ (the two PE flavors Authenticode targets). //! - Walk the attribute-certificate table; parse each entry as an //! `AuthenticodeSignature`. //! - Extract the signer's certificate Subject CN as the trust attribution //! string (the conventional "who claims to have signed this" reference). //! - Verdicts: `Skip` (not PE), `Pass` (PE — with detail describing //! signing state), `Error` (parser failure — fail-open by policy). //! //! Deferred: //! - Cryptographic verification of the CMS chain back to a Microsoft- or //! public-CA-rooted Authenticode CA. Like the macOS staple, current //! detection is presence + structural sanity. //! - Timestamp counter-signature verification (`signtool`'s `/tw` mode). //! - Catalog-signed binaries (Microsoft uses `.cat` files for OS binaries; //! creators almost never use this). use object::read::pe::ImageNtHeaders; use crate::storage::FileType; use super::{ErrorPolicy, LayerResult, LayerVerdict}; pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; /// Path-based entry. Mmaps the spooled file and delegates. PE parsing /// walks the NT headers + attribute-cert directory at fixed offsets, so /// demand-paging covers the whole inspection without buffering. pub fn verify_authenticode_path(path: &std::path::Path, file_type: FileType) -> LayerResult { if !matches!(file_type, FileType::Download) { return skip("Not a download file type"); } match crate::scanning::spool::mmap_read(path) { Ok(map) => verify_authenticode(&map, file_type), Err(e) => error(e), } } /// Top-level entry. Detect PE format, walk the attribute-cert table, and /// surface a Pass detail describing the signing state. pub fn verify_authenticode(data: &[u8], file_type: FileType) -> LayerResult { if !matches!(file_type, FileType::Download) { return skip("Not a download file type"); } if !looks_like_pe(data) { return skip("Not a PE binary"); } // Try PE32+ first (most common for modern .exe / .msi), fall back to PE32. match verify_pe::(data) { Ok(layer) => layer, Err(_) => match verify_pe::(data) { Ok(layer) => layer, Err(e) => error(format!("PE parse failed: {e}")), }, } } fn skip(reason: &'static str) -> LayerResult { LayerResult { layer: "signing_windows", verdict: LayerVerdict::Skip, detail: Some(reason.to_string()), } } fn pass(detail: String) -> LayerResult { LayerResult { layer: "signing_windows", verdict: LayerVerdict::Pass, detail: Some(detail), } } fn error(detail: String) -> LayerResult { LayerResult { layer: "signing_windows", verdict: LayerVerdict::Error, detail: Some(detail), } } /// Heuristic: file begins with MZ + has a PE header pointer at 0x3C /// pointing to a "PE\0\0" magic. Cheap enough to run before involving the /// full parser. pub(crate) fn looks_like_pe(data: &[u8]) -> bool { if data.len() < 64 || &data[0..2] != b"MZ" { return false; } let e_lfanew = u32::from_le_bytes([data[60], data[61], data[62], data[63]]) as usize; if data.len() < e_lfanew + 4 { return false; } &data[e_lfanew..e_lfanew + 4] == b"PE\0\0" } fn verify_pe(data: &[u8]) -> Result { use object::read::pe::PeFile; use authenticode::AttributeCertificateIterator; let pe: PeFile = PeFile::parse(data).map_err(|e| format!("object PE parse: {e}"))?; let iter = match AttributeCertificateIterator::new(&pe) { Ok(Some(iter)) => iter, // No certificate table — unsigned PE. Pass with informational detail. Ok(None) => return Ok(pass("PE present, no embedded signature".to_string())), Err(e) => return Err(format!("attribute-cert table: {e:?}")), }; let mut signer_names: Vec = Vec::new(); let mut had_signature = false; for cert_entry in iter { let cert = match cert_entry { Ok(c) => c, Err(e) => return Err(format!("attribute cert entry: {e:?}")), }; let sig = match cert.get_authenticode_signature() { Ok(s) => s, Err(e) => return Err(format!("authenticode parse: {e:?}")), }; had_signature = true; for c in sig.certificates() { if let Some(name) = extract_subject_cn(c) && !name.is_empty() && !signer_names.iter().any(|n| n == &name) { signer_names.push(name); } } } Ok(classify(had_signature, &signer_names)) } /// Extract the Subject CN from an x509-cert `Certificate`. Authenticode /// signers always carry a CN; older releases occasionally use only an O. /// We surface whichever is present, preferring CN. fn extract_subject_cn(cert: &x509_cert::Certificate) -> Option { use const_oid::db::rfc4519; // Walk the subject Name's RDNs for either CN or O. let rdns = &cert.tbs_certificate.subject.0; let mut cn: Option = None; let mut o: Option = None; for rdn in rdns.iter() { for attr in rdn.0.iter() { let val = attr.value.decode_as::() .ok() .map(|s| s.to_string()) .or_else(|| { attr.value .decode_as::() .ok() .map(|s| s.to_string()) }); match attr.oid { rfc4519::CN if val.is_some() => cn = val, rfc4519::O if val.is_some() => o = val, _ => {} } } } cn.or(o) } fn classify(had_signature: bool, signers: &[String]) -> LayerResult { match (had_signature, signers.is_empty()) { (true, false) => pass(format!("Signed by: {}", signers.join(", "))), (true, true) => pass("Signed, no subject name extractable".to_string()), (false, _) => pass("PE present, no embedded signature".to_string()), } } #[cfg(test)] mod tests { use super::*; #[test] fn skips_non_download_types() { let r = verify_authenticode(b"any bytes", FileType::Audio); assert_eq!(r.verdict, LayerVerdict::Skip); } #[test] fn skips_non_pe_bytes() { let r = verify_authenticode(b"plain text upload", FileType::Download); assert_eq!(r.verdict, LayerVerdict::Skip); assert!(r.detail.unwrap().contains("Not a PE")); } #[test] fn rejects_short_input() { assert!(!looks_like_pe(b"MZ")); assert!(!looks_like_pe(&[])); } #[test] fn rejects_mz_without_pe_header() { let mut data = vec![0u8; 1024]; data[0..2].copy_from_slice(b"MZ"); // e_lfanew points to garbage. data[60..64].copy_from_slice(&0u32.to_le_bytes()); assert!(!looks_like_pe(&data)); } #[test] fn detects_minimal_pe_structure() { // Synthesize an MZ header with a valid e_lfanew pointing at PE\0\0. let mut data = vec![0u8; 512]; data[0..2].copy_from_slice(b"MZ"); let pe_offset = 128u32; data[60..64].copy_from_slice(&pe_offset.to_le_bytes()); data[pe_offset as usize..pe_offset as usize + 4].copy_from_slice(b"PE\0\0"); assert!(looks_like_pe(&data)); } #[test] fn classify_with_signer_yields_pass_and_detail() { let r = classify(true, &["Example Corp".to_string()]); assert_eq!(r.verdict, LayerVerdict::Pass); assert!(r.detail.unwrap().contains("Example Corp")); } #[test] fn classify_signed_without_extractable_subject() { let r = classify(true, &[]); assert_eq!(r.verdict, LayerVerdict::Pass); assert!(r.detail.unwrap().contains("no subject name")); } #[test] fn classify_pe_no_signature() { let r = classify(false, &[]); assert_eq!(r.verdict, LayerVerdict::Pass); assert!(r.detail.unwrap().contains("no embedded signature")); } #[test] fn synthetic_pe_parser_failure_is_error() { // A minimally-shaped PE header that the full PE parser will reject. // The layer's policy is FailOpen, so the aggregator treats this as // Skip-equivalent — but the chip itself surfaces as Error so the // dashboard health panel sees it. let mut data = vec![0u8; 512]; data[0..2].copy_from_slice(b"MZ"); let pe_offset = 64u32; data[60..64].copy_from_slice(&pe_offset.to_le_bytes()); data[pe_offset as usize..pe_offset as usize + 4].copy_from_slice(b"PE\0\0"); // The rest is zeros; PE parser will reject. Either Error or Pass // (if PE32+ parser succeeds with zero data) is acceptable. let r = verify_authenticode(&data, FileType::Download); assert!(matches!(r.verdict, LayerVerdict::Error | LayerVerdict::Pass)); } #[test] fn path_entry_matches_buffered_on_plain_text() { let data = b"definitely not a PE"; let buffered = verify_authenticode(data, FileType::Download); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), data).unwrap(); let path_based = verify_authenticode_path(tmp.path(), FileType::Download); assert_eq!(buffered.verdict, path_based.verdict); } }