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/key.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""The public entry point: :class:`Key` and :meth:`Key.sign`.
|
|
2
|
+
|
|
3
|
+
A ``Key`` holds a signing identity and the entitlements to seal. ``sign``
|
|
4
|
+
dispatches on what it is handed: an ``.ipa`` archive, an ``.app`` bundle folder,
|
|
5
|
+
or a bare Mach-O file. Failures raise; a completed run returns a
|
|
6
|
+
:class:`SignResult`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import archive, bundle, macho
|
|
17
|
+
from .credentials import ProvisioningProfile, load_entitlements, load_identity, load_profile
|
|
18
|
+
from .errors import BundleError, InvalidInputError, MachOError
|
|
19
|
+
from .signer import (
|
|
20
|
+
FileContext,
|
|
21
|
+
Signer,
|
|
22
|
+
bundle_id_fallback,
|
|
23
|
+
embedded_info_plist_hash,
|
|
24
|
+
sign_macho_file,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class SignResult:
|
|
30
|
+
"""What a completed signing run produced.
|
|
31
|
+
|
|
32
|
+
``output_path`` is where the artifact actually landed, ``bundle_id`` the
|
|
33
|
+
identifier sealed into the CodeDirectory, and ``signed_count`` how many
|
|
34
|
+
Mach-O files were signed.
|
|
35
|
+
|
|
36
|
+
``app_name`` and ``app_version`` describe the app itself, read from the
|
|
37
|
+
bundle's ``Info.plist``. ``app_version`` is the release version
|
|
38
|
+
(``CFBundleShortVersionString``), which is what a person recognises, not the
|
|
39
|
+
build number. Both are empty for a bare Mach-O, which has no ``Info.plist``
|
|
40
|
+
to read them from.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
output_path: str
|
|
44
|
+
bundle_id: str
|
|
45
|
+
signed_count: int
|
|
46
|
+
app_name: str = ""
|
|
47
|
+
app_version: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _looks_like_macho(path: Path) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
with open(path, "rb") as handle:
|
|
53
|
+
return handle.read(4) in bundle.MACHO_MAGICS
|
|
54
|
+
except OSError:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Key:
|
|
59
|
+
"""A signing identity plus the entitlements to seal.
|
|
60
|
+
|
|
61
|
+
``pkey`` is a ``.p12`` path and ``prov`` a ``.mobileprovision`` path. Pass
|
|
62
|
+
``adhoc=True`` for a credential-less ad-hoc key, in which case both may be
|
|
63
|
+
``None``. ``entitlements`` replaces the profile's entitlements with a plist
|
|
64
|
+
file of the caller's own.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
pkey: str | os.PathLike | None = None,
|
|
70
|
+
prov: str | os.PathLike | None = None,
|
|
71
|
+
password: str | None = None,
|
|
72
|
+
*,
|
|
73
|
+
adhoc: bool = False,
|
|
74
|
+
entitlements: str | os.PathLike | None = None,
|
|
75
|
+
team_id: str | None = None,
|
|
76
|
+
subject_cn: str | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.adhoc = bool(adhoc)
|
|
79
|
+
self.password = password
|
|
80
|
+
self.profile: ProvisioningProfile | None = None
|
|
81
|
+
self.identity = None
|
|
82
|
+
self._entitlements_override = entitlements
|
|
83
|
+
self._team_id_override = team_id
|
|
84
|
+
self._subject_cn_override = subject_cn
|
|
85
|
+
|
|
86
|
+
if self.adhoc:
|
|
87
|
+
if pkey is not None or prov is not None:
|
|
88
|
+
raise InvalidInputError("an ad-hoc key takes no p12 or profile")
|
|
89
|
+
else:
|
|
90
|
+
if pkey is None:
|
|
91
|
+
raise InvalidInputError("a signing key needs a .p12 path")
|
|
92
|
+
if prov is None:
|
|
93
|
+
raise InvalidInputError("a signing key needs a .mobileprovision path")
|
|
94
|
+
|
|
95
|
+
if prov is not None:
|
|
96
|
+
self.profile = load_profile(prov)
|
|
97
|
+
|
|
98
|
+
if not self.adhoc:
|
|
99
|
+
self.identity = load_identity(pkey, password, self.profile)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def team_id(self) -> str:
|
|
103
|
+
"""Team identifier sealed into the CodeDirectory."""
|
|
104
|
+
if self._team_id_override is not None:
|
|
105
|
+
return self._team_id_override
|
|
106
|
+
return self.profile.team_id if self.profile else ""
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def subject_cn(self) -> str:
|
|
110
|
+
"""Leaf certificate common name, pinned by the designated requirement."""
|
|
111
|
+
if self._subject_cn_override is not None:
|
|
112
|
+
return self._subject_cn_override
|
|
113
|
+
return self.identity.subject_cn if self.identity else ""
|
|
114
|
+
|
|
115
|
+
def _entitlements(self) -> tuple[dict, bytes]:
|
|
116
|
+
if self._entitlements_override is not None:
|
|
117
|
+
return load_entitlements(self._entitlements_override)
|
|
118
|
+
if self.profile is not None:
|
|
119
|
+
return self.profile.entitlements, self.profile.entitlements_plist
|
|
120
|
+
return {}, b""
|
|
121
|
+
|
|
122
|
+
def _signer(self) -> Signer:
|
|
123
|
+
entitlements, plist_bytes = self._entitlements()
|
|
124
|
+
return Signer(
|
|
125
|
+
identity=self.identity,
|
|
126
|
+
adhoc=self.adhoc,
|
|
127
|
+
entitlements=entitlements,
|
|
128
|
+
entitlements_plist=plist_bytes,
|
|
129
|
+
team_id=self.team_id,
|
|
130
|
+
subject_cn=self.subject_cn,
|
|
131
|
+
signing_time=datetime.datetime.now(datetime.timezone.utc),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def sign(
|
|
135
|
+
self,
|
|
136
|
+
input_path: str | os.PathLike,
|
|
137
|
+
output_path: str | os.PathLike | None = None,
|
|
138
|
+
*,
|
|
139
|
+
keep_work_dir: bool = False,
|
|
140
|
+
tmp_folder: str | os.PathLike | None = None,
|
|
141
|
+
bundle_id: str | None = None,
|
|
142
|
+
) -> SignResult:
|
|
143
|
+
"""Sign ``input_path`` and return where the result landed.
|
|
144
|
+
|
|
145
|
+
An ``.ipa`` unpacks into ``.ipasign_tmp`` beside the input, signs the
|
|
146
|
+
bundle and repacks into ``output_path``. An ``.app`` folder is signed in
|
|
147
|
+
place. A bare Mach-O is signed in place, so passing ``output_path`` for
|
|
148
|
+
one is an error rather than a parameter that quietly does nothing.
|
|
149
|
+
|
|
150
|
+
``bundle_id`` names the identifier to seal for a bare Mach-O, which has
|
|
151
|
+
no bundle to read one from. Bundles keep the identifier in their own
|
|
152
|
+
``Info.plist``.
|
|
153
|
+
"""
|
|
154
|
+
path = Path(input_path)
|
|
155
|
+
if not path.exists():
|
|
156
|
+
raise InvalidInputError(f"input does not exist: {path}")
|
|
157
|
+
|
|
158
|
+
if path.is_dir():
|
|
159
|
+
return self._sign_folder(path, output_path)
|
|
160
|
+
|
|
161
|
+
if path.suffix.lower() == ".ipa":
|
|
162
|
+
return self._sign_ipa(path, output_path, keep_work_dir, tmp_folder)
|
|
163
|
+
|
|
164
|
+
if _looks_like_macho(path):
|
|
165
|
+
if output_path is not None:
|
|
166
|
+
raise InvalidInputError(
|
|
167
|
+
"a bare Mach-O is signed in place; an output path would be ignored"
|
|
168
|
+
)
|
|
169
|
+
return self._sign_macho(path, bundle_id)
|
|
170
|
+
|
|
171
|
+
raise InvalidInputError(f"do not know how to sign: {path}")
|
|
172
|
+
|
|
173
|
+
def _sign_ipa(
|
|
174
|
+
self,
|
|
175
|
+
ipa: Path,
|
|
176
|
+
output: str | os.PathLike | None,
|
|
177
|
+
keep_work_dir: bool,
|
|
178
|
+
tmp_folder: str | os.PathLike | None,
|
|
179
|
+
) -> SignResult:
|
|
180
|
+
if output is None:
|
|
181
|
+
raise InvalidInputError("signing an .ipa needs an output path")
|
|
182
|
+
|
|
183
|
+
work = Path(tmp_folder) if tmp_folder is not None else None
|
|
184
|
+
# A caller-named directory implies wanting to look inside it.
|
|
185
|
+
keep = keep_work_dir or tmp_folder is not None
|
|
186
|
+
|
|
187
|
+
unpacked = archive.unpack(ipa, work)
|
|
188
|
+
try:
|
|
189
|
+
result = bundle.sign_bundle(self._signer(), unpacked.app, self.profile)
|
|
190
|
+
target = Path(output)
|
|
191
|
+
archive.pack(unpacked.root, target)
|
|
192
|
+
return SignResult(
|
|
193
|
+
output_path=str(target),
|
|
194
|
+
bundle_id=result.bundle_id,
|
|
195
|
+
signed_count=result.signed_count,
|
|
196
|
+
app_name=result.app_name,
|
|
197
|
+
app_version=result.app_version,
|
|
198
|
+
)
|
|
199
|
+
finally:
|
|
200
|
+
if not keep:
|
|
201
|
+
archive.cleanup(unpacked.root)
|
|
202
|
+
|
|
203
|
+
def _sign_folder(self, folder: Path, output: str | os.PathLike | None) -> SignResult:
|
|
204
|
+
if output is not None and Path(output) != folder:
|
|
205
|
+
raise InvalidInputError(
|
|
206
|
+
"a bundle is signed in place; move the folder first if you want a copy"
|
|
207
|
+
)
|
|
208
|
+
result = bundle.sign_bundle(self._signer(), folder, self.profile)
|
|
209
|
+
return SignResult(
|
|
210
|
+
output_path=str(folder),
|
|
211
|
+
bundle_id=result.bundle_id,
|
|
212
|
+
signed_count=result.signed_count,
|
|
213
|
+
app_name=result.app_name,
|
|
214
|
+
app_version=result.app_version,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _sign_macho(self, path: Path, bundle_id: str | None) -> SignResult:
|
|
218
|
+
try:
|
|
219
|
+
data = path.read_bytes()
|
|
220
|
+
except OSError as exc:
|
|
221
|
+
raise MachOError(f"cannot read {path}: {exc}") from exc
|
|
222
|
+
|
|
223
|
+
slc = macho.MachOFile.parse(data).slices[0]
|
|
224
|
+
resolved_id = bundle_id or bundle_id_fallback(slc, path)
|
|
225
|
+
ctx = FileContext(
|
|
226
|
+
bundle_id=resolved_id,
|
|
227
|
+
info_plist_hash=embedded_info_plist_hash(slc),
|
|
228
|
+
)
|
|
229
|
+
count = sign_macho_file(self._signer(), path, ctx)
|
|
230
|
+
|
|
231
|
+
# A bare Mach-O may still carry an embedded Info.plist, which is the only
|
|
232
|
+
# place its name and version could come from.
|
|
233
|
+
name = version = ""
|
|
234
|
+
if slc.info_plist:
|
|
235
|
+
try:
|
|
236
|
+
info = bundle.parse_info_plist(slc.info_plist)
|
|
237
|
+
except BundleError:
|
|
238
|
+
info = {}
|
|
239
|
+
name = bundle.display_name(info)
|
|
240
|
+
version = bundle.app_version(info)
|
|
241
|
+
|
|
242
|
+
return SignResult(
|
|
243
|
+
output_path=str(path),
|
|
244
|
+
bundle_id=resolved_id,
|
|
245
|
+
signed_count=count,
|
|
246
|
+
app_name=name,
|
|
247
|
+
app_version=version,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
__all__ = ["Key", "SignResult"]
|