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/__init__.py +40 -0
- ipasign/_apple_ca.py +284 -0
- ipasign/_plist.py +28 -0
- ipasign/archive.py +173 -0
- ipasign/blobs.py +362 -0
- ipasign/bundle.py +282 -0
- ipasign/cms.py +164 -0
- ipasign/credentials.py +259 -0
- ipasign/errors.py +56 -0
- ipasign/key.py +251 -0
- ipasign/macho.py +504 -0
- ipasign/signer.py +275 -0
- ipasign-1.0.dist-info/METADATA +96 -0
- ipasign-1.0.dist-info/RECORD +17 -0
- ipasign-1.0.dist-info/WHEEL +5 -0
- ipasign-1.0.dist-info/licenses/LICENSE +21 -0
- ipasign-1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|