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.
- ipa_forge/__init__.py +1 -0
- ipa_forge/altstore/__init__.py +1 -0
- ipa_forge/altstore/source.py +44 -0
- ipa_forge/analysis/__init__.py +18 -0
- ipa_forge/analysis/classdump.py +103 -0
- ipa_forge/analysis/diff.py +127 -0
- ipa_forge/analysis/security.py +72 -0
- ipa_forge/analysis/strings.py +40 -0
- ipa_forge/analysis/symbols.py +42 -0
- ipa_forge/analysis/type_encoding.py +164 -0
- ipa_forge/bundle/__init__.py +1 -0
- ipa_forge/bundle/inventory.py +96 -0
- ipa_forge/bundle/ipa.py +81 -0
- ipa_forge/bundle/models.py +58 -0
- ipa_forge/bundle/plist.py +18 -0
- ipa_forge/cli/__init__.py +1 -0
- ipa_forge/cli/analysis.py +225 -0
- ipa_forge/cli/common.py +41 -0
- ipa_forge/cli/hooks.py +357 -0
- ipa_forge/cli/main.py +170 -0
- ipa_forge/gui/__init__.py +1 -0
- ipa_forge/gui/analysis.html +229 -0
- ipa_forge/gui/app.py +304 -0
- ipa_forge/gui/index.html +244 -0
- ipa_forge/hooks/__init__.py +0 -0
- ipa_forge/hooks/scan.py +81 -0
- ipa_forge/hooks/verify.py +348 -0
- ipa_forge/machO/__init__.py +1 -0
- ipa_forge/machO/arch.py +94 -0
- ipa_forge/machO/detect.py +50 -0
- ipa_forge/machO/injector.py +99 -0
- ipa_forge/machO/objc.py +534 -0
- ipa_forge/manifest.py +74 -0
- ipa_forge/patch/__init__.py +1 -0
- ipa_forge/patch/base.py +48 -0
- ipa_forge/patch/binary.py +131 -0
- ipa_forge/patch/dylib.py +59 -0
- ipa_forge/patch/engine.py +48 -0
- ipa_forge/patch/loader.py +88 -0
- ipa_forge/patch/paths.py +19 -0
- ipa_forge/patch/plist.py +61 -0
- ipa_forge/patch/resolver.py +37 -0
- ipa_forge/patch/resource.py +135 -0
- ipa_forge/patch/schema.py +118 -0
- ipa_forge/patch/version.py +25 -0
- ipa_forge/patches.py +76 -0
- ipa_forge/pipeline.py +235 -0
- ipa_forge/signing/__init__.py +1 -0
- ipa_forge/signing/backend.py +111 -0
- ipa_forge/signing/pipeline.py +99 -0
- ipa_forge/signing/profile.py +131 -0
- ipa_forge/signing/provider.py +113 -0
- ipa_forge/signing/reconcile.py +27 -0
- ipa_forge/validators/__init__.py +1 -0
- ipa_forge/validators/archive_validator.py +25 -0
- ipa_forge/validators/bundle_validator.py +27 -0
- ipa_forge/validators/ipa_validator.py +39 -0
- ipa_forge-0.1.0.dist-info/METADATA +914 -0
- ipa_forge-0.1.0.dist-info/RECORD +62 -0
- ipa_forge-0.1.0.dist-info/WHEEL +4 -0
- ipa_forge-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|