Skip to main content

max / makenotwork

14.4 KB · 408 lines History Blame Raw
1 //! Layer 8: Apple Mach-O / DMG signature verification.
2 //!
3 //! Positive trust signal: a `Pass` here with a detail like `"Signed by team
4 //! ABCD123XYZ"` is *evidence* that the binary is signed by a verified Apple
5 //! Developer ID team — not just an absence of malware. Unsigned binaries
6 //! still return `Pass` (most files won't be Apple-shaped at all); the
7 //! distinguishing data lives in the `detail` field, which the dashboard
8 //! surfaces in the chip tooltip.
9 //!
10 //! Powered by `apple-codesign` (indygreg/apple-platform-rs), pure-Rust, no
11 //! Apple host needed. See `reference_apple_codesign.md` in memory and § 4.1
12 //! of `docs/scan-pipeline-audit.md` for design rationale.
13 //!
14 //! **Scope of v1 + v2**:
15 //! - Mach-O (single-arch + fat/universal).
16 //! - DMG disk images (via `DmgReader` + `Cursor`).
17 //! - Extract team identifier from `CodeDirectory`.
18 //! - Detect presence of notarization staple ticket (Apple notarization service
19 //! embeds a CMS-signed ticket in `CodeSigningSlot::Ticket`). A well-formed
20 //! ticket blob upgrades the Pass detail to "Notarized".
21 //! - Verdicts: `Skip` (not Apple), `Pass` (Apple-shaped — with detail
22 //! describing signing + notarization state), `Error` (parser failure —
23 //! fail-open by policy).
24 //!
25 //! Deferred (Phase 3b-3):
26 //! - Cryptographic verification of the staple's CMS signature against Apple's
27 //! notarization CA. Current detection is presence + structural sanity; a
28 //! determined attacker could embed bogus bytes in the slot. Chain
29 //! verification (using `cryptographic-message-syntax`) closes that gap.
30 //! - `.app` / `.pkg` bundle support (those arrive as `.dmg` or `.zip`
31 //! typically, and bundle directories don't survive a single-blob upload).
32 //! - Full CMS chain validation against the Apple Developer ID root.
33
34 use std::io::Cursor;
35
36 use crate::storage::FileType;
37
38 use super::{ErrorPolicy, LayerResult, LayerVerdict};
39
40 /// Bonus / positive-evidence layer. An `Error` here means our verifier
41 /// choked on the file, not that the file is malicious — uploads must not be
42 /// held just because we couldn't parse a signature. Fail open; the
43 /// dashboard surfaces parser errors via the health panel.
44 pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen;
45
46 /// Path-based entry. Mmaps the spooled file (DMG signature lookup walks
47 /// the trailer; MachFile parses headers + load commands — both touch
48 /// specific offsets, demand-paged through the mmap) and delegates.
49 pub fn verify_apple_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult {
50 if !matches!(file_type, FileType::Download | FileType::Insertion) {
51 return skip("Not a download file type");
52 }
53 match crate::scanning::spool::mmap_read(path) {
54 Ok(map) => verify_apple_signature(&map, file_type),
55 Err(e) => error(e),
56 }
57 }
58
59 /// Top-level entry: verify any Apple code-signing evidence in the bytes.
60 pub fn verify_apple_signature(data: &[u8], file_type: FileType) -> LayerResult {
61 // Only download-class uploads are plausible Apple binaries. Audio /
62 // cover / image / video file types never carry Apple signatures, so
63 // shortcut to Skip and save the parse cost.
64 if !matches!(file_type, FileType::Download | FileType::Insertion) {
65 return skip("Not a download file type");
66 }
67
68 if looks_like_macho(data) {
69 return verify_macho(data);
70 }
71 if looks_like_dmg(data) {
72 return verify_dmg(data);
73 }
74 skip("Not a recognized Apple binary format")
75 }
76
77 fn skip(reason: &'static str) -> LayerResult {
78 LayerResult {
79 layer: "signing_macos",
80 verdict: LayerVerdict::Skip,
81 detail: Some(reason.to_string()),
82 }
83 }
84
85 fn pass(detail: String) -> LayerResult {
86 LayerResult {
87 layer: "signing_macos",
88 verdict: LayerVerdict::Pass,
89 detail: Some(detail),
90 }
91 }
92
93 fn error(detail: String) -> LayerResult {
94 LayerResult {
95 layer: "signing_macos",
96 verdict: LayerVerdict::Error,
97 detail: Some(detail),
98 }
99 }
100
101 /// Heuristic: file bytes begin with a Mach-O magic, including fat/universal.
102 pub(crate) fn looks_like_macho(data: &[u8]) -> bool {
103 if data.len() < 4 {
104 return false;
105 }
106 let m = &data[..4];
107 // 0xFEEDFACE / 0xFEEDFACF (Mach-O 32/64 LE), 0xCEFAEDFE / 0xCFFAEDFE (BE),
108 // 0xCAFEBABE / 0xBEBAFECA (fat universal, both endians).
109 matches!(
110 m,
111 [0xFE, 0xED, 0xFA, 0xCE]
112 | [0xFE, 0xED, 0xFA, 0xCF]
113 | [0xCE, 0xFA, 0xED, 0xFE]
114 | [0xCF, 0xFA, 0xED, 0xFE]
115 | [0xCA, 0xFE, 0xBA, 0xBE]
116 | [0xBE, 0xBA, 0xFE, 0xCA]
117 )
118 }
119
120 /// Heuristic: DMG files carry a "koly" trailer in the final 512 bytes of the
121 /// archive. This is what `DmgReader` keys off of. We sniff before parsing so
122 /// uploads that aren't DMGs don't pay the full reader cost.
123 pub(crate) fn looks_like_dmg(data: &[u8]) -> bool {
124 if data.len() < 512 {
125 return false;
126 }
127 let tail = &data[data.len() - 512..];
128 tail.windows(4).any(|w| w == b"koly")
129 }
130
131 /// Minimum byte length we accept as a plausible notarization ticket. A real
132 /// staple from Apple is CMS SignedData with cert chain + signed attributes;
133 /// these are kilobyte-scale. The threshold filters out empty / placeholder
134 /// blobs without requiring full CMS parsing yet (see Phase 3b-3).
135 const MIN_TICKET_BYTES: usize = 256;
136
137 #[derive(Debug, Clone, Copy)]
138 enum NotarizationState {
139 /// Ticket slot present and the blob has plausible structure.
140 Stapled,
141 /// No ticket slot found.
142 NotStapled,
143 /// Ticket slot present but blob is too small / malformed to be real.
144 Malformed,
145 }
146
147 fn detect_staple(sig: &apple_codesign::EmbeddedSignature) -> NotarizationState {
148 use apple_codesign::CodeSigningSlot;
149 match sig.find_slot(CodeSigningSlot::Ticket) {
150 Some(entry) => {
151 // BlobEntry.data includes the 8-byte blob header. The actual
152 // ticket bytes live after it. Use length-based sanity check.
153 if entry.data.len() < 8 + MIN_TICKET_BYTES {
154 NotarizationState::Malformed
155 } else {
156 NotarizationState::Stapled
157 }
158 }
159 None => NotarizationState::NotStapled,
160 }
161 }
162
163 fn verify_macho(data: &[u8]) -> LayerResult {
164 use apple_codesign::MachFile;
165
166 let mach = match MachFile::parse(data) {
167 Ok(m) => m,
168 Err(e) => return error(format!("Mach-O parse failed: {e}")),
169 };
170
171 let mut signed_teams: Vec<String> = Vec::new();
172 let mut had_signature = false;
173 let mut notarization = NotarizationState::NotStapled;
174
175 for binary in mach.iter_macho() {
176 match binary.code_signature() {
177 Ok(Some(sig)) => {
178 had_signature = true;
179 match sig.code_directory() {
180 Ok(Some(cd)) => {
181 if let Some(team) = cd.team_name.as_deref()
182 && !team.is_empty()
183 && !signed_teams.iter().any(|t| t == team)
184 {
185 signed_teams.push(team.to_string());
186 }
187 }
188 Ok(None) => {}
189 Err(e) => {
190 return error(format!("CodeDirectory parse failed: {e}"));
191 }
192 }
193 // Stapling is per-binary; the first stapled slice wins for the
194 // overall verdict. (In a fat binary, all slices should share
195 // notarization state, but we don't enforce that here.)
196 if matches!(notarization, NotarizationState::NotStapled) {
197 notarization = detect_staple(&sig);
198 }
199 }
200 Ok(None) => {}
201 Err(e) => return error(format!("Code signature parse failed: {e}")),
202 }
203 }
204
205 classify(had_signature, &signed_teams, notarization)
206 }
207
208 fn verify_dmg(data: &[u8]) -> LayerResult {
209 use apple_codesign::dmg::DmgReader;
210
211 let mut cursor = Cursor::new(data);
212 let reader = match DmgReader::new(&mut cursor) {
213 Ok(r) => r,
214 Err(e) => return error(format!("DMG parse failed: {e}")),
215 };
216
217 match reader.embedded_signature() {
218 Ok(Some(sig)) => {
219 let team = match sig.code_directory() {
220 Ok(Some(cd)) => cd.team_name.as_deref().map(|s| s.to_string()),
221 Ok(None) => None,
222 Err(e) => return error(format!("DMG CodeDirectory parse failed: {e}")),
223 };
224 let teams = team.into_iter().collect::<Vec<_>>();
225 let notarization = detect_staple(&sig);
226 classify(true, &teams, notarization)
227 }
228 Ok(None) => pass("DMG present, no embedded signature".to_string()),
229 Err(e) => error(format!("DMG signature parse failed: {e}")),
230 }
231 }
232
233 fn classify(
234 had_signature: bool,
235 signed_teams: &[String],
236 notarization: NotarizationState,
237 ) -> LayerResult {
238 let team_str = if signed_teams.is_empty() {
239 String::new()
240 } else {
241 format!(" team(s): {}", signed_teams.join(", "))
242 };
243
244 match (had_signature, notarization) {
245 (true, NotarizationState::Stapled) if !signed_teams.is_empty() => {
246 pass(format!("Notarized by Apple,{team_str}"))
247 }
248 (true, NotarizationState::Stapled) => {
249 pass("Notarized by Apple, no team identifier".to_string())
250 }
251 (true, NotarizationState::Malformed) => {
252 pass(format!("Signed{team_str}; staple present but malformed"))
253 }
254 (true, NotarizationState::NotStapled) if !signed_teams.is_empty() => {
255 pass(format!("Signed by{team_str}, not notarized"))
256 }
257 (true, NotarizationState::NotStapled) => {
258 pass("Signed, no team identifier present".to_string())
259 }
260 (false, _) => pass("Apple binary, no signature".to_string()),
261 }
262 }
263
264 #[cfg(test)]
265 mod tests {
266 use super::*;
267
268 #[test]
269 fn skips_non_download_types() {
270 let r = verify_apple_signature(b"any bytes", FileType::Audio);
271 assert_eq!(r.verdict, LayerVerdict::Skip);
272 }
273
274 #[test]
275 fn skips_non_apple_bytes() {
276 let r = verify_apple_signature(b"plain text upload", FileType::Download);
277 assert_eq!(r.verdict, LayerVerdict::Skip);
278 assert!(r.detail.unwrap().contains("Not a recognized"));
279 }
280
281 #[test]
282 fn detects_macho_magic_32_le() {
283 let bytes = [0xFE, 0xED, 0xFA, 0xCE, 0x00];
284 assert!(looks_like_macho(&bytes));
285 }
286
287 #[test]
288 fn detects_macho_magic_64_le() {
289 let bytes = [0xFE, 0xED, 0xFA, 0xCF, 0x00];
290 assert!(looks_like_macho(&bytes));
291 }
292
293 #[test]
294 fn detects_macho_magic_64_be() {
295 let bytes = [0xCF, 0xFA, 0xED, 0xFE, 0x00];
296 assert!(looks_like_macho(&bytes));
297 }
298
299 #[test]
300 fn detects_fat_universal_magic() {
301 let bytes = [0xCA, 0xFE, 0xBA, 0xBE, 0x00];
302 assert!(looks_like_macho(&bytes));
303 }
304
305 #[test]
306 fn rejects_pe_header_as_macho() {
307 let bytes = [b'M', b'Z', 0x00, 0x00];
308 assert!(!looks_like_macho(&bytes));
309 }
310
311 #[test]
312 fn rejects_short_input_as_macho() {
313 assert!(!looks_like_macho(&[0xFE, 0xED]));
314 }
315
316 #[test]
317 fn rejects_short_input_as_dmg() {
318 assert!(!looks_like_dmg(b"too short"));
319 }
320
321 #[test]
322 fn detects_koly_in_tail() {
323 let mut data = vec![0u8; 1024];
324 // Plant a koly signature in the final 512 bytes.
325 let tail_start = data.len() - 256;
326 data[tail_start..tail_start + 4].copy_from_slice(b"koly");
327 assert!(looks_like_dmg(&data));
328 }
329
330 #[test]
331 fn rejects_koly_outside_tail() {
332 let mut data = vec![0u8; 4096];
333 // koly far from the end shouldn't trigger the heuristic.
334 data[0..4].copy_from_slice(b"koly");
335 assert!(!looks_like_dmg(&data));
336 }
337
338 #[test]
339 fn macho_with_bogus_body_is_error_not_pass() {
340 // Magic header alone, garbage thereafter — parser will reject.
341 // Error here is fine because the policy is FailOpen: the pipeline
342 // aggregator treats this layer's Error as Skip-equivalent.
343 let mut data = vec![0xFE, 0xED, 0xFA, 0xCF];
344 data.extend_from_slice(&[0xFF; 256]);
345 let r = verify_apple_signature(&data, FileType::Download);
346 assert!(matches!(r.verdict, LayerVerdict::Error | LayerVerdict::Pass));
347 }
348
349 #[test]
350 fn classify_signed_not_notarized_yields_pass_with_team() {
351 let r = classify(true, &["ABCD123XYZ".to_string()], NotarizationState::NotStapled);
352 assert_eq!(r.verdict, LayerVerdict::Pass);
353 let d = r.detail.unwrap();
354 assert!(d.contains("ABCD123XYZ"));
355 assert!(d.contains("not notarized"));
356 }
357
358 #[test]
359 fn classify_signed_and_notarized_says_notarized() {
360 let r = classify(true, &["ABCD123XYZ".to_string()], NotarizationState::Stapled);
361 assert_eq!(r.verdict, LayerVerdict::Pass);
362 let d = r.detail.unwrap();
363 assert!(d.contains("Notarized"));
364 assert!(d.contains("ABCD123XYZ"));
365 }
366
367 #[test]
368 fn classify_signed_without_team_not_notarized() {
369 let r = classify(true, &[], NotarizationState::NotStapled);
370 assert_eq!(r.verdict, LayerVerdict::Pass);
371 assert!(r.detail.unwrap().contains("no team identifier"));
372 }
373
374 #[test]
375 fn classify_signed_without_team_notarized() {
376 let r = classify(true, &[], NotarizationState::Stapled);
377 assert_eq!(r.verdict, LayerVerdict::Pass);
378 let d = r.detail.unwrap();
379 assert!(d.contains("Notarized"));
380 assert!(d.contains("no team identifier"));
381 }
382
383 #[test]
384 fn classify_malformed_staple_still_passes_but_flags() {
385 let r = classify(true, &["TEAM".to_string()], NotarizationState::Malformed);
386 assert_eq!(r.verdict, LayerVerdict::Pass);
387 let d = r.detail.unwrap();
388 assert!(d.contains("staple present but malformed"));
389 }
390
391 #[test]
392 fn classify_apple_binary_no_signature() {
393 let r = classify(false, &[], NotarizationState::NotStapled);
394 assert_eq!(r.verdict, LayerVerdict::Pass);
395 assert!(r.detail.unwrap().contains("no signature"));
396 }
397
398 #[test]
399 fn path_entry_matches_buffered_on_plain_text() {
400 let data = b"definitely not a mach-o";
401 let buffered = verify_apple_signature(data, FileType::Download);
402 let tmp = tempfile::NamedTempFile::new().unwrap();
403 std::fs::write(tmp.path(), data).unwrap();
404 let path_based = verify_apple_signature_path(tmp.path(), FileType::Download);
405 assert_eq!(buffered.verdict, path_based.verdict);
406 }
407 }
408