ipa-forge 0.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.
Files changed (62) hide show
  1. ipa_forge/__init__.py +1 -0
  2. ipa_forge/altstore/__init__.py +1 -0
  3. ipa_forge/altstore/source.py +44 -0
  4. ipa_forge/analysis/__init__.py +18 -0
  5. ipa_forge/analysis/classdump.py +103 -0
  6. ipa_forge/analysis/diff.py +127 -0
  7. ipa_forge/analysis/security.py +72 -0
  8. ipa_forge/analysis/strings.py +40 -0
  9. ipa_forge/analysis/symbols.py +42 -0
  10. ipa_forge/analysis/type_encoding.py +164 -0
  11. ipa_forge/bundle/__init__.py +1 -0
  12. ipa_forge/bundle/inventory.py +96 -0
  13. ipa_forge/bundle/ipa.py +81 -0
  14. ipa_forge/bundle/models.py +58 -0
  15. ipa_forge/bundle/plist.py +18 -0
  16. ipa_forge/cli/__init__.py +1 -0
  17. ipa_forge/cli/analysis.py +225 -0
  18. ipa_forge/cli/common.py +41 -0
  19. ipa_forge/cli/hooks.py +357 -0
  20. ipa_forge/cli/main.py +170 -0
  21. ipa_forge/gui/__init__.py +1 -0
  22. ipa_forge/gui/analysis.html +229 -0
  23. ipa_forge/gui/app.py +304 -0
  24. ipa_forge/gui/index.html +244 -0
  25. ipa_forge/hooks/__init__.py +0 -0
  26. ipa_forge/hooks/scan.py +81 -0
  27. ipa_forge/hooks/verify.py +348 -0
  28. ipa_forge/machO/__init__.py +1 -0
  29. ipa_forge/machO/arch.py +94 -0
  30. ipa_forge/machO/detect.py +50 -0
  31. ipa_forge/machO/injector.py +99 -0
  32. ipa_forge/machO/objc.py +534 -0
  33. ipa_forge/manifest.py +74 -0
  34. ipa_forge/patch/__init__.py +1 -0
  35. ipa_forge/patch/base.py +48 -0
  36. ipa_forge/patch/binary.py +131 -0
  37. ipa_forge/patch/dylib.py +59 -0
  38. ipa_forge/patch/engine.py +48 -0
  39. ipa_forge/patch/loader.py +88 -0
  40. ipa_forge/patch/paths.py +19 -0
  41. ipa_forge/patch/plist.py +61 -0
  42. ipa_forge/patch/resolver.py +37 -0
  43. ipa_forge/patch/resource.py +135 -0
  44. ipa_forge/patch/schema.py +118 -0
  45. ipa_forge/patch/version.py +25 -0
  46. ipa_forge/patches.py +76 -0
  47. ipa_forge/pipeline.py +235 -0
  48. ipa_forge/signing/__init__.py +1 -0
  49. ipa_forge/signing/backend.py +111 -0
  50. ipa_forge/signing/pipeline.py +99 -0
  51. ipa_forge/signing/profile.py +131 -0
  52. ipa_forge/signing/provider.py +113 -0
  53. ipa_forge/signing/reconcile.py +27 -0
  54. ipa_forge/validators/__init__.py +1 -0
  55. ipa_forge/validators/archive_validator.py +25 -0
  56. ipa_forge/validators/bundle_validator.py +27 -0
  57. ipa_forge/validators/ipa_validator.py +39 -0
  58. ipa_forge-0.1.0.dist-info/METADATA +914 -0
  59. ipa_forge-0.1.0.dist-info/RECORD +62 -0
  60. ipa_forge-0.1.0.dist-info/WHEEL +4 -0
  61. ipa_forge-0.1.0.dist-info/entry_points.txt +2 -0
  62. ipa_forge-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,118 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Pydantic schema for the external YAML/JSON patch-definition contract.
3
+
4
+ The core engine understands these operation *types* but never a specific
5
+ bundle id or byte pattern -- those only ever live in user-supplied definition
6
+ files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated, Any, Literal
12
+
13
+ from pydantic import BaseModel, Field, model_validator
14
+
15
+
16
+ class VersionExact(BaseModel):
17
+ exact: str
18
+
19
+
20
+ class VersionRange(BaseModel):
21
+ min: str | None = None
22
+ max: str | None = None
23
+
24
+
25
+ class TargetSpec(BaseModel):
26
+ bundle_id: str
27
+ version: VersionExact | VersionRange
28
+
29
+
30
+ class BinaryReplaceSpec(BaseModel):
31
+ type: Literal["binary_replace"]
32
+ id: str
33
+ executable: str
34
+ pattern: str
35
+ replacement: str
36
+ expected_matches: int = 1
37
+ arch: str | None = None
38
+
39
+
40
+ class ResourceReplaceSpec(BaseModel):
41
+ type: Literal["resource_replace"]
42
+ id: str
43
+ path: str
44
+ source: str
45
+
46
+
47
+ class ResourceAddSpec(BaseModel):
48
+ type: Literal["resource_add"]
49
+ id: str
50
+ path: str
51
+ source: str
52
+
53
+
54
+ class ResourceRemoveSpec(BaseModel):
55
+ type: Literal["resource_remove"]
56
+ id: str
57
+ path: str
58
+
59
+
60
+ class DylibInjectSpec(BaseModel):
61
+ type: Literal["dylib_inject"]
62
+ id: str
63
+ executable: str
64
+ # The load path to add as an LC_LOAD_DYLIB/LC_LOAD_WEAK_DYLIB entry (e.g.
65
+ # "@rpath/libFoo.dylib"), not a source file to copy in -- getting the
66
+ # dylib itself into the bundle is a separate resource_add operation, so
67
+ # this stays a single-responsibility Mach-O load-command edit.
68
+ install_name: str
69
+ arch: str | None = None
70
+ load_command: Literal["LC_LOAD_DYLIB", "LC_LOAD_WEAK_DYLIB"] = "LC_LOAD_DYLIB"
71
+
72
+
73
+ class PlistEditSpec(BaseModel):
74
+ type: Literal["plist_edit"]
75
+ id: str
76
+ action: Literal["set", "remove"]
77
+ key: str
78
+ value: Any = None
79
+ path: str = "Info.plist"
80
+
81
+ @model_validator(mode="after")
82
+ def _value_required_for_set(self) -> PlistEditSpec:
83
+ if self.action == "set" and self.value is None:
84
+ raise ValueError("plist_edit with action 'set' requires a non-null 'value'")
85
+ return self
86
+
87
+
88
+ PatchSpec = Annotated[
89
+ BinaryReplaceSpec | ResourceReplaceSpec | ResourceAddSpec | ResourceRemoveSpec | DylibInjectSpec | PlistEditSpec,
90
+ Field(discriminator="type"),
91
+ ]
92
+
93
+
94
+ class HookSpec(BaseModel):
95
+ """Declare a runtime hook the patch set relies on, so `forge` can verify
96
+ it against the actual binary and catch silent no-ops on version drift."""
97
+
98
+ class_name: str = Field(alias="class")
99
+ selector: str
100
+ kind: Literal["instance", "class"] = "instance"
101
+ added: bool = False
102
+ """True when the tweak adds the method itself (logos' %new) -- absence is
103
+ expected and counts as a pass; presence in selrefs confirms the app calls it."""
104
+ required: bool = False
105
+ """Fail the run when this hook can't attach (missing class/selector or
106
+ selector-only-elsewhere). Non-required hooks only warn."""
107
+
108
+ model_config = {"populate_by_name": True}
109
+
110
+
111
+ class PatchDefinition(BaseModel):
112
+ target: TargetSpec
113
+ patches: list[PatchSpec] = Field(
114
+ min_length=1, description="At least one operation -- an empty definition is always a mistake."
115
+ )
116
+ hooks: list[HookSpec] | None = None
117
+ """Runtime hook targets to verify against the binary before patching;
118
+ catches silent hook no-ops when a new app version renames/removes classes."""
@@ -0,0 +1,25 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Minimal dotted-version comparison for CFBundleShortVersionString matching.
3
+
4
+ Non-numeric segments are stripped ("1.0.0-beta" parses to (1, 0, 0)), so
5
+ range matching treats suffixed and clean versions as equal. `VersionExact` in
6
+ patch/schema.py deliberately uses raw string equality instead -- exact:
7
+ "1.0.0" will not match a bundle whose version is "1.0.0-beta".
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ def _parse_segment(digits: str) -> int:
14
+ try:
15
+ return int(digits)
16
+ except ValueError: # unreachable: digits is already filtered to [0-9]
17
+ return 0
18
+
19
+
20
+ def parse_version(v: str) -> tuple[int, ...]:
21
+ parts = []
22
+ for segment in v.split("."):
23
+ digits = "".join(ch for ch in segment if ch.isdigit())
24
+ parts.append(_parse_segment(digits))
25
+ return tuple(parts)
ipa_forge/patches.py ADDED
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Patch-set registry: one canonical definition per app.
3
+
4
+ The layout convention: ``patches/<app>/<app>.yaml`` (version lives *inside*
5
+ the definition's ``target.version``, not in the directory name). The GUI and
6
+ CLI use this to discover which patch set applies to a given IPA and to warn
7
+ on version mismatch without blocking.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from ipa_forge.patch.loader import PatchLoadError, load_patch_definition
16
+ from ipa_forge.patch.schema import VersionExact
17
+
18
+ _DEFAULT_PATCHES_DIR = Path(__file__).resolve().parent.parent / "patches"
19
+
20
+
21
+ def patches_root() -> Path:
22
+ """The patch-set root; overridable via IPA_FORGE_PATCHES_DIR (tests use
23
+ a temporary tree instead of the repo's patch sets)."""
24
+ import os
25
+
26
+ override = os.environ.get("IPA_FORGE_PATCHES_DIR")
27
+ return Path(override) if override else _DEFAULT_PATCHES_DIR
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class PatchSet:
32
+ name: str # directory name, e.g. "youtube"
33
+ definition: Path # path to the canonical <app>.yaml
34
+ bundle_id: str
35
+ version_spec: str # "exact: 21.32.4" or "min: … max: …" summary
36
+
37
+ @property
38
+ def version_exact(self) -> str | None:
39
+ # only exact specs are interesting for the GUI warning
40
+ spec = self.version_spec
41
+ return spec.split("exact: ", 1)[1].strip("'\"") if "exact:" in spec else None
42
+
43
+
44
+ def discover_patch_sets(patches_dir: Path | None = None) -> list[PatchSet]:
45
+ """Scan ``patches/*/<name>.yaml`` (the canonical definition per app)."""
46
+ base = patches_dir or patches_root()
47
+ if not base.is_dir():
48
+ return []
49
+ sets: list[PatchSet] = []
50
+ for app_dir in sorted(base.iterdir()):
51
+ if not app_dir.is_dir() or app_dir.name.startswith("."):
52
+ continue
53
+ candidate = app_dir / f"{app_dir.name}.yaml"
54
+ if not candidate.is_file():
55
+ continue
56
+ try:
57
+ definition = load_patch_definition(candidate)
58
+ except PatchLoadError:
59
+ continue # not a definition we can parse; skip
60
+ version = definition.target.version
61
+ spec = (
62
+ f"exact: {version.exact}" if isinstance(version, VersionExact) else f"min: {version.min} max: {version.max}"
63
+ )
64
+ sets.append(
65
+ PatchSet(
66
+ name=app_dir.name,
67
+ definition=candidate,
68
+ bundle_id=definition.target.bundle_id,
69
+ version_spec=spec,
70
+ )
71
+ )
72
+ return sets
73
+
74
+
75
+ def find_patch_sets_for_bundle(bundle_id: str, patches_dir: Path | None = None) -> list[PatchSet]:
76
+ return [p for p in discover_patch_sets(patches_dir) if p.bundle_id == bundle_id]
ipa_forge/pipeline.py ADDED
@@ -0,0 +1,235 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """End-to-end pipeline orchestration: the 17 stages described in the
3
+ architecture doc, from raw .ipa in to AltStore-Classic-ready .ipa out.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import tempfile
9
+ import warnings
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from ipa_forge.bundle.ipa import extract_ipa, load_bundle, repack_ipa
14
+ from ipa_forge.bundle.models import AppBundle
15
+ from ipa_forge.hooks.verify import HookDecl, verify_hooks
16
+ from ipa_forge.hooks.verify import failing as hook_failures
17
+ from ipa_forge.machO.objc import analyze_bundle
18
+ from ipa_forge.manifest import Manifest, ProfileManifestEntry, sha256_of
19
+ from ipa_forge.patch.base import PatchContext
20
+ from ipa_forge.patch.engine import apply_all, dry_run_all
21
+ from ipa_forge.patch.loader import PatchLoadError, build_operations, load_patch_definition
22
+ from ipa_forge.patch.resolver import resolve_definitions
23
+ from ipa_forge.patch.schema import PatchDefinition
24
+ from ipa_forge.signing.backend import SigningBackendError
25
+ from ipa_forge.signing.pipeline import sign_bundle, sign_target_path
26
+ from ipa_forge.signing.profile import ProfileError, load_profile_pool, validate_profile
27
+ from ipa_forge.signing.provider import LocalIdentityProvider, SigningProvider, VerifyResult, resolve_identity
28
+ from ipa_forge.validators.archive_validator import validate_final_archive
29
+ from ipa_forge.validators.bundle_validator import validate_bundle
30
+ from ipa_forge.validators.ipa_validator import IpaValidationError, validate_ipa_structure
31
+
32
+
33
+ class PipelineError(Exception):
34
+ pass
35
+
36
+
37
+ @dataclass
38
+ class PipelineResult:
39
+ manifest: Manifest
40
+ output_path: Path | None
41
+ verify_results: list[VerifyResult] = field(default_factory=list)
42
+
43
+
44
+ def _hook_decls(definition: PatchDefinition) -> list[HookDecl]:
45
+ return [
46
+ HookDecl(
47
+ class_name=h.class_name,
48
+ selector=h.selector,
49
+ kind=h.kind,
50
+ added=h.added,
51
+ required=h.required,
52
+ )
53
+ for h in (definition.hooks or [])
54
+ ]
55
+
56
+
57
+ def _verify_definition_hooks(bundle: AppBundle, definition: PatchDefinition) -> list[dict[str, object]]:
58
+ """Verify the definition's declared hooks against the app's main binary.
59
+ Returns the hook report (list of dicts). Raises PipelineError when a
60
+ required hook cannot attach."""
61
+ decls = _hook_decls(definition)
62
+ if not decls:
63
+ return []
64
+ analysis = analyze_bundle(bundle)
65
+ results = verify_hooks(analysis, decls)
66
+ bad_required = [r for r in hook_failures(results) if r.required]
67
+ if bad_required:
68
+ detail = "; ".join(f"{r.class_name} {r.selector}: {r.status} ({r.detail})" for r in bad_required)
69
+ raise PipelineError(f"required hook verification failed: {detail}")
70
+ return [
71
+ {
72
+ "class": r.class_name,
73
+ "selector": r.selector,
74
+ "kind": r.kind,
75
+ "status": r.status,
76
+ "detail": r.detail,
77
+ "required": r.required,
78
+ }
79
+ for r in results
80
+ ]
81
+
82
+
83
+ def run_pipeline(
84
+ ipa_path: Path,
85
+ patch_definition_path: Path,
86
+ identity_query: str,
87
+ profile_paths: list[Path],
88
+ output_path: Path,
89
+ *,
90
+ provider: SigningProvider | None = None,
91
+ dry_run: bool = False,
92
+ no_sign: bool = False,
93
+ allow_version_mismatch: bool = False,
94
+ ) -> PipelineResult:
95
+ """profile_paths accepts one or more .mobileprovision files. A single
96
+ profile signs every target, exactly as before per-extension profiles
97
+ existed. Supplying more than one lets nested app extensions / watch apps
98
+ be matched and signed with their own profile by their own bundle id --
99
+ see signing/pipeline.py::sign_bundle and signing/profile.py::ProfilePool."""
100
+ provider = provider or LocalIdentityProvider()
101
+
102
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_") as tmp:
103
+ work_dir = Path(tmp)
104
+
105
+ # Stages 1-3: validate + extract, inventory, parse Info.plist
106
+ try:
107
+ validate_ipa_structure(ipa_path)
108
+ except IpaValidationError as e:
109
+ raise PipelineError(str(e)) from e
110
+ app_path = extract_ipa(ipa_path, work_dir / "extracted")
111
+ bundle = load_bundle(app_path)
112
+ validate_bundle(bundle)
113
+
114
+ # Stage 4: resolve applicable patch definitions (bundle_id + version match)
115
+ try:
116
+ definition = load_patch_definition(patch_definition_path)
117
+ except PatchLoadError as e:
118
+ raise PipelineError(str(e)) from e
119
+ matched = resolve_definitions(bundle, [definition], ignore_version=allow_version_mismatch)
120
+ ops = [op for d in matched for op in build_operations(d)]
121
+ if not ops:
122
+ # F1: a single user-supplied definition that matches nothing is
123
+ # almost always a mistake (typo'd bundle id or version range).
124
+ # Warn in dry-run so version-gate checks still work; refuse to
125
+ # produce an unpatched IPA in a real run.
126
+ warnings.warn(
127
+ f"patch definition '{patch_definition_path}' matches 0 patch operations for "
128
+ f"{bundle.bundle_id} v{bundle.version} (definition targets "
129
+ f"{definition.target.bundle_id}); nothing will be applied",
130
+ stacklevel=2,
131
+ )
132
+ if not dry_run:
133
+ raise PipelineError(
134
+ f"patch definition '{patch_definition_path}' matches no operations for "
135
+ f"{bundle.bundle_id} v{bundle.version} (definition targets "
136
+ f"{definition.target.bundle_id}); refusing to produce an unpatched IPA"
137
+ )
138
+
139
+ if (
140
+ allow_version_mismatch
141
+ and bundle.bundle_id == definition.target.bundle_id
142
+ and bundle.version != getattr(definition.target.version, "exact", None)
143
+ ):
144
+ warnings.warn(
145
+ f"version mismatch allowed: definition targets "
146
+ f"{definition.target.bundle_id} "
147
+ f"{getattr(definition.target.version, 'exact', '?')} but the IPA is "
148
+ f"{bundle.bundle_id} v{bundle.version}; hook verification below "
149
+ f"is the safety net",
150
+ stacklevel=2,
151
+ )
152
+
153
+ # Stage 4.5: verify declared runtime hooks against the actual binary --
154
+ # catches silent hook no-ops on version drift before anything mutates.
155
+ hook_report = _verify_definition_hooks(bundle, definition)
156
+ ctx = PatchContext(bundle=bundle, patch_source_dir=patch_definition_path.parent)
157
+
158
+ # Stage 5: dry-run gate -- hard fail before anything mutates
159
+ dry_results = dry_run_all(ops, ctx)
160
+ failed_dry = [r for r in dry_results if r.status != "dry_run_ok"]
161
+ if failed_dry:
162
+ raise PipelineError(
163
+ "patch dry-run validation failed: " + "; ".join(f"{r.op_id}: {r.message}" for r in failed_dry)
164
+ )
165
+
166
+ if dry_run:
167
+ manifest = Manifest.from_patch_results(
168
+ ipa_path, bundle.bundle_id, bundle.version, bundle.build, dry_results
169
+ )
170
+ manifest.hook_report = hook_report
171
+ return PipelineResult(manifest=manifest, output_path=None)
172
+
173
+ # Stages 6-8: apply resources -> binary patches -> dylib injection (fixed order)
174
+ results = apply_all(ops, ctx)
175
+ failed_apply = [r for r in results if r.status == "failed"]
176
+ if failed_apply:
177
+ raise PipelineError(
178
+ "patch application failed: " + "; ".join(f"{r.op_id}: {r.message}" for r in failed_apply)
179
+ )
180
+
181
+ # Stage 9: re-validate bundle consistency post-mutation
182
+ validate_bundle(bundle)
183
+
184
+ # Stage 10: emit manifest, before signing
185
+ manifest = Manifest.from_patch_results(ipa_path, bundle.bundle_id, bundle.version, bundle.build, results)
186
+ manifest.hook_report = hook_report
187
+
188
+ if no_sign:
189
+ # Unsigned output for AltStore-style installers, which perform
190
+ # their own signing (and profile generation) at install time.
191
+ # The payload is patched but carries no code signature, matching
192
+ # what AltStore Classic accepts as input.
193
+ repack_ipa(bundle.extraction_root, output_path)
194
+ validate_final_archive(output_path, work_dir / "final_check", expect_profile=False)
195
+ manifest.output_sha256 = sha256_of(output_path)
196
+ return PipelineResult(manifest=manifest, output_path=output_path)
197
+
198
+ try:
199
+ # Stage 11: load + validate provisioning profile(s)
200
+ profiles = load_profile_pool(profile_paths)
201
+ main_profile, _ = profiles.select_for(bundle.bundle_id)
202
+ validate_profile(main_profile, bundle.bundle_id)
203
+ manifest.profile = ProfileManifestEntry(
204
+ uuid=main_profile.uuid,
205
+ team_id=main_profile.team_identifier,
206
+ expiration=str(main_profile.expiration_date),
207
+ )
208
+
209
+ # Stages 12-13 (entitlement reconciliation + profile embedding, per
210
+ # target -- each profile-bearing target selects its own profile by
211
+ # its own bundle id) and stage 14 (recursive bottom-up codesign):
212
+ identity = resolve_identity(provider, identity_query)
213
+ sign_bundle(bundle, provider, identity, profiles)
214
+
215
+ # Stage 15: verify every signed bundle/dylib, then a final deep+strict pass on the app bundle
216
+ sign_paths = {sign_target_path(t) for t in bundle.executables}
217
+ sign_paths.discard(bundle.root)
218
+ verify_results = [provider.verify(p) for p in sign_paths]
219
+ verify_results.append(provider.verify(bundle.root, deep=True))
220
+ failed_verify = [v for v in verify_results if not v.ok]
221
+ if failed_verify:
222
+ raise PipelineError(
223
+ "codesign verification failed: " + "; ".join(f"{v.target}: {v.message}" for v in failed_verify)
224
+ )
225
+ except (ProfileError, SigningBackendError) as e:
226
+ raise PipelineError(str(e)) from e
227
+
228
+ # Stage 16: repackage
229
+ repack_ipa(bundle.extraction_root, output_path)
230
+
231
+ # Stage 17: final validation -- re-extract the produced IPA from scratch
232
+ validate_final_archive(output_path, work_dir / "final_check")
233
+
234
+ manifest.output_sha256 = sha256_of(output_path)
235
+ return PipelineResult(manifest=manifest, output_path=output_path, verify_results=verify_results)
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,111 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The only module in ipa_forge permitted to invoke codesign/security directly.
3
+
4
+ Per the architecture's hard constraint, Apple's code signature format is never
5
+ reimplemented -- everything here shells out to Apple's own tooling.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import plistlib
11
+ import re
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ class SigningBackendError(Exception):
19
+ pass
20
+
21
+
22
+ @dataclass
23
+ class Identity:
24
+ sha1: str
25
+ name: str
26
+
27
+
28
+ _IDENTITY_LINE = re.compile(r'^\s*\d+\)\s+([0-9A-Fa-f]{40})\s+"(.+)"$')
29
+
30
+
31
+ def list_identities() -> list[Identity]:
32
+ result = subprocess.run(
33
+ ["security", "find-identity", "-v", "-p", "codesigning"],
34
+ capture_output=True,
35
+ text=True,
36
+ check=False,
37
+ )
38
+ if result.returncode != 0:
39
+ raise SigningBackendError(f"security find-identity failed: {result.stderr}")
40
+
41
+ identities = []
42
+ for line in result.stdout.splitlines():
43
+ m = _IDENTITY_LINE.match(line)
44
+ if m:
45
+ identities.append(Identity(sha1=m.group(1), name=m.group(2)))
46
+ return identities
47
+
48
+
49
+ def decode_provisioning_profile(path: Path) -> dict[str, Any]:
50
+ """Decode a .mobileprovision (a CMS-signed plist) via `security cms -D -i`,
51
+ per Apple's own recommended inspection procedure."""
52
+ result = subprocess.run(
53
+ ["security", "cms", "-D", "-i", str(path)],
54
+ capture_output=True,
55
+ check=False,
56
+ )
57
+ if result.returncode != 0:
58
+ raise SigningBackendError(
59
+ f"failed to decode provisioning profile {path}: {result.stderr.decode(errors='replace')}"
60
+ )
61
+ return plistlib.loads(result.stdout)
62
+
63
+
64
+ def codesign_sign(target: Path, identity: str, entitlements_plist: Path) -> None:
65
+ result = subprocess.run(
66
+ [
67
+ "codesign",
68
+ "--force",
69
+ "--sign",
70
+ identity,
71
+ "--entitlements",
72
+ str(entitlements_plist),
73
+ str(target),
74
+ ],
75
+ capture_output=True,
76
+ text=True,
77
+ check=False,
78
+ )
79
+ if result.returncode != 0:
80
+ raise SigningBackendError(f"codesign failed for {target}: {result.stderr}")
81
+
82
+
83
+ def codesign_verify(target: Path, deep: bool = False) -> None:
84
+ args = ["codesign", "--verify"]
85
+ if deep:
86
+ args.append("--deep")
87
+ args += ["--strict", "--verbose=4", str(target)]
88
+ result = subprocess.run(args, capture_output=True, text=True, check=False)
89
+ if result.returncode != 0:
90
+ raise SigningBackendError(f"codesign verification failed for {target}: {result.stderr}")
91
+
92
+
93
+ def codesign_dump_entitlements(target: Path) -> dict[str, Any]:
94
+ # `:-` is deprecated by Apple in favor of plain `-` but is the only form
95
+ # that emits a directly-parseable plist to stdout on current codesign;
96
+ # `-` emits a human-readable dump instead. Revisit if Apple removes it.
97
+ result = subprocess.run(
98
+ ["codesign", "-d", "--entitlements", ":-", str(target)],
99
+ capture_output=True,
100
+ check=False,
101
+ )
102
+ stderr = result.stderr.decode(errors="replace")
103
+ if result.returncode != 0:
104
+ if "code object is not signed at all" in stderr:
105
+ # Expected for a freshly extracted IPA before our own signing pass
106
+ # has run -- not an error, just "no entitlements yet".
107
+ return {}
108
+ raise SigningBackendError(f"failed to dump entitlements for {target}: {stderr}")
109
+ if not result.stdout.strip():
110
+ return {}
111
+ return plistlib.loads(result.stdout)
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Recursive bottom-up signing orchestration + per-bundle provisioning-profile embedding."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import shutil
7
+ import warnings
8
+ from pathlib import Path
9
+
10
+ from ipa_forge.bundle.models import AppBundle, MachOTarget
11
+ from ipa_forge.signing.profile import ProfilePool
12
+ from ipa_forge.signing.provider import SigningProvider, SignResult
13
+ from ipa_forge.signing.reconcile import reconcile_entitlements
14
+
15
+ _BUNDLE_SUFFIX_BY_KIND = {"main": ".app", "framework": ".framework", "appex": ".appex", "watch_app": ".app"}
16
+
17
+ # Kinds that own their own Info.plist and can therefore carry their own
18
+ # embedded.mobileprovision. Frameworks/dylibs/other are signed but never
19
+ # embed a profile of their own.
20
+ _PROFILE_BEARING_KINDS = {"main", "appex", "watch_app"}
21
+
22
+
23
+ def sign_target_path(target: MachOTarget) -> Path:
24
+ """The path codesign should actually be pointed at for this target.
25
+
26
+ Bundle-kind executables (main app, frameworks, extensions, watch apps)
27
+ must be signed via their *bundle directory*, not the raw executable file
28
+ -- otherwise codesign never creates the bundle-level _CodeSignature/
29
+ CodeResources seal covering Info.plist and other resources, and a later
30
+ `--verify --strict` on the bundle fails with "a sealed resource is
31
+ missing or invalid". Flat dylibs (not wrapped in a bundle) are signed
32
+ directly.
33
+ """
34
+ suffix = _BUNDLE_SUFFIX_BY_KIND.get(target.kind)
35
+ if suffix is None:
36
+ return target.path
37
+ for parent in target.path.parents:
38
+ if parent.name.endswith(suffix):
39
+ return parent
40
+ return target.path
41
+
42
+
43
+ def embed_provisioning_profile(bundle_dir: Path, profile_path: Path) -> None:
44
+ dest = bundle_dir / "embedded.mobileprovision"
45
+ shutil.copyfile(profile_path, dest)
46
+
47
+
48
+ def sign_bundle(
49
+ bundle: AppBundle,
50
+ provider: SigningProvider,
51
+ identity: str,
52
+ profiles: ProfilePool,
53
+ ) -> list[SignResult]:
54
+ """Sign every executable bottom-up. `bundle.executables` is already ordered
55
+ deepest-nested-first by the inventory walker, so a naive linear pass over
56
+ it satisfies "sign dependencies before dependents" without hard-coding a
57
+ bundle layout.
58
+
59
+ Each profile-bearing target (main app, app extension, watch app) gets its
60
+ own profile selected from the pool by its own bundle id, embedded into
61
+ its own bundle directory, and its entitlements reconciled against that
62
+ profile independently. Frameworks/dylibs/other have no bundle id of
63
+ their own and are reconciled against the main app's selected profile --
64
+ this exactly matches the single-profile behavior this project shipped
65
+ with before per-extension profiles existed, so a single supplied profile
66
+ still signs everything as before; only supplying multiple profiles
67
+ changes anything.
68
+ """
69
+ main_profile, main_profile_path = profiles.select_for(bundle.bundle_id)
70
+
71
+ results: list[SignResult] = []
72
+ for target in bundle.executables:
73
+ if target.kind in _PROFILE_BEARING_KINDS:
74
+ if target.bundle_id:
75
+ profile, profile_path = profiles.select_for(target.bundle_id)
76
+ # F6: legacy single-profile mode uses the lone profile for
77
+ # every target even when it doesn't authorize this one -- warn
78
+ # so a mismatched extension doesn't silently ship uninstallable.
79
+ if len(profiles.entries) == 1 and profile.bundle_id_pattern not in ("*", target.bundle_id):
80
+ warnings.warn(
81
+ f"profile '{profile.name}' does not authorize '{target.bundle_id}' but will be "
82
+ "embedded anyway (single-profile mode); supply a matching --profile for it",
83
+ stacklevel=2,
84
+ )
85
+ else:
86
+ # This bundle's own Info.plist couldn't be read -- fall back
87
+ # to the main app's profile rather than leaving it unembedded.
88
+ profile, profile_path = main_profile, main_profile_path
89
+ embed_provisioning_profile(sign_target_path(target), profile_path)
90
+ else:
91
+ profile = main_profile
92
+
93
+ original_entitlements = provider.dump_entitlements(target.path)
94
+ reconciled = reconcile_entitlements(original_entitlements, profile.entitlements)
95
+ result = provider.sign(sign_target_path(target), reconciled, identity)
96
+ results.append(result)
97
+ if target.kind == "main":
98
+ bundle.entitlements = reconciled
99
+ return results