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/cms.py ADDED
@@ -0,0 +1,164 @@
1
+ """CMS / PKCS#7 signature generation.
2
+
3
+ Apple seals a code signature with a detached CMS ``SignedData`` whose content is
4
+ the CodeDirectory. The structure carries a handful of signed attributes, three of
5
+ them standard and two Apple specific, and one detail decides whether the result
6
+ verifies at all: the RSA signature is computed over the signed attributes
7
+ encoded as a ``SET OF`` (tag ``0x31``), not over the ``[0]`` implicit form the
8
+ SignerInfo carries them in. RFC 5652 section 5.4 signs the SET value; signing the
9
+ implicit form produces a blob that parses cleanly and that ``codesign`` rejects
10
+ with "code or signature have been modified".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+ import hashlib
17
+ from dataclasses import dataclass
18
+
19
+ from asn1crypto import algos, cms, core, x509 as asn1x509
20
+ from cryptography import x509
21
+ from cryptography.hazmat.primitives import hashes, serialization
22
+ from cryptography.hazmat.primitives.asymmetric import padding
23
+
24
+ from .credentials import Identity
25
+ from .errors import BlobError
26
+
27
+ OID_APPLE_CDHASHES_PLIST = "1.2.840.113635.100.9.1"
28
+ OID_APPLE_CDHASHES = "1.2.840.113635.100.9.2"
29
+
30
+ # Content bytes of OID 2.16.840.1.101.3.4.2.1 (sha256). The wrapping SEQUENCE
31
+ # carries no parameters: adding a NULL there changes the bytes and breaks
32
+ # parity with Apple's own output.
33
+ _SHA256_OID_CONTENT = bytes.fromhex("608648016503040201")
34
+
35
+
36
+ def _der_length(length: int) -> bytes:
37
+ if length < 0x80:
38
+ return bytes([length])
39
+ raw = length.to_bytes((length.bit_length() + 7) // 8, "big")
40
+ return bytes([0x80 | len(raw)]) + raw
41
+
42
+
43
+ def _der_tlv(tag: int, body: bytes) -> bytes:
44
+ return bytes([tag]) + _der_length(len(body)) + body
45
+
46
+
47
+ def _apple_cdhash_attribute(digest: bytes) -> bytes:
48
+ """``SEQUENCE { OID sha256, OCTET STRING <digest> }`` for attribute 9.2."""
49
+ inner = _der_tlv(0x06, _SHA256_OID_CONTENT)
50
+ return _der_tlv(0x30, inner + _der_tlv(0x04, digest))
51
+
52
+
53
+ def _asn1_certificate(cert: x509.Certificate) -> asn1x509.Certificate:
54
+ return asn1x509.Certificate.load(cert.public_bytes(serialization.Encoding.DER))
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class CmsParts:
59
+ """The inputs the CMS builder needs, already hashed by the caller."""
60
+
61
+ code_directory: bytes
62
+ cdhashes_plist: bytes
63
+ apple_hash: bytes
64
+ signing_time: datetime.datetime | None = None
65
+
66
+
67
+ def build_cms(identity: Identity, parts: CmsParts) -> bytes:
68
+ """Build the detached CMS ``SignedData`` for a signed CodeDirectory."""
69
+ code_directory = parts.code_directory
70
+ if not code_directory:
71
+ raise BlobError("cannot sign an empty CodeDirectory")
72
+ if len(parts.apple_hash) != hashes.SHA256.digest_size:
73
+ raise BlobError("Apple cdhashes attribute needs a full SHA-256 digest")
74
+
75
+ digest = hashlib.sha256(code_directory).digest()
76
+ signing_time = parts.signing_time or datetime.datetime.now(datetime.timezone.utc)
77
+
78
+ signed_attrs = cms.CMSAttributes(
79
+ [
80
+ cms.CMSAttribute({"type": "content_type", "values": ["data"]}),
81
+ cms.CMSAttribute(
82
+ {
83
+ "type": "signing_time",
84
+ "values": [cms.Time({"utc_time": signing_time})],
85
+ }
86
+ ),
87
+ cms.CMSAttribute(
88
+ {"type": "message_digest", "values": [core.OctetString(digest)]}
89
+ ),
90
+ cms.CMSAttribute(
91
+ {
92
+ "type": OID_APPLE_CDHASHES,
93
+ "values": [core.Any.load(_apple_cdhash_attribute(parts.apple_hash))],
94
+ }
95
+ ),
96
+ cms.CMSAttribute(
97
+ {
98
+ "type": OID_APPLE_CDHASHES_PLIST,
99
+ "values": [core.OctetString(parts.cdhashes_plist)],
100
+ }
101
+ ),
102
+ ]
103
+ )
104
+
105
+ # RFC 5652: the signature covers the SET-encoded attributes, never the
106
+ # implicit [0] form the SignerInfo stores them in.
107
+ signature = identity.private_key.sign(
108
+ signed_attrs.dump(), padding.PKCS1v15(), hashes.SHA256()
109
+ )
110
+
111
+ leaf = identity.certificate
112
+ signer = cms.SignerInfo(
113
+ {
114
+ "version": "v1",
115
+ "sid": cms.SignerIdentifier(
116
+ {
117
+ "issuer_and_serial_number": cms.IssuerAndSerialNumber(
118
+ {
119
+ "issuer": asn1x509.Name.load(leaf.issuer.public_bytes()),
120
+ "serial_number": leaf.serial_number,
121
+ }
122
+ )
123
+ }
124
+ ),
125
+ "digest_algorithm": algos.DigestAlgorithm(
126
+ {"algorithm": "sha256", "parameters": None}
127
+ ),
128
+ "signed_attrs": signed_attrs,
129
+ "signature_algorithm": algos.SignedDigestAlgorithm(
130
+ {"algorithm": "rsassa_pkcs1v15"}
131
+ ),
132
+ "signature": signature,
133
+ }
134
+ )
135
+
136
+ certificates = cms.CertificateSet(
137
+ [cms.CertificateChoices({"certificate": _asn1_certificate(cert)}) for cert in identity.chain]
138
+ )
139
+
140
+ signed_data = cms.SignedData(
141
+ {
142
+ "version": "v1",
143
+ "digest_algorithms": cms.DigestAlgorithms(
144
+ [algos.DigestAlgorithm({"algorithm": "sha256", "parameters": None})]
145
+ ),
146
+ "encap_content_info": cms.ContentInfo({"content_type": "data"}),
147
+ "certificates": certificates,
148
+ "signer_infos": cms.SignerInfos([signer]),
149
+ }
150
+ )
151
+
152
+ return cms.ContentInfo(
153
+ {"content_type": "signed_data", "content": signed_data}
154
+ ).dump()
155
+
156
+
157
+ def cms_content(cms_bytes: bytes) -> bytes | None:
158
+ """Return the detached content of a CMS blob, or None when absent."""
159
+ info = cms.ContentInfo.load(cms_bytes)
160
+ content = info["content"]["encap_content_info"]["content"]
161
+ return content.native if content.native else None
162
+
163
+
164
+ __all__ = ["CmsParts", "build_cms"]
ipasign/credentials.py ADDED
@@ -0,0 +1,259 @@
1
+ """Signing identity and provisioning profile loading.
2
+
3
+ A ``.p12`` holds the private key and the leaf certificate. A
4
+ ``.mobileprovision`` is a CMS-signed plist carrying the entitlements to seal and
5
+ the team identifier. This module turns both into the pieces the signature
6
+ builders want, and picks the intermediate certificate that completes the chain.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import plistlib
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from cryptography import x509
16
+ from cryptography.hazmat.primitives.asymmetric import rsa
17
+ from cryptography.hazmat.primitives.serialization import pkcs12
18
+
19
+ from . import _apple_ca
20
+ from .errors import CredentialError, ProfileError
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class ProvisioningProfile:
25
+ """The parts of a provisioning profile signing needs."""
26
+
27
+ data: bytes
28
+ entitlements: dict
29
+ entitlements_plist: bytes
30
+ team_id: str
31
+ application_id: str
32
+ developer_certificates: list[bytes] = field(default_factory=list)
33
+
34
+ @property
35
+ def get_task_allow(self) -> bool:
36
+ """True only when ``get-task-allow`` is present and set to a true value."""
37
+ return bool(self.entitlements.get("get-task-allow", False))
38
+
39
+ def matches(self, bundle_id: str) -> bool:
40
+ """Whether this profile was issued for ``bundle_id``."""
41
+ return bool(self.application_id) and self.application_id.endswith(bundle_id)
42
+
43
+
44
+ def _plist_from_cms(data: bytes) -> dict:
45
+ """Pull the plist payload out of a CMS-wrapped provisioning profile."""
46
+ from asn1crypto import cms
47
+
48
+ try:
49
+ info = cms.ContentInfo.load(data)
50
+ except Exception as exc: # asn1crypto raises several unrelated types
51
+ raise ProfileError(f"provisioning profile is not a CMS structure: {exc}") from exc
52
+
53
+ if info["content_type"].native != "signed_data":
54
+ raise ProfileError(f"provisioning profile content is {info['content_type'].native}")
55
+
56
+ content = info["content"]["encap_content_info"]["content"]
57
+ if content.native is None:
58
+ raise ProfileError("provisioning profile has no plist payload")
59
+
60
+ payload = content.native
61
+ try:
62
+ parsed = plistlib.loads(payload)
63
+ except Exception as exc:
64
+ raise ProfileError(f"provisioning profile payload is not a plist: {exc}") from exc
65
+ if not isinstance(parsed, dict):
66
+ raise ProfileError("provisioning profile payload is not a dictionary")
67
+ return parsed
68
+
69
+
70
+ def load_profile(path: str | Path) -> ProvisioningProfile:
71
+ """Read a ``.mobileprovision`` file and pull out the signing inputs."""
72
+ try:
73
+ data = Path(path).read_bytes()
74
+ except FileNotFoundError as exc:
75
+ raise ProfileError(f"provisioning profile not found: {path}") from exc
76
+ except OSError as exc:
77
+ raise ProfileError(f"cannot read provisioning profile {path}: {exc}") from exc
78
+
79
+ if not data:
80
+ raise ProfileError(f"provisioning profile is empty: {path}")
81
+
82
+ plist = _plist_from_cms(data)
83
+
84
+ team_ids = plist.get("TeamIdentifier") or []
85
+ if not team_ids:
86
+ raise ProfileError("provisioning profile carries no TeamIdentifier")
87
+ team_id = team_ids[0]
88
+
89
+ entitlements = plist.get("Entitlements")
90
+ if not isinstance(entitlements, dict):
91
+ raise ProfileError("provisioning profile carries no Entitlements dictionary")
92
+
93
+ certificates = [
94
+ cert
95
+ for cert in (plist.get("DeveloperCertificates") or [])
96
+ if isinstance(cert, (bytes, bytearray))
97
+ ]
98
+
99
+ return ProvisioningProfile(
100
+ data=data,
101
+ entitlements=entitlements,
102
+ entitlements_plist=plistlib.dumps(entitlements, fmt=plistlib.FMT_XML, sort_keys=False),
103
+ team_id=team_id,
104
+ application_id=str(entitlements.get("application-identifier", "")),
105
+ developer_certificates=certificates,
106
+ )
107
+
108
+
109
+ def _embedded_intermediate(leaf: x509.Certificate) -> x509.Certificate | None:
110
+ """The known Apple intermediate that issued ``leaf``, if any."""
111
+ for pem in _apple_ca.WWDR_BY_ISSUER_HASH.values():
112
+ candidate = x509.load_pem_x509_certificate(pem.encode("ascii"))
113
+ if candidate.subject == leaf.issuer:
114
+ return candidate
115
+ return None
116
+
117
+
118
+ @dataclass(slots=True)
119
+ class Identity:
120
+ """A loaded signing identity and the chain that completes it."""
121
+
122
+ private_key: rsa.RSAPrivateKey
123
+ certificate: x509.Certificate
124
+ chain: list[x509.Certificate]
125
+
126
+ @property
127
+ def subject_cn(self) -> str:
128
+ """The leaf certificate's common name, pinned by the requirement."""
129
+ for attr in self.certificate.subject:
130
+ if attr.oid == x509.oid.NameOID.COMMON_NAME:
131
+ return attr.value
132
+ raise CredentialError("leaf certificate has no subject common name")
133
+
134
+
135
+ def _load_der_certificates(blobs: list[bytes]) -> list[x509.Certificate]:
136
+ certs: list[x509.Certificate] = []
137
+ for blob in blobs:
138
+ try:
139
+ certs.append(x509.load_der_x509_certificate(blob))
140
+ except Exception:
141
+ continue
142
+ return certs
143
+
144
+
145
+ def _select_chain(
146
+ leaf: x509.Certificate,
147
+ from_p12: list[x509.Certificate],
148
+ from_profile: list[x509.Certificate],
149
+ ) -> list[x509.Certificate]:
150
+ """Pick the certificates to embed, intermediates first and the leaf last.
151
+
152
+ A chain carried inside the p12 is preferred, but only when it actually
153
+ contains the leaf's issuer. A p12 holding an unrelated or root-only chain
154
+ must not shadow the intermediate that matches the leaf.
155
+ """
156
+ candidates = from_p12 or from_profile
157
+ chain: list[x509.Certificate] = []
158
+ for cert in candidates:
159
+ if cert == leaf:
160
+ continue
161
+ if cert not in chain:
162
+ chain.append(cert)
163
+
164
+ issuer = leaf.issuer
165
+ if not any(cert.subject == issuer for cert in chain):
166
+ chain = []
167
+
168
+ if not chain:
169
+ embedded = _embedded_intermediate(leaf)
170
+ if embedded is None:
171
+ raise CredentialError(
172
+ "no certificate in the p12 or profile issued the leaf, and no "
173
+ f"known Apple intermediate matches its issuer ({issuer.rfc4514_string()})"
174
+ )
175
+ chain = [embedded]
176
+
177
+ # Terminate the chain with the Apple root that issued one of its members.
178
+ for pem in _apple_ca.ROOTS:
179
+ root = x509.load_pem_x509_certificate(pem.encode("ascii"))
180
+ if root in chain:
181
+ continue
182
+ if any(cert.issuer == root.subject for cert in chain):
183
+ chain.append(root)
184
+
185
+ chain.append(leaf)
186
+ return chain
187
+
188
+
189
+ def load_identity(
190
+ p12_path: str | Path,
191
+ password: str | None,
192
+ profile: ProvisioningProfile | None = None,
193
+ ) -> Identity:
194
+ """Read a ``.p12`` and build the signing chain.
195
+
196
+ The key and leaf come from the p12. Intermediates come from the p12 when it
197
+ carries a usable chain, otherwise from the profile's developer certificates,
198
+ otherwise from the embedded Apple intermediates.
199
+ """
200
+ try:
201
+ data = Path(p12_path).read_bytes()
202
+ except FileNotFoundError as exc:
203
+ raise CredentialError(f"identity file not found: {p12_path}") from exc
204
+ except OSError as exc:
205
+ raise CredentialError(f"cannot read identity file {p12_path}: {exc}") from exc
206
+
207
+ secret = None if password is None else password.encode("utf-8")
208
+ try:
209
+ key, leaf, extras = pkcs12.load_key_and_certificates(data, secret)
210
+ except Exception as exc:
211
+ raise CredentialError(
212
+ f"cannot open {p12_path}: wrong password or not a PKCS#12 file ({exc})"
213
+ ) from exc
214
+
215
+ if key is None:
216
+ raise CredentialError(f"{p12_path} carries no private key")
217
+ if leaf is None:
218
+ raise CredentialError(f"{p12_path} carries no certificate")
219
+ if not isinstance(key, rsa.RSAPrivateKey):
220
+ raise CredentialError(f"only RSA signing keys are supported, got {type(key).__name__}")
221
+
222
+ if leaf.public_key().public_numbers() != key.public_key().public_numbers():
223
+ raise CredentialError(f"private key in {p12_path} does not match its certificate")
224
+
225
+ from_profile = _load_der_certificates(profile.developer_certificates) if profile else []
226
+ chain = _select_chain(leaf, list(extras or []), from_profile)
227
+ return Identity(private_key=key, certificate=leaf, chain=chain)
228
+
229
+
230
+ def load_entitlements(path: str | Path) -> tuple[dict, bytes]:
231
+ """Read an entitlements plist supplied by the caller.
232
+
233
+ Returns the parsed dictionary and the plist bytes to seal, with the caller's
234
+ key order preserved.
235
+ """
236
+ try:
237
+ raw = Path(path).read_bytes()
238
+ except FileNotFoundError as exc:
239
+ raise CredentialError(f"entitlements file not found: {path}") from exc
240
+ except OSError as exc:
241
+ raise CredentialError(f"cannot read entitlements file {path}: {exc}") from exc
242
+
243
+ try:
244
+ parsed = plistlib.loads(raw)
245
+ except Exception as exc:
246
+ raise CredentialError(f"entitlements file {path} is not a plist: {exc}") from exc
247
+ if not isinstance(parsed, dict):
248
+ raise CredentialError(f"entitlements file {path} is not a dictionary")
249
+
250
+ return parsed, plistlib.dumps(parsed, fmt=plistlib.FMT_XML, sort_keys=False)
251
+
252
+
253
+ __all__ = [
254
+ "Identity",
255
+ "ProvisioningProfile",
256
+ "load_entitlements",
257
+ "load_identity",
258
+ "load_profile",
259
+ ]
ipasign/errors.py ADDED
@@ -0,0 +1,56 @@
1
+ """Exception types raised by the public API.
2
+
3
+ Everything the library raises derives from :class:`IpasignError`, so callers
4
+ can catch one base class and still tell the failure modes apart.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class IpasignError(Exception):
11
+ """Base class for every error ipasign raises."""
12
+
13
+
14
+ class InvalidInputError(IpasignError):
15
+ """The input is not something this library knows how to sign."""
16
+
17
+
18
+ class MachOError(IpasignError):
19
+ """A Mach-O file is malformed or cannot be rewritten."""
20
+
21
+
22
+ class BlobError(IpasignError):
23
+ """A code signature blob could not be built or parsed."""
24
+
25
+
26
+ class CredentialError(IpasignError):
27
+ """A signing identity, password or provisioning profile is unusable."""
28
+
29
+
30
+ class ProfileError(CredentialError):
31
+ """A provisioning profile could not be read or is missing required data."""
32
+
33
+
34
+ class BundleError(IpasignError):
35
+ """A bundle is missing the structure signing requires."""
36
+
37
+
38
+ class ArchiveError(IpasignError):
39
+ """An archive could not be unpacked or repacked."""
40
+
41
+
42
+ class NotEnoughSpaceError(MachOError):
43
+ """The load commands have no room for a new LC_CODE_SIGNATURE."""
44
+
45
+
46
+ __all__ = [
47
+ "IpasignError",
48
+ "InvalidInputError",
49
+ "MachOError",
50
+ "BlobError",
51
+ "CredentialError",
52
+ "ProfileError",
53
+ "BundleError",
54
+ "ArchiveError",
55
+ "NotEnoughSpaceError",
56
+ ]