Skip to main content

max / makenotwork

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