protool 2.0.0__tar.gz → 3.0.1__tar.gz
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.
- {protool-2.0.0 → protool-3.0.1}/PKG-INFO +18 -7
- {protool-2.0.0 → protool-3.0.1}/README.md +7 -0
- protool-3.0.1/protool/__init__.py +326 -0
- {protool-2.0.0 → protool-3.0.1}/protool/command_line.py +14 -3
- {protool-2.0.0 → protool-3.0.1}/pyproject.toml +19 -14
- protool-2.0.0/protool/__init__.py +0 -251
- {protool-2.0.0 → protool-3.0.1}/LICENSE +0 -0
|
@@ -1,26 +1,30 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: protool
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.1
|
|
4
4
|
Summary: A tool for dealing with provisioning profiles
|
|
5
|
-
Home-page: https://github.com/Microsoft/protool
|
|
6
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
7
|
Keywords: provisioning,profiles,apple,ios,xcode,mobileprovision
|
|
8
8
|
Author: Dale Myers
|
|
9
9
|
Author-email: dalemy@microsoft.com
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.11,<4.0
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
12
12
|
Classifier: Environment :: Console
|
|
13
13
|
Classifier: Environment :: MacOS X
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: License :: OSI Approved :: MIT License
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
22
|
Classifier: Programming Language :: Python :: 3.8
|
|
18
23
|
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
21
24
|
Classifier: Topic :: Software Development
|
|
22
25
|
Classifier: Topic :: Utilities
|
|
23
|
-
Requires-Dist: pyOpenSSL (>=
|
|
26
|
+
Requires-Dist: pyOpenSSL (>=25.1.0,<26.0.0)
|
|
27
|
+
Project-URL: Homepage, https://github.com/Microsoft/protool
|
|
24
28
|
Project-URL: Repository, https://github.com/Microsoft/protool
|
|
25
29
|
Description-Content-Type: text/markdown
|
|
26
30
|
|
|
@@ -70,6 +74,13 @@ Alternatively, from the command line:
|
|
|
70
74
|
# Get the raw XML (identical to using `security cms -D -i /path/to/profile`)
|
|
71
75
|
protool decode --profile /path/to/profile
|
|
72
76
|
|
|
77
|
+
Custom diff commands (`tool_override` in Python or `--tool` on the command line)
|
|
78
|
+
accept an executable and optional arguments, for example `--tool 'diff -u'`.
|
|
79
|
+
Quote executable paths or arguments containing spaces within the command string,
|
|
80
|
+
for example `--tool '"/path with spaces/diff" -u'`. Commands run directly, without
|
|
81
|
+
a shell: pipes, redirection, environment-variable expansion, and command
|
|
82
|
+
substitution are not supported. Profile paths are always passed as literal arguments.
|
|
83
|
+
|
|
73
84
|
|
|
74
85
|
# Contributing
|
|
75
86
|
|
|
@@ -44,6 +44,13 @@ Alternatively, from the command line:
|
|
|
44
44
|
# Get the raw XML (identical to using `security cms -D -i /path/to/profile`)
|
|
45
45
|
protool decode --profile /path/to/profile
|
|
46
46
|
|
|
47
|
+
Custom diff commands (`tool_override` in Python or `--tool` on the command line)
|
|
48
|
+
accept an executable and optional arguments, for example `--tool 'diff -u'`.
|
|
49
|
+
Quote executable paths or arguments containing spaces within the command string,
|
|
50
|
+
for example `--tool '"/path with spaces/diff" -u'`. Commands run directly, without
|
|
51
|
+
a shell: pipes, redirection, environment-variable expansion, and command
|
|
52
|
+
substitution are not supported. Profile paths are always passed as literal arguments.
|
|
53
|
+
|
|
47
54
|
|
|
48
55
|
# Contributing
|
|
49
56
|
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""A utility for dealing with provisioning profiles"""
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import datetime
|
|
9
|
+
import os
|
|
10
|
+
import plistlib
|
|
11
|
+
import shlex
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
from OpenSSL import crypto
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_certificate_properties(cert: crypto.X509) -> dict[str, Any]:
|
|
20
|
+
"""Extract key properties from an X509 certificate."""
|
|
21
|
+
subject = cert.get_subject()
|
|
22
|
+
issuer = cert.get_issuer()
|
|
23
|
+
|
|
24
|
+
# Helper to safely get X509Name components
|
|
25
|
+
def get_component(name_obj, component: str) -> str | None:
|
|
26
|
+
try:
|
|
27
|
+
return getattr(name_obj, component, None)
|
|
28
|
+
except AttributeError:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
# Parse ASN.1 TIME to ISO 8601 format
|
|
32
|
+
def parse_asn1_time(time_bytes: bytes | None) -> str | None:
|
|
33
|
+
if time_bytes is None:
|
|
34
|
+
return None
|
|
35
|
+
time_str = time_bytes.decode("utf-8")
|
|
36
|
+
# ASN.1 format: YYYYMMDDhhmmssZ
|
|
37
|
+
# Convert to ISO 8601: YYYY-MM-DDThh:mm:ssZ
|
|
38
|
+
try:
|
|
39
|
+
return f"{time_str[0:4]}-{time_str[4:6]}-{time_str[6:8]}T{time_str[8:10]}:{time_str[10:12]}:{time_str[12:14]}Z"
|
|
40
|
+
except (IndexError, ValueError):
|
|
41
|
+
return time_str # Return as-is if parsing fails
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"CommonName": get_component(subject, "CN"),
|
|
45
|
+
"Organization": get_component(subject, "O"),
|
|
46
|
+
"OrganizationalUnit": get_component(subject, "OU"),
|
|
47
|
+
"Country": get_component(subject, "C"),
|
|
48
|
+
"IssuerCommonName": get_component(issuer, "CN"),
|
|
49
|
+
"IssuerOrganization": get_component(issuer, "O"),
|
|
50
|
+
"SerialNumber": str(
|
|
51
|
+
cert.get_serial_number() # Other libs struggle with large integers
|
|
52
|
+
),
|
|
53
|
+
"NotBefore": parse_asn1_time(cert.get_notBefore()),
|
|
54
|
+
"NotAfter": parse_asn1_time(cert.get_notAfter()),
|
|
55
|
+
"SignatureAlgorithm": (
|
|
56
|
+
cert.get_signature_algorithm().decode("utf-8")
|
|
57
|
+
if cert.get_signature_algorithm()
|
|
58
|
+
else None
|
|
59
|
+
),
|
|
60
|
+
"Fingerprint": (
|
|
61
|
+
cert.digest("sha256").decode("utf-8") if hasattr(cert, "digest") else None
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ProvisioningType(Enum):
|
|
67
|
+
"""Enum representing the type of provisioning profile."""
|
|
68
|
+
|
|
69
|
+
IOS_DEVELOPMENT = 1
|
|
70
|
+
APP_STORE_DISTRIBUTION = 3
|
|
71
|
+
AD_HOC_DISTRIBUTION = 5
|
|
72
|
+
ENTERPRISE_DISTRIBUTION = 7
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# pylint: disable=too-many-instance-attributes
|
|
76
|
+
class ProvisioningProfile:
|
|
77
|
+
"""Represents a provisioning profile."""
|
|
78
|
+
|
|
79
|
+
file_path: str
|
|
80
|
+
file_name: str
|
|
81
|
+
xml: str
|
|
82
|
+
_contents: dict[str, Any]
|
|
83
|
+
_decode_certificates: bool
|
|
84
|
+
|
|
85
|
+
app_id_name: str | None
|
|
86
|
+
application_identifier_prefix: str | None
|
|
87
|
+
creation_date: datetime.datetime | None
|
|
88
|
+
platform: list[str] | None
|
|
89
|
+
entitlements: dict[str, Any]
|
|
90
|
+
expiration_date: datetime.datetime | None
|
|
91
|
+
name: str | None
|
|
92
|
+
team_identifier: list[str] | None
|
|
93
|
+
team_name: str | None
|
|
94
|
+
time_to_live: int | None
|
|
95
|
+
uuid: str | None
|
|
96
|
+
version: int | None
|
|
97
|
+
provisioned_devices: list[str] | None
|
|
98
|
+
provisions_all_devices: bool | None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def profile_type(self) -> ProvisioningType:
|
|
102
|
+
"""Determine the profile type from the various values in the profile."""
|
|
103
|
+
if self.provisions_all_devices:
|
|
104
|
+
return ProvisioningType.ENTERPRISE_DISTRIBUTION
|
|
105
|
+
|
|
106
|
+
if not self.entitlements.get("get-task-allow") and self.provisioned_devices:
|
|
107
|
+
return ProvisioningType.AD_HOC_DISTRIBUTION
|
|
108
|
+
|
|
109
|
+
if not self.entitlements.get("get-task-allow") and not self.provisioned_devices:
|
|
110
|
+
return ProvisioningType.APP_STORE_DISTRIBUTION
|
|
111
|
+
|
|
112
|
+
if self.entitlements.get("get-task-allow") and self.provisioned_devices:
|
|
113
|
+
return ProvisioningType.IOS_DEVELOPMENT
|
|
114
|
+
|
|
115
|
+
raise Exception("Unable to determine provisioning profile type")
|
|
116
|
+
|
|
117
|
+
def developer_certificates(self) -> list[crypto.X509]:
|
|
118
|
+
"""Returns developer certificates as a list of PyOpenSSL X509."""
|
|
119
|
+
dev_certs: list[crypto.X509] = []
|
|
120
|
+
raw_cert_items: list[bytes] = cast(
|
|
121
|
+
list[bytes], self._contents.get("DeveloperCertificates", [])
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
for cert_item in raw_cert_items:
|
|
125
|
+
loaded_cert: crypto.X509 = crypto.load_certificate(
|
|
126
|
+
crypto.FILETYPE_ASN1, cert_item
|
|
127
|
+
)
|
|
128
|
+
dev_certs.append(loaded_cert)
|
|
129
|
+
|
|
130
|
+
return dev_certs
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
file_path: str,
|
|
135
|
+
*,
|
|
136
|
+
sort_keys: bool = True,
|
|
137
|
+
decode_certificates: bool = False,
|
|
138
|
+
) -> None:
|
|
139
|
+
self.file_path = os.path.abspath(file_path)
|
|
140
|
+
self.file_name = os.path.basename(self.file_path)
|
|
141
|
+
self._decode_certificates = decode_certificates
|
|
142
|
+
self.load_from_disk(sort_keys=sort_keys)
|
|
143
|
+
|
|
144
|
+
def load_from_disk(self, *, sort_keys: bool = True) -> None:
|
|
145
|
+
"""Load the provisioning profile details from disk and parse them."""
|
|
146
|
+
self.xml = self._get_xml()
|
|
147
|
+
self._contents = plistlib.loads(self.xml.encode())
|
|
148
|
+
|
|
149
|
+
if sort_keys:
|
|
150
|
+
self.xml = plistlib.dumps(self._contents, sort_keys=True).decode("utf-8")
|
|
151
|
+
|
|
152
|
+
self._parse_contents()
|
|
153
|
+
|
|
154
|
+
# If we decoded certificates, we need to regenerate the XML to include them
|
|
155
|
+
if self._decode_certificates:
|
|
156
|
+
contents_copy = copy.deepcopy(self._contents)
|
|
157
|
+
del contents_copy["DeveloperCertificates"]
|
|
158
|
+
del contents_copy["DER-Encoded-Profile"]
|
|
159
|
+
self.xml = plistlib.dumps(contents_copy, sort_keys=sort_keys).decode(
|
|
160
|
+
"utf-8"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def contents(self) -> dict[str, Any]:
|
|
164
|
+
"""Return a copy of the content dict."""
|
|
165
|
+
return copy.deepcopy(self._contents)
|
|
166
|
+
|
|
167
|
+
def _parse_contents(self) -> None:
|
|
168
|
+
"""Parse the contents of the profile."""
|
|
169
|
+
self.app_id_name = self._contents.get("AppIDName")
|
|
170
|
+
self.application_identifier_prefix = self._contents.get(
|
|
171
|
+
"ApplicationIdentifierPrefix"
|
|
172
|
+
)
|
|
173
|
+
self.creation_date = self._contents.get("CreationDate")
|
|
174
|
+
self.platform = self._contents.get("Platform")
|
|
175
|
+
self.entitlements = self._contents.get("Entitlements", {})
|
|
176
|
+
self.expiration_date = self._contents.get("ExpirationDate")
|
|
177
|
+
self.name = self._contents.get("Name")
|
|
178
|
+
self.team_identifier = self._contents.get("TeamIdentifier")
|
|
179
|
+
self.team_name = self._contents.get("TeamName")
|
|
180
|
+
self.time_to_live = self._contents.get("TimeToLive")
|
|
181
|
+
self.uuid = self._contents.get("UUID")
|
|
182
|
+
self.version = self._contents.get("Version")
|
|
183
|
+
self.provisioned_devices = self._contents.get("ProvisionedDevices")
|
|
184
|
+
self.provisions_all_devices = self._contents.get("ProvisionsAllDevices", False)
|
|
185
|
+
|
|
186
|
+
# Decode certificates if requested
|
|
187
|
+
if self._decode_certificates:
|
|
188
|
+
decoded_certs: list[dict[str, Any]] = []
|
|
189
|
+
for cert in self.developer_certificates():
|
|
190
|
+
try:
|
|
191
|
+
decoded_certs.append(_extract_certificate_properties(cert))
|
|
192
|
+
except Exception as ex:
|
|
193
|
+
# Log error but continue with other certificates
|
|
194
|
+
print(
|
|
195
|
+
f"Warning: Failed to decode certificate: {ex}", file=sys.stderr
|
|
196
|
+
)
|
|
197
|
+
self._contents["DecodedDeveloperCertificates"] = decoded_certs
|
|
198
|
+
|
|
199
|
+
def _get_xml(self) -> str:
|
|
200
|
+
"""Load the XML contents of a provisioning profile."""
|
|
201
|
+
if not os.path.exists(self.file_path):
|
|
202
|
+
raise Exception(f"File does not exist: {self.file_path}")
|
|
203
|
+
|
|
204
|
+
security_cmd = ["security", "cms", "-D", "-i", self.file_path]
|
|
205
|
+
return subprocess.check_output(
|
|
206
|
+
security_cmd, universal_newlines=True, stderr=subprocess.DEVNULL
|
|
207
|
+
).strip()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# pylint: enable=too-many-instance-attributes
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def profiles(profiles_dir: str | None = None) -> list[ProvisioningProfile]:
|
|
214
|
+
"""Returns a list of all currently installed provisioning profiles."""
|
|
215
|
+
if profiles_dir:
|
|
216
|
+
dir_path = os.path.expanduser(profiles_dir)
|
|
217
|
+
else:
|
|
218
|
+
user_path = os.path.expanduser("~")
|
|
219
|
+
dir_path = os.path.join(
|
|
220
|
+
user_path, "Library", "MobileDevice", "Provisioning Profiles"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
all_profiles: list[ProvisioningProfile] = []
|
|
224
|
+
for profile in os.listdir(dir_path):
|
|
225
|
+
full_path = os.path.join(dir_path, profile)
|
|
226
|
+
_, ext = os.path.splitext(full_path)
|
|
227
|
+
if ext == ".mobileprovision":
|
|
228
|
+
provisioning_profile = ProvisioningProfile(full_path)
|
|
229
|
+
all_profiles.append(provisioning_profile)
|
|
230
|
+
|
|
231
|
+
return all_profiles
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def diff(
|
|
235
|
+
a_path: str,
|
|
236
|
+
b_path: str,
|
|
237
|
+
*,
|
|
238
|
+
sort_keys: bool = True,
|
|
239
|
+
ignore_keys: list[str] | None = None,
|
|
240
|
+
tool_override: str | None = None,
|
|
241
|
+
) -> str:
|
|
242
|
+
"""Diff two provisioning profiles without shell expansion of tool_override."""
|
|
243
|
+
|
|
244
|
+
# pylint: disable=too-many-locals
|
|
245
|
+
|
|
246
|
+
if tool_override is None:
|
|
247
|
+
diff_command = ["opendiff"]
|
|
248
|
+
else:
|
|
249
|
+
diff_command = shlex.split(tool_override)
|
|
250
|
+
|
|
251
|
+
if not diff_command or not diff_command[0]:
|
|
252
|
+
raise ValueError("Diff command must include an executable")
|
|
253
|
+
|
|
254
|
+
profile_a = ProvisioningProfile(a_path, sort_keys=sort_keys)
|
|
255
|
+
profile_b = ProvisioningProfile(b_path, sort_keys=sort_keys)
|
|
256
|
+
|
|
257
|
+
if ignore_keys is None:
|
|
258
|
+
a_xml = profile_a.xml
|
|
259
|
+
b_xml = profile_b.xml
|
|
260
|
+
else:
|
|
261
|
+
a_dict = profile_a.contents()
|
|
262
|
+
b_dict = profile_b.contents()
|
|
263
|
+
|
|
264
|
+
for key in ignore_keys:
|
|
265
|
+
try:
|
|
266
|
+
del a_dict[key]
|
|
267
|
+
except KeyError:
|
|
268
|
+
pass
|
|
269
|
+
try:
|
|
270
|
+
del b_dict[key]
|
|
271
|
+
except KeyError:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
a_xml = plistlib.dumps(a_dict).decode("utf-8")
|
|
275
|
+
b_xml = plistlib.dumps(b_dict).decode("utf-8")
|
|
276
|
+
|
|
277
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
278
|
+
a_temp_path = os.path.join(temp_dir, profile_a.file_name)
|
|
279
|
+
b_temp_path = os.path.join(temp_dir, profile_b.file_name)
|
|
280
|
+
|
|
281
|
+
with open(a_temp_path, "w", encoding="utf-8") as temp_profile:
|
|
282
|
+
temp_profile.write(a_xml)
|
|
283
|
+
|
|
284
|
+
with open(b_temp_path, "w", encoding="utf-8") as temp_profile:
|
|
285
|
+
temp_profile.write(b_xml)
|
|
286
|
+
|
|
287
|
+
diff_command.extend([a_temp_path, b_temp_path])
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
diff_contents = subprocess.check_output(
|
|
291
|
+
diff_command, universal_newlines=True
|
|
292
|
+
).strip()
|
|
293
|
+
except subprocess.CalledProcessError as ex:
|
|
294
|
+
# Diff tools usually return a non-0 exit code if there are differences,
|
|
295
|
+
# so we just swallow this error
|
|
296
|
+
diff_contents = ex.output
|
|
297
|
+
|
|
298
|
+
return diff_contents
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def value_for_key(profile_path: str, key: str) -> Any | None:
|
|
302
|
+
"""Return the value for a given key"""
|
|
303
|
+
|
|
304
|
+
profile = ProvisioningProfile(profile_path)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
value = profile.contents()[key]
|
|
308
|
+
return value
|
|
309
|
+
except KeyError:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def decode(profile_path: str, xml: bool = True, *, decode_certificates: bool = False):
|
|
314
|
+
"""Decode a profile, returning as a dictionary if xml is set to False."""
|
|
315
|
+
|
|
316
|
+
profile = ProvisioningProfile(profile_path, decode_certificates=decode_certificates)
|
|
317
|
+
|
|
318
|
+
if xml:
|
|
319
|
+
return profile.xml
|
|
320
|
+
|
|
321
|
+
return profile.contents()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
print("This should only be used as a module.")
|
|
326
|
+
sys.exit(1)
|
|
@@ -94,7 +94,9 @@ def _handle_read(args: argparse.Namespace) -> int:
|
|
|
94
94
|
def _handle_decode(args: argparse.Namespace) -> int:
|
|
95
95
|
"""Handle the decode sub command."""
|
|
96
96
|
try:
|
|
97
|
-
print(
|
|
97
|
+
print(
|
|
98
|
+
protool.decode(args.profile, decode_certificates=args.decode_certificates)
|
|
99
|
+
)
|
|
98
100
|
except Exception as ex:
|
|
99
101
|
print(f"Could not decode: {ex}", file=sys.stderr)
|
|
100
102
|
return 1
|
|
@@ -129,7 +131,7 @@ def _handle_arguments() -> int:
|
|
|
129
131
|
dest="tool",
|
|
130
132
|
action="store",
|
|
131
133
|
default=None,
|
|
132
|
-
help="Specify a diff
|
|
134
|
+
help="Specify a diff executable with optional quoted arguments (no shell expansion). It should take two file paths as the final two arguments. Defaults to opendiff", # pylint: disable=line-too-long
|
|
133
135
|
)
|
|
134
136
|
|
|
135
137
|
diff_parser.add_argument(
|
|
@@ -175,7 +177,7 @@ def _handle_arguments() -> int:
|
|
|
175
177
|
dest="tool",
|
|
176
178
|
action="store",
|
|
177
179
|
default=None,
|
|
178
|
-
help="Specify a diff
|
|
180
|
+
help="Specify a diff executable with optional quoted arguments (no shell expansion). It should take two file paths as the final two arguments. Defaults to opendiff", # pylint: disable=line-too-long
|
|
179
181
|
)
|
|
180
182
|
|
|
181
183
|
gitdiff_parser.add_argument(
|
|
@@ -227,6 +229,15 @@ def _handle_arguments() -> int:
|
|
|
227
229
|
help="The profile to read the value from",
|
|
228
230
|
)
|
|
229
231
|
|
|
232
|
+
decode_parser.add_argument(
|
|
233
|
+
"-d",
|
|
234
|
+
"--decode-certificates",
|
|
235
|
+
dest="decode_certificates",
|
|
236
|
+
action="store_true",
|
|
237
|
+
default=False,
|
|
238
|
+
help="Decode and extract certificate properties",
|
|
239
|
+
)
|
|
240
|
+
|
|
230
241
|
decode_parser.set_defaults(subcommand="decode")
|
|
231
242
|
|
|
232
243
|
args = parser.parse_args()
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "protool"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.1"
|
|
4
4
|
description = "A tool for dealing with provisioning profiles"
|
|
5
5
|
|
|
6
6
|
license = "MIT"
|
|
7
7
|
|
|
8
|
-
authors = [
|
|
9
|
-
"Dale Myers <dalemy@microsoft.com>"
|
|
10
|
-
]
|
|
8
|
+
authors = ["Dale Myers <dalemy@microsoft.com>"]
|
|
11
9
|
|
|
12
10
|
readme = 'README.md'
|
|
13
11
|
|
|
14
12
|
repository = "https://github.com/Microsoft/protool"
|
|
15
13
|
homepage = "https://github.com/Microsoft/protool"
|
|
16
14
|
|
|
17
|
-
keywords = [
|
|
15
|
+
keywords = [
|
|
16
|
+
'provisioning',
|
|
17
|
+
'profiles',
|
|
18
|
+
'apple',
|
|
19
|
+
'ios',
|
|
20
|
+
'xcode',
|
|
21
|
+
'mobileprovision',
|
|
22
|
+
]
|
|
18
23
|
|
|
19
24
|
classifiers = [
|
|
20
25
|
'Development Status :: 3 - Alpha',
|
|
@@ -27,23 +32,23 @@ classifiers = [
|
|
|
27
32
|
'Programming Language :: Python :: 3.10',
|
|
28
33
|
'Programming Language :: Python :: 3.11',
|
|
29
34
|
'Topic :: Software Development',
|
|
30
|
-
'Topic :: Utilities'
|
|
35
|
+
'Topic :: Utilities',
|
|
31
36
|
]
|
|
32
37
|
|
|
33
38
|
[tool.poetry.scripts]
|
|
34
39
|
protool = 'protool:command_line.run'
|
|
35
40
|
|
|
36
41
|
[tool.poetry.dependencies]
|
|
37
|
-
python = "^3.
|
|
38
|
-
pyOpenSSL ="^
|
|
42
|
+
python = "^3.11"
|
|
43
|
+
pyOpenSSL = "^25.1.0"
|
|
39
44
|
|
|
40
45
|
[tool.poetry.dev-dependencies]
|
|
41
|
-
black = "^
|
|
42
|
-
mypy = "^1.
|
|
43
|
-
pylint = "^
|
|
44
|
-
pytest = "^
|
|
45
|
-
pytest-cov = "^
|
|
46
|
-
types-pyOpenSSL = "^
|
|
46
|
+
black = "^26.1.0"
|
|
47
|
+
mypy = "^1.19.1"
|
|
48
|
+
pylint = "^4.0.4"
|
|
49
|
+
pytest = "^9.0.3"
|
|
50
|
+
pytest-cov = "^7.0.0"
|
|
51
|
+
types-pyOpenSSL = "^24.1.0.20240722"
|
|
47
52
|
|
|
48
53
|
[build-system]
|
|
49
54
|
requires = ["poetry-core"]
|
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
"""A utility for dealing with provisioning profiles"""
|
|
4
|
-
|
|
5
|
-
from enum import Enum
|
|
6
|
-
|
|
7
|
-
import copy
|
|
8
|
-
import datetime
|
|
9
|
-
import os
|
|
10
|
-
import plistlib
|
|
11
|
-
import shutil
|
|
12
|
-
import subprocess
|
|
13
|
-
import sys
|
|
14
|
-
import tempfile
|
|
15
|
-
from typing import Any, cast, Dict, List, Optional
|
|
16
|
-
from OpenSSL import crypto
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class ProvisioningType(Enum):
|
|
20
|
-
"""Enum representing the type of provisioning profile."""
|
|
21
|
-
|
|
22
|
-
IOS_DEVELOPMENT = 1
|
|
23
|
-
APP_STORE_DISTRIBUTION = 3
|
|
24
|
-
AD_HOC_DISTRIBUTION = 5
|
|
25
|
-
ENTERPRISE_DISTRIBUTION = 7
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# pylint: disable=too-many-instance-attributes
|
|
29
|
-
class ProvisioningProfile:
|
|
30
|
-
"""Represents a provisioning profile."""
|
|
31
|
-
|
|
32
|
-
file_path: str
|
|
33
|
-
file_name: str
|
|
34
|
-
xml: str
|
|
35
|
-
_contents: Dict[str, Any]
|
|
36
|
-
|
|
37
|
-
app_id_name: Optional[str]
|
|
38
|
-
application_identifier_prefix: Optional[str]
|
|
39
|
-
creation_date: Optional[datetime.datetime]
|
|
40
|
-
platform: Optional[List[str]]
|
|
41
|
-
entitlements: Dict[str, Any]
|
|
42
|
-
expiration_date: Optional[datetime.datetime]
|
|
43
|
-
name: Optional[str]
|
|
44
|
-
team_identifier: Optional[List[str]]
|
|
45
|
-
team_name: Optional[str]
|
|
46
|
-
time_to_live: Optional[int]
|
|
47
|
-
uuid: Optional[str]
|
|
48
|
-
version: Optional[int]
|
|
49
|
-
provisioned_devices: Optional[List[str]]
|
|
50
|
-
provisions_all_devices: Optional[bool]
|
|
51
|
-
|
|
52
|
-
@property
|
|
53
|
-
def profile_type(self) -> ProvisioningType:
|
|
54
|
-
"""Determine the profile type from the various values in the profile."""
|
|
55
|
-
if self.provisions_all_devices:
|
|
56
|
-
return ProvisioningType.ENTERPRISE_DISTRIBUTION
|
|
57
|
-
|
|
58
|
-
if not self.entitlements.get("get-task-allow") and self.provisioned_devices:
|
|
59
|
-
return ProvisioningType.AD_HOC_DISTRIBUTION
|
|
60
|
-
|
|
61
|
-
if not self.entitlements.get("get-task-allow") and not self.provisioned_devices:
|
|
62
|
-
return ProvisioningType.APP_STORE_DISTRIBUTION
|
|
63
|
-
|
|
64
|
-
if self.entitlements.get("get-task-allow") and self.provisioned_devices:
|
|
65
|
-
return ProvisioningType.IOS_DEVELOPMENT
|
|
66
|
-
|
|
67
|
-
raise Exception("Unable to determine provisioning profile type")
|
|
68
|
-
|
|
69
|
-
def developer_certificates(self) -> List[crypto.X509]:
|
|
70
|
-
"""Returns developer certificates as a list of PyOpenSSL X509."""
|
|
71
|
-
dev_certs: List[crypto.X509] = []
|
|
72
|
-
raw_cert_items: List[str] = cast(
|
|
73
|
-
List[str], self._contents.get("DeveloperCertificates", [])
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
for cert_item in raw_cert_items:
|
|
77
|
-
loaded_cert: crypto.X509 = crypto.load_certificate(
|
|
78
|
-
crypto.FILETYPE_ASN1, cert_item.encode()
|
|
79
|
-
)
|
|
80
|
-
dev_certs.append(loaded_cert)
|
|
81
|
-
|
|
82
|
-
return dev_certs
|
|
83
|
-
|
|
84
|
-
def __init__(self, file_path: str, *, sort_keys: bool = True) -> None:
|
|
85
|
-
self.file_path = os.path.abspath(file_path)
|
|
86
|
-
self.file_name = os.path.basename(self.file_path)
|
|
87
|
-
self.load_from_disk(sort_keys=sort_keys)
|
|
88
|
-
|
|
89
|
-
def load_from_disk(self, *, sort_keys: bool = True) -> None:
|
|
90
|
-
"""Load the provisioning profile details from disk and parse them."""
|
|
91
|
-
self.xml = self._get_xml()
|
|
92
|
-
self._contents = plistlib.loads(self.xml.encode())
|
|
93
|
-
|
|
94
|
-
if sort_keys:
|
|
95
|
-
self.xml = plistlib.dumps(self._contents, sort_keys=True).decode("utf-8")
|
|
96
|
-
|
|
97
|
-
self._parse_contents()
|
|
98
|
-
|
|
99
|
-
def contents(self) -> Dict[str, Any]:
|
|
100
|
-
"""Return a copy of the content dict."""
|
|
101
|
-
return copy.deepcopy(self._contents)
|
|
102
|
-
|
|
103
|
-
def _parse_contents(self) -> None:
|
|
104
|
-
"""Parse the contents of the profile."""
|
|
105
|
-
self.app_id_name = self._contents.get("AppIDName")
|
|
106
|
-
self.application_identifier_prefix = self._contents.get(
|
|
107
|
-
"ApplicationIdentifierPrefix"
|
|
108
|
-
)
|
|
109
|
-
self.creation_date = self._contents.get("CreationDate")
|
|
110
|
-
self.platform = self._contents.get("Platform")
|
|
111
|
-
self.entitlements = self._contents.get("Entitlements", {})
|
|
112
|
-
self.expiration_date = self._contents.get("ExpirationDate")
|
|
113
|
-
self.name = self._contents.get("Name")
|
|
114
|
-
self.team_identifier = self._contents.get("TeamIdentifier")
|
|
115
|
-
self.team_name = self._contents.get("TeamName")
|
|
116
|
-
self.time_to_live = self._contents.get("TimeToLive")
|
|
117
|
-
self.uuid = self._contents.get("UUID")
|
|
118
|
-
self.version = self._contents.get("Version")
|
|
119
|
-
self.provisioned_devices = self._contents.get("ProvisionedDevices")
|
|
120
|
-
self.provisions_all_devices = self._contents.get("ProvisionsAllDevices", False)
|
|
121
|
-
|
|
122
|
-
def _get_xml(self) -> str:
|
|
123
|
-
"""Load the XML contents of a provisioning profile."""
|
|
124
|
-
if not os.path.exists(self.file_path):
|
|
125
|
-
raise Exception(f"File does not exist: {self.file_path}")
|
|
126
|
-
|
|
127
|
-
security_cmd = f'security cms -D -i "{self.file_path}" 2> /dev/null'
|
|
128
|
-
return subprocess.check_output(
|
|
129
|
-
security_cmd, universal_newlines=True, shell=True
|
|
130
|
-
).strip()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
# pylint: enable=too-many-instance-attributes
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def profiles(profiles_dir: Optional[str] = None) -> List[ProvisioningProfile]:
|
|
137
|
-
"""Returns a list of all currently installed provisioning profiles."""
|
|
138
|
-
if profiles_dir:
|
|
139
|
-
dir_path = os.path.expanduser(profiles_dir)
|
|
140
|
-
else:
|
|
141
|
-
user_path = os.path.expanduser("~")
|
|
142
|
-
dir_path = os.path.join(
|
|
143
|
-
user_path, "Library", "MobileDevice", "Provisioning Profiles"
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
all_profiles = []
|
|
147
|
-
for profile in os.listdir(dir_path):
|
|
148
|
-
full_path = os.path.join(dir_path, profile)
|
|
149
|
-
_, ext = os.path.splitext(full_path)
|
|
150
|
-
if ext == ".mobileprovision":
|
|
151
|
-
provisioning_profile = ProvisioningProfile(full_path)
|
|
152
|
-
all_profiles.append(provisioning_profile)
|
|
153
|
-
|
|
154
|
-
return all_profiles
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def diff(
|
|
158
|
-
a_path: str,
|
|
159
|
-
b_path: str,
|
|
160
|
-
*,
|
|
161
|
-
sort_keys: bool = True,
|
|
162
|
-
ignore_keys: Optional[List[str]] = None,
|
|
163
|
-
tool_override: Optional[str] = None,
|
|
164
|
-
) -> str:
|
|
165
|
-
"""Diff two provisioning profiles."""
|
|
166
|
-
|
|
167
|
-
# pylint: disable=too-many-locals
|
|
168
|
-
|
|
169
|
-
if tool_override is None:
|
|
170
|
-
diff_tool = "opendiff"
|
|
171
|
-
else:
|
|
172
|
-
diff_tool = tool_override
|
|
173
|
-
|
|
174
|
-
profile_a = ProvisioningProfile(a_path, sort_keys=sort_keys)
|
|
175
|
-
profile_b = ProvisioningProfile(b_path, sort_keys=sort_keys)
|
|
176
|
-
|
|
177
|
-
if ignore_keys is None:
|
|
178
|
-
a_xml = profile_a.xml
|
|
179
|
-
b_xml = profile_b.xml
|
|
180
|
-
else:
|
|
181
|
-
a_dict = profile_a.contents()
|
|
182
|
-
b_dict = profile_b.contents()
|
|
183
|
-
|
|
184
|
-
for key in ignore_keys:
|
|
185
|
-
try:
|
|
186
|
-
del a_dict[key]
|
|
187
|
-
except KeyError:
|
|
188
|
-
pass
|
|
189
|
-
try:
|
|
190
|
-
del b_dict[key]
|
|
191
|
-
except KeyError:
|
|
192
|
-
pass
|
|
193
|
-
|
|
194
|
-
a_xml = plistlib.dumps(a_dict).decode("utf-8")
|
|
195
|
-
b_xml = plistlib.dumps(b_dict).decode("utf-8")
|
|
196
|
-
|
|
197
|
-
temp_dir = tempfile.mkdtemp()
|
|
198
|
-
|
|
199
|
-
a_temp_path = os.path.join(temp_dir, profile_a.file_name)
|
|
200
|
-
b_temp_path = os.path.join(temp_dir, profile_b.file_name)
|
|
201
|
-
|
|
202
|
-
with open(a_temp_path, "w", encoding="utf-8") as temp_profile:
|
|
203
|
-
temp_profile.write(a_xml)
|
|
204
|
-
|
|
205
|
-
with open(b_temp_path, "w", encoding="utf-8") as temp_profile:
|
|
206
|
-
temp_profile.write(b_xml)
|
|
207
|
-
|
|
208
|
-
# We deliberately don't wrap the tool so that arguments work as well
|
|
209
|
-
diff_command = f'{diff_tool} "{a_temp_path}" "{b_temp_path}"'
|
|
210
|
-
|
|
211
|
-
try:
|
|
212
|
-
diff_contents = subprocess.check_output(
|
|
213
|
-
diff_command, universal_newlines=True, shell=True
|
|
214
|
-
).strip()
|
|
215
|
-
except subprocess.CalledProcessError as ex:
|
|
216
|
-
# Diff tools usually return a non-0 exit code if there are differences,
|
|
217
|
-
# so we just swallow this error
|
|
218
|
-
diff_contents = ex.output
|
|
219
|
-
|
|
220
|
-
# Cleanup
|
|
221
|
-
shutil.rmtree(temp_dir)
|
|
222
|
-
|
|
223
|
-
return diff_contents
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def value_for_key(profile_path: str, key: str) -> Optional[Any]:
|
|
227
|
-
"""Return the value for a given key"""
|
|
228
|
-
|
|
229
|
-
profile = ProvisioningProfile(profile_path)
|
|
230
|
-
|
|
231
|
-
try:
|
|
232
|
-
value = profile.contents()[key]
|
|
233
|
-
return value
|
|
234
|
-
except KeyError:
|
|
235
|
-
return None
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def decode(profile_path: str, xml: bool = True):
|
|
239
|
-
"""Decode a profile, returning as a dictionary if xml is set to False."""
|
|
240
|
-
|
|
241
|
-
profile = ProvisioningProfile(profile_path)
|
|
242
|
-
|
|
243
|
-
if xml:
|
|
244
|
-
return profile.xml
|
|
245
|
-
|
|
246
|
-
return profile.contents()
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if __name__ == "__main__":
|
|
250
|
-
print("This should only be used as a module.")
|
|
251
|
-
sys.exit(1)
|
|
File without changes
|