ipasign 1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ipasign/macho.py ADDED
@@ -0,0 +1,504 @@
1
+ """Mach-O container parsing and rewriting.
2
+
3
+ A Mach-O file is either thin (one slice) or fat (several slices behind a fat
4
+ header). Everything here works on an in-memory ``bytes`` buffer and hands out
5
+ views into it, so a caller can read a binary once and sign it without a second
6
+ pass over the file.
7
+
8
+ Only what signing needs is modelled: the header, the load commands worth
9
+ looking at, the segments that carry a signature and the embedded info plist.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import struct
15
+ from dataclasses import dataclass, field
16
+
17
+ from .errors import MachOError, NotEnoughSpaceError
18
+
19
+ # Header magics, as seen when the first four bytes are read little-endian.
20
+ MH_MAGIC = 0xFEEDFACE
21
+ MH_CIGAM = 0xCEFAEDFE
22
+ MH_MAGIC_64 = 0xFEEDFACF
23
+ MH_CIGAM_64 = 0xCFFAEDFE
24
+
25
+ # Fat headers are detected on their raw bytes. The canonical order
26
+ # ``ca fe ba be`` stores its fields big-endian; the byte-swapped form stores
27
+ # them little-endian. Getting this backwards rejects real Apple frameworks.
28
+ FAT_MAGIC_BYTES = b"\xca\xfe\xba\xbe"
29
+ FAT_CIGAM_BYTES = b"\xbe\xba\xfe\xca"
30
+
31
+ THIN_MAGICS = (MH_MAGIC, MH_CIGAM, MH_MAGIC_64, MH_CIGAM_64)
32
+
33
+ MH_EXECUTE = 0x2
34
+
35
+ LC_SEGMENT = 0x1
36
+ LC_SEGMENT_64 = 0x19
37
+ LC_CODE_SIGNATURE = 0x1D
38
+ LC_ENCRYPTION_INFO = 0x21
39
+ LC_ENCRYPTION_INFO_64 = 0x2C
40
+
41
+ FAT_ALIGN_SHIFT = 14 # 16384-byte slice alignment
42
+ SIGNATURE_ALIGN = 4096
43
+
44
+ _HEADER_SIZE = {False: 28, True: 32}
45
+ _SEGMENT_SIZE = {False: 56, True: 72}
46
+ _SECTION_SIZE = {False: 68, True: 80}
47
+
48
+ # name, vmaddr, vmsize, fileoff, filesize, maxprot, initprot, nsects, flags
49
+ _SEGMENT = {False: "16s8I", True: "16s4Q4I"}
50
+ # sectname, segname, addr, size, offset, align, reloff, nreloc, flags, reserved
51
+ _SECTION = {False: "16s16s9I", True: "16s16s2Q8I"}
52
+
53
+
54
+ def _cstr(raw: bytes) -> str:
55
+ """Decode a fixed-size NUL-padded name field."""
56
+ return raw.split(b"\0", 1)[0].decode("utf-8", "replace")
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class Section:
61
+ """One entry of a segment's section table."""
62
+
63
+ name: str
64
+ segname: str
65
+ addr: int
66
+ size: int
67
+ offset: int
68
+ align: int
69
+ reloff: int
70
+ nreloc: int
71
+ flags: int
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class Segment:
76
+ """A load command describing one segment."""
77
+
78
+ name: str
79
+ vmaddr: int
80
+ vmsize: int
81
+ fileoff: int
82
+ filesize: int
83
+ maxprot: int
84
+ initprot: int
85
+ flags: int
86
+ sections: list[Section]
87
+ command_offset: int
88
+ command_size: int
89
+ is_64: bool
90
+
91
+ def section(self, name: str) -> Section | None:
92
+ for sect in self.sections:
93
+ if sect.name == name:
94
+ return sect
95
+ return None
96
+
97
+
98
+ @dataclass(slots=True)
99
+ class LoadCommand:
100
+ """A load command this library has an opinion about."""
101
+
102
+ cmd: int
103
+ size: int
104
+ offset: int
105
+ data: tuple[int, ...] = ()
106
+
107
+
108
+ @dataclass(slots=True)
109
+ class Slice:
110
+ """One architecture of a Mach-O file, pointing back at the source buffer."""
111
+
112
+ data: bytes
113
+ base: int
114
+ size: int
115
+ big_endian: bool
116
+ is_64: bool
117
+ cpu_type: int
118
+ cpu_subtype: int
119
+ file_type: int
120
+ ncmds: int
121
+ sizeofcmds: int
122
+ flags: int
123
+ segments: list[Segment] = field(default_factory=list)
124
+ commands: list[LoadCommand] = field(default_factory=list)
125
+ code_signature: LoadCommand | None = None
126
+ info_plist: bytes = b""
127
+ encrypted: bool = False
128
+
129
+ @property
130
+ def endian(self) -> str:
131
+ return ">" if self.big_endian else "<"
132
+
133
+ @property
134
+ def header_size(self) -> int:
135
+ return _HEADER_SIZE[self.is_64]
136
+
137
+ @property
138
+ def view(self) -> memoryview:
139
+ """The slice's bytes as a view, without copying."""
140
+ return memoryview(self.data)[self.base : self.base + self.size]
141
+
142
+ @property
143
+ def is_execute(self) -> bool:
144
+ return self.file_type == MH_EXECUTE
145
+
146
+ @property
147
+ def code_length(self) -> int:
148
+ """Where the signature region starts.
149
+
150
+ For a signed file that is ``LC_CODE_SIGNATURE.dataoff``. For an unsigned
151
+ one it is the file size rounded up to a 16-byte boundary, which is where
152
+ a signature would be appended.
153
+ """
154
+ if self.code_signature is not None:
155
+ return self.code_signature.data[0]
156
+ return round_up(self.size, 16)
157
+
158
+ @property
159
+ def text_segment(self) -> Segment | None:
160
+ for seg in self.segments:
161
+ if seg.name == "__TEXT":
162
+ return seg
163
+ return None
164
+
165
+ @property
166
+ def linkedit_segment(self) -> Segment | None:
167
+ for seg in self.segments:
168
+ if seg.name == "__LINKEDIT":
169
+ return seg
170
+ return None
171
+
172
+ @property
173
+ def load_commands_free_space(self) -> int:
174
+ """Bytes between the end of the load commands and the first section data."""
175
+ text = self.text_segment
176
+ if text is None:
177
+ return 0
178
+ text_sect = text.section("__text")
179
+ if text_sect is None or text_sect.offset <= self.header_size + self.sizeofcmds:
180
+ return 0
181
+ return text_sect.offset - self.header_size - self.sizeofcmds
182
+
183
+ @property
184
+ def exec_seg_limit(self) -> int:
185
+ """The ``__TEXT`` segment vmsize, sealed into the CodeDirectory."""
186
+ text = self.text_segment
187
+ return text.vmsize if text is not None else 0
188
+
189
+
190
+ def _parse_slice(data: bytes, base: int, size: int) -> Slice:
191
+ if base + size > len(data):
192
+ raise MachOError("slice runs past the end of the file")
193
+ magic = struct.unpack_from("<I", data, base)[0]
194
+ if magic not in THIN_MAGICS:
195
+ raise MachOError(f"not a Mach-O magic at offset {base}: 0x{magic:08x}")
196
+
197
+ big_endian = magic in (MH_CIGAM, MH_CIGAM_64)
198
+ is_64 = magic in (MH_MAGIC_64, MH_CIGAM_64)
199
+ endian = ">" if big_endian else "<"
200
+
201
+ fields = struct.unpack_from(endian + ("8I" if is_64 else "7I"), data, base)
202
+ slc = Slice(
203
+ data=data,
204
+ base=base,
205
+ size=size,
206
+ big_endian=big_endian,
207
+ is_64=is_64,
208
+ cpu_type=fields[1],
209
+ cpu_subtype=fields[2],
210
+ file_type=fields[3],
211
+ ncmds=fields[4],
212
+ sizeofcmds=fields[5],
213
+ flags=fields[6],
214
+ )
215
+
216
+ # Parsing walks absolute file offsets; everything stored on the dataclasses is
217
+ # relative to the slice base, because the writers rebuild a slice-only buffer.
218
+ cursor = base + _HEADER_SIZE[is_64]
219
+ for _ in range(slc.ncmds):
220
+ if cursor + 8 > base + size:
221
+ raise MachOError("load command runs past the end of the slice")
222
+ cmd, cmdsize = struct.unpack_from(endian + "II", data, cursor)
223
+ if cmdsize < 8 or cursor + cmdsize > base + size:
224
+ raise MachOError(f"bad load command size {cmdsize} for cmd 0x{cmd:x}")
225
+
226
+ offset = cursor - base
227
+ if cmd in (LC_SEGMENT, LC_SEGMENT_64):
228
+ slc.segments.append(_parse_segment(data, cursor, offset, cmd == LC_SEGMENT_64, endian))
229
+ elif cmd == LC_CODE_SIGNATURE:
230
+ dataoff, datasize = struct.unpack_from(endian + "II", data, cursor + 8)
231
+ slc.code_signature = LoadCommand(cmd, cmdsize, offset, (dataoff, datasize))
232
+ elif cmd in (LC_ENCRYPTION_INFO, LC_ENCRYPTION_INFO_64):
233
+ cryptid = struct.unpack_from(endian + "I", data, cursor + 16)[0]
234
+ slc.encrypted = cryptid >= 1
235
+ slc.commands.append(LoadCommand(cmd, cmdsize, offset))
236
+ else:
237
+ slc.commands.append(LoadCommand(cmd, cmdsize, offset))
238
+
239
+ cursor += cmdsize
240
+
241
+ text = slc.text_segment
242
+ if text is not None:
243
+ info = text.section("__info_plist")
244
+ if info is not None and info.offset and info.size:
245
+ slc.info_plist = bytes(data[base + info.offset : base + info.offset + info.size])
246
+
247
+ return slc
248
+
249
+
250
+ def _parse_segment(data: bytes, cursor: int, offset: int, is_64: bool, endian: str) -> Segment:
251
+ fields = struct.unpack_from(endian + _SEGMENT[is_64], data, cursor + 8)
252
+ name = _cstr(fields[0])
253
+ vmaddr, vmsize, fileoff, filesize = fields[1:5]
254
+ maxprot, initprot, nsects, flags = fields[5:9]
255
+ command_size = struct.unpack_from(endian + "I", data, cursor + 4)[0]
256
+
257
+ sections: list[Section] = []
258
+ sect_off = cursor + _SEGMENT_SIZE[is_64]
259
+ for _ in range(nsects):
260
+ raw = struct.unpack_from(endian + _SECTION[is_64], data, sect_off)
261
+ sections.append(
262
+ Section(
263
+ name=_cstr(raw[0]),
264
+ segname=_cstr(raw[1]),
265
+ addr=raw[2],
266
+ size=raw[3],
267
+ offset=raw[4],
268
+ align=raw[5],
269
+ reloff=raw[6],
270
+ nreloc=raw[7],
271
+ flags=raw[8],
272
+ )
273
+ )
274
+ sect_off += _SECTION_SIZE[is_64]
275
+
276
+ return Segment(
277
+ name=name,
278
+ vmaddr=vmaddr,
279
+ vmsize=vmsize,
280
+ fileoff=fileoff,
281
+ filesize=filesize,
282
+ maxprot=maxprot,
283
+ initprot=initprot,
284
+ flags=flags,
285
+ sections=sections,
286
+ command_offset=offset,
287
+ command_size=command_size,
288
+ is_64=is_64,
289
+ )
290
+
291
+
292
+ @dataclass(slots=True)
293
+ class FatArch:
294
+ """One entry of a fat header."""
295
+
296
+ cpu_type: int
297
+ cpu_subtype: int
298
+ offset: int
299
+ size: int
300
+ align: int
301
+
302
+
303
+ @dataclass(slots=True)
304
+ class MachOFile:
305
+ """A parsed Mach-O file, thin or fat.
306
+
307
+ ``data`` is the original buffer and stays untouched, so a failed sign cannot
308
+ leave a half-written binary behind.
309
+ """
310
+
311
+ data: bytes
312
+ is_fat: bool
313
+ fat_big_endian: bool
314
+ slices: list[Slice]
315
+ archs: list[FatArch] = field(default_factory=list)
316
+
317
+ @classmethod
318
+ def parse(cls, data: bytes) -> "MachOFile":
319
+ try:
320
+ return cls._parse(data)
321
+ except struct.error as exc:
322
+ # struct.error is not part of the public API: a truncated or
323
+ # malformed binary becomes a MachOError at the boundary.
324
+ raise MachOError(f"malformed Mach-O: {exc}") from exc
325
+
326
+ @classmethod
327
+ def _parse(cls, data: bytes) -> "MachOFile":
328
+ if len(data) < 8:
329
+ raise MachOError("file is too short to be a Mach-O")
330
+ head = bytes(data[:4])
331
+ if head in (FAT_MAGIC_BYTES, FAT_CIGAM_BYTES):
332
+ return cls._parse_fat(data, head == FAT_MAGIC_BYTES)
333
+ magic = struct.unpack_from("<I", data, 0)[0]
334
+ if magic in THIN_MAGICS:
335
+ return cls(
336
+ data=data,
337
+ is_fat=False,
338
+ fat_big_endian=False,
339
+ slices=[_parse_slice(data, 0, len(data))],
340
+ )
341
+ raise MachOError(f"unrecognised Mach-O magic 0x{magic:08x}")
342
+
343
+ @classmethod
344
+ def _parse_fat(cls, data: bytes, big_endian: bool) -> "MachOFile":
345
+ endian = ">" if big_endian else "<"
346
+ if len(data) < 8:
347
+ raise MachOError("fat header is truncated")
348
+ nfat = struct.unpack_from(endian + "I", data, 4)[0]
349
+ if len(data) < 8 + nfat * 20:
350
+ raise MachOError(f"fat header claims {nfat} architectures but is truncated")
351
+ archs: list[FatArch] = []
352
+ slices: list[Slice] = []
353
+ for i in range(nfat):
354
+ cputype, cpusubtype, offset, size, align = struct.unpack_from(
355
+ endian + "5I", data, 8 + i * 20
356
+ )
357
+ if offset + size > len(data):
358
+ raise MachOError(f"fat arch {i} runs past the end of the file")
359
+ archs.append(FatArch(cputype, cpusubtype, offset, size, align))
360
+ slices.append(_parse_slice(data, offset, size))
361
+ return cls(data=data, is_fat=True, fat_big_endian=big_endian, slices=slices, archs=archs)
362
+
363
+
364
+ def align_up(value: int, align: int) -> int:
365
+ """Round ``value`` up to a multiple of ``align``, never a no-op."""
366
+ return value + (align - value % align)
367
+
368
+
369
+ def round_up(value: int, align: int) -> int:
370
+ """Round ``value`` up to a multiple of ``align``, a no-op when already aligned."""
371
+ return value if value % align == 0 else value + align - value % align
372
+
373
+
374
+ def signature_region_size(code_length: int) -> int:
375
+ """How much room to reserve for a signature starting at ``code_length``.
376
+
377
+ One slot per page for both SHA-1 and SHA-256, plus a fixed tail for the
378
+ SuperBlob header, Requirements, Entitlements and the CMS blob.
379
+ """
380
+ return align_up(((code_length // SIGNATURE_ALIGN) + 1) * (20 + 32), SIGNATURE_ALIGN) + 32768
381
+
382
+
383
+ def grow_slice(slc: Slice, new_length: int) -> bytes:
384
+ """Return the slice resized to ``new_length`` with room for a signature.
385
+
386
+ The signature region itself is left as zero padding; call
387
+ :func:`place_signature` once the blob is built. ``__LINKEDIT`` and
388
+ ``LC_CODE_SIGNATURE`` are brought up to date here.
389
+ """
390
+ if new_length < slc.size:
391
+ raise MachOError("cannot shrink a slice to make room for a signature")
392
+
393
+ out = bytearray(slc.view)
394
+ out.extend(b"\0" * (new_length - len(out)))
395
+
396
+ code_length = slc.code_length
397
+ _set_signature_command(slc, out, code_length, new_length - code_length)
398
+
399
+ if new_length > slc.size:
400
+ _grow_linkedit(slc, out, new_length)
401
+
402
+ return bytes(out)
403
+
404
+
405
+ def place_signature(slc: Slice, data: bytes, code_length: int, signature: bytes) -> bytes:
406
+ """Write ``signature`` into a slice previously sized by :func:`grow_slice`."""
407
+ out = bytearray(data)
408
+ end = code_length + len(signature)
409
+ if end > len(out) or code_length < 0:
410
+ raise MachOError("signature does not fit in the region it was sized for")
411
+ out[code_length:end] = signature
412
+ _set_signature_command(slc, out, code_length, len(out) - code_length)
413
+ return bytes(out)
414
+
415
+
416
+ def _set_signature_command(slc: Slice, out: bytearray, code_length: int, region: int) -> None:
417
+ """Create or update the ``LC_CODE_SIGNATURE`` load command."""
418
+ endian = slc.endian
419
+ if slc.code_signature is None:
420
+ if slc.load_commands_free_space < 16:
421
+ raise NotEnoughSpaceError(
422
+ "no room in the load commands for LC_CODE_SIGNATURE; "
423
+ f"{slc.load_commands_free_space} bytes free, 16 needed"
424
+ )
425
+ cmd_off = slc.header_size + slc.sizeofcmds
426
+ struct.pack_into(endian + "IIII", out, cmd_off, LC_CODE_SIGNATURE, 16, code_length, region)
427
+ struct.pack_into(endian + "I", out, 16, slc.ncmds + 1)
428
+ struct.pack_into(endian + "I", out, 20, slc.sizeofcmds + 16)
429
+ else:
430
+ struct.pack_into(endian + "II", out, slc.code_signature.offset + 8, code_length, region)
431
+
432
+
433
+ def _grow_linkedit(slc: Slice, out: bytearray, new_length: int) -> None:
434
+ """Extend ``__LINKEDIT`` to cover the grown file."""
435
+ linkedit = slc.linkedit_segment
436
+ if linkedit is None:
437
+ raise MachOError("slice has no __LINKEDIT segment")
438
+ endian = slc.endian
439
+ grow = new_length - slc.size
440
+ if linkedit.is_64:
441
+ vmsize = align_up(linkedit.vmsize + grow, SIGNATURE_ALIGN)
442
+ struct.pack_into(endian + "Q", out, linkedit.command_offset + 32, vmsize)
443
+ struct.pack_into(endian + "Q", out, linkedit.command_offset + 48, new_length - linkedit.fileoff)
444
+ else:
445
+ vmsize = align_up((linkedit.vmsize + grow) & 0xFFFFFFFF, SIGNATURE_ALIGN)
446
+ struct.pack_into(endian + "I", out, linkedit.command_offset + 28, vmsize)
447
+ struct.pack_into(endian + "I", out, linkedit.command_offset + 36, new_length - linkedit.fileoff)
448
+
449
+
450
+ def build_fat(
451
+ slices: list[bytes],
452
+ archs: list[FatArch],
453
+ big_endian: bool,
454
+ align: int = 1 << FAT_ALIGN_SHIFT,
455
+ ) -> bytes:
456
+ """Lay slices out behind a fat header at ``align`` and return the container."""
457
+ if len(slices) != len(archs):
458
+ raise MachOError("slice count does not match fat arch count")
459
+
460
+ endian = ">" if big_endian else "<"
461
+ magic = FAT_MAGIC_BYTES if big_endian else FAT_CIGAM_BYTES
462
+ offset = align_up(8 + 20 * len(archs), align)
463
+
464
+ out = bytearray(b"\0" * offset)
465
+ out[0:4] = magic
466
+ struct.pack_into(endian + "I", out, 4, len(archs))
467
+
468
+ for i, (blob, arch) in enumerate(zip(slices, archs)):
469
+ if len(out) < offset:
470
+ out.extend(b"\0" * (offset - len(out)))
471
+ out[offset : offset + len(blob)] = blob
472
+ struct.pack_into(
473
+ endian + "5I",
474
+ out,
475
+ 8 + i * 20,
476
+ arch.cpu_type,
477
+ arch.cpu_subtype,
478
+ offset,
479
+ len(blob),
480
+ FAT_ALIGN_SHIFT,
481
+ )
482
+ offset = align_up(offset + len(blob), align)
483
+
484
+ if len(out) < offset:
485
+ out.extend(b"\0" * (offset - len(out)))
486
+ return bytes(out)
487
+
488
+
489
+ __all__ = [
490
+ "MachOFile",
491
+ "Slice",
492
+ "Segment",
493
+ "Section",
494
+ "FatArch",
495
+ "LoadCommand",
496
+ "align_up",
497
+ "round_up",
498
+ "signature_region_size",
499
+ "grow_slice",
500
+ "place_signature",
501
+ "build_fat",
502
+ "MH_EXECUTE",
503
+ "LC_CODE_SIGNATURE",
504
+ ]