Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
# Kanade — Kernel-Agnostic Native ARM64 Dylib Embedder
<h1 align="center">Kanade</h1>

<p align="center">
<img src="icon.webp" alt="Kanade icon" width="180" />
</p>

<p align="center">
<em><strong>K</strong>ernel-<strong>A</strong>gnostic <strong>N</strong>ative
<strong>A</strong>RM64 <strong>D</strong>ylib <strong>E</strong>mbedder — host-side
Mach-O patching for <a href="https://github.com/IPA-Patch">IPA-Patch</a> tweaks.</em>
</p>

<p align="center">
<img alt="license" src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" />
Expand Down Expand Up @@ -72,6 +82,35 @@ assets/*/dump.cs.index.json
.cache/
```

## The input IPA must be decrypted

`build_patched_ipa.sh --input` (and `verify_sites --ipa`) both expect a
**decrypted** IPA — the recipe pipeline cannot operate on anything else.

Every App Store app ships **FairPlay-encrypted**: the main executable and its
frameworks are stored as ciphertext on disk, and the kernel only decrypts
them in memory at launch, on the device the purchase is tied to. Kanade
patches the target Mach-O **on disk, before signing** — it locates each hook
site by its machine-code prologue and overwrites it with `B <cave>`. Against
an encrypted binary those bytes are meaningless ciphertext, so the recipe can
neither find nor patch them. The input **must be decrypted first**.

A *decrypted* IPA is one whose Mach-O slices have had the FairPlay layer
stripped — the `cryptid` flag cleared and the encrypted pages dumped in their
plaintext form. You make one **once per app version**, on a device that can
already run the target app:

- **[TrollDecrypt](https://github.com/donato-fiore/TrollDecrypt)** (TrollStore)
— dumps a decrypted `.ipa` straight to the Files app. Easiest.
- **[palera1n](https://palera.in/) + Filza** — jailbreak, then pull the
decrypted bundle off disk.
- Any on-device FairPlay dumper works; the only requirement is that the
resulting `.ipa` is decrypted.

Drop the result at `assets/<version>/AppName-<version>.ipa` (gitignored).
Kanade ships only the tooling — never the target app — so you always supply
your own legally-obtained, decrypted copy.

## Usage

Add both submodules to your consumer project:
Expand Down
Binary file added icon.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ipa-patch-shared"
version = "0.1.1"
version = "0.1.2"
description = "Kanade — static-patch tooling for IPA-Patch projects (arm64 encoder, Mach-O ops, IPA pipeline)."
readme = "README.md"
requires-python = ">=3.12"
Expand Down
87 changes: 87 additions & 0 deletions tests/test_recipeload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Tests for ``tools.recipeload.load_recipe``.

The loader resolves a ``--recipe`` value to a module. Its contract has
three branches that every patch/verify tool depends on, plus one safety
property that previously bit us:

* a dotted name imports as-is,
* a bare name resolves ``recipes.<name>`` first,
* a bare name that is itself a package (``recipes``) falls back to the
bare import, and
* a ModuleNotFoundError raised *inside* an existing recipe is NOT
swallowed as "recipe not found" — the real cause must surface.
"""

from __future__ import annotations

import sys
import textwrap
from pathlib import Path

import pytest

from tools.recipeload import load_recipe


def _write(pkg_root: Path, relpath: str, body: str) -> None:
target = pkg_root / relpath
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(textwrap.dedent(body))


@pytest.fixture
def recipes_on_path(tmp_path, monkeypatch):
"""Build a throwaway ``recipes/`` package on a fresh sys.path entry."""
monkeypatch.syspath_prepend(str(tmp_path))
_write(tmp_path, "recipes/__init__.py", 'DYLIB_PATH = "from-package"\n')
_write(tmp_path, "recipes/kioutest.py", 'DYLIB_PATH = "from-submodule"\n')
# A recipe that exists but imports a module that does not.
_write(
tmp_path,
"recipes/broken.py",
"import a_module_that_does_not_exist # noqa: F401\n",
)
# Drop any cached imports from a previous test run.
for mod in [m for m in sys.modules if m == "recipes" or m.startswith("recipes.")]:
monkeypatch.delitem(sys.modules, mod, raising=False)
return tmp_path


def test_dotted_name_imports_as_is(recipes_on_path):
mod = load_recipe("recipes.kioutest")
assert mod.DYLIB_PATH == "from-submodule"


def test_bare_name_prefers_recipes_submodule(recipes_on_path):
mod = load_recipe("kioutest")
assert mod.DYLIB_PATH == "from-submodule"


def test_bare_package_name_falls_back_to_direct_import(recipes_on_path):
# "recipes" -> "recipes.recipes" (absent) -> "recipes" (the package)
mod = load_recipe("recipes")
assert mod.DYLIB_PATH == "from-package"


def test_inner_import_error_is_not_masked(recipes_on_path):
# The recipe exists; the missing module is inside it. We must see the
# real ModuleNotFoundError, not a misleading "recipe not found".
with pytest.raises(ModuleNotFoundError) as exc:
load_recipe("recipes.broken")
assert exc.value.name == "a_module_that_does_not_exist"


def test_missing_recipe_raises_systemexit(recipes_on_path):
with pytest.raises(SystemExit) as exc:
load_recipe("does_not_exist_anywhere")
assert "does_not_exist_anywhere" in str(exc.value)


def test_dotted_name_missing_intermediate_package_is_systemexit(recipes_on_path):
# A multi-dot path whose intermediate package is absent raises
# ModuleNotFoundError(name="recipes.missing"); that ancestor of the
# candidate must be treated as "recipe not found" (clean SystemExit),
# not re-raised as a traceback.
with pytest.raises(SystemExit) as exc:
load_recipe("recipes.missing.sub")
assert "recipes.missing.sub" in str(exc.value)
28 changes: 2 additions & 26 deletions tools/patch_macho.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,36 +29,12 @@
from __future__ import annotations

import argparse
import importlib
import os
import sys

from tools.caves import apply_patches
from tools.machoops import add_lc_load_dylib, assert_slot_in_bss, reserve_hook_slot


def _load_recipe(name: str):
"""Import a recipe module by short name (e.g. ``kioukifexporter``)
or fully-qualified module path (e.g. ``recipes.kioukifexporter``).

Bare names without a dot are tried in two orders:
1. ``recipes.<name>`` — the common case (a sub-module of the
consumer's ``recipes/`` package, e.g. ``recipes.kioukifexporter``)
2. ``<name>`` directly — handles the case where the consumer exposes
the entire recipe surface through a package ``__init__.py``
(e.g. ``--recipe recipes`` maps to the ``recipes`` package itself)
Fully-qualified names (containing a dot) are imported as-is.
"""
candidates = [f"recipes.{name}", name] if "." not in name else [name]
last_exc: ImportError | None = None
for candidate in candidates:
try:
return importlib.import_module(candidate)
except ImportError as e:
last_exc = e
raise SystemExit(
f"error: failed to import recipe {name!r}: {last_exc}"
) from last_exc
from tools.recipeload import load_recipe


def main() -> int:
Expand Down Expand Up @@ -95,7 +71,7 @@ def main() -> int:
print(f"error: not a file: {args.target}", file=sys.stderr)
return 2

recipe = _load_recipe(args.recipe)
recipe = load_recipe(args.recipe)
target_basename = getattr(recipe, "TARGET_BASENAME", None)
if target_basename and not args.skip_target_check:
if os.path.basename(args.target) != target_basename:
Expand Down
10 changes: 3 additions & 7 deletions tools/patch_plist.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
from __future__ import annotations

import argparse
import importlib
import plistlib
import sys

from tools.recipeload import load_recipe


def _detect_format(raw: bytes) -> plistlib.PlistFormat:
if raw.startswith(b"bplist"):
Expand All @@ -51,12 +52,7 @@ def _parse_kv(s: str) -> tuple[str, object]:


def _load_recipe_keys(name: str) -> dict:
if "." not in name:
name = f"recipes.{name}"
try:
mod = importlib.import_module(name)
except ImportError as e:
raise SystemExit(f"error: failed to import recipe {name!r}: {e}") from e
mod = load_recipe(name)
keys = getattr(mod, "PLIST_KEYS", None)
if not isinstance(keys, dict):
raise SystemExit(
Expand Down
46 changes: 46 additions & 0 deletions tools/recipeload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Shared recipe-module loader for the patch/verify tools.

A recipe is a Python module on ``PYTHONPATH`` describing what to patch.
Every tool that takes ``--recipe`` resolves the name the same way through
``load_recipe`` so the behaviour can never drift between tools again.

Resolution order for a ``--recipe`` value:
* a fully-qualified module path (contains a dot) is imported as-is, e.g.
``recipes.kioukifexporter``;
* a bare name is tried as ``recipes.<name>`` first (the common case — a
sub-module of the consumer's ``recipes/`` package), then as ``<name>``
directly, which handles consumers that expose the whole recipe surface
through a ``recipes`` package ``__init__.py`` (``--recipe recipes``).

Only a genuinely-absent candidate triggers the fallback: a
``ModuleNotFoundError`` raised from *inside* a recipe that does exist (a
typo'd import within it) is re-raised unchanged, so a real error is never
masked as "recipe not found".
"""

from __future__ import annotations

import importlib
from types import ModuleType


def load_recipe(name: str) -> ModuleType:
candidates = [name] if "." in name else [f"recipes.{name}", name]
last_missing: ModuleNotFoundError | None = None
for candidate in candidates:
try:
return importlib.import_module(candidate)
except ModuleNotFoundError as e:
# Swallow only "this candidate does not exist", which means the
# missing module is the candidate itself or one of its ancestor
# packages (e.g. candidate "recipes.foo.bar" with "recipes.foo"
# absent). If e.name is a descendant or unrelated module, the
# candidate imported but something *inside* it is missing — re-raise
# so the real cause is not hidden as "recipe not found".
missing = e.name or ""
if candidate == missing or candidate.startswith(f"{missing}."):
last_missing = e
continue
raise
raise SystemExit(f"error: failed to import recipe {name!r}: {last_missing}")
10 changes: 3 additions & 7 deletions tools/verify_lc_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,13 @@
from __future__ import annotations

import argparse
import importlib
import sys

from tools.recipeload import load_recipe


def _needle_from_recipe(name: str) -> str:
if "." not in name:
name = f"recipes.{name}"
try:
mod = importlib.import_module(name)
except ImportError as e:
raise SystemExit(f"error: failed to import recipe {name!r}: {e}") from e
mod = load_recipe(name)
needle = getattr(mod, "DYLIB_PATH", None)
if not needle:
raise SystemExit(f"error: recipe {name!r} does not define DYLIB_PATH")
Expand Down
19 changes: 3 additions & 16 deletions tools/verify_sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@
from __future__ import annotations

import argparse
import importlib
import json
import os
import sys
import zipfile
from typing import Iterable

from tools.recipeload import load_recipe

# ---------------------------------------------------------------------------
# Site label parsing.
#
Expand Down Expand Up @@ -210,22 +211,8 @@ def read_prologue(args: argparse.Namespace, offset: int) -> bytes | None:
# ---------------------------------------------------------------------------


def _load_recipe(name: str):
# If the caller passed a bare package name (e.g. "recipes") or a short
# name without a dot (e.g. "kiouenginebridge"), try importing it directly
# first; if that fails, fall back to prefixing "recipes." so that short
# names like "kiouenginebridge" still resolve to "recipes.kiouenginebridge".
candidates = [name] if "." in name else [name, f"recipes.{name}"]
for candidate in candidates:
try:
return importlib.import_module(candidate)
except ImportError:
continue
raise SystemExit(f"error: failed to import recipe {name!r}")


def verify(args: argparse.Namespace) -> int:
recipe = _load_recipe(args.recipe)
recipe = load_recipe(args.recipe)
# Recipes across IPA-Patch siblings use slightly different row shapes:
# * KiouEditor / KiouKifExporter: 4-tuple (slot, off, prologue, label)
# * KiouForge: 6-tuple (slot, off, prologue, kind,
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading