Skip to main content

max / makenotwork

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