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/blobs.py ADDED
@@ -0,0 +1,362 @@
1
+ """Code signature blob construction.
2
+
3
+ The signature region of a Mach-O is a SuperBlob: a header with a table of
4
+ (type, offset) entries followed by the blobs themselves. This module builds each
5
+ blob type and assembles them into the SuperBlob a verifier walks.
6
+
7
+ All multi-byte integers on disk are big-endian, whatever the Mach-O header says.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import struct
14
+ from dataclasses import dataclass, field
15
+
16
+ from .errors import BlobError
17
+
18
+ CSMAGIC_REQUIREMENT = 0xFADE0C00
19
+ CSMAGIC_REQUIREMENTS = 0xFADE0C01
20
+ CSMAGIC_CODEDIRECTORY = 0xFADE0C02
21
+ CSMAGIC_EMBEDDED_SIGNATURE = 0xFADE0CC0
22
+ CSMAGIC_EMBEDDED_ENTITLEMENTS = 0xFADE7171
23
+ CSMAGIC_EMBEDDED_DER_ENTITLEMENTS = 0xFADE7172
24
+ CSMAGIC_BLOBWRAPPER = 0xFADE0B01
25
+
26
+ CSSLOT_CODEDIRECTORY = 0
27
+ CSSLOT_REQUIREMENTS = 2
28
+ CSSLOT_ENTITLEMENTS = 5
29
+ CSSLOT_DER_ENTITLEMENTS = 7
30
+ CSSLOT_ALTERNATE_CODEDIRECTORIES = 0x1000
31
+ CSSLOT_SIGNATURESLOT = 0x10000
32
+
33
+ CS_SEC_CODESIGNATURE_ADHOC = 0x2
34
+
35
+ CS_EXECSEG_MAIN_BINARY = 0x1
36
+ CS_EXECSEG_ALLOW_UNSIGNED = 0x10
37
+
38
+ CD_VERSION = 0x20400
39
+ CD_PAGE_SHIFT = 12
40
+ CD_PAGE_SIZE = 1 << CD_PAGE_SHIFT
41
+ CD_HASH_TYPE_SHA256 = 2
42
+ CD_HASH_SIZE_SHA256 = 32
43
+ CD_HEADER_SIZE = 88
44
+
45
+ # Requirement expression opcodes and match operations.
46
+ REQ_OP_TRUE = 1
47
+ REQ_OP_IDENT = 2
48
+ REQ_OP_AND = 6
49
+ REQ_OP_CERT_FIELD = 11
50
+ REQ_OP_CERT_GENERIC = 14
51
+ REQ_OP_APPLE_GENERIC_ANCHOR = 15
52
+ REQ_MATCH_EXISTS = 0
53
+ REQ_MATCH_EQUAL = 1
54
+ REQ_TYPE_DESIGNATED = 3
55
+
56
+ WWDR_INTERMEDIATE_OID = bytes.fromhex("2a864886f76364060201")
57
+
58
+
59
+ def _be32(value: int) -> bytes:
60
+ return struct.pack(">I", value)
61
+
62
+
63
+ def _be64(value: int) -> bytes:
64
+ return struct.pack(">Q", value)
65
+
66
+
67
+ def _padded_string(text: bytes) -> bytes:
68
+ """Length-prefixed string padded to a 4-byte boundary."""
69
+ pad = (4 - len(text) % 4) % 4
70
+ return _be32(len(text)) + text + b"\0" * pad
71
+
72
+
73
+ def blob(magic: int, body: bytes) -> bytes:
74
+ """Wrap ``body`` in the standard magic plus length header."""
75
+ return _be32(magic) + _be32(len(body) + 8) + body
76
+
77
+
78
+ def _der_length(length: int) -> bytes:
79
+ if length < 0x80:
80
+ return bytes([length])
81
+ raw = length.to_bytes((length.bit_length() + 7) // 8, "big")
82
+ return bytes([0x80 | len(raw)]) + raw
83
+
84
+
85
+ def _der_int(value: int) -> bytes:
86
+ """Minimal two's complement INTEGER, sign included."""
87
+ if value == 0:
88
+ raw = b"\0"
89
+ elif value > 0:
90
+ raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
91
+ if raw[0] & 0x80:
92
+ raw = b"\0" + raw
93
+ else:
94
+ size = (value.bit_length() + 8) // 8
95
+ while True:
96
+ try:
97
+ raw = value.to_bytes(size, "big", signed=True)
98
+ except OverflowError:
99
+ size += 1
100
+ continue
101
+ # Strip redundant leading 0xff bytes, but keep the sign bit set.
102
+ while len(raw) > 1 and raw[0] == 0xFF and raw[1] & 0x80:
103
+ raw = raw[1:]
104
+ break
105
+ return b"\x02" + _der_length(len(raw)) + raw
106
+
107
+
108
+ def _der_value(value: object) -> bytes:
109
+ if isinstance(value, bool):
110
+ return b"\x01\x01" + (b"\xff" if value else b"\x00")
111
+ if isinstance(value, int):
112
+ return _der_int(value)
113
+ if isinstance(value, str):
114
+ raw = value.encode("utf-8")
115
+ return b"\x0c" + _der_length(len(raw)) + raw
116
+ if isinstance(value, (list, tuple)):
117
+ body = b"".join(_der_value(item) for item in value)
118
+ return b"\x30" + _der_length(len(body)) + body
119
+ if isinstance(value, dict):
120
+ entries = bytearray()
121
+ for key in sorted(value):
122
+ if not isinstance(key, str):
123
+ raise BlobError(f"entitlement keys must be strings, got {type(key).__name__}")
124
+ entry = _der_value(key) + _der_value(value[key])
125
+ entries += b"\x30" + _der_length(len(entry)) + entry
126
+ return b"\xb0" + _der_length(len(entries)) + bytes(entries)
127
+ raise BlobError(f"entitlements cannot hold a {type(value).__name__}")
128
+
129
+
130
+ def entitlements_der(entitlements: dict) -> bytes:
131
+ """DER form of an entitlements dictionary, wrapped in the version marker."""
132
+ body = b"\x02\x01\x01" + _der_value(entitlements)
133
+ return blob(CSMAGIC_EMBEDDED_DER_ENTITLEMENTS, b"\x70" + _der_length(len(body)) + body)
134
+
135
+
136
+ def entitlements_xml(plist_bytes: bytes) -> bytes:
137
+ """XML entitlements blob, the plist bytes carried verbatim."""
138
+ return blob(CSMAGIC_EMBEDDED_ENTITLEMENTS, plist_bytes)
139
+
140
+
141
+ def requirements(bundle_id: str, subject_cn: str) -> bytes:
142
+ """Designated requirement pinning the bundle id, the leaf CN and the WWDR CA.
143
+
144
+ With no bundle id or no subject CN there is nothing to pin, so the empty
145
+ requirement set is emitted instead.
146
+ """
147
+ if not bundle_id or not subject_cn:
148
+ return _be32(CSMAGIC_REQUIREMENTS) + _be32(12) + _be32(0)
149
+
150
+ expr = bytearray()
151
+ expr += _be32(REQ_OP_TRUE)
152
+ expr += _be32(REQ_OP_AND)
153
+ expr += _be32(REQ_OP_IDENT)
154
+ expr += _padded_string(bundle_id.encode("utf-8"))
155
+ expr += _be32(REQ_OP_AND)
156
+ expr += _be32(REQ_OP_APPLE_GENERIC_ANCHOR)
157
+ expr += _be32(REQ_OP_AND)
158
+ expr += _be32(REQ_OP_CERT_FIELD)
159
+ expr += _be32(0)
160
+ expr += _padded_string(b"subject.CN")
161
+ expr += _be32(REQ_MATCH_EQUAL)
162
+ expr += _padded_string(subject_cn.encode("utf-8"))
163
+ expr += _be32(REQ_OP_CERT_GENERIC)
164
+ expr += _be32(1)
165
+ expr += _padded_string(WWDR_INTERMEDIATE_OID)
166
+ expr += _be32(REQ_MATCH_EXISTS)
167
+
168
+ inner = blob(CSMAGIC_REQUIREMENT, bytes(expr))
169
+ outer = bytearray()
170
+ outer += _be32(CSMAGIC_REQUIREMENTS)
171
+ outer += _be32(20 + len(inner))
172
+ outer += _be32(1)
173
+ outer += _be32(REQ_TYPE_DESIGNATED)
174
+ outer += _be32(20)
175
+ outer += inner
176
+ return bytes(outer)
177
+
178
+
179
+ @dataclass(slots=True)
180
+ class CodeDirectorySpec:
181
+ """Everything the CodeDirectory builder needs besides the code itself."""
182
+
183
+ bundle_id: str
184
+ team_id: str
185
+ code_limit: int
186
+ exec_seg_limit: int
187
+ is_execute: bool
188
+ adhoc: bool = False
189
+ info_plist_hash: bytes = b""
190
+ requirements_hash: bytes = b""
191
+ code_resources_hash: bytes = b""
192
+ entitlements_hash: bytes = b""
193
+ der_entitlements_hash: bytes = b""
194
+ get_task_allow: bool = False
195
+ code_slots: list[bytes] = field(default_factory=list)
196
+
197
+
198
+ def _special_slots(spec: CodeDirectorySpec) -> list[bytes]:
199
+ """Hashes for slots -7 through -1, highest index first.
200
+
201
+ Leading empty slots are dropped: a slot index that no blob occupies costs
202
+ nothing, and Apple never writes them. An empty slot between two filled ones
203
+ stays and is serialised as a zero hash.
204
+ """
205
+ empty = b"\0" * CD_HASH_SIZE_SHA256
206
+ slots: list[bytes] = []
207
+ if spec.is_execute:
208
+ slots.append(spec.der_entitlements_hash or empty)
209
+ slots.append(empty)
210
+ slots.append(spec.entitlements_hash or empty)
211
+ slots.append(empty)
212
+ slots.append(spec.code_resources_hash or empty)
213
+ slots.append(spec.requirements_hash or empty)
214
+ slots.append(spec.info_plist_hash or empty)
215
+
216
+ first_used = 0
217
+ for index, value in enumerate(slots):
218
+ if value != empty:
219
+ first_used = index
220
+ break
221
+ else:
222
+ return []
223
+ return slots[first_used:]
224
+
225
+
226
+ def exec_seg_flags(spec: CodeDirectorySpec) -> int:
227
+ """Bitfield for the executable segment.
228
+
229
+ ``MAIN_BINARY`` marks the process entry point and is required on any
230
+ ``MH_EXECUTE``. ``ALLOW_UNSIGNED`` is development only and is set solely
231
+ when ``get-task-allow`` is true, not merely present.
232
+ """
233
+ flags = 0
234
+ if spec.is_execute:
235
+ flags |= CS_EXECSEG_MAIN_BINARY
236
+ if spec.get_task_allow:
237
+ flags |= CS_EXECSEG_ALLOW_UNSIGNED
238
+ return flags
239
+
240
+
241
+ def code_directory(spec: CodeDirectorySpec) -> bytes:
242
+ """Build the SHA-256 CodeDirectory for one slice."""
243
+ if not spec.bundle_id:
244
+ raise BlobError("a CodeDirectory needs a bundle identifier")
245
+ if spec.code_limit <= 0:
246
+ raise BlobError("a CodeDirectory needs a positive code limit")
247
+
248
+ ident = spec.bundle_id.encode("utf-8") + b"\0"
249
+ team = spec.team_id.encode("utf-8") + b"\0" if spec.team_id else b""
250
+ slots = _special_slots(spec)
251
+
252
+ n_code = spec.code_limit // CD_PAGE_SIZE
253
+ if spec.code_limit % CD_PAGE_SIZE:
254
+ n_code += 1
255
+ if len(spec.code_slots) != n_code:
256
+ raise BlobError(f"expected {n_code} code slots, got {len(spec.code_slots)}")
257
+
258
+ ident_offset = CD_HEADER_SIZE
259
+ team_offset = ident_offset + len(ident) if team else 0
260
+ hash_offset = ident_offset + len(ident) + len(team) + len(slots) * CD_HASH_SIZE_SHA256
261
+
262
+ out = bytearray()
263
+ out += _be32(CSMAGIC_CODEDIRECTORY)
264
+ out += _be32(0) # length, patched below
265
+ out += _be32(CD_VERSION)
266
+ out += _be32(CS_SEC_CODESIGNATURE_ADHOC if spec.adhoc else 0)
267
+ out += _be32(hash_offset)
268
+ out += _be32(ident_offset)
269
+ out += _be32(len(slots))
270
+ out += _be32(n_code)
271
+ out += _be32(spec.code_limit)
272
+ out += bytes([CD_HASH_SIZE_SHA256, CD_HASH_TYPE_SHA256, 0, CD_PAGE_SHIFT])
273
+ out += _be32(0) # spare2
274
+ out += _be32(0) # scatterOffset
275
+ out += _be32(team_offset)
276
+ out += _be32(0) # spare3
277
+ out += _be64(0) # codeLimit64
278
+ out += _be64(0) # execSegBase
279
+ out += _be64(spec.exec_seg_limit)
280
+ out += _be64(exec_seg_flags(spec))
281
+ assert len(out) == CD_HEADER_SIZE, "CodeDirectory header size drifted"
282
+
283
+ out += ident
284
+ out += team
285
+ for value in slots:
286
+ if len(value) != CD_HASH_SIZE_SHA256:
287
+ raise BlobError("special slot hash has the wrong size")
288
+ out += value
289
+ for value in spec.code_slots:
290
+ if len(value) != CD_HASH_SIZE_SHA256:
291
+ raise BlobError("code slot hash has the wrong size")
292
+ out += value
293
+
294
+ struct.pack_into(">I", out, 4, len(out))
295
+ return bytes(out)
296
+
297
+
298
+ def hash_pages(data: memoryview, limit: int, page_size: int = CD_PAGE_SIZE) -> list[bytes]:
299
+ """SHA-256 each page of ``data`` up to ``limit`` bytes.
300
+
301
+ The last page may be short and is hashed as-is, never zero padded.
302
+ """
303
+ if limit < 0 or limit > len(data):
304
+ raise BlobError(f"code limit {limit} is outside the slice (size {len(data)})")
305
+ slots: list[bytes] = []
306
+ offset = 0
307
+ while offset < limit:
308
+ end = min(offset + page_size, limit)
309
+ slots.append(hashlib.sha256(data[offset:end]).digest())
310
+ offset = end
311
+ return slots
312
+
313
+
314
+ def superblob(entries: list[tuple[int, bytes]]) -> bytes:
315
+ """Assemble blobs into a SuperBlob, in the order given.
316
+
317
+ Empty blobs are skipped and the count and offsets reflect only what is
318
+ present.
319
+ """
320
+ present = [(kind, body) for kind, body in entries if body]
321
+ header_size = 12 + 8 * len(present)
322
+ out = bytearray()
323
+ out += _be32(CSMAGIC_EMBEDDED_SIGNATURE)
324
+ out += _be32(0) # length, patched below
325
+ out += _be32(len(present))
326
+
327
+ offset = header_size
328
+ bodies = bytearray()
329
+ for kind, body in present:
330
+ out += _be32(kind)
331
+ out += _be32(offset)
332
+ bodies += body
333
+ offset += len(body)
334
+ out += bodies
335
+
336
+ struct.pack_into(">I", out, 4, len(out))
337
+ return bytes(out)
338
+
339
+
340
+ def blob_wrapper(payload: bytes) -> bytes:
341
+ """Wrap a CMS signature in the blob type the signature slot expects."""
342
+ return blob(CSMAGIC_BLOBWRAPPER, payload)
343
+
344
+
345
+ __all__ = [
346
+ "CodeDirectorySpec",
347
+ "code_directory",
348
+ "entitlements_der",
349
+ "entitlements_xml",
350
+ "exec_seg_flags",
351
+ "hash_pages",
352
+ "requirements",
353
+ "superblob",
354
+ "blob",
355
+ "blob_wrapper",
356
+ "CSSLOT_CODEDIRECTORY",
357
+ "CSSLOT_REQUIREMENTS",
358
+ "CSSLOT_ENTITLEMENTS",
359
+ "CSSLOT_DER_ENTITLEMENTS",
360
+ "CSSLOT_ALTERNATE_CODEDIRECTORIES",
361
+ "CSSLOT_SIGNATURESLOT",
362
+ ]
ipasign/bundle.py ADDED
@@ -0,0 +1,282 @@
1
+ """Bundle traversal, sealing and signing order.
2
+
3
+ An app bundle is signed from the leaves up: every Mach-O in the tree is signed
4
+ bare first, then each nested bundle is re-signed with its own identifier and
5
+ CodeResources, and the root executable comes last. The order matters because a
6
+ parent's seal hashes the child's already-signed binary, and because anything
7
+ written after a seal invalidates it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from . import _plist
18
+ from .credentials import ProvisioningProfile
19
+ from .errors import BundleError
20
+ from .signer import FileContext, Signer, sign_macho_file, write_atomic
21
+
22
+ BUNDLE_SUFFIXES = (".app", ".appex", ".framework", ".xctest")
23
+ CODE_RESOURCES = "_CodeSignature/CodeResources"
24
+
25
+ MACHO_MAGICS = (
26
+ b"\xcf\xfa\xed\xfe",
27
+ b"\xce\xfa\xed\xfe",
28
+ b"\xfe\xed\xfa\xce",
29
+ b"\xfe\xed\xfa\xcf",
30
+ b"\xca\xfe\xba\xbe",
31
+ b"\xbe\xba\xfe\xca",
32
+ )
33
+
34
+
35
+ def is_macho_file(path: Path) -> bool:
36
+ """Whether ``path`` starts with a thin or fat Mach-O magic."""
37
+ try:
38
+ with open(path, "rb") as handle:
39
+ return handle.read(4) in MACHO_MAGICS
40
+ except OSError:
41
+ return False
42
+
43
+
44
+ def parse_info_plist(raw: bytes) -> dict:
45
+ """Parse ``Info.plist`` bytes into a dictionary."""
46
+ try:
47
+ parsed = _plist.loads(raw)
48
+ except Exception as exc:
49
+ raise BundleError(f"Info.plist is not a plist: {exc}") from exc
50
+ if not isinstance(parsed, dict):
51
+ raise BundleError("Info.plist is not a dictionary")
52
+ return parsed
53
+
54
+
55
+ def display_name(info: dict) -> str:
56
+ """The name a user recognises, falling back until one is found.
57
+
58
+ Most bundles carry ``CFBundleDisplayName``, some only ``CFBundleName``, and
59
+ a few neither, in which case the executable name is the best available.
60
+ """
61
+ for key in ("CFBundleDisplayName", "CFBundleName", "CFBundleExecutable"):
62
+ value = info.get(key)
63
+ if value:
64
+ return str(value)
65
+ return ""
66
+
67
+
68
+ def app_version(info: dict) -> str:
69
+ """The release version, with the build number as a fallback."""
70
+ for key in ("CFBundleShortVersionString", "CFBundleVersion"):
71
+ value = info.get(key)
72
+ if value:
73
+ return str(value)
74
+ return ""
75
+
76
+
77
+ def read_info_plist(bundle: Path) -> dict:
78
+ """Parse a bundle's ``Info.plist``."""
79
+ info_path = bundle / "Info.plist"
80
+ try:
81
+ raw = info_path.read_bytes()
82
+ except FileNotFoundError as exc:
83
+ raise BundleError(f"bundle has no Info.plist: {bundle}") from exc
84
+ except OSError as exc:
85
+ raise BundleError(f"cannot read {info_path}: {exc}") from exc
86
+
87
+ return parse_info_plist(raw)
88
+
89
+
90
+ def bundle_details(bundle: Path) -> tuple[str, str, bytes]:
91
+ """(bundle id, executable name, Info.plist bytes) for a bundle."""
92
+ info = read_info_plist(bundle)
93
+ bundle_id = str(info.get("CFBundleIdentifier", ""))
94
+ executable = str(info.get("CFBundleExecutable", ""))
95
+ if not bundle_id or not executable:
96
+ raise BundleError(f"{bundle}/Info.plist has no CFBundleIdentifier or CFBundleExecutable")
97
+ return bundle_id, executable, (bundle / "Info.plist").read_bytes()
98
+
99
+
100
+ def collect_macho_files(root: Path) -> list[Path]:
101
+ """Every Mach-O file under ``root``, dSYM and WatchKit stubs excluded."""
102
+ found: list[Path] = []
103
+ for dirpath, _dirnames, filenames in os.walk(root):
104
+ for name in filenames:
105
+ path = Path(dirpath) / name
106
+ relative = path.relative_to(root).as_posix()
107
+ if ".dSYM" in relative or "_WatchKitStub" in relative:
108
+ continue
109
+ if is_macho_file(path):
110
+ found.append(path)
111
+ found.sort()
112
+ return found
113
+
114
+
115
+ def collect_nested_bundles(root: Path) -> list[Path]:
116
+ """Nested bundles under ``root``, deepest first.
117
+
118
+ ``root`` itself is not included; the caller signs it separately and last.
119
+ """
120
+ found: list[Path] = []
121
+ for dirpath, dirnames, _filenames in os.walk(root):
122
+ for name in dirnames:
123
+ path = Path(dirpath) / name
124
+ if path.name.endswith(BUNDLE_SUFFIXES):
125
+ found.append(path)
126
+ found.sort(key=lambda path: len(path.relative_to(root).parts), reverse=True)
127
+ return found
128
+
129
+
130
+ def _file_digests(path: Path) -> tuple[bytes, bytes]:
131
+ sha1 = hashlib.sha1()
132
+ sha256 = hashlib.sha256()
133
+ with open(path, "rb") as handle:
134
+ while chunk := handle.read(1 << 20):
135
+ sha1.update(chunk)
136
+ sha256.update(chunk)
137
+ return sha1.digest(), sha256.digest()
138
+
139
+
140
+ def _is_lproj(key: str) -> bool:
141
+ return ".lproj/" in key
142
+
143
+
144
+ def generate_code_resources(bundle: Path, executable: str) -> bytes:
145
+ """Build the ``CodeResources`` plist sealing every file in ``bundle``."""
146
+ keys: set[str] = set()
147
+ for dirpath, _dirnames, filenames in os.walk(bundle):
148
+ for name in filenames:
149
+ relative = (Path(dirpath) / name).relative_to(bundle).as_posix()
150
+ keys.add(relative)
151
+
152
+ keys.discard(CODE_RESOURCES)
153
+ keys.discard(executable)
154
+
155
+ files: dict[str, object] = {}
156
+ files2: dict[str, object] = {}
157
+
158
+ for key in sorted(keys):
159
+ sha1, sha256 = _file_digests(bundle / key)
160
+
161
+ omit_files = key.endswith(".lproj/locversion.plist")
162
+ omit_files2 = omit_files or key == ".DS_Store" or key == "Info.plist" or key == "PkgInfo"
163
+
164
+ if not omit_files:
165
+ if _is_lproj(key):
166
+ files[key] = {"hash": sha1, "optional": True}
167
+ else:
168
+ files[key] = sha1
169
+
170
+ if not omit_files2:
171
+ entry: dict[str, object] = {"hash": sha1, "hash2": sha256}
172
+ if _is_lproj(key):
173
+ entry["optional"] = True
174
+ files2[key] = entry
175
+
176
+ resources: dict[str, object] = {
177
+ "files": files,
178
+ "files2": files2,
179
+ "rules": {
180
+ "^.*": True,
181
+ "^.*\\.lproj/": {"optional": True, "weight": 1000.0},
182
+ "^.*\\.lproj/locversion.plist$": {"omit": True, "weight": 1100.0},
183
+ "^Base\\.lproj/": {"weight": 1010.0},
184
+ "^version.plist$": True,
185
+ },
186
+ "rules2": {
187
+ "^.*": True,
188
+ ".*\\.dSYM($|/)": {"weight": 11.0},
189
+ "^(.*/)?\\.DS_Store$": {"omit": True, "weight": 2000.0},
190
+ "^.*\\.lproj/": {"optional": True, "weight": 1000.0},
191
+ "^.*\\.lproj/locversion.plist$": {"omit": True, "weight": 1100.0},
192
+ "^Base\\.lproj/": {"weight": 1010.0},
193
+ "^Info\\.plist$": {"omit": True, "weight": 20.0},
194
+ "^PkgInfo$": {"omit": True, "weight": 20.0},
195
+ "^embedded\\.provisionprofile$": {"weight": 20.0},
196
+ "^version\\.plist$": {"weight": 20.0},
197
+ },
198
+ }
199
+ return _plist.dumps(resources)
200
+
201
+
202
+ @dataclass(slots=True)
203
+ class BundleResult:
204
+ """Outcome of signing one bundle tree."""
205
+
206
+ bundle_id: str
207
+ signed_count: int
208
+ app_name: str = ""
209
+ app_version: str = ""
210
+
211
+
212
+ def _write_code_resources(bundle: Path, data: bytes) -> None:
213
+ target = bundle / CODE_RESOURCES
214
+ target.parent.mkdir(parents=True, exist_ok=True)
215
+ write_atomic(target, data)
216
+
217
+
218
+ def sign_bundle_member(signer: Signer, bundle: Path) -> int:
219
+ """Sign a bundle's executable with its own id, Info.plist and CodeResources."""
220
+ bundle_id, executable, info_plist = bundle_details(bundle)
221
+ resources = generate_code_resources(bundle, executable)
222
+ _write_code_resources(bundle, resources)
223
+
224
+ ctx = FileContext(
225
+ bundle_id=bundle_id,
226
+ info_plist_hash=hashlib.sha256(info_plist).digest(),
227
+ code_resources_hash=hashlib.sha256(resources).digest(),
228
+ )
229
+ return sign_macho_file(signer, bundle / executable, ctx)
230
+
231
+
232
+ def sign_bundle(
233
+ signer: Signer,
234
+ root: Path,
235
+ profile: ProvisioningProfile | None = None,
236
+ ) -> BundleResult:
237
+ """Sign an app bundle tree in place, deepest bundle first."""
238
+ root = Path(root).resolve()
239
+ if not root.is_dir():
240
+ raise BundleError(f"not a directory: {root}")
241
+
242
+ if profile is not None and profile.data:
243
+ write_atomic(root / "embedded.mobileprovision", profile.data)
244
+
245
+ signed = 0
246
+ for path in collect_macho_files(root):
247
+ signed += sign_macho_file(signer, path)
248
+
249
+ for bundle in collect_nested_bundles(root):
250
+ try:
251
+ signed += sign_bundle_member(signer, bundle)
252
+ except BundleError:
253
+ # A nested folder that is not really a signable bundle (no
254
+ # Info.plist or no executable) is left alone, like the reference.
255
+ continue
256
+
257
+ bundle_id, _executable, info = bundle_details(root)
258
+ parsed = parse_info_plist(info)
259
+ signed += sign_bundle_member(signer, root)
260
+ return BundleResult(
261
+ bundle_id=bundle_id,
262
+ signed_count=signed,
263
+ app_name=display_name(parsed),
264
+ app_version=app_version(parsed),
265
+ )
266
+
267
+
268
+ __all__ = [
269
+ "BundleResult",
270
+ "MACHO_MAGICS",
271
+ "app_version",
272
+ "bundle_details",
273
+ "collect_macho_files",
274
+ "collect_nested_bundles",
275
+ "display_name",
276
+ "generate_code_resources",
277
+ "is_macho_file",
278
+ "parse_info_plist",
279
+ "read_info_plist",
280
+ "sign_bundle",
281
+ "sign_bundle_member",
282
+ ]