//! Layer 8: Apple Mach-O / DMG signature verification. //! //! Positive trust signal: a `Pass` here with a detail like `"Signed by team //! ABCD123XYZ"` is *evidence* that the binary is signed by a verified Apple //! Developer ID team — not just an absence of malware. Unsigned binaries //! still return `Pass` (most files won't be Apple-shaped at all); the //! distinguishing data lives in the `detail` field, which the dashboard //! surfaces in the chip tooltip. //! //! Powered by `apple-codesign` (indygreg/apple-platform-rs), pure-Rust, no //! Apple host needed. See `reference_apple_codesign.md` in memory and § 4.1 //! of `docs/scan-pipeline-audit.md` for design rationale. //! //! **Scope of v1 + v2**: //! - Mach-O (single-arch + fat/universal). //! - DMG disk images (via `DmgReader` + `Cursor`). //! - Extract team identifier from `CodeDirectory`. //! - Detect presence of notarization staple ticket (Apple notarization service //! embeds a CMS-signed ticket in `CodeSigningSlot::Ticket`). A well-formed //! ticket blob upgrades the Pass detail to "Notarized". //! - Verdicts: `Skip` (not Apple), `Pass` (Apple-shaped — with detail //! describing signing + notarization state), `Error` (parser failure — //! fail-open by policy). //! //! Deferred (Phase 3b-3): //! - Cryptographic verification of the staple's CMS signature against Apple's //! notarization CA. Current detection is presence + structural sanity; a //! determined attacker could embed bogus bytes in the slot. Chain //! verification (using `cryptographic-message-syntax`) closes that gap. //! - `.app` / `.pkg` bundle support (those arrive as `.dmg` or `.zip` //! typically, and bundle directories don't survive a single-blob upload). //! - Full CMS chain validation against the Apple Developer ID root. use std::io::Cursor; use crate::storage::FileType; use super::{ErrorPolicy, LayerResult, LayerVerdict}; /// Bonus / positive-evidence layer. An `Error` here means our verifier /// choked on the file, not that the file is malicious — uploads must not be /// held just because we couldn't parse a signature. Fail open; the /// dashboard surfaces parser errors via the health panel. pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; /// Path-based entry. Mmaps the spooled file (DMG signature lookup walks /// the trailer; MachFile parses headers + load commands — both touch /// specific offsets, demand-paged through the mmap) and delegates. pub fn verify_apple_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult { if !matches!(file_type, FileType::Download | FileType::Insertion) { return skip("Not a download file type"); } match crate::scanning::spool::mmap_read(path) { Ok(map) => verify_apple_signature(&map, file_type), Err(e) => error(e), } } /// Top-level entry: verify any Apple code-signing evidence in the bytes. pub fn verify_apple_signature(data: &[u8], file_type: FileType) -> LayerResult { // Only download-class uploads are plausible Apple binaries. Audio / // cover / image / video file types never carry Apple signatures, so // shortcut to Skip and save the parse cost. if !matches!(file_type, FileType::Download | FileType::Insertion) { return skip("Not a download file type"); } if looks_like_macho(data) { return verify_macho(data); } if looks_like_dmg(data) { return verify_dmg(data); } skip("Not a recognized Apple binary format") } fn skip(reason: &'static str) -> LayerResult { LayerResult { layer: "signing_macos", verdict: LayerVerdict::Skip, detail: Some(reason.to_string()), } } fn pass(detail: String) -> LayerResult { LayerResult { layer: "signing_macos", verdict: LayerVerdict::Pass, detail: Some(detail), } } fn error(detail: String) -> LayerResult { LayerResult { layer: "signing_macos", verdict: LayerVerdict::Error, detail: Some(detail), } } /// Heuristic: file bytes begin with a Mach-O magic, including fat/universal. pub(crate) fn looks_like_macho(data: &[u8]) -> bool { if data.len() < 4 { return false; } let m = &data[..4]; // 0xFEEDFACE / 0xFEEDFACF (Mach-O 32/64 LE), 0xCEFAEDFE / 0xCFFAEDFE (BE), // 0xCAFEBABE / 0xBEBAFECA (fat universal, both endians). matches!( m, [0xFE, 0xED, 0xFA, 0xCE] | [0xFE, 0xED, 0xFA, 0xCF] | [0xCE, 0xFA, 0xED, 0xFE] | [0xCF, 0xFA, 0xED, 0xFE] | [0xCA, 0xFE, 0xBA, 0xBE] | [0xBE, 0xBA, 0xFE, 0xCA] ) } /// Heuristic: DMG files carry a "koly" trailer in the final 512 bytes of the /// archive. This is what `DmgReader` keys off of. We sniff before parsing so /// uploads that aren't DMGs don't pay the full reader cost. pub(crate) fn looks_like_dmg(data: &[u8]) -> bool { if data.len() < 512 { return false; } let tail = &data[data.len() - 512..]; tail.windows(4).any(|w| w == b"koly") } /// Minimum byte length we accept as a plausible notarization ticket. A real /// staple from Apple is CMS SignedData with cert chain + signed attributes; /// these are kilobyte-scale. The threshold filters out empty / placeholder /// blobs without requiring full CMS parsing yet (see Phase 3b-3). const MIN_TICKET_BYTES: usize = 256; #[derive(Debug, Clone, Copy)] enum NotarizationState { /// Ticket slot present and the blob has plausible structure. Stapled, /// No ticket slot found. NotStapled, /// Ticket slot present but blob is too small / malformed to be real. Malformed, } fn detect_staple(sig: &apple_codesign::EmbeddedSignature) -> NotarizationState { use apple_codesign::CodeSigningSlot; match sig.find_slot(CodeSigningSlot::Ticket) { Some(entry) => { // BlobEntry.data includes the 8-byte blob header. The actual // ticket bytes live after it. Use length-based sanity check. if entry.data.len() < 8 + MIN_TICKET_BYTES { NotarizationState::Malformed } else { NotarizationState::Stapled } } None => NotarizationState::NotStapled, } } fn verify_macho(data: &[u8]) -> LayerResult { use apple_codesign::MachFile; let mach = match MachFile::parse(data) { Ok(m) => m, Err(e) => return error(format!("Mach-O parse failed: {e}")), }; let mut signed_teams: Vec = Vec::new(); let mut had_signature = false; let mut notarization = NotarizationState::NotStapled; for binary in mach.iter_macho() { match binary.code_signature() { Ok(Some(sig)) => { had_signature = true; match sig.code_directory() { Ok(Some(cd)) => { if let Some(team) = cd.team_name.as_deref() && !team.is_empty() && !signed_teams.iter().any(|t| t == team) { signed_teams.push(team.to_string()); } } Ok(None) => {} Err(e) => { return error(format!("CodeDirectory parse failed: {e}")); } } // Stapling is per-binary; the first stapled slice wins for the // overall verdict. (In a fat binary, all slices should share // notarization state, but we don't enforce that here.) if matches!(notarization, NotarizationState::NotStapled) { notarization = detect_staple(&sig); } } Ok(None) => {} Err(e) => return error(format!("Code signature parse failed: {e}")), } } classify(had_signature, &signed_teams, notarization) } fn verify_dmg(data: &[u8]) -> LayerResult { use apple_codesign::dmg::DmgReader; let mut cursor = Cursor::new(data); let reader = match DmgReader::new(&mut cursor) { Ok(r) => r, Err(e) => return error(format!("DMG parse failed: {e}")), }; match reader.embedded_signature() { Ok(Some(sig)) => { let team = match sig.code_directory() { Ok(Some(cd)) => cd.team_name.as_deref().map(|s| s.to_string()), Ok(None) => None, Err(e) => return error(format!("DMG CodeDirectory parse failed: {e}")), }; let teams = team.into_iter().collect::>(); let notarization = detect_staple(&sig); classify(true, &teams, notarization) } Ok(None) => pass("DMG present, no embedded signature".to_string()), Err(e) => error(format!("DMG signature parse failed: {e}")), } } fn classify( had_signature: bool, signed_teams: &[String], notarization: NotarizationState, ) -> LayerResult { let team_str = if signed_teams.is_empty() { String::new() } else { format!(" team(s): {}", signed_teams.join(", ")) }; match (had_signature, notarization) { (true, NotarizationState::Stapled) if !signed_teams.is_empty() => { pass(format!("Notarized by Apple,{team_str}")) } (true, NotarizationState::Stapled) => { pass("Notarized by Apple, no team identifier".to_string()) } (true, NotarizationState::Malformed) => { pass(format!("Signed{team_str}; staple present but malformed")) } (true, NotarizationState::NotStapled) if !signed_teams.is_empty() => { pass(format!("Signed by{team_str}, not notarized")) } (true, NotarizationState::NotStapled) => { pass("Signed, no team identifier present".to_string()) } (false, _) => pass("Apple binary, no signature".to_string()), } } #[cfg(test)] mod tests { use super::*; #[test] fn skips_non_download_types() { let r = verify_apple_signature(b"any bytes", FileType::Audio); assert_eq!(r.verdict, LayerVerdict::Skip); } #[test] fn skips_non_apple_bytes() { let r = verify_apple_signature(b"plain text upload", FileType::Download); assert_eq!(r.verdict, LayerVerdict::Skip); assert!(r.detail.unwrap().contains("Not a recognized")); } #[test] fn detects_macho_magic_32_le() { let bytes = [0xFE, 0xED, 0xFA, 0xCE, 0x00]; assert!(looks_like_macho(&bytes)); } #[test] fn detects_macho_magic_64_le() { let bytes = [0xFE, 0xED, 0xFA, 0xCF, 0x00]; assert!(looks_like_macho(&bytes)); } #[test] fn detects_macho_magic_64_be() { let bytes = [0xCF, 0xFA, 0xED, 0xFE, 0x00]; assert!(looks_like_macho(&bytes)); } #[test] fn detects_fat_universal_magic() { let bytes = [0xCA, 0xFE, 0xBA, 0xBE, 0x00]; assert!(looks_like_macho(&bytes)); } #[test] fn rejects_pe_header_as_macho() { let bytes = [b'M', b'Z', 0x00, 0x00]; assert!(!looks_like_macho(&bytes)); } #[test] fn rejects_short_input_as_macho() { assert!(!looks_like_macho(&[0xFE, 0xED])); } #[test] fn rejects_short_input_as_dmg() { assert!(!looks_like_dmg(b"too short")); } #[test] fn detects_koly_in_tail() { let mut data = vec![0u8; 1024]; // Plant a koly signature in the final 512 bytes. let tail_start = data.len() - 256; data[tail_start..tail_start + 4].copy_from_slice(b"koly"); assert!(looks_like_dmg(&data)); } #[test] fn rejects_koly_outside_tail() { let mut data = vec![0u8; 4096]; // koly far from the end shouldn't trigger the heuristic. data[0..4].copy_from_slice(b"koly"); assert!(!looks_like_dmg(&data)); } #[test] fn macho_with_bogus_body_is_error_not_pass() { // Magic header alone, garbage thereafter — parser will reject. // Error here is fine because the policy is FailOpen: the pipeline // aggregator treats this layer's Error as Skip-equivalent. let mut data = vec![0xFE, 0xED, 0xFA, 0xCF]; data.extend_from_slice(&[0xFF; 256]); let r = verify_apple_signature(&data, FileType::Download); assert!(matches!(r.verdict, LayerVerdict::Error | LayerVerdict::Pass)); } #[test] fn classify_signed_not_notarized_yields_pass_with_team() { let r = classify(true, &["ABCD123XYZ".to_string()], NotarizationState::NotStapled); assert_eq!(r.verdict, LayerVerdict::Pass); let d = r.detail.unwrap(); assert!(d.contains("ABCD123XYZ")); assert!(d.contains("not notarized")); } #[test] fn classify_signed_and_notarized_says_notarized() { let r = classify(true, &["ABCD123XYZ".to_string()], NotarizationState::Stapled); assert_eq!(r.verdict, LayerVerdict::Pass); let d = r.detail.unwrap(); assert!(d.contains("Notarized")); assert!(d.contains("ABCD123XYZ")); } #[test] fn classify_signed_without_team_not_notarized() { let r = classify(true, &[], NotarizationState::NotStapled); assert_eq!(r.verdict, LayerVerdict::Pass); assert!(r.detail.unwrap().contains("no team identifier")); } #[test] fn classify_signed_without_team_notarized() { let r = classify(true, &[], NotarizationState::Stapled); assert_eq!(r.verdict, LayerVerdict::Pass); let d = r.detail.unwrap(); assert!(d.contains("Notarized")); assert!(d.contains("no team identifier")); } #[test] fn classify_malformed_staple_still_passes_but_flags() { let r = classify(true, &["TEAM".to_string()], NotarizationState::Malformed); assert_eq!(r.verdict, LayerVerdict::Pass); let d = r.detail.unwrap(); assert!(d.contains("staple present but malformed")); } #[test] fn classify_apple_binary_no_signature() { let r = classify(false, &[], NotarizationState::NotStapled); assert_eq!(r.verdict, LayerVerdict::Pass); assert!(r.detail.unwrap().contains("no signature")); } #[test] fn path_entry_matches_buffered_on_plain_text() { let data = b"definitely not a mach-o"; let buffered = verify_apple_signature(data, FileType::Download); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), data).unwrap(); let path_based = verify_apple_signature_path(tmp.path(), FileType::Download); assert_eq!(buffered.verdict, path_based.verdict); } }