diff --git a/.github/workflows/dry-build.yml b/.github/workflows/dry-build.yml index b0d50cf4..9e9883d4 100644 --- a/.github/workflows/dry-build.yml +++ b/.github/workflows/dry-build.yml @@ -63,3 +63,14 @@ jobs: CGO_ENABLED: 1 CGO_CFLAGS: -mmacosx-version-min=10.15 CGO_LDFLAGS: -mmacosx-version-min=10.15 + build_ios: + name: Build for iOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + cache: true + - run: brew install ldid cmake + - run: ./tools/build-ios.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36e475b4..2b79c15a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,6 +108,48 @@ jobs: name: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }} path: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }} if-no-files-found: error + build_ios: + name: Build for iOS + runs-on: macos-latest + needs: [get_version, test] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + cache: true + - run: brew install ldid cmake + - run: ./tools/build-ios.sh "ipatool-$VERSION-ios-arm64" + env: + VERSION: ${{ needs.get_version.outputs.version }} + - uses: actions/upload-artifact@v4 + with: + name: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64 + path: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64 + if-no-files-found: error + release_ios: + name: Release for iOS + runs-on: ubuntu-latest + needs: [get_version, build_ios, release_windows] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64 + path: bin + - run: chmod +x "bin/$FILE" && tar -czvf "$FILE.tar.gz" "bin/$FILE" + env: + FILE: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64 + - run: ./tools/sha256sum.sh "$TARBALL" > "$TARBALL.sha256sum" + env: + TARBALL: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64.tar.gz + - uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64.* + tag: ${{ github.ref }} + overwrite: false + file_glob: true release_windows: name: Release for Windows runs-on: ubuntu-latest diff --git a/README.md b/README.md index b442acf2..66233d1c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## Requirements -- A supported operating system (macOS, Linux or Windows). +- A supported operating system (macOS, Linux, Windows or iOS). - An Apple Account already configured to use the App Store. ## Installation diff --git a/cmd/common.go b/cmd/common.go index 14dc01cd..da3c4186 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -61,7 +61,7 @@ func newCookieJar(stateDirectory string) http.CookieJar { // newKeychain returns a new keychain instance. func newKeychain(stateDirectory string, logger log.Logger, interactive bool) keychain.Keychain { - ring := util.Must(keyring.Open(keyring.Config{ + ring := util.Must(openKeyring(keyring.Config{ AllowedBackends: []keyring.BackendType{ keyring.KeychainBackend, keyring.SecretServiceBackend, diff --git a/cmd/keyring_default.go b/cmd/keyring_default.go new file mode 100644 index 00000000..a8ac6ae1 --- /dev/null +++ b/cmd/keyring_default.go @@ -0,0 +1,12 @@ +//go:build !ios + +package cmd + +import ( + "github.com/byteness/keyring" + "github.com/majd/ipatool/v2/pkg/keychain" +) + +func openKeyring(config keyring.Config) (keychain.Keyring, error) { + return keyring.Open(config) //nolint:wrapcheck +} diff --git a/cmd/keyring_ios.go b/cmd/keyring_ios.go new file mode 100644 index 00000000..85650ccb --- /dev/null +++ b/cmd/keyring_ios.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + gokeychain "github.com/byteness/go-keychain" + "github.com/byteness/keyring" + "github.com/majd/ipatool/v2/pkg/keychain" +) + +// iosKeyring fixes the update query in keyring v1.9.0, which includes attributes +// that SecItemUpdate rejects on iOS (kSecMatchLimit and kSecReturnAttributes). +type iosKeyring struct { + keyring.Keyring + service string +} + +func openKeyring(config keyring.Config) (keychain.Keyring, error) { + ring, err := keyring.Open(config) + if err != nil { + return nil, fmt.Errorf("open iOS keyring: %w", err) + } + + return &iosKeyring{Keyring: ring, service: config.ServiceName}, nil +} + +func (k *iosKeyring) Set(item keyring.Item) error { + query := gokeychain.NewItem() + query.SetSecClass(gokeychain.SecClassGenericPassword) + query.SetService(k.service) + query.SetAccount(item.Key) + + attributes := gokeychain.NewItem() + attributes.SetData(item.Data) + attributes.SetLabel(item.Label) + attributes.SetDescription(item.Description) + + err := gokeychain.UpdateItem(query, attributes) + if err == gokeychain.ErrorItemNotFound { + return k.Keyring.Set(item) //nolint:wrapcheck + } + + if err != nil { + return fmt.Errorf("update iOS keyring item: %w", err) + } + + return nil +} diff --git a/cmd/keyring_ios_test.go b/cmd/keyring_ios_test.go new file mode 100644 index 00000000..8dde8863 --- /dev/null +++ b/cmd/keyring_ios_test.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/byteness/keyring" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("iOS keyring", func() { + It("persists and updates credentials without invalid SecItemUpdate parameters", func() { + ring, err := openKeyring(keyring.Config{ + AllowedBackends: []keyring.BackendType{keyring.KeychainBackend}, + ServiceName: fmt.Sprintf("ipatool-test-%d", time.Now().UnixNano()), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { Expect(ring.Remove("account")).To(Succeed()) }) + Expect(ring.Set(keyring.Item{Key: "account", Data: []byte("first"), Label: "ipatool"})).To(Succeed()) + Expect(ring.Set(keyring.Item{Key: "account", Data: []byte("updated"), Label: "ipatool"})).To(Succeed()) + item, err := ring.Get("account") + Expect(err).NotTo(HaveOccurred()) + Expect(item.Data).To(Equal([]byte("updated"))) + Expect(item.Label).To(Equal("ipatool")) + }) +}) diff --git a/go.mod b/go.mod index c798f336..68d82d20 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/avast/retry-go v3.0.0+incompatible github.com/blacktop/go-macho v1.1.282 github.com/bodgit/sevenzip v1.6.3 + github.com/byteness/go-keychain v0.0.0-20191008050251-8e49817e8af4 github.com/byteness/keyring v1.9.0 github.com/ebitengine/purego v0.10.2 github.com/juju/persistent-cookiejar v1.0.0 @@ -29,7 +30,6 @@ require ( github.com/blacktop/go-dwarf v1.0.14 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/windows v1.0.1 // indirect - github.com/byteness/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/byteness/go-libsecret v0.0.0-20260108215642-107379d3dee0 // indirect github.com/byteness/percent v0.2.2 // indirect github.com/danieljoos/wincred v1.2.3 // indirect diff --git a/internal/sap/unicorn/cache.go b/internal/sap/unicorn/cache.go index 73785028..d2d14465 100644 --- a/internal/sap/unicorn/cache.go +++ b/internal/sap/unicorn/cache.go @@ -12,61 +12,11 @@ import ( "net/http" "os" "path/filepath" - "runtime" "strings" - "time" ) const maxArtifactSize = 64 << 20 -var artifactHTTPClient = &http.Client{Timeout: 2 * time.Minute} - -type runtimePaths struct { - library string - dependencies []string -} - -func cachedRuntimePaths(ctx context.Context) (runtimePaths, error) { - goos := runtime.GOOS - if goos == "linux" && linuxUsesMusl() { - goos = "linux-musl" - } - - selected, err := artifactFor(goos, runtime.GOARCH) - if err != nil { - return runtimePaths{}, err - } - - cache, err := os.UserCacheDir() - if err != nil { - return runtimePaths{}, fmt.Errorf("locate user cache: %w", err) - } - - root := filepath.Join(cache, "ipatool", "unicorn", unicornVersion) - paths := runtimePaths{dependencies: make([]string, 0, len(selected.dependencies))} - - for _, dependency := range selected.dependencies { - path, err := ensureLibrary(ctx, root, dependency, artifactHTTPClient) - if err != nil { - return runtimePaths{}, err - } - - paths.dependencies = append(paths.dependencies, path) - } - - paths.library, err = ensureLibrary(ctx, root, selected, artifactHTTPClient) - if err != nil { - return runtimePaths{}, err - } - - paths.library, err = prepareRuntimeLibrary(paths.library) - if err != nil { - return runtimePaths{}, err - } - - return paths, nil -} - func linuxUsesMusl() bool { return linuxUsesMuslFor("/proc/self/exe", muslLoaderInstalled) } diff --git a/internal/sap/unicorn/cache_runtime.go b/internal/sap/unicorn/cache_runtime.go new file mode 100644 index 00000000..9451e4c5 --- /dev/null +++ b/internal/sap/unicorn/cache_runtime.go @@ -0,0 +1,61 @@ +//go:build !ios + +package unicorn + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "time" +) + +var artifactHTTPClient = &http.Client{Timeout: 2 * time.Minute} + +type runtimePaths struct { + library string + dependencies []string +} + +func cachedRuntimePaths(ctx context.Context) (runtimePaths, error) { + goos := runtime.GOOS + if goos == "linux" && linuxUsesMusl() { + goos = "linux-musl" + } + + selected, err := artifactFor(goos, runtime.GOARCH) + if err != nil { + return runtimePaths{}, err + } + + cache, err := os.UserCacheDir() + if err != nil { + return runtimePaths{}, fmt.Errorf("locate user cache: %w", err) + } + + root := filepath.Join(cache, "ipatool", "unicorn", unicornVersion) + paths := runtimePaths{dependencies: make([]string, 0, len(selected.dependencies))} + + for _, dependency := range selected.dependencies { + path, err := ensureLibrary(ctx, root, dependency, artifactHTTPClient) + if err != nil { + return runtimePaths{}, err + } + + paths.dependencies = append(paths.dependencies, path) + } + + paths.library, err = ensureLibrary(ctx, root, selected, artifactHTTPClient) + if err != nil { + return runtimePaths{}, err + } + + paths.library, err = prepareRuntimeLibrary(paths.library) + if err != nil { + return runtimePaths{}, err + } + + return paths, nil +} diff --git a/internal/sap/unicorn/library_ios.go b/internal/sap/unicorn/library_ios.go new file mode 100644 index 00000000..f9b8fff4 --- /dev/null +++ b/internal/sap/unicorn/library_ios.go @@ -0,0 +1,22 @@ +package unicorn + +import ( + "context" + "fmt" + + "github.com/ebitengine/purego" +) + +// iOS builds statically link Unicorn because the desktop runtime artifacts +// cannot be loaded on iOS. tools/build-ios.sh exports its symbols for purego. +func openLibrary(ctx context.Context) (library, error) { + if err := ctx.Err(); err != nil { + return library{}, fmt.Errorf("load Unicorn library: %w", err) + } + + if _, err := purego.Dlsym(purego.RTLD_DEFAULT, "uc_version"); err != nil { + return library{}, fmt.Errorf("unicorn is not linked; build with tools/build-ios.sh: %w", err) + } + + return library{handle: purego.RTLD_DEFAULT, close: func() error { return nil }}, nil +} diff --git a/internal/sap/unicorn/library_unix.go b/internal/sap/unicorn/library_unix.go index ce918e82..50bb9055 100644 --- a/internal/sap/unicorn/library_unix.go +++ b/internal/sap/unicorn/library_unix.go @@ -1,4 +1,4 @@ -//go:build darwin || linux +//go:build (darwin && !ios) || linux package unicorn diff --git a/internal/sap/unicorn/runtime_library_default.go b/internal/sap/unicorn/runtime_library_default.go index e8b19f81..7d603fe2 100644 --- a/internal/sap/unicorn/runtime_library_default.go +++ b/internal/sap/unicorn/runtime_library_default.go @@ -1,4 +1,4 @@ -//go:build !windows || !arm64 +//go:build !ios && (!windows || !arm64) package unicorn diff --git a/resources/ios-entitlements.plist b/resources/ios-entitlements.plist new file mode 100644 index 00000000..441964df --- /dev/null +++ b/resources/ios-entitlements.plist @@ -0,0 +1,14 @@ + + + + + application-identifier + dev.majd.ipatool + com.apple.private.security.no-container + + keychain-access-groups + + dev.majd.ipatool + + + diff --git a/tools/build-ios.sh b/tools/build-ios.sh new file mode 100755 index 00000000..cd833bf0 --- /dev/null +++ b/tools/build-ios.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -eu + +# Build a standalone executable for jailbroken arm64 devices running iOS 15+. +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +OUTPUT=${1:-ipatool-ios-arm64} +VERSION=${VERSION:-dev} + +for TOOL in ldid cmake; do + command -v "$TOOL" >/dev/null 2>&1 || { + echo "$TOOL is required; install it with 'brew install ldid cmake'." >&2 + exit 1 + } +done + +SDKROOT=$(xcrun --sdk iphoneos --show-sdk-path) +CC=$(xcrun --sdk iphoneos --find clang) +export SDKROOT CC +export IPHONEOS_DEPLOYMENT_TARGET=15.0 +export GOOS=ios GOARCH=arm64 CGO_ENABLED=1 + +BUILD_DIR=$(mktemp -d /tmp/ipatool-ios.XXXXXX) +trap 'rm -rf "$BUILD_DIR"' EXIT HUP INT TERM + +# Keep this version in sync with internal/sap/unicorn/artifact.go. +curl -fL --retry 3 https://github.com/unicorn-engine/unicorn/archive/refs/tags/2.1.4.tar.gz -o "$BUILD_DIR/unicorn.tar.gz" +echo "ea8863f095a0136388694e5a6063afd9bb7650e30243dd6251af59c5ce5601f4 $BUILD_DIR/unicorn.tar.gz" | shasum -a 256 -c - +tar -xzf "$BUILD_DIR/unicorn.tar.gz" -C "$BUILD_DIR" +# Use iOS W^X memory protection for Unicorn, including nested Go callbacks. +patch -d "$BUILD_DIR/unicorn-2.1.4" -p1 < "$ROOT/tools/patches/unicorn-ios.patch" +ARCHFLAGS='-arch arm64' cmake -S "$BUILD_DIR/unicorn-2.1.4" -B "$BUILD_DIR/build" \ + -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT="$SDKROOT" \ + -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + -DCMAKE_C_FLAGS='-target arm64-apple-ios15.0' -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF -DUNICORN_ARCH=x86 -DUNICORN_BUILD_TESTS=OFF -DUNICORN_INSTALL=OFF +cmake --build "$BUILD_DIR/build" --parallel "$(sysctl -n hw.ncpu)" + +# purego resolves the statically linked Unicorn API through dlsym. +export CGO_LDFLAGS="${CGO_LDFLAGS:-} -Wl,-force_load,$BUILD_DIR/build/libunicorn.a -Wl,-export_dynamic" +cd "$ROOT" +go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o "$OUTPUT" . +ldid -S"$ROOT/resources/ios-entitlements.plist" "$OUTPUT" diff --git a/tools/patches/unicorn-ios.patch b/tools/patches/unicorn-ios.patch new file mode 100644 index 00000000..5f834734 --- /dev/null +++ b/tools/patches/unicorn-ios.patch @@ -0,0 +1,76 @@ +--- a/qemu/configure ++++ b/qemu/configure +@@ -2152,7 +2152,7 @@ + if [ "$darwin" = "yes" ] ; then + cat > $TMPC << EOF + #include +-int main() { pthread_jit_write_protect_supported_np(); return 0;} ++int main() { pthread_jit_write_protect_supported_np(); pthread_jit_write_protect_np(0); return 0;} + EOF + if ! compile_prog ""; then + have_pthread_jit_protect='no' +--- a/qemu/include/tcg/tcg-apple-jit.h ++++ b/qemu/include/tcg/tcg-apple-jit.h +@@ -104,13 +104,11 @@ + #endif + + +-#if defined(__APPLE__) && defined(HAVE_PTHREAD_JIT_PROTECT) && (defined(__arm__) || defined(__aarch64__)) ++#if defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) + +-/* write protect enable = write disable */ +-static inline void jit_write_protect(int enabled) +-{ +- return pthread_jit_write_protect_np(enabled); +-} ++struct uc_struct; ++void ipatool_jit_write_protect(struct uc_struct *uc, int enabled); ++#define jit_write_protect(enabled) ipatool_jit_write_protect(uc, enabled) + + #define JIT_CALLBACK_GUARD(x) \ + { \ +--- a/qemu/accel/tcg/translate-all.c ++++ b/qemu/accel/tcg/translate-all.c +@@ -1019,7 +1019,7 @@ + static inline void *alloc_code_gen_buffer(struct uc_struct *uc) + { + TCGContext *tcg_ctx = uc->tcg_ctx; +- int prot = PROT_WRITE | PROT_READ | PROT_EXEC; ++ int prot = PROT_WRITE | PROT_READ; + int flags = MAP_PRIVATE | MAP_ANONYMOUS; + size_t size = tcg_ctx->code_gen_buffer_size; + void *buf; +@@ -2172,7 +2172,20 @@ + } + + +-#if defined(__APPLE__) && defined(HAVE_PTHREAD_JIT_PROTECT) && (defined(__arm__) || defined(__aarch64__)) ++/* iOS CLI processes cannot use MAP_JIT outside an app sandbox. Toggle the ++ * translation buffer between writable and executable instead of using RWX. */ ++void ipatool_jit_write_protect(struct uc_struct *uc, int enabled) ++{ ++ TCGContext *ctx = uc->tcg_ctx; ++ if (ctx && ctx->initial_buffer && ++ mprotect(ctx->initial_buffer, ctx->initial_buffer_size, ++ PROT_READ | (enabled ? PROT_EXEC : PROT_WRITE)) != 0) { ++ perror("protect Unicorn translation buffer"); ++ abort(); ++ } ++} ++ ++#if defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) + static bool tb_exec_is_locked(struct uc_struct *uc) + { + return uc->current_executable; +--- a/uc.c ++++ b/uc.c +@@ -35,8 +35,7 @@ + static uc_err uc_snapshot(uc_engine *uc); + static uc_err uc_restore_latest_snapshot(uc_engine *uc); + +-#if defined(__APPLE__) && defined(HAVE_PTHREAD_JIT_PROTECT) && \ +- (defined(__arm__) || defined(__aarch64__)) ++#if defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) + static void save_jit_state(uc_engine *uc) + { + if (!uc->nested) {