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/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"]