Skip to main content

max / makenotwork

15.0 KB · 427 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. Test-only,
49 /// the live path verifies already-resident bytes (ultra-fuzz Run 11 Sec L1).
50 #[cfg(test)]
51 pub fn verify_apple_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult {
52 if !matches!(file_type, FileType::Download | FileType::Insertion) {
53 return skip("Not a download file type");
54 }
55 match crate::scanning::spool::mmap_read(path) {
56 Ok(map) => verify_apple_signature(&map, file_type),
57 Err(e) => error(e),
58 }
59 }
60
61 /// Top-level entry: verify any Apple code-signing evidence in the bytes.
62 pub fn verify_apple_signature(data: &[u8], file_type: FileType) -> LayerResult {
63 // Only download-class uploads are plausible Apple binaries. Audio /
64 // cover / image / video file types never carry Apple signatures, so
65 // shortcut to Skip and save the parse cost.
66 if !matches!(file_type, FileType::Download | FileType::Insertion) {
67 return skip("Not a download file type");
68 }
69
70 if looks_like_macho(data) {
71 return verify_macho(data);
72 }
73 if looks_like_dmg(data) {
74 return verify_dmg(data);
75 }
76 skip("Not a recognized Apple binary format")
77 }
78
79 fn skip(reason: &'static str) -> LayerResult {
80 LayerResult {
81 layer: "signing_macos",
82 verdict: LayerVerdict::Skip,
83 detail: Some(reason.to_string()),
84 }
85 }
86
87 fn pass(detail: String) -> LayerResult {
88 LayerResult {
89 layer: "signing_macos",
90 verdict: LayerVerdict::Pass,
91 detail: Some(detail),
92 }
93 }
94
95 fn error(detail: String) -> LayerResult {
96 LayerResult {
97 layer: "signing_macos",
98 verdict: LayerVerdict::Error,
99 detail: Some(detail),
100 }
101 }
102
103 /// Heuristic: file bytes begin with a Mach-O magic, including fat/universal.
104 pub(crate) fn looks_like_macho(data: &[u8]) -> bool {
105 if data.len() < 4 {
106 return false;
107 }
108 let m = &data[..4];
109 // 0xFEEDFACE / 0xFEEDFACF (Mach-O 32/64 LE), 0xCEFAEDFE / 0xCFFAEDFE (BE),
110 // 0xCAFEBABE / 0xBEBAFECA (fat universal, both endians).
111 matches!(
112 m,
113 [0xFE, 0xED, 0xFA, 0xCE | 0xCF]
114 | [0xCE | 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
221 .team_name
222 .as_deref()
223 .map(std::string::ToString::to_string),
224 Ok(None) => None,
225 Err(e) => return error(format!("DMG CodeDirectory parse failed: {e}")),
226 };
227 let teams = team.into_iter().collect::<Vec<_>>();
228 let notarization = detect_staple(&sig);
229 classify(true, &teams, notarization)
230 }
231 Ok(None) => pass("DMG present, no embedded signature".to_string()),
232 Err(e) => error(format!("DMG signature parse failed: {e}")),
233 }
234 }
235
236 fn classify(
237 had_signature: bool,
238 signed_teams: &[String],
239 notarization: NotarizationState,
240 ) -> LayerResult {
241 let team_str = if signed_teams.is_empty() {
242 String::new()
243 } else {
244 format!(" team(s): {}", signed_teams.join(", "))
245 };
246
247 // These details describe *presence* of signing/notarization structures, not
248 // a verified trust chain (this layer never validates the CMS chain and can
249 // never emit Fail). Word them as "present (unverified)" so the admin surface
250 // doesn't read them as a green trust verdict.
251 match (had_signature, notarization) {
252 (true, NotarizationState::Stapled) if !signed_teams.is_empty() => pass(format!(
253 "Notarized by Apple (ticket present, unverified),{team_str}"
254 )),
255 (true, NotarizationState::Stapled) => {
256 pass("Notarized by Apple (ticket present, unverified), no team identifier".to_string())
257 }
258 (true, NotarizationState::Malformed) => pass(format!(
259 "Signature present{team_str} (unverified); staple malformed"
260 )),
261 (true, NotarizationState::NotStapled) if !signed_teams.is_empty() => pass(format!(
262 "Signature present by{team_str} (unverified), not notarized"
263 )),
264 (true, NotarizationState::NotStapled) => {
265 pass("Signature present (unverified), no team identifier".to_string())
266 }
267 (false, _) => pass("Apple binary, no signature".to_string()),
268 }
269 }
270
271 #[cfg(test)]
272 mod tests {
273 use super::*;
274
275 #[test]
276 fn skips_non_download_types() {
277 let r = verify_apple_signature(b"any bytes", FileType::Audio);
278 assert_eq!(r.verdict, LayerVerdict::Skip);
279 }
280
281 #[test]
282 fn skips_non_apple_bytes() {
283 let r = verify_apple_signature(b"plain text upload", FileType::Download);
284 assert_eq!(r.verdict, LayerVerdict::Skip);
285 assert!(r.detail.unwrap().contains("Not a recognized"));
286 }
287
288 #[test]
289 fn detects_macho_magic_32_le() {
290 let bytes = [0xFE, 0xED, 0xFA, 0xCE, 0x00];
291 assert!(looks_like_macho(&bytes));
292 }
293
294 #[test]
295 fn detects_macho_magic_64_le() {
296 let bytes = [0xFE, 0xED, 0xFA, 0xCF, 0x00];
297 assert!(looks_like_macho(&bytes));
298 }
299
300 #[test]
301 fn detects_macho_magic_64_be() {
302 let bytes = [0xCF, 0xFA, 0xED, 0xFE, 0x00];
303 assert!(looks_like_macho(&bytes));
304 }
305
306 #[test]
307 fn detects_fat_universal_magic() {
308 let bytes = [0xCA, 0xFE, 0xBA, 0xBE, 0x00];
309 assert!(looks_like_macho(&bytes));
310 }
311
312 #[test]
313 fn rejects_pe_header_as_macho() {
314 let bytes = [b'M', b'Z', 0x00, 0x00];
315 assert!(!looks_like_macho(&bytes));
316 }
317
318 #[test]
319 fn rejects_short_input_as_macho() {
320 assert!(!looks_like_macho(&[0xFE, 0xED]));
321 }
322
323 #[test]
324 fn rejects_short_input_as_dmg() {
325 assert!(!looks_like_dmg(b"too short"));
326 }
327
328 #[test]
329 fn detects_koly_in_tail() {
330 let mut data = vec![0u8; 1024];
331 // Plant a koly signature in the final 512 bytes.
332 let tail_start = data.len() - 256;
333 data[tail_start..tail_start + 4].copy_from_slice(b"koly");
334 assert!(looks_like_dmg(&data));
335 }
336
337 #[test]
338 fn rejects_koly_outside_tail() {
339 let mut data = vec![0u8; 4096];
340 // koly far from the end shouldn't trigger the heuristic.
341 data[0..4].copy_from_slice(b"koly");
342 assert!(!looks_like_dmg(&data));
343 }
344
345 #[test]
346 fn macho_with_bogus_body_is_error_not_pass() {
347 // Magic header alone, garbage thereafter, parser will reject.
348 // Error here is fine because the policy is FailOpen: the pipeline
349 // aggregator treats this layer's Error as Skip-equivalent.
350 let mut data = vec![0xFE, 0xED, 0xFA, 0xCF];
351 data.extend_from_slice(&[0xFF; 256]);
352 let r = verify_apple_signature(&data, FileType::Download);
353 assert!(matches!(
354 r.verdict,
355 LayerVerdict::Error | LayerVerdict::Pass
356 ));
357 }
358
359 #[test]
360 fn classify_signed_not_notarized_yields_pass_with_team() {
361 let r = classify(
362 true,
363 &["ABCD123XYZ".to_string()],
364 NotarizationState::NotStapled,
365 );
366 assert_eq!(r.verdict, LayerVerdict::Pass);
367 let d = r.detail.unwrap();
368 assert!(d.contains("ABCD123XYZ"));
369 assert!(d.contains("not notarized"));
370 }
371
372 #[test]
373 fn classify_signed_and_notarized_says_notarized() {
374 let r = classify(
375 true,
376 &["ABCD123XYZ".to_string()],
377 NotarizationState::Stapled,
378 );
379 assert_eq!(r.verdict, LayerVerdict::Pass);
380 let d = r.detail.unwrap();
381 assert!(d.contains("Notarized"));
382 assert!(d.contains("ABCD123XYZ"));
383 }
384
385 #[test]
386 fn classify_signed_without_team_not_notarized() {
387 let r = classify(true, &[], NotarizationState::NotStapled);
388 assert_eq!(r.verdict, LayerVerdict::Pass);
389 assert!(r.detail.unwrap().contains("no team identifier"));
390 }
391
392 #[test]
393 fn classify_signed_without_team_notarized() {
394 let r = classify(true, &[], NotarizationState::Stapled);
395 assert_eq!(r.verdict, LayerVerdict::Pass);
396 let d = r.detail.unwrap();
397 assert!(d.contains("Notarized"));
398 assert!(d.contains("no team identifier"));
399 }
400
401 #[test]
402 fn classify_malformed_staple_still_passes_but_flags() {
403 let r = classify(true, &["TEAM".to_string()], NotarizationState::Malformed);
404 assert_eq!(r.verdict, LayerVerdict::Pass);
405 let d = r.detail.unwrap();
406 assert!(d.contains("staple malformed"));
407 assert!(d.contains("unverified"));
408 }
409
410 #[test]
411 fn classify_apple_binary_no_signature() {
412 let r = classify(false, &[], NotarizationState::NotStapled);
413 assert_eq!(r.verdict, LayerVerdict::Pass);
414 assert!(r.detail.unwrap().contains("no signature"));
415 }
416
417 #[test]
418 fn path_entry_matches_buffered_on_plain_text() {
419 let data = b"definitely not a mach-o";
420 let buffered = verify_apple_signature(data, FileType::Download);
421 let tmp = tempfile::NamedTempFile::new().unwrap();
422 std::fs::write(tmp.path(), data).unwrap();
423 let path_based = verify_apple_signature_path(tmp.path(), FileType::Download);
424 assert_eq!(buffered.verdict, path_based.verdict);
425 }
426 }
427