From 8d14bbe953a2b8f5a921f730919c485b4d6a0529 Mon Sep 17 00:00:00 2001 From: Rob Lazzurs Date: Wed, 21 Oct 2026 22:11:47 +0000 Subject: [PATCH 1/9] Run Apple's macOS CoreADI under an x86_64 emulator, as libmacadi.so A C++ port of sacca97/os-findmy-e's adi-core (MIT, see macadi/NOTICE.md): a Mach-O loader, a Unicorn x86_64 VM with the few pieces of macOS userland the library calls, and the CoreADI dispatcher framing. Unicorn 2.1.4 (GPL-2.0) is fetched at build time, pinned by SHA-256, and linked only into libmacadi.so. The library itself is never in the repository or the build. In iTunes 12.8.3 for Mac the package holding it is stored uncompressed and contiguous, so it is one HTTP range request of 3.5 MB; scripts/locate_mac_coreadi.py re-derives the offsets and checks both pinned hashes, and macadi-host.yml builds the core for the host and loads the library into it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012JDrrt9ff7WwPepHs6B2Gx --- .github/workflows/macadi-host.yml | 55 ++ app/src/main/cpp/CMakeLists.txt | 17 + app/src/main/cpp/macadi/MacAdi.cmake | 40 ++ app/src/main/cpp/macadi/NOTICE.md | 43 ++ app/src/main/cpp/macadi/core_adi.cpp | 210 ++++++++ app/src/main/cpp/macadi/core_adi.h | 80 +++ app/src/main/cpp/macadi/error.h | 28 + app/src/main/cpp/macadi/jni.cpp | 172 ++++++ app/src/main/cpp/macadi/macho.cpp | 178 +++++++ app/src/main/cpp/macadi/macho.h | 50 ++ app/src/main/cpp/macadi/vm.cpp | 663 ++++++++++++++++++++++++ app/src/main/cpp/macadi/vm.h | 136 +++++ app/src/test/macadi-host/CMakeLists.txt | 11 + app/src/test/macadi-host/macadi_cli.cpp | 89 ++++ scripts/locate_mac_coreadi.py | 179 +++++++ 15 files changed, 1951 insertions(+) create mode 100644 .github/workflows/macadi-host.yml create mode 100644 app/src/main/cpp/macadi/MacAdi.cmake create mode 100644 app/src/main/cpp/macadi/NOTICE.md create mode 100644 app/src/main/cpp/macadi/core_adi.cpp create mode 100644 app/src/main/cpp/macadi/core_adi.h create mode 100644 app/src/main/cpp/macadi/error.h create mode 100644 app/src/main/cpp/macadi/jni.cpp create mode 100644 app/src/main/cpp/macadi/macho.cpp create mode 100644 app/src/main/cpp/macadi/macho.h create mode 100644 app/src/main/cpp/macadi/vm.cpp create mode 100644 app/src/main/cpp/macadi/vm.h create mode 100644 app/src/test/macadi-host/CMakeLists.txt create mode 100644 app/src/test/macadi-host/macadi_cli.cpp create mode 100644 scripts/locate_mac_coreadi.py diff --git a/.github/workflows/macadi-host.yml b/.github/workflows/macadi-host.yml new file mode 100644 index 00000000..fccb0121 --- /dev/null +++ b/.github/workflows/macadi-host.yml @@ -0,0 +1,55 @@ +name: Emulated Mac (host) + +# The emulated Mac's native core, built for the host and run against Apple's real macOS CoreADI. +# +# Builds app/src/main/cpp/macadi/ exactly as the app does (MacAdi.cmake is shared), fetches the +# library with the same range request the app makes, checks both pinned hashes, and has the +# emulator load it and answer "not provisioned". Nothing is sent to Apple but that one download: +# provisioning, which would register a machine, is not run. + +on: + pull_request: + branches: [ "main" ] + paths: + - "app/src/main/cpp/macadi/**" + - "app/src/test/macadi-host/**" + - "app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi*.java" + - "scripts/locate_mac_coreadi.py" + - ".github/workflows/macadi-host.yml" + push: + branches: [ "main" ] + paths: + - "app/src/main/cpp/macadi/**" + - "app/src/test/macadi-host/**" + - "app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi*.java" + - "scripts/locate_mac_coreadi.py" + - ".github/workflows/macadi-host.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + host: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Fetch Apple's macOS CoreADI the way the app does, and check both hashes + run: python scripts/locate_mac_coreadi.py --remote --out "$RUNNER_TEMP/CoreADI.x86_64" + + - name: Build the native core for the host + run: | + cmake -S app/src/test/macadi-host -B "$RUNNER_TEMP/macadi" -DCMAKE_BUILD_TYPE=Release + cmake --build "$RUNNER_TEMP/macadi" --parallel + + - name: Load the library and ask whether it is provisioned + run: | + out=$("$RUNNER_TEMP/macadi/macadi_cli" "$RUNNER_TEMP/CoreADI.x86_64" 979a0 /nonexistent \ + 00112233445566778899aabbccddeeff -1 --load-only) + echo "$out" + test "$out" = '{"loaded": true, "provisioned": false}' diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 66fc6d35..88461c32 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -42,3 +42,20 @@ include("${CMAKE_CURRENT_SOURCE_DIR}/stubs/AppleStubs.cmake") add_apple_stub(CoreFoundation) add_apple_stub(mediaplatform) + +# -------------------------------------------------------------------------------------------- +# The emulated Mac. +# +# Since 2026-10-12 Apple gives the Find My token only to a Mac or iPhone identity, and a machine +# provisioned by the Android libraries above cannot present as one. So this runs Apple's *macOS* +# CoreADI instead, under an x86_64 emulator - see macadi/MacAdi.cmake and MacCoreAdi.java. +# +# Its own library, not part of libopentagviewer.so: the emulator is GPL-2.0 and several MB, and +# keeping it separate means it is only loaded when the emulated Mac is actually used. +# -------------------------------------------------------------------------------------------- + +include("${CMAKE_CURRENT_SOURCE_DIR}/macadi/MacAdi.cmake") + +add_library(macadi SHARED macadi/jni.cpp) +target_compile_options(macadi PRIVATE -fexceptions) +target_link_libraries(macadi PRIVATE macadi_core log) diff --git a/app/src/main/cpp/macadi/MacAdi.cmake b/app/src/main/cpp/macadi/MacAdi.cmake new file mode 100644 index 00000000..e8880ad6 --- /dev/null +++ b/app/src/main/cpp/macadi/MacAdi.cmake @@ -0,0 +1,40 @@ +# The emulated Mac's Anisette core: Apple's macOS CoreADI, x86_64, run under Unicorn. +# +# Defines the static library `macadi_core`. Included by app/src/main/cpp/CMakeLists.txt for the +# app, and by app/src/test/macadi-host/ for the host check - so both build exactly these sources +# against exactly this Unicorn, rather than two copies of how they are built. +# +# **Unicorn is GPL-2.0.** It is fetched at build time, pinned by hash, and linked only into +# libmacadi.so, never into libopentagviewer.so. An APK built with this carries GPL-2.0 code. +# +# Apple's library is never part of the build or the repository. The app downloads it from +# Apple at runtime - see MacCoreAdiFetcher. + +include_guard(GLOBAL) +include(FetchContent) + +# Only the x86 target: CoreADI is x86_64 code, and every other architecture is megabytes of +# QEMU that would never run. +set(UNICORN_ARCH "x86" CACHE STRING "Enabled unicorn architectures" FORCE) +set(UNICORN_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(UNICORN_INSTALL OFF CACHE BOOL "" FORCE) +set(UNICORN_LEGACY_STATIC_ARCHIVE OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + unicorn + URL https://github.com/unicorn-engine/unicorn/archive/refs/tags/2.1.4.tar.gz + URL_HASH SHA256=ea8863f095a0136388694e5a6063afd9bb7650e30243dd6251af59c5ce5601f4) +FetchContent_MakeAvailable(unicorn) + +add_library(macadi_core STATIC + ${CMAKE_CURRENT_LIST_DIR}/macho.cpp + ${CMAKE_CURRENT_LIST_DIR}/vm.cpp + ${CMAKE_CURRENT_LIST_DIR}/core_adi.cpp) +target_include_directories(macadi_core PUBLIC + ${CMAKE_CURRENT_LIST_DIR} + ${unicorn_SOURCE_DIR}/include) +target_compile_features(macadi_core PUBLIC cxx_std_17) +# Errors are C++ exceptions up to the JNI boundary, where they become Java ones. +target_compile_options(macadi_core PRIVATE -fexceptions) +set_target_properties(macadi_core PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_link_libraries(macadi_core PUBLIC unicorn) diff --git a/app/src/main/cpp/macadi/NOTICE.md b/app/src/main/cpp/macadi/NOTICE.md new file mode 100644 index 00000000..935ebfd4 --- /dev/null +++ b/app/src/main/cpp/macadi/NOTICE.md @@ -0,0 +1,43 @@ +# Third-party notices for the emulated Mac + +## sacca97/os-findmy-e + +`macho.*`, `vm.*` and `core_adi.*` are a C++ port of +[sacca97/os-findmy-e](https://github.com/sacca97/os-findmy-e) `native/adi-core/src/{macho,vm,adi}.rs`, +and the provisioning headers in `MacProvisioning.java` and the identity shapes in `MacIdentity.java` +follow its `tools/machoadi/{gsa,mac_anisette}.py`. Its licence: + +``` +MIT License + +Copyright (c) 2026 sacca + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## Unicorn + +[Unicorn](https://github.com/unicorn-engine/unicorn) 2.1.4 is fetched from source at build time +(`MacAdi.cmake`, pinned by SHA-256) and linked into `libmacadi.so`. It is licensed under +**GPL-2.0**, so an APK containing `libmacadi.so` contains GPL-2.0 code. + +## Apple's CoreADI + +Not part of this repository or of any build. The app downloads it at runtime from Apple's own +server - see `MacCoreAdiFetcher.java` and `scripts/locate_mac_coreadi.py`. diff --git a/app/src/main/cpp/macadi/core_adi.cpp b/app/src/main/cpp/macadi/core_adi.cpp new file mode 100644 index 00000000..755cd86b --- /dev/null +++ b/app/src/main/cpp/macadi/core_adi.cpp @@ -0,0 +1,210 @@ +#include "core_adi.h" + +#include + +#include "error.h" + +namespace macadi { +namespace { + +constexpr uint32_t INITIALIZE = 0x12DB31C5; +constexpr uint32_t IS_MACHINE_PROVISIONED = 0xB0EDA7AF; +constexpr uint32_t PROVISIONING_START = 0x716BD86C; +constexpr uint32_t PROVISIONING_END = 0x0B2B3196; +constexpr uint32_t SET_IDMS_ROUTING = 0x632B8D6E; +constexpr uint32_t GET_IDMS_ROUTING = 0x85FE63B0; +constexpr uint32_t SYNCHRONIZE = 0xA54951EC; +constexpr uint32_t REQUEST_OTP = 0xCFE0B46A; +constexpr uint32_t STORAGE_DISPOSE = 0x3E58E7F9; + +void be32(std::vector &m, uint32_t v) { + for (int s = 24; s >= 0; s -= 8) m.push_back(static_cast(v >> s)); +} + +void be64(std::vector &m, uint64_t v) { + for (int s = 56; s >= 0; s -= 8) m.push_back(static_cast(v >> s)); +} + +std::vector dsIdMessage(int64_t dsId) { + std::vector m; + be64(m, static_cast(dsId)); + return m; +} + +const std::string LEGACY_STORE = "adi.pb"; + +} // namespace + +CoreAdi::CoreAdi(const std::vector &image, uint64_t dispatch) + : vm_(image), dispatch_(dispatch) {} + +void CoreAdi::check(const char *op, Reply r) { + if (r.rc < 0 || r.ret < 0) { + throw Error(std::string("adi: ") + op + " failed: " + + std::to_string(r.ret < 0 ? r.ret : r.rc)); + } +} + +CoreAdi::Reply CoreAdi::call(uint32_t code, const std::vector &message) { + std::vector buf(std::max(0x800, 8 + message.size()), 0); + buf[3] = 1; // encryption type: clear + buf[7] = 1; + std::copy(message.begin(), message.end(), buf.begin() + 8); + const uint64_t bufAddr = vm_.scratch(buf.size()); + vm_.write(bufAddr, buf); + + const uint64_t params = vm_.scratch(0x20); + vm_.write(params, std::vector(0x20, 0)); + vm_.writeU64(params, bufAddr); + vm_.writeU32(params + 0x08, static_cast(8 + message.size())); + vm_.writeU32(params + 0x0C, 4); + vm_.writeU32(params + 0x10, 0); + + const int32_t rc = static_cast(static_cast(vm_.invoke(dispatch_, {code, params}))); + const std::vector head = vm_.read(bufAddr, 4); + const int32_t ret = static_cast((static_cast(head[0]) << 24) | + (static_cast(head[1]) << 16) | + (static_cast(head[2]) << 8) | + static_cast(head[3])); + vm_.write(bufAddr + 4, std::vector(4, 0)); + return Reply{rc, ret}; +} + +uint64_t CoreAdi::cell(uint64_t v) { + const uint64_t a = vm_.scratch(8); + vm_.writeU64(a, v); + return a; +} + +uint64_t CoreAdi::input(const std::vector &data) { + const uint64_t a = vm_.scratch(std::max(data.size(), 16)); + vm_.write(a, data); + return a; +} + +std::vector CoreAdi::slice(uint64_t ptr, uint64_t len) { + const uint64_t p = vm_.readU64(ptr); + const uint32_t n = vm_.readU32(len); + if (p == 0 || n == 0) return {}; + return vm_.read(p, n); +} + +void CoreAdi::beginSession() { + // Provisioning writes the legacy whole-machine blob as well as the per-user one, and while it + // is present the library reports the machine unprovisioned (see storePut). A process that + // restarts never sees it, because storePut refuses it; one that provisions and then asks for + // headers in the same process would, and sent Apple an empty machine id (ec -80009). So it + // goes before every session, not only on load. + vm_.files().erase(std::string(STORE_DIR) + "/" + LEGACY_STORE); + vm_.resetSession(); + initialize(); +} + +CoreAdi::Reply CoreAdi::initialize() { + vm_.scratchReset(); + return call(INITIALIZE, {}); +} + +CoreAdi::Reply CoreAdi::isMachineProvisioned(int64_t dsId) { + vm_.scratchReset(); + return call(IS_MACHINE_PROVISIONED, dsIdMessage(dsId)); +} + +CoreAdi::Reply CoreAdi::storageDispose(uint64_t ptr) { + std::vector m; + be64(m, ptr); + return call(STORAGE_DISPOSE, m); +} + +CoreAdi::Reply CoreAdi::setRouting(int64_t dsId, uint64_t routing) { + vm_.scratchReset(); + std::vector m = dsIdMessage(dsId); + be64(m, routing); + return call(SET_IDMS_ROUTING, m); +} + +void CoreAdi::storePut(const std::string &name, const std::vector &data) { + // The legacy whole-machine blob is never restored: whenever it is present, the library + // reports the machine unprovisioned without reading the per-user one (os-findmy-e, + // mac_anisette.py). Restoring it would make every later session look unprovisioned. + if (name == LEGACY_STORE) return; + vm_.files()[std::string(STORE_DIR) + "/" + name] = data; +} + +std::vector>> CoreAdi::storeFiles() { + const std::string prefix = std::string(STORE_DIR) + "/"; + std::vector>> out; + for (const auto &f : vm_.files()) { + if (f.first.rfind(prefix, 0) == 0) out.emplace_back(f.first.substr(prefix.size()), f.second); + } + return out; +} + +Headers CoreAdi::headers(int64_t dsId) { + beginSession(); + if (isMachineProvisioned(dsId).ret != 0) throw NotProvisioned(); + + vm_.scratchReset(); + const uint64_t midP = cell(0), midL = cell(0), otpP = cell(0), otpL = cell(0); + std::vector m = dsIdMessage(dsId); + for (const uint64_t v : {midP, midL, otpP, otpL}) be64(m, v); + const Reply otpReply = call(REQUEST_OTP, m); + Headers h{slice(midP, midL), slice(otpP, otpL), 0}; + // The library hands back pointers into its own storage; release them. + if (!h.mid.empty() && !h.otp.empty()) storageDispose(vm_.readU64(midP)); + check("RequestOTP", otpReply); + + vm_.scratchReset(); + const uint64_t routingCell = cell(0); + std::vector r = dsIdMessage(dsId); + be64(r, routingCell); + check("GetIDMSRouting", call(GET_IDMS_ROUTING, r)); + h.routing = vm_.readU64(routingCell); + return h; +} + +std::pair, uint32_t> CoreAdi::provisioningBegin( + int64_t dsId, const std::vector &spim) { + beginSession(); + if (isMachineProvisioned(dsId).ret == 0) throw AlreadyProvisioned(); + + vm_.scratchReset(); + const uint64_t spimBuf = input(spim); + const uint64_t cpimP = cell(0), cpimL = cell(0), session = cell(0); + std::vector m = dsIdMessage(dsId); + be64(m, spimBuf); + be32(m, static_cast(spim.size())); + for (const uint64_t v : {cpimP, cpimL, session}) be64(m, v); + check("ProvisioningStart", call(PROVISIONING_START, m)); + return {slice(cpimP, cpimL), vm_.readU32(session)}; +} + +void CoreAdi::provisioningFinish(int64_t dsId, uint32_t session, const std::vector &ptm, + const std::vector &tk, uint64_t routing) { + vm_.scratchReset(); + const uint64_t ptmBuf = input(ptm), tkBuf = input(tk); + std::vector m; + be32(m, session); + be64(m, ptmBuf); + be32(m, static_cast(ptm.size())); + be64(m, tkBuf); + be32(m, static_cast(tk.size())); + check("ProvisioningEnd", call(PROVISIONING_END, m)); + check("SetIDMSRouting", setRouting(dsId, routing)); +} + +std::pair, std::vector> CoreAdi::sync( + int64_t dsId, const std::vector &sim) { + beginSession(); + vm_.scratchReset(); + const uint64_t simBuf = input(sim); + const uint64_t midP = cell(0), midL = cell(0), srmP = cell(0), srmL = cell(0); + std::vector m = dsIdMessage(dsId); + be64(m, simBuf); + be32(m, static_cast(sim.size())); + for (const uint64_t v : {midP, midL, srmP, srmL}) be64(m, v); + check("Synchronize", call(SYNCHRONIZE, m)); + return {slice(midP, midL), slice(srmP, srmL)}; +} + +} // namespace macadi diff --git a/app/src/main/cpp/macadi/core_adi.h b/app/src/main/cpp/macadi/core_adi.h new file mode 100644 index 00000000..88618ac6 --- /dev/null +++ b/app/src/main/cpp/macadi/core_adi.h @@ -0,0 +1,80 @@ +// The CoreADI dispatcher, `_vdfut768ig(function_code, CoreADIParameters *)`, and the handful of +// operations Anisette needs from it. +// +// Requests are big-endian; pointers inside a message are 8-byte big-endian addresses *inside the +// emulated process*. After Dadoum/apple-crates adi/src/{core_adi,calls}.rs, by way of +// sacca97/os-findmy-e native/adi-core/src/adi.rs (MIT), which this ports. +// +// **One high-level call is one session.** Each starts by putting the VM back to its post-load +// state and calling Initialize, the way a fresh process would. Nothing accumulates between calls, +// which is what lets a bump allocator stand in for malloc. The one exception is provisioning: +// provisioningBegin and provisioningFinish must share a session, with nothing in between. + +#pragma once + +#include +#include +#include +#include + +#include "vm.h" + +namespace macadi { + +struct Headers { + std::vector mid; // X-Apple-I-MD-M, raw + std::vector otp; // X-Apple-I-MD, raw + uint64_t routing; // X-Apple-I-MD-RINFO +}; + +class CoreAdi { +public: + // `dispatch` is the offset of _vdfut768ig in this build. Java knows it, by the build's sha256, + // and refuses any file it does not recognise before this is ever called. + CoreAdi(const std::vector &image, uint64_t dispatch); + + void setHostUuid(const uint8_t uuid[16]) { vm_.setHostUuid(uuid); } + void setFrozenTime(int64_t secs) { vm_.setFrozenTime(secs); } + + // The store, as (file name, bytes). Persist it after every call that can change it - which is + // every call, since RequestOTP rewrites it. + void storePut(const std::string &name, const std::vector &data); + std::vector>> storeFiles(); + + // One session: what a machine puts on one request. + Headers headers(int64_t dsId); + + // Start provisioning with Apple's spim; returns (cpim, session). + std::pair, uint32_t> provisioningBegin(int64_t dsId, + const std::vector &spim); + // Finish with what Apple returned for the cpim. Same session as provisioningBegin. + void provisioningFinish(int64_t dsId, uint32_t session, const std::vector &ptm, + const std::vector &tk, uint64_t routing); + + // Answer Apple's resync request (a 434 carrying X-Apple-I-MD-DATA): returns (mid, srm). + std::pair, std::vector> sync(int64_t dsId, + const std::vector &sim); + +private: + struct Reply { + int32_t rc; // the dispatcher's status + int32_t ret; // the operation's own, negative on failure + }; + + void beginSession(); + Reply call(uint32_t code, const std::vector &message); + uint64_t cell(uint64_t v); + uint64_t input(const std::vector &data); + std::vector slice(uint64_t ptr, uint64_t len); + static void check(const char *op, Reply r); + + Reply initialize(); + Reply isMachineProvisioned(int64_t dsId); + Reply storageDispose(uint64_t ptr); + Reply setRouting(int64_t dsId, uint64_t routing); + + Vm vm_; + uint64_t dispatch_; +}; + +} // namespace macadi diff --git a/app/src/main/cpp/macadi/error.h b/app/src/main/cpp/macadi/error.h new file mode 100644 index 00000000..e9b70ed5 --- /dev/null +++ b/app/src/main/cpp/macadi/error.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace macadi { + +// Everything this library can fail with. The message is what reaches Java, and through it the +// log the user is asked to attach, so it says which layer failed: the Mach-O, the emulation, or +// Apple's library answering with an error of its own. +class Error : public std::runtime_error { +public: + explicit Error(const std::string &message) : std::runtime_error(message) {} +}; + +// The library is not provisioned yet. Distinct because it is the one failure that has a remedy: +// provisioning. +class NotProvisioned : public Error { +public: + NotProvisioned() : Error("the machine is not provisioned") {} +}; + +class AlreadyProvisioned : public Error { +public: + AlreadyProvisioned() : Error("the machine is already provisioned") {} +}; + +} // namespace macadi diff --git a/app/src/main/cpp/macadi/jni.cpp b/app/src/main/cpp/macadi/jni.cpp new file mode 100644 index 00000000..754aa7ce --- /dev/null +++ b/app/src/main/cpp/macadi/jni.cpp @@ -0,0 +1,172 @@ +// JNI face of the emulated Mac: dev.wander.android.opentagviewer.anisette.MacCoreAdi. +// +// A handle is a CoreAdi* passed to Java as a long. Java owns its lifetime and uses it from one +// thread at a time, under its own lock. Every native failure becomes an IllegalStateException +// carrying the C++ message, except "not provisioned", which has its own type because it is the +// one failure with a remedy. + +#include + +#include +#include +#include + +#include "core_adi.h" +#include "error.h" + +namespace { + +constexpr const char *STATE_EXCEPTION = "java/lang/IllegalStateException"; +constexpr const char *NOT_PROVISIONED = + "dev/wander/android/opentagviewer/anisette/MacCoreAdi$NotProvisionedException"; + +macadi::CoreAdi *adi(jlong handle) { return reinterpret_cast(handle); } + +std::vector bytes(JNIEnv *env, jbyteArray array) { + if (array == nullptr) return {}; + const jsize n = env->GetArrayLength(array); + std::vector out(static_cast(n)); + if (n > 0) env->GetByteArrayRegion(array, 0, n, reinterpret_cast(out.data())); + return out; +} + +jbyteArray array(JNIEnv *env, const std::vector &data) { + jbyteArray out = env->NewByteArray(static_cast(data.size())); + if (out != nullptr && !data.empty()) { + env->SetByteArrayRegion(out, 0, static_cast(data.size()), + reinterpret_cast(data.data())); + } + return out; +} + +void raise(JNIEnv *env, const char *type, const char *message) { + jclass cls = env->FindClass(type); + if (cls != nullptr) env->ThrowNew(cls, message); +} + +// Runs `body`, turning any C++ exception into a Java one. Nothing may unwind past a JNI frame. +template +T guarded(JNIEnv *env, T onError, F body) { + try { + return body(); + } catch (const macadi::NotProvisioned &e) { + raise(env, NOT_PROVISIONED, e.what()); + } catch (const std::exception &e) { + raise(env, STATE_EXCEPTION, e.what()); + } catch (...) { + raise(env, STATE_EXCEPTION, "unknown native failure"); + } + return onError; +} + +} // namespace + +extern "C" { + +JNIEXPORT jlong JNICALL +Java_dev_wander_android_opentagviewer_anisette_MacCoreAdi_nativeOpen( + JNIEnv *env, jclass, jbyteArray image, jlong dispatch) { + return guarded(env, 0, [&]() { + auto opened = std::make_unique(bytes(env, image), + static_cast(dispatch)); + return reinterpret_cast(opened.release()); + }); +} + +JNIEXPORT void JNICALL +Java_dev_wander_android_opentagviewer_anisette_MacCoreAdi_nativeClose(JNIEnv *, jclass, + jlong handle) { + delete adi(handle); +} + +JNIEXPORT void JNICALL +Java_dev_wander_android_opentagviewer_anisette_MacCoreAdi_nativeSetHostUuid( + JNIEnv *env, jclass, jlong handle, jbyteArray uuid) { + guarded(env, 0, [&]() { + const std::vector raw = bytes(env, uuid); + if (raw.size() != 16) throw macadi::Error("a host UUID is 16 bytes"); + adi(handle)->setHostUuid(raw.data()); + return 0; + }); +} + +JNIEXPORT void JNICALL +Java_dev_wander_android_opentagviewer_anisette_MacCoreAdi_nativeSetFrozenTime( + JNIEnv *, jclass, jlong handle, jlong secs) { + adi(handle)->setFrozenTime(secs); +} + +JNIEXPORT void JNICALL +Java_dev_wander_android_opentagviewer_anisette_MacCoreAdi_nativeStorePut( + JNIEnv *env, jclass, jlong handle, jstring name, jbyteArray data) { + guarded(env, 0, [&]() { + const char *chars = env->GetStringUTFChars(name, nullptr); + const std::string n = chars == nullptr ? std::string() : chars; + if (chars != nullptr) env->ReleaseStringUTFChars(name, chars); + adi(handle)->storePut(n, bytes(env, data)); + return 0; + }); +} + +// The store as alternating {name bytes (UTF-8), contents} entries. +JNIEXPORT jobjectArray JNICALL +Java_dev_wander_android_opentagviewer_anisette_MacCoreAdi_nativeStoreFiles(JNIEnv *env, jclass, + jlong handle) { + return guarded(env, nullptr, [&]() -> jobjectArray { + const auto files = adi(handle)->storeFiles(); + jclass bytesClass = env->FindClass("[B"); + jobjectArray out = env->NewObjectArray(static_cast(files.size() * 2), bytesClass, nullptr); + if (out == nullptr) return nullptr; + for (size_t i = 0; i < files.size(); i++) { + const std::vector name(files[i].first.begin(), files[i].first.end()); + jbyteArray n = array(env, name); + jbyteArray c = array(env, files[i].second); + env->SetObjectArrayElement(out, static_cast(i * 2), n); + env->SetObjectArrayElement(out, static_cast(i * 2 + 1), c); + env->DeleteLocalRef(n); + env->DeleteLocalRef(c); + } + return out; + }); +} + +// {mid, otp}; routing goes into routingOut[0]. +JNIEXPORT jobjectArray JNICALL +Java_dev_wander_android_opentagviewer_anisette_MacCoreAdi_nativeHeaders( + JNIEnv *env, jclass, jlong handle, jlong dsId, jlongArray routingOut) { + return guarded(env, nullptr, [&]() -> jobjectArray { + const macadi::Headers h = adi(handle)->headers(dsId); + const jlong routing = static_cast(h.routing); + env->SetLongArrayRegion(routingOut, 0, 1, &routing); + jobjectArray out = env->NewObjectArray(2, env->FindClass("[B"), nullptr); + if (out == nullptr) return nullptr; + env->SetObjectArrayElement(out, 0, array(env, h.mid)); + env->SetObjectArrayElement(out, 1, array(env, h.otp)); + return out; + }); +} + +// The cpim; the session goes into sessionOut[0]. +JNIEXPORT jbyteArray JNICALL +Java_dev_wander_android_opentagviewer_anisette_MacCoreAdi_nativeProvisioningBegin( + JNIEnv *env, jclass, jlong handle, jlong dsId, jbyteArray spim, jintArray sessionOut) { + return guarded(env, nullptr, [&]() { + const auto started = adi(handle)->provisioningBegin(dsId, bytes(env, spim)); + const jint session = static_cast(started.second); + env->SetIntArrayRegion(sessionOut, 0, 1, &session); + return array(env, started.first); + }); +} + +JNIEXPORT void JNICALL +Java_dev_wander_android_opentagviewer_anisette_MacCoreAdi_nativeProvisioningFinish( + JNIEnv *env, jclass, jlong handle, jlong dsId, jint session, jbyteArray ptm, + jbyteArray tk, jlong routing) { + guarded(env, 0, [&]() { + adi(handle)->provisioningFinish(dsId, static_cast(session), bytes(env, ptm), + bytes(env, tk), static_cast(routing)); + return 0; + }); +} + +} // extern "C" diff --git a/app/src/main/cpp/macadi/macho.cpp b/app/src/main/cpp/macadi/macho.cpp new file mode 100644 index 00000000..cd401773 --- /dev/null +++ b/app/src/main/cpp/macadi/macho.cpp @@ -0,0 +1,178 @@ +#include "macho.h" + +#include +#include + +#include "error.h" + +namespace macadi { +namespace { + +constexpr uint32_t MH_MAGIC_64 = 0xFEEDFACF; +constexpr uint32_t LC_SEGMENT_64 = 0x19; +constexpr uint32_t LC_DYLD_INFO = 0x22; +constexpr uint32_t LC_DYLD_INFO_ONLY = 0x80000022; + +uint32_t u32At(const std::vector &d, size_t o) { + if (o + 4 > d.size()) throw Error("mach-o: truncated"); + uint32_t v; + std::memcpy(&v, d.data() + o, 4); + return v; // little-endian host on every ABI this is built for +} + +uint64_t u64At(const std::vector &d, size_t o) { + if (o + 8 > d.size()) throw Error("mach-o: truncated"); + uint64_t v; + std::memcpy(&v, d.data() + o, 8); + return v; +} + +uint64_t uleb(const std::vector &d, size_t &o) { + uint64_t v = 0; + unsigned shift = 0; + for (;;) { + if (o >= d.size()) throw Error("mach-o: truncated leb"); + const uint8_t b = d[o++]; + if (shift < 64) v |= static_cast(b & 0x7F) << shift; + shift += 7; + if ((b & 0x80) == 0) return v; + } +} + +int64_t sleb(const std::vector &d, size_t &o) { + int64_t v = 0; + unsigned shift = 0; + for (;;) { + if (o >= d.size()) throw Error("mach-o: truncated leb"); + const uint8_t b = d[o++]; + if (shift < 64) v |= static_cast(b & 0x7F) << shift; + shift += 7; + if ((b & 0x80) == 0) { + if (shift < 64 && (b & 0x40) != 0) v |= -(static_cast(1) << shift); + return v; + } + } +} + +} // namespace + +MachO MachO::parse(const std::vector &d) { + if (u32At(d, 0) != MH_MAGIC_64) throw Error("mach-o: not a thin 64-bit Mach-O"); + const uint32_t ncmds = u32At(d, 16); + + MachO m; + size_t bindOff = 0, bindLen = 0, lazyOff = 0, lazyLen = 0; + size_t off = 32; + for (uint32_t i = 0; i < ncmds; i++) { + const uint32_t cmd = u32At(d, off); + const uint32_t size = u32At(d, off + 4); + if (cmd == LC_SEGMENT_64) { + m.segments_.push_back(Segment{ + u64At(d, off + 24), u64At(d, off + 32), u64At(d, off + 40), + u64At(d, off + 48), (u32At(d, off + 60) & 2) != 0}); + } else if (cmd == LC_DYLD_INFO || cmd == LC_DYLD_INFO_ONLY) { + bindOff = u32At(d, off + 16); + bindLen = u32At(d, off + 20); + lazyOff = u32At(d, off + 32); + lazyLen = u32At(d, off + 36); + } + if (size == 0) throw Error("mach-o: zero-sized load command"); + off += size; + } + + std::vector binds = m.readBinds(d, bindOff, bindLen, false); + std::vector lazy = m.readBinds(d, lazyOff, lazyLen, true); + binds.insert(binds.end(), lazy.begin(), lazy.end()); + m.binds_ = std::move(binds); + return m; +} + +std::vector MachO::readBinds(const std::vector &d, size_t start, size_t len, + bool lazy) const { + std::vector out; + if (len == 0) return out; + if (start + len > d.size()) throw Error("mach-o: bind stream outside the file"); + + const size_t end = start + len; + size_t o = start; + std::string symbol; + int64_t addend = 0; + size_t seg = 0; + uint64_t segOff = 0; + + auto push = [&]() { + if (seg >= segments_.size()) throw Error("mach-o: bad bind segment"); + out.push_back(Bind{segments_[seg].vmaddr + segOff, symbol, addend}); + }; + + while (o < end) { + const uint8_t byte = d[o++]; + const uint8_t op = byte & 0xF0; + const uint64_t imm = byte & 0x0F; + switch (op) { + case 0x00: // DONE - but lazy streams carry one per entry, so keep going there + if (!lazy) return out; + break; + case 0x10: // SET_DYLIB_ORDINAL_IMM + case 0x30: // SET_DYLIB_SPECIAL_IMM + case 0x50: // SET_TYPE_IMM + break; + case 0x20: // SET_DYLIB_ORDINAL_ULEB + uleb(d, o); + break; + case 0x40: { // SET_SYMBOL_TRAILING_FLAGS_IMM + const void *nul = std::memchr(d.data() + o, 0, end - o); + if (nul == nullptr) throw Error("mach-o: bad symbol"); + const size_t z = static_cast(nul) - (d.data() + o); + symbol.assign(reinterpret_cast(d.data() + o), z); + o += z + 1; + break; + } + case 0x60: // SET_ADDEND_SLEB + addend = sleb(d, o); + break; + case 0x70: // SET_SEGMENT_AND_OFFSET_ULEB + seg = static_cast(imm); + segOff = uleb(d, o); + break; + case 0x80: // ADD_ADDR_ULEB + segOff += uleb(d, o); + break; + case 0x90: // DO_BIND + push(); + segOff += 8; + break; + case 0xA0: // DO_BIND_ADD_ADDR_ULEB + push(); + segOff += uleb(d, o) + 8; + break; + case 0xB0: // DO_BIND_ADD_ADDR_IMM_SCALED + push(); + segOff += imm * 8 + 8; + break; + case 0xC0: { // DO_BIND_ULEB_TIMES_SKIPPING_ULEB + const uint64_t count = uleb(d, o); + const uint64_t skip = uleb(d, o); + for (uint64_t i = 0; i < count; i++) { + push(); + segOff += skip + 8; + } + break; + } + default: + throw Error("mach-o: unsupported bind opcode " + std::to_string(op)); + } + } + return out; +} + +std::vector MachO::bindSymbols() const { + std::vector out; + std::unordered_set seen; + for (const Bind &b : binds_) { + if (seen.insert(b.symbol).second) out.push_back(b.symbol); + } + return out; +} + +} // namespace macadi diff --git a/app/src/main/cpp/macadi/macho.h b/app/src/main/cpp/macadi/macho.h new file mode 100644 index 00000000..818bcf78 --- /dev/null +++ b/app/src/main/cpp/macadi/macho.h @@ -0,0 +1,50 @@ +// A minimal reader for the one Mach-O this library loads: Apple's CoreADI, x86_64 slice, as it +// ships in iTunes 12.8.3 for Mac. Segments to map and the dyld bind stream to stub - nothing else. +// +// Classic LC_DYLD_INFO only. The builds this is for predate chained fixups, and a build that used +// them would be refused by the sha256 check in Java long before it got here. + +#pragma once + +#include +#include +#include + +namespace macadi { + +struct Segment { + uint64_t vmaddr; + uint64_t vmsize; + uint64_t fileoff; + uint64_t filesize; + bool writable; +}; + +struct Bind { + uint64_t address; + std::string symbol; + int64_t addend; +}; + +class MachO { +public: + // Throws macadi::Error on anything that is not a thin little-endian 64-bit Mach-O with a + // bind stream this reader understands. + static MachO parse(const std::vector &image); + + const std::vector &segments() const { return segments_; } + const std::vector &binds() const { return binds_; } + + // Distinct imported symbols, in first-seen order: binds, then lazy binds. The index of a symbol + // in this list is what places its stub, so the order is part of the memory layout. + std::vector bindSymbols() const; + +private: + std::vector readBinds(const std::vector &d, size_t start, size_t len, + bool lazy) const; + + std::vector segments_; + std::vector binds_; +}; + +} // namespace macadi diff --git a/app/src/main/cpp/macadi/vm.cpp b/app/src/main/cpp/macadi/vm.cpp new file mode 100644 index 00000000..e53c2e44 --- /dev/null +++ b/app/src/main/cpp/macadi/vm.cpp @@ -0,0 +1,663 @@ +#include "vm.h" + +#include +#include +#include + +#include "error.h" +#include "macho.h" + +namespace macadi { + +const char *const STORE_DIR = "/Users/Shared/adi"; + +namespace { + +constexpr uint64_t PAGE = 0x1000; +constexpr uint64_t RETURN_ADDRESS = 0x40000000; +constexpr uint64_t HEAP_ADDRESS = 0x40010000; +constexpr uint64_t HEAP_SIZE = 0x01000000; +constexpr uint64_t STACK_ADDRESS = 0x50000000; +constexpr uint64_t STACK_SIZE = 0x00200000; +constexpr uint64_t STUB_ADDRESS = 0x60000000; +constexpr uint64_t STUB_SIZE = 0x20000; +constexpr uint64_t DATA_ADDRESS = 0x60020000; +constexpr uint64_t DATA_SIZE = 0x20000; +constexpr uint64_t SCRATCH_ADDRESS = 0x60040000; +constexpr uint64_t SCRATCH_SIZE = 0x40000; +constexpr uint64_t OBJECT_ADDRESS = 0x60080000; +constexpr uint64_t OBJECT_SIZE = 0x40000; +constexpr uint64_t SLOT = 0x10; + +constexpr size_t STAT_SIZE = 144; +constexpr size_t STATFS_SIZE = 2168; +constexpr uint16_t S_IFDIR_ = 0040000; +constexpr uint16_t S_IFREG_ = 0100000; +constexpr uint64_t O_CREAT_ = 0x0200; // Darwin's values, not Linux's +constexpr uint64_t O_TRUNC_ = 0x0400; +constexpr uint64_t ENOENT_ = 2; +constexpr uint64_t EBADF_ = 9; +constexpr uint64_t EEXIST_ = 17; +constexpr uint64_t EISDIR_ = 21; +constexpr uint64_t FAILED = ~static_cast(0); + +const char *const DATA_SYMBOLS[] = { + "stack_chk_guard", "kCFAllocatorDefault", "kCFBooleanFalse", + "kCFBooleanTrue", "kCFErrorDomainOSStatus", "kCFURLFileSecurityKey", + "kCFURLIsDirectoryKey", "kCFURLIsHiddenKey", "kCFURLTypeIdentifierKey", + "kUTTypePlainText", +}; + +const int ARG_REGS[] = {UC_X86_REG_RDI, UC_X86_REG_RSI, UC_X86_REG_RDX, + UC_X86_REG_RCX, UC_X86_REG_R8, UC_X86_REG_R9}; + +std::string norm(const std::string &s) { + const size_t i = s.find_first_not_of('_'); + return i == std::string::npos ? std::string() : s.substr(i); +} + +bool isDataSymbol(const std::string &normalised) { + for (const char *d : DATA_SYMBOLS) { + if (normalised == d) return true; + } + return false; +} + +// CRC-32, as the Python harness does (zlib.crc32), so inodes are stable per path. +uint64_t inode(const std::string &path) { + uint32_t crc = ~0u; + for (const unsigned char b : path) { + crc ^= b; + for (int i = 0; i < 8; i++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + return 0x10000 + ((~crc) & 0xFFFFF); +} + +void put16(uint8_t *b, uint16_t v) { std::memcpy(b, &v, 2); } +void put32(uint8_t *b, uint32_t v) { std::memcpy(b, &v, 4); } +void put64(uint8_t *b, uint64_t v) { std::memcpy(b, &v, 8); } + +} // namespace + +bool Vm::handlerFor(const std::string &n, Handler &out) { + auto set = [&](Kind k, uint64_t v = 0) { + out = Handler{k, v}; + return true; + }; + if (n == "stack_chk_fail") return set(Kind::Abort); + if (n == "pthread_rwlock_init" || n == "pthread_rwlock_rdlock" || + n == "pthread_rwlock_wrlock" || n == "pthread_rwlock_unlock" || + n == "pthread_rwlock_destroy") + return set(Kind::Ret, 0); + if (n == "getuid") return set(Kind::Ret, 501); + if (n == "umask") return set(Kind::Ret, 0022); + if (n == "arc4random") return set(Kind::Ret, 0x12345678); + if (n == "arc4random_buf") return set(Kind::Arc4Buf); + if (n == "gethostuuid") return set(Kind::HostUuid); + if (n == "gettimeofday") return set(Kind::TimeOfDay); + if (n == "error") return set(Kind::Errno); // ___error, i.e. errno's address + if (n == "objc_msgSend") return set(Kind::ObjcMsgSend); + if (n == "NSVersionOfRunTimeLibrary") return set(Kind::Ret, 0x100000000ull); + if (n == "CFErrorGetCode" || n == "CFErrorGetDomain" || n == "CFRelease" || + n == "CSBackupIsItemExcluded" || n == "notify_post") + return set(Kind::Ret, 0); + if (n == "CSBackupSetItemExcluded" || n == "CFFileSecuritySetMode" || + n == "CFURLSetResourcePropertyForKey") + return set(Kind::Ret, 1); + if (n == "malloc") return set(Kind::Malloc); + if (n == "free" || n == "fchmod" || n == "fcntl" || n == "lockf") return set(Kind::Ret, 0); + if (n == "open") return set(Kind::Open); + if (n == "close") return set(Kind::Close); + if (n == "read") return set(Kind::Read); + if (n == "write") return set(Kind::Write); + if (n == "lstat$INODE64" || n == "stat$INODE64") return set(Kind::Lstat); + if (n == "fstat$INODE64") return set(Kind::Fstat); + if (n == "statfs$INODE64") return set(Kind::Statfs); + if (n == "mkdir") return set(Kind::Mkdir); + if (n == "unlink") return set(Kind::Unlink); + if (n == "chmod") return set(Kind::Chmod); + if (n == "ftruncate") return set(Kind::Ftruncate); + if (n == "getenv") return set(Kind::Getenv); + if (n == "CFURLCreateFromFileSystemRepresentation") return set(Kind::CfurlCreate); + if (n == "CFURLCopyResourcePropertyForKey") return set(Kind::CfurlCopyProp); + return false; +} + +void Vm::check(uc_err err, const char *what) const { + if (err != UC_ERR_OK) { + throw Error(std::string("unicorn: ") + what + ": " + uc_strerror(err)); + } +} + +Vm::Vm(const std::vector &image) { + const MachO macho = MachO::parse(image); + check(uc_open(UC_ARCH_X86, UC_MODE_64, &uc_), "open"); + + try { + const std::pair regions[] = { + {RETURN_ADDRESS, PAGE}, {HEAP_ADDRESS, HEAP_SIZE}, + {STACK_ADDRESS, STACK_SIZE}, {STUB_ADDRESS, STUB_SIZE}, + {DATA_ADDRESS, DATA_SIZE}, {SCRATCH_ADDRESS, SCRATCH_SIZE}, + {OBJECT_ADDRESS, OBJECT_SIZE}, + }; + for (const auto &r : regions) check(uc_mem_map(uc_, r.first, r.second, UC_PROT_ALL), "map"); + + for (const Segment &seg : macho.segments()) { + const uint64_t size = (seg.vmsize + PAGE - 1) / PAGE * PAGE; + if (size == 0) continue; + check(uc_mem_map(uc_, seg.vmaddr, size, UC_PROT_ALL), "map segment"); + if (seg.filesize != 0) { + if (seg.fileoff + seg.filesize > image.size()) { + throw Error("mach-o: segment outside the file"); + } + check(uc_mem_write(uc_, seg.vmaddr, image.data() + seg.fileoff, seg.filesize), + "load segment"); + } + } + const std::vector rets(STUB_SIZE, 0xC3); + check(uc_mem_write(uc_, STUB_ADDRESS, rets.data(), rets.size()), "write stubs"); + + std::unordered_map stubAddr; + std::unordered_map dataCell; + const std::vector symbols = macho.bindSymbols(); + for (size_t i = 0; i < symbols.size(); i++) { + const std::string &sym = symbols[i]; + const std::string n = norm(sym); + if (isDataSymbol(n)) { + const uint64_t cell = DATA_ADDRESS + i * SLOT; + const uint64_t obj = OBJECT_ADDRESS + i * SLOT; + writeU64(cell, obj); + dataCell[sym] = cell; + objectOf_[n] = obj; + keyByObject_[obj] = sym; + } else { + const uint64_t addr = STUB_ADDRESS + i * SLOT; + stubAddr[sym] = addr; + Handler h{}; + if (handlerFor(n, h)) handlers_[addr] = h; + } + } + for (const Bind &b : macho.binds()) { + uint64_t target; + if (auto it = dataCell.find(b.symbol); it != dataCell.end()) { + target = it->second; + } else if (auto st = stubAddr.find(b.symbol); st != stubAddr.end()) { + target = st->second; + } else { + throw Error("mach-o: unbound symbol " + b.symbol); + } + writeU64(b.address, target + static_cast(b.addend)); + } + + heapCur_ = HEAP_ADDRESS; + scratchCur_ = SCRATCH_ADDRESS; + objCur_ = OBJECT_ADDRESS; + nextUrl_ = OBJECT_ADDRESS; + nextFd_ = 3; + const uint8_t defaultUuid[16] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + std::memcpy(hostUuid_, defaultUuid, 16); + dirs_ = {"/", "/Users", "/Users/Shared", STORE_DIR}; + + // Long-lived cells (errno, getenv strings) live below the per-call scratch base. + errnoAddr_ = bumpScratch(8); + const std::pair env[] = { + {"HOME", "/Users/aditest"}, {"USER", "aditest"}, {"TMPDIR", "/tmp/"}}; + std::vector> cells; + for (const auto &kv : env) { + const std::string v = kv.second; + const uint64_t a = bumpScratch(v.size() + 1); + getenvAddr_[kv.first] = a; + cells.emplace_back(a, v); + } + scratchBase_ = scratchCur_; + for (const auto &c : cells) { + std::vector bytes(c.second.begin(), c.second.end()); + bytes.push_back(0); + write(c.first, bytes); + } + + check(uc_hook_add(uc_, &stubHook_, UC_HOOK_CODE, reinterpret_cast(&Vm::onStub), + this, STUB_ADDRESS, STUB_ADDRESS + STUB_SIZE - 1), + "hook stubs"); + check(uc_hook_add(uc_, &unmappedHook_, UC_HOOK_MEM_UNMAPPED, + reinterpret_cast(&Vm::onUnmapped), this, 1, 0), + "hook unmapped"); + + for (const Segment &seg : macho.segments()) { + if (seg.writable && seg.vmsize != 0) { + snapshot_.segments.emplace_back(seg.vmaddr, read(seg.vmaddr, seg.vmsize)); + } + } + snapshot_.cells = read(DATA_ADDRESS, PAGE); + snapshot_.heapCur = heapCur_; + snapshot_.objCur = objCur_; + snapshot_.nextUrl = nextUrl_; + snapshot_.nextFd = nextFd_; + zeros_.assign(STACK_SIZE, 0); + } catch (...) { + uc_close(uc_); + uc_ = nullptr; + throw; + } +} + +Vm::~Vm() { + if (uc_ != nullptr) uc_close(uc_); +} + +void Vm::onStub(uc_engine *uc, uint64_t address, uint32_t, void *self) { + Vm *vm = static_cast(self); + const auto it = vm->handlers_.find(address); + if (it == vm->handlers_.end()) return; // an import nothing needed answering: just `ret` + // Nothing may throw past this frame: it is called from inside Unicorn's C code. + try { + uint64_t result = 0; + if (vm->call(it->second, result)) { + uc_reg_write(uc, UC_X86_REG_RAX, &result); + } + } catch (...) { + vm->fault_ = "a stand-in for an imported function failed"; + uc_emu_stop(uc); + } +} + +bool Vm::onUnmapped(uc_engine *uc, uc_mem_type type, uint64_t address, int size, int64_t, + void *self) { + Vm *vm = static_cast(self); + uint64_t rip = 0; + uc_reg_read(uc, UC_X86_REG_RIP, &rip); + const char *kind = type == UC_MEM_READ_UNMAPPED ? "read" + : type == UC_MEM_WRITE_UNMAPPED ? "write" + : "fetch"; + char message[160]; + std::snprintf(message, sizeof message, "%s-unmapped at %#llx (size %d) rip=%#llx", kind, + static_cast(address), size, + static_cast(rip)); + vm->fault_ = message; + return false; +} + +uint64_t Vm::arg(int i) const { + uint64_t v = 0; + uc_reg_read(uc_, ARG_REGS[i], &v); + return v; +} + +std::string Vm::cstr(uint64_t addr) const { + std::string out; + while (out.size() < 4096) { + uint8_t b = 0; + if (uc_mem_read(uc_, addr + out.size(), &b, 1) != UC_ERR_OK || b == 0) break; + out.push_back(static_cast(b)); + } + return out; +} + +uint64_t Vm::fail(uint64_t err) { + poke32(errnoAddr_, static_cast(err)); + return FAILED; +} + +// Writes made on the library's behalf from inside a hook. Failure is ignored, as in the reference: +// the library supplied the address, and an unmapped one faults on its own at the next access. +void Vm::poke32(uint64_t addr, uint32_t v) { uc_mem_write(uc_, addr, &v, 4); } + +void Vm::poke64(uint64_t addr, uint64_t v) { uc_mem_write(uc_, addr, &v, 8); } + +void Vm::fillStat(uint64_t addr, const std::string &path, bool isDir, int64_t size) { + uint8_t b[STAT_SIZE] = {}; + put32(b + 0, 0x1000003); + put16(b + 4, static_cast((isDir ? S_IFDIR_ : S_IFREG_) | 0755)); + put16(b + 6, isDir ? 2 : 1); + put64(b + 8, inode(path)); + put32(b + 16, 501); + put32(b + 20, 20); + put64(b + 96, static_cast(size)); + put64(b + 104, static_cast((size + 511) / 512)); + put32(b + 112, 4096); + uc_mem_write(uc_, addr, b, sizeof b); +} + +uint64_t Vm::bumpScratch(uint64_t size) { + const uint64_t addr = scratchCur_; + scratchCur_ += (size + 0xF) & ~static_cast(0xF); + if (scratchCur_ > SCRATCH_ADDRESS + SCRATCH_SIZE) throw Error("emulation: scratch exhausted"); + return addr; +} + +bool Vm::call(const Handler &h, uint64_t &r) { + switch (h.kind) { + case Kind::Abort: + fault_ = "stack check failed"; + uc_emu_stop(uc_); + return false; + case Kind::Ret: + r = h.value; + return true; + case Kind::Arc4Buf: { + const uint64_t buf = arg(0); + const size_t n = static_cast(arg(1)); + std::vector bytes(n); + for (size_t i = 0; i < n; i++) bytes[i] = static_cast(i * 7 + 3); + uc_mem_write(uc_, buf, bytes.data(), bytes.size()); + r = 0; + return true; + } + case Kind::HostUuid: + uc_mem_write(uc_, arg(0), hostUuid_, 16); + r = 0; + return true; + case Kind::TimeOfDay: { + const uint64_t tv = arg(0); + if (tv != 0) { + uint64_t s, us; + if (frozenTime_ >= 0) { + s = static_cast(frozenTime_); + us = 0; + } else { + timespec now{}; + clock_gettime(CLOCK_REALTIME, &now); + s = static_cast(now.tv_sec); + us = static_cast(now.tv_nsec / 1000); + } + poke64(tv, s); + poke64(tv + 8, us); + } + r = 0; + return true; + } + case Kind::Errno: + r = errnoAddr_; + return true; + case Kind::ObjcMsgSend: { + const std::string sel = cstr(arg(1)); + if (sel == "processInfo") { + r = objCur_; + objCur_ += 0x100; + } else if (sel == "operatingSystem") { + r = 0x0D; + } else if (sel == "isOperatingSystemAtLeastVersion:") { + r = 1; + } else { + r = 0; + } + return true; + } + case Kind::Malloc: { + // A bump allocator, like the harness Apple has accepted. Nothing is freed: the VM is + // put back to its post-load snapshot between sessions instead. + const uint64_t size = (std::max(arg(0), 0x20) + 0xF) & ~static_cast(0xF); + const uint64_t addr = heapCur_; + if (addr + size > HEAP_ADDRESS + HEAP_SIZE) { + fault_ = "emulated heap exhausted"; + uc_emu_stop(uc_); + return false; + } + heapCur_ = addr + size; + r = addr; + return true; + } + case Kind::Open: { + std::string path = cstr(arg(0)); + const uint64_t flags = arg(1); + if (auto it = files_.find(path); it != files_.end()) { + if (flags & O_TRUNC_) it->second.clear(); + } else if (flags & O_CREAT_) { + files_[path] = {}; + } else if (dirs_.count(path) != 0) { + r = fail(EISDIR_); + return true; + } else { + r = fail(ENOENT_); + return true; + } + const uint64_t fd = nextFd_++; + fds_[fd] = Fd{std::move(path), 0}; + r = fd; + return true; + } + case Kind::Close: + fds_.erase(arg(0)); + r = 0; + return true; + case Kind::Read: { + const uint64_t fd = arg(0), buf = arg(1); + const size_t n = static_cast(arg(2)); + auto it = fds_.find(fd); + if (it == fds_.end()) { + r = fail(EBADF_); + return true; + } + const auto f = files_.find(it->second.path); + static const std::vector empty; + const std::vector &data = f == files_.end() ? empty : f->second; + const size_t start = std::min(it->second.off, data.size()); + const size_t len = std::min(n, data.size() - start); + if (len != 0) uc_mem_write(uc_, buf, data.data() + start, len); + it->second.off += len; + r = len; + return true; + } + case Kind::Write: { + const uint64_t fd = arg(0), buf = arg(1); + const size_t n = static_cast(arg(2)); + auto it = fds_.find(fd); + if (it == fds_.end()) { + r = fail(EBADF_); + return true; + } + std::vector payload(n); + if (n != 0 && uc_mem_read(uc_, buf, payload.data(), n) != UC_ERR_OK) payload.clear(); + std::vector &data = files_[it->second.path]; + const size_t off = it->second.off; + if (off > data.size()) data.resize(off, 0); + const size_t end = off + payload.size(); + if (end > data.size()) data.resize(end, 0); + std::copy(payload.begin(), payload.end(), data.begin() + static_cast(off)); + it->second.off = end; + r = n; + return true; + } + case Kind::Lstat: { + const std::string path = cstr(arg(0)); + const uint64_t buf = arg(1); + if (auto f = files_.find(path); f != files_.end()) { + fillStat(buf, path, false, static_cast(f->second.size())); + r = 0; + } else if (dirs_.count(path) != 0) { + fillStat(buf, path, true, 0); + r = 0; + } else { + r = fail(ENOENT_); + } + return true; + } + case Kind::Fstat: { + const uint64_t fd = arg(0), buf = arg(1); + auto it = fds_.find(fd); + if (it == fds_.end()) { + r = fail(EBADF_); + return true; + } + const auto f = files_.find(it->second.path); + const int64_t size = f == files_.end() ? 0 : static_cast(f->second.size()); + const std::string path = it->second.path; + fillStat(buf, path, false, size); + r = 0; + return true; + } + case Kind::Statfs: { + std::vector raw(STATFS_SIZE, 0); + put32(raw.data(), 0x1000003); + uc_mem_write(uc_, arg(1), raw.data(), raw.size()); + r = 0; + return true; + } + case Kind::Mkdir: { + std::string path = cstr(arg(0)); + if (files_.count(path) != 0 || dirs_.count(path) != 0) { + r = fail(EEXIST_); + return true; + } + dirs_.insert(std::move(path)); + r = 0; + return true; + } + case Kind::Unlink: { + const std::string path = cstr(arg(0)); + r = files_.erase(path) != 0 ? 0 : fail(ENOENT_); + return true; + } + case Kind::Chmod: { + const std::string path = cstr(arg(0)); + r = (files_.count(path) != 0 || dirs_.count(path) != 0) ? 0 : fail(ENOENT_); + return true; + } + case Kind::Ftruncate: { + const uint64_t fd = arg(0); + const size_t len = static_cast(arg(1)); + auto it = fds_.find(fd); + if (it == fds_.end()) { + r = fail(EBADF_); + return true; + } + files_[it->second.path].resize(len, 0); + r = 0; + return true; + } + case Kind::Getenv: { + const auto it = getenvAddr_.find(cstr(arg(0))); + r = it == getenvAddr_.end() ? 0 : it->second; + return true; + } + case Kind::CfurlCreate: { + const uint64_t buf = arg(1); + const size_t len = static_cast(arg(2)); + std::string path(len, '\0'); + if (len != 0 && uc_mem_read(uc_, buf, path.data(), len) != UC_ERR_OK) path.clear(); + nextUrl_ += 0x10; + urls_[nextUrl_] = path; + r = nextUrl_; + return true; + } + case Kind::CfurlCopyProp: { + const uint64_t url = arg(0), key = arg(1), out = arg(2); + const auto u = urls_.find(url); + const std::string path = u == urls_.end() ? std::string() : u->second; + const auto k = keyByObject_.find(key); + const std::string name = k == keyByObject_.end() ? std::string() : norm(k->second); + auto obj = [&](const char *n) { + const auto it = objectOf_.find(n); + return it == objectOf_.end() ? 0 : it->second; + }; + if (name == "kCFURLIsDirectoryKey") { + poke64(out, dirs_.count(path) != 0 ? obj("kCFBooleanTrue") : obj("kCFBooleanFalse")); + r = 1; + } else if (name == "kCFURLIsHiddenKey") { + poke64(out, obj("kCFBooleanFalse")); + r = 1; + } else if (name == "kCFURLFileSecurityKey" || name == "kCFURLTypeIdentifierKey") { + poke64(out, OBJECT_ADDRESS); + r = 1; + } else { + r = 0; + } + return true; + } + } + r = 0; + return true; +} + +uint64_t Vm::invoke(uint64_t address, const std::vector &args) { + for (size_t i = 0; i < args.size() && i < 6; i++) { + check(uc_reg_write(uc_, ARG_REGS[i], &args[i]), "set argument"); + } + uint64_t rsp = STACK_ADDRESS + STACK_SIZE - 0x1000; + writeU64(rsp, RETURN_ADDRESS); + check(uc_reg_write(uc_, UC_X86_REG_RSP, &rsp), "set rsp"); + uint64_t zero = 0; + check(uc_reg_write(uc_, UC_X86_REG_RBP, &zero), "set rbp"); + fault_.clear(); + + const uc_err err = uc_emu_start(uc_, address, RETURN_ADDRESS, 0, 0); + if (!fault_.empty()) { + const std::string message = fault_; + fault_.clear(); + throw Error("emulation: " + message); + } + if (err != UC_ERR_OK) { + uint64_t rip = 0; + uc_reg_read(uc_, UC_X86_REG_RIP, &rip); + char message[160]; + std::snprintf(message, sizeof message, "emulation failed at rip=%#llx: %s", + static_cast(rip), uc_strerror(err)); + throw Error(message); + } + uint64_t rax = 0; + check(uc_reg_read(uc_, UC_X86_REG_RAX, &rax), "read rax"); + return rax; +} + +void Vm::resetSession() { + for (const auto &seg : snapshot_.segments) write(seg.first, seg.second); + write(DATA_ADDRESS, snapshot_.cells); + check(uc_mem_unmap(uc_, HEAP_ADDRESS, HEAP_SIZE), "unmap heap"); + check(uc_mem_map(uc_, HEAP_ADDRESS, HEAP_SIZE, UC_PROT_ALL), "map heap"); + check(uc_mem_write(uc_, STACK_ADDRESS, zeros_.data(), zeros_.size()), "clear stack"); + const uint64_t base = scratchBase_; + const size_t dirty = static_cast(scratchCur_ - scratchBase_); + heapCur_ = snapshot_.heapCur; + objCur_ = snapshot_.objCur; + nextUrl_ = snapshot_.nextUrl; + nextFd_ = snapshot_.nextFd; + scratchCur_ = scratchBase_; + fds_.clear(); + urls_.clear(); + fault_.clear(); + writeU64(errnoAddr_, 0); + if (dirty != 0) { + check(uc_mem_write(uc_, base, zeros_.data(), std::min(dirty, zeros_.size())), + "clear scratch"); + } +} + +uint64_t Vm::scratch(size_t size) { return bumpScratch(size); } + +void Vm::scratchReset() { scratchCur_ = scratchBase_; } + +void Vm::write(uint64_t addr, const std::vector &data) { + if (data.empty()) return; + check(uc_mem_write(uc_, addr, data.data(), data.size()), "write"); +} + +void Vm::writeU32(uint64_t addr, uint32_t v) { + check(uc_mem_write(uc_, addr, &v, 4), "write"); +} + +void Vm::writeU64(uint64_t addr, uint64_t v) { + check(uc_mem_write(uc_, addr, &v, 8), "write"); +} + +std::vector Vm::read(uint64_t addr, size_t len) const { + std::vector out(len); + if (len != 0) check(uc_mem_read(uc_, addr, out.data(), len), "read"); + return out; +} + +uint32_t Vm::readU32(uint64_t addr) const { + uint32_t v = 0; + check(uc_mem_read(uc_, addr, &v, 4), "read"); + return v; +} + +uint64_t Vm::readU64(uint64_t addr) const { + uint64_t v = 0; + check(uc_mem_read(uc_, addr, &v, 8), "read"); + return v; +} + +void Vm::setHostUuid(const uint8_t uuid[16]) { std::memcpy(hostUuid_, uuid, 16); } + +} // namespace macadi diff --git a/app/src/main/cpp/macadi/vm.h b/app/src/main/cpp/macadi/vm.h new file mode 100644 index 00000000..3d685fef --- /dev/null +++ b/app/src/main/cpp/macadi/vm.h @@ -0,0 +1,136 @@ +// Apple's macOS CoreADI, x86_64, run under Unicorn - plus the few pieces of macOS userland it +// asks for. +// +// The dylib's preferred base is 0, so every segment is mapped at its literal vmaddr and nothing +// is rebased. Each imported function gets a stub address holding a single `ret`; a code hook on +// the stub page runs the handler in C++, sets rax, and the `ret` returns to the caller. Imported +// data symbols point at per-symbol cells. +// +// Ported from sacca97/os-findmy-e (MIT), native/adi-core/src/vm.rs, which in turn mirrors the +// Python harness its results were measured with. Behaviour is kept identical on purpose, down to +// the deterministic arc4random: that harness is what Apple has been seen to accept, and a +// "tidier" stand-in is an untested one. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace macadi { + +// Where the library keeps its provisioning state. A virtual directory: the files live in memory +// here, and Java persists them between sessions. +extern const char *const STORE_DIR; + +class Vm { +public: + explicit Vm(const std::vector &image); + ~Vm(); + Vm(const Vm &) = delete; + Vm &operator=(const Vm &) = delete; + + // Run address(args...) until it returns to our sentinel; the result is rax. + uint64_t invoke(uint64_t address, const std::vector &args); + + // Back to the state right after loading, as if a fresh process had started. The store, the + // host UUID and the clock are kept; library memory, descriptors and allocator counters are + // not. Translated code stays cached, which is what makes this cheap. Between sessions only - + // a provisioning session id lives in library memory. + void resetSession(); + + // Per-call scratch for request buffers and out-cells, reset between top-level calls. + uint64_t scratch(size_t size); + void scratchReset(); + + void write(uint64_t addr, const std::vector &data); + void writeU32(uint64_t addr, uint32_t v); + void writeU64(uint64_t addr, uint64_t v); + std::vector read(uint64_t addr, size_t len) const; + uint32_t readU32(uint64_t addr) const; + uint64_t readU64(uint64_t addr) const; + + std::map> &files() { return files_; } + + void setHostUuid(const uint8_t uuid[16]); + + // Pin gettimeofday to `secs` for tests; negative is the real clock. + // + // Never in the app. A frozen clock makes the OTP a constant, and Apple then accepts the + // machine for about a minute and refuses it after (os-findmy-e EVIDENCE.md sections 8-9). + void setFrozenTime(int64_t secs) { frozenTime_ = secs; } + +private: + enum class Kind { + Abort, Ret, Arc4Buf, HostUuid, TimeOfDay, Errno, ObjcMsgSend, Malloc, Open, Close, Read, + Write, Lstat, Fstat, Statfs, Mkdir, Unlink, Chmod, Ftruncate, Getenv, CfurlCreate, + CfurlCopyProp, + }; + struct Handler { + Kind kind; + uint64_t value; + }; + struct Fd { + std::string path; + size_t off; + }; + struct Snapshot { + std::vector>> segments; + std::vector cells; + uint64_t heapCur; + uint64_t objCur; + uint64_t nextUrl; + uint64_t nextFd; + }; + + static bool handlerFor(const std::string &name, Handler &out); + static void onStub(uc_engine *uc, uint64_t address, uint32_t size, void *self); + static bool onUnmapped(uc_engine *uc, uc_mem_type type, uint64_t address, int size, + int64_t value, void *self); + + // Runs the stand-in for one imported function. Returns false when it stopped the emulation + // instead of returning a value. + bool call(const Handler &h, uint64_t &result); + + uint64_t arg(int i) const; + std::string cstr(uint64_t addr) const; + uint64_t fail(uint64_t err); + void poke32(uint64_t addr, uint32_t v); + void poke64(uint64_t addr, uint64_t v); + void fillStat(uint64_t addr, const std::string &path, bool isDir, int64_t size); + uint64_t bumpScratch(uint64_t size); + void check(uc_err err, const char *what) const; + + uc_engine *uc_ = nullptr; + uc_hook stubHook_ = 0; + uc_hook unmappedHook_ = 0; + + std::unordered_map handlers_; + std::unordered_map objectOf_; + std::unordered_map keyByObject_; + uint64_t heapCur_ = 0; + uint64_t scratchCur_ = 0; + uint64_t scratchBase_ = 0; + uint64_t objCur_ = 0; + uint64_t nextUrl_ = 0; + uint64_t errnoAddr_ = 0; + std::map getenvAddr_; + std::map> files_; + std::set dirs_; + std::map fds_; + uint64_t nextFd_ = 3; + std::map urls_; + uint8_t hostUuid_[16]; + int64_t frozenTime_ = -1; + std::string fault_; + + Snapshot snapshot_; + std::vector zeros_; +}; + +} // namespace macadi diff --git a/app/src/test/macadi-host/CMakeLists.txt b/app/src/test/macadi-host/CMakeLists.txt new file mode 100644 index 00000000..920aa8b7 --- /dev/null +++ b/app/src/test/macadi-host/CMakeLists.txt @@ -0,0 +1,11 @@ +# Host build of the emulated Mac's Anisette core, for checking it against a reference without a +# device. See README.md. + +cmake_minimum_required(VERSION 3.22.1) +project(macadi_host CXX C) + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +include(${CMAKE_CURRENT_SOURCE_DIR}/../../main/cpp/macadi/MacAdi.cmake) + +add_executable(macadi_cli macadi_cli.cpp) +target_link_libraries(macadi_cli PRIVATE macadi_core) diff --git a/app/src/test/macadi-host/macadi_cli.cpp b/app/src/test/macadi-host/macadi_cli.cpp new file mode 100644 index 00000000..4f861d88 --- /dev/null +++ b/app/src/test/macadi-host/macadi_cli.cpp @@ -0,0 +1,89 @@ +// One Anisette header set from the emulated Mac, on the host, printed as JSON. +// +// macadi_cli +// [--load-only] +// +// The store directory is read and never written: this is for comparing output, and a check that +// advanced the machine's state would make the next comparison against a different machine. +// +// --load-only stops after loading and one Initialize + IsMachineProvisioned, which needs no +// provisioned store and so can run anywhere the library can be downloaded. + +#include +#include +#include +#include +#include +#include +#include + +#include "core_adi.h" +#include "error.h" + +namespace { + +std::vector readFile(const std::string &path) { + std::ifstream in(path, std::ios::binary); + if (!in) throw macadi::Error("cannot read " + path); + return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; +} + +std::string hex(const std::vector &b) { + static const char *digits = "0123456789abcdef"; + std::string out; + for (const uint8_t c : b) { + out.push_back(digits[c >> 4]); + out.push_back(digits[c & 15]); + } + return out; +} + +} // namespace + +int main(int argc, char **argv) { + if (argc < 6) { + std::fprintf(stderr, "usage: %s coreadi dispatch_hex store_dir host_uuid_hex frozen [--load-only]\n", + argv[0]); + return 2; + } + try { + const std::vector image = readFile(argv[1]); + macadi::CoreAdi adi(image, std::strtoull(argv[2], nullptr, 16)); + + const std::string uuidHex = argv[4]; + if (uuidHex.size() != 32) throw macadi::Error("host uuid must be 32 hex characters"); + uint8_t uuid[16]; + for (int i = 0; i < 16; i++) uuid[i] = static_cast(std::stoul(uuidHex.substr(i * 2, 2), nullptr, 16)); + adi.setHostUuid(uuid); + adi.setFrozenTime(std::stoll(argv[5])); + + if (argc > 6 && std::string(argv[6]) == "--load-only") { + try { + adi.headers(-2); + std::printf("{\"loaded\": true, \"provisioned\": true}\n"); + } catch (const macadi::NotProvisioned &) { + std::printf("{\"loaded\": true, \"provisioned\": false}\n"); + } + return 0; + } + + if (DIR *dir = opendir(argv[3])) { + while (const dirent *e = readdir(dir)) { + const std::string name = e->d_name; + if (name == "." || name == "..") continue; + adi.storePut(name, readFile(std::string(argv[3]) + "/" + name)); + } + closedir(dir); + } else { + throw macadi::Error(std::string("cannot open store ") + argv[3]); + } + + const macadi::Headers h = adi.headers(-2); + std::printf("{\"mid\": \"%s\", \"otp\": \"%s\", \"routing\": %llu}\n", hex(h.mid).c_str(), + hex(h.otp).c_str(), static_cast(h.routing)); + return 0; + } catch (const std::exception &e) { + std::fprintf(stderr, "error: %s\n", e.what()); + return 1; + } +} diff --git a/scripts/locate_mac_coreadi.py b/scripts/locate_mac_coreadi.py new file mode 100644 index 00000000..2be0fe27 --- /dev/null +++ b/scripts/locate_mac_coreadi.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Check where Apple's macOS CoreADI sits in the iTunes 12.8.3 installer, and optionally extract it. + +The app fetches the library with one HTTP range request into a 291 MB disk image +(`MacCoreAdiFetcher`). That only works because, in that exact image, the package payload holding +the library is stored uncompressed and in one piece, so its byte range is a constant. This script +is how anybody can confirm the constants rather than take them on trust. + +The constants are read from the Java sources, not copied here, so the two cannot drift apart: +`PAYLOAD_OFFSET`, `PAYLOAD_LENGTH`, `PAYLOAD_SHA256` and `DMG_URL` from `MacCoreAdiFetcher.java`, +`BUILD_SHA256` from `MacCoreAdi.java`. + + # Ask Apple for the range, the way the app does, and check both hashes + python scripts/locate_mac_coreadi.py --remote + + # The same against a full copy of the image, and show which chunks of it the range spans + python scripts/locate_mac_coreadi.py --dmg iTunes12.8.3.dmg + + # Also write the x86_64 library out (the host check in app/src/test/macadi-host/ uses it) + python scripts/locate_mac_coreadi.py --remote --out /tmp/CoreADI.x86_64 + +Apple's library is never written into the repository. `--out` is for a scratch location. +""" + +from __future__ import annotations + +import argparse +import gzip +import hashlib +import plistlib +import re +import struct +import sys +import urllib.request +from pathlib import Path +from typing import NamedTuple + +ROOT = Path(__file__).resolve().parent.parent +JAVA = ROOT / "app/src/main/java/dev/wander/android/opentagviewer/anisette" +FRAMEWORK_PATH = "System/Library/PrivateFrameworks/CoreADI.framework/Versions/A/CoreADI" +CPU_TYPE_X86_64 = 0x01000007 + + +def constant(source: str, name: str) -> str: + """The value of `NAME = ...;` in a Java source, with string concatenation and `_` folded.""" + match = re.search(rf"\b{name}\s*=\s*(.*?);", source, re.DOTALL) + if match is None: + raise SystemExit(f"{name} not found in the Java sources") + text = match.group(1) + strings = re.findall(r'"([^"]*)"', text) + if strings: + return "".join(strings) + return text.strip().rstrip("L").replace("_", "") + + +class Constants(NamedTuple): + url: str + offset: int + length: int + payload_sha256: str + build_sha256: str + + +def load_constants() -> Constants: + fetcher = (JAVA / "MacCoreAdiFetcher.java").read_text() + adi = (JAVA / "MacCoreAdi.java").read_text() + return Constants( + url=constant(fetcher, "DMG_URL"), + offset=int(constant(fetcher, "PAYLOAD_OFFSET")), + length=int(constant(fetcher, "PAYLOAD_LENGTH")), + payload_sha256=constant(fetcher, "PAYLOAD_SHA256"), + build_sha256=constant(adi, "BUILD_SHA256"), + ) + + +def fetch_range(url: str, offset: int, length: int) -> bytes: + request = urllib.request.Request( + url, headers={"Range": f"bytes={offset}-{offset + length - 1}"}) + with urllib.request.urlopen(request, timeout=120) as response: + if response.status != 206: + raise SystemExit(f"Apple answered HTTP {response.status} to the range; expected 206") + return response.read() + + +def file_from_cpio(cpio: bytes, wanted: str) -> bytes: + """One file out of an odc cpio archive - the same walk as MacCoreAdiFetcher.fileFromCpio.""" + pos = 0 + while pos + 76 <= len(cpio): + if cpio[pos:pos + 6] != b"070707": + raise SystemExit(f"not an odc cpio archive at offset {pos}") + name_size = int(cpio[pos + 59:pos + 65], 8) + file_size = int(cpio[pos + 65:pos + 76], 8) + name = cpio[pos + 76:pos + 76 + name_size - 1].decode() + data = pos + 76 + name_size + if name == "TRAILER!!!": + break + if name.removeprefix("./") == wanted and file_size > 0: + return cpio[data:data + file_size] + pos = data + file_size + raise SystemExit(f"{wanted} is not in the payload") + + +def x86_64_slice(fat: bytes) -> bytes: + magic, count = struct.unpack(">II", fat[:8]) + if magic != 0xCAFEBABE: + raise SystemExit("the library is not a universal binary") + for i in range(count): + cpu, _, offset, size, _ = struct.unpack(">IIIII", fat[8 + 20 * i:28 + 20 * i]) + if cpu == CPU_TYPE_X86_64: + return fat[offset:offset + size] + raise SystemExit("the library has no x86_64 slice") + + +def chunks_spanned(dmg: Path, offset: int, length: int) -> list[str]: + """Which UDIF chunks the range lies in, and that they are stored raw and back to back.""" + with dmg.open("rb") as f: + f.seek(-512, 2) + koly = f.read(512) + if koly[:4] != b"koly": + raise SystemExit("not a UDIF disk image") + xml_offset, xml_length = struct.unpack(">QQ", koly[0xD8:0xE8]) + f.seek(xml_offset) + resources = plistlib.loads(f.read(xml_length)) + + spanned = [] + for part in resources["resource-fork"]["blkx"]: + table = part["Data"] + count = struct.unpack(">I", table[200:204])[0] + for i in range(count): + kind, _, _, _, at, size = struct.unpack(">IIQQQQ", table[204 + 40 * i:244 + 40 * i]) + if at < offset + length and at + size > offset: + if kind != 1: + raise SystemExit(f"the range crosses a compressed chunk (type {kind:#x})") + spanned.append(f"raw chunk at {at}, {size} bytes") + return spanned + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--remote", action="store_true", help="range-fetch from Apple") + source.add_argument("--dmg", type=Path, help="a local copy of iTunes12.8.3.dmg") + parser.add_argument("--out", type=Path, help="write the x86_64 library here") + args = parser.parse_args() + + c = load_constants() + print(f"payload: bytes {c.offset}..{c.offset + c.length - 1} of the image") + + if args.remote: + payload = fetch_range(c.url, c.offset, c.length) + else: + for line in chunks_spanned(args.dmg, c.offset, c.length): + print(f" {line}") + with args.dmg.open("rb") as f: + f.seek(c.offset) + payload = f.read(c.length) + + payload_hash = hashlib.sha256(payload).hexdigest() + print(f"payload sha256 {payload_hash}") + if payload_hash != c.payload_sha256: + raise SystemExit("payload hash does not match PAYLOAD_SHA256") + + library = x86_64_slice(file_from_cpio(gzip.decompress(payload), FRAMEWORK_PATH)) + library_hash = hashlib.sha256(library).hexdigest() + print(f"library sha256 {library_hash} ({len(library)} bytes)") + if library_hash != c.build_sha256: + raise SystemExit("library hash does not match BUILD_SHA256") + + if args.out: + args.out.write_bytes(library) + print(f"wrote {args.out}") + print("ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From a0f9bf12567b5fbbd3633908e9e5f47bc62c4a87 Mon Sep 17 00:00:00 2001 From: Rob Lazzurs Date: Wed, 21 Oct 2026 22:11:47 +0000 Subject: [PATCH 2/9] Sign in as the emulated Mac, so Find My answers again Since 2026-10-12 Apple issues searchPartyToken only to Mac- or iPhone-minted Anisette, and the edge now refuses any client claiming Android, so the Android ADI path cannot reach Find My. EmulatedMacAnisette fetches the macOS library from Apple, provisions one emulated Mac with akd's headers, keeps its identity and store for the life of the install, and is what AppDependencies hands out. Also: read Grand Slam's hsc 409 without au as a second factor; send the routing number Apple assigned at provisioning; give CloudKit a UUID, since a Mac's device id is not one; drop the legacy adi.pb before every session, which otherwise made a freshly provisioned machine report unprovisioned (ec -80009). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012JDrrt9ff7WwPepHs6B2Gx --- .../anisette/AnisetteSource.java | 11 + .../anisette/EmulatedMacAnisette.java | 351 ++++++++++++++++++ .../opentagviewer/anisette/MacCoreAdi.java | 161 ++++++++ .../anisette/MacCoreAdiFetcher.java | 301 +++++++++++++++ .../opentagviewer/anisette/MacIdentity.java | 205 ++++++++++ .../anisette/MacProvisioning.java | 218 +++++++++++ .../opentagviewer/python/AppDependencies.java | 9 +- app/src/main/python/icloud_bridge.py | 51 ++- app/src/main/python/main.py | 61 ++- .../anisette/MacCoreAdiFetcherTest.java | 174 +++++++++ app/src/test/python/test_emulated_mac.py | 154 ++++++++ 11 files changed, 1691 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisette.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdiFetcher.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/MacIdentity.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/MacProvisioning.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/MacCoreAdiFetcherTest.java create mode 100644 app/src/test/python/test_emulated_mac.py diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/AnisetteSource.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/AnisetteSource.java index ba5bf60e..9096e6c7 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/anisette/AnisetteSource.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/AnisetteSource.java @@ -94,6 +94,17 @@ public interface AnisetteSource { */ String serial(); + /** + * {@code X-Apple-I-MD-RINFO}: the routing number the machine was provisioned under. + * + *

FindMy.py's own providers send a constant - 17106176, which is what Android-backed + * Anisette servers use. That is right for {@link LocalAnisette}, which is one, and so is the + * default here. {@link EmulatedMacAnisette} answers with the number Apple assigned its Mac. + */ + default String router() { + return "17106176"; + } + /** Short human-readable state, for logs and diagnostics. */ String describe(); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisette.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisette.java new file mode 100644 index 00000000..37fa58ca --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisette.java @@ -0,0 +1,351 @@ +package dev.wander.android.opentagviewer.anisette; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Base64; +import android.util.Log; + +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Locale; +import java.util.Map; + +/** + * Anisette from an emulated Mac on this device: Apple's macOS CoreADI under an x86_64 emulator. + * + *

Why this replaced {@link LocalAnisette}. That one runs Apple's Android libraries, + * which mint Android machines. Since 2026-10-12 Apple issues the Find My token only to a sign-in + * made with a Mac- or iPhone-minted machine, and from about 2026-10-20 its edge refuses any client + * that admits to being Android - so an Android-minted machine can no longer sign in to Find My by + * any combination of headers. A Mac-minted one can, and this mints them. See {@link MacCoreAdi}. + * + *

The machine must be the same one for the life of the session, and not only at + * sign-in: the Find My token only works alongside Anisette from the machine that obtained it + * (os-findmy-e EVIDENCE.md section 6). So everything this hands out comes from one identity, + * stored under {@code files/anisette/mac/}, and the same identity answers before and after it is + * ready. + * + *

Once per process. The emulator, the library and the identity are static, for the same + * reason {@link LocalAnisette}'s are: {@code AppDependencies} builds a new source for every caller, + * and two emulators over one store would each think they owned it. + */ +public final class EmulatedMacAnisette implements AnisetteSource { + private static final String TAG = "EmulatedMacAnisette"; + + private static final String PREFERENCES = "anisette-emulated-mac"; + private static final String KEY_SESSION_WAS_LOCAL = "sessionEstablishedLocally"; + + /** How long one generated set is reused. See {@code LocalAnisette.CACHE_FOR_MS}. */ + private static final long CACHE_FOR_MS = 5_000; + + /** Guards everything static below, and every call into the emulator. */ + private static final Object LOCK = new Object(); + private static MacCoreAdi adi; + private static MacIdentity identity; + private static Map cached; + private static long cachedAt; + + private final Context context; + private final UserSettings settings; + private final boolean hasExistingSession; + private volatile String unavailableReason; + + public EmulatedMacAnisette(Context context, UserSettings settings, boolean hasExistingSession) { + this.context = context.getApplicationContext(); + this.settings = settings; + this.hasExistingSession = hasExistingSession; + } + + private File directory() { + return new File(this.context.getFilesDir(), "anisette/mac"); + } + + private File storeDirectory() { + return new File(directory(), "store"); + } + + @Override + public boolean ensureReady() { + synchronized (LOCK) { + if (adi != null) { + return true; + } + if (!this.settings.usesLocalAnisette(this.hasExistingSession)) { + this.unavailableReason = "a remote Anisette server is selected in Settings"; + return false; + } + + MacCoreAdi opened = null; + try { + final byte[] library = MacCoreAdiFetcher.ensure( + new File(directory(), "CoreADI-4.19-x86_64")); + + MacIdentity current = loadOrCreateIdentity(); + opened = open(library, current); + + if (current.provisionedAtMillis() == 0) { + current = provision(opened, current); + } else if (!isProvisioned(opened)) { + // The identity says provisioned but the store has lost it. Apple will not + // provision the same device id twice (HTTP 433), so this is a new machine: + // a second device-list entry, which is better than never signing in again. + Log.w(TAG, "the emulated Mac's store no longer holds its provisioning;" + + " registering a new machine"); + opened.close(); + current = MacIdentity.generate(); + current.save(directory()); + clearStore(); + opened = open(library, current); + current = provision(opened, current); + } + + adi = opened; + identity = current; + cached = null; + this.unavailableReason = null; + Log.i(TAG, "ready as an emulated Mac, device " + current.deviceId()); + return true; + } catch (final Exception | LinkageError e) { + // LinkageError too: an UnsatisfiedLinkError loading libmacadi.so is the likeliest + // failure on a device nobody has tried, and it must fall back, not crash sign-in. + if (opened != null) { + opened.close(); + } + this.unavailableReason = e.getMessage() == null ? e.toString() : e.getMessage(); + Log.w(TAG, "the emulated Mac is unavailable, falling back to a remote server: " + + this.unavailableReason, e); + return false; + } + } + } + + private MacCoreAdi open(byte[] library, MacIdentity current) throws IOException { + final MacCoreAdi opened = new MacCoreAdi(library); + opened.setHostUuid(current.hostUuidBytes()); + final File[] stored = storeDirectory().listFiles(); + if (stored != null) { + for (final File file : stored) { + if (file.isFile() && !file.getName().endsWith(".partial")) { + opened.storePut(file.getName(), MacCoreAdiFetcher.readFile(file)); + } + } + } + return opened; + } + + private static boolean isProvisioned(MacCoreAdi opened) { + try { + opened.headers(MacCoreAdi.ANONYMOUS_DS_ID); + return true; + } catch (final MacCoreAdi.NotProvisionedException e) { + return false; + } + } + + /** + * Register this machine with Apple and keep the result. + * + *

The store is written before the identity is marked provisioned. If the process dies in + * between, the next launch finds a provisioned store under an unprovisioned identity, the + * library refuses to provision again, and the failure is visible - rather than the other way + * round, an identity marked provisioned over an empty store, which costs a second device. + */ + private MacIdentity provision(MacCoreAdi opened, MacIdentity current) throws Exception { + final long routing = new MacProvisioning(current, opened).provision(); + saveStore(opened); + final MacIdentity done = current.provisioned(routing, System.currentTimeMillis()); + done.save(directory()); + return done; + } + + private void saveStore(MacCoreAdi opened) throws IOException { + final File dir = storeDirectory(); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("could not create " + dir); + } + for (final Map.Entry file : opened.storeFiles().entrySet()) { + // The legacy whole-machine blob makes the library report "not provisioned" whenever + // it is present, and is never read back - see CoreAdi::beginSession. + if ("adi.pb".equals(file.getKey())) { + continue; + } + final File target = new File(dir, file.getKey()); + final File partial = new File(dir, file.getKey() + ".partial"); + try (FileOutputStream out = new FileOutputStream(partial)) { + out.write(file.getValue()); + out.getFD().sync(); + } + if (!partial.renameTo(target)) { + throw new IOException("could not store " + target); + } + } + } + + private void clearStore() { + final File[] stored = storeDirectory().listFiles(); + if (stored != null) { + for (final File file : stored) { + if (!file.delete()) { + Log.w(TAG, "could not delete " + file); + } + } + } + } + + /** + * The identity, read or - on a fresh install - created and saved. Never provisions: that + * needs the library, and this has to answer without it. + */ + private MacIdentity loadOrCreateIdentity() throws IOException { + synchronized (LOCK) { + if (identity != null) { + return identity; + } + final MacIdentity stored = MacIdentity.load(directory()); + if (stored != null) { + return stored; + } + final MacIdentity fresh = MacIdentity.generate(); + fresh.save(directory()); + Log.i(TAG, "generated a new emulated Mac identity - this must now be kept"); + return fresh; + } + } + + private MacIdentity currentIdentity() { + try { + return loadOrCreateIdentity(); + } catch (final IOException e) { + throw new IllegalStateException("could not read or create the emulated Mac", e); + } + } + + @Override + public String unavailableReason() { + return this.unavailableReason; + } + + @Override + public boolean isChangingMachineIdentity() { + return wasSessionEstablishedLocally() && !ensureReady(); + } + + @Override + public String otp() { + return header("X-Apple-I-MD"); + } + + @Override + public String machine() { + return header("X-Apple-I-MD-M"); + } + + /** + * The routing number the library reports, which is the one Apple assigned at provisioning. + * Not FindMy.py's constant: that is an Android or Windows value, and this is a Mac. + */ + @Override + public String router() { + final String value = header("X-Apple-I-MD-RINFO"); + return value.isEmpty() ? AnisetteSource.super.router() : value; + } + + private String header(String name) { + if (!ensureReady()) { + return ""; + } + synchronized (LOCK) { + final long now = System.currentTimeMillis(); + if (cached == null || now - cachedAt > CACHE_FOR_MS) { + try { + final MacCoreAdi.Headers generated = + adi.headers(MacCoreAdi.ANONYMOUS_DS_ID); + // RequestOTP rewrites the store, and a store that goes back in time is a + // machine Apple has seen move backwards. + saveStore(adi); + cached = Map.of( + "X-Apple-I-MD-M", + Base64.encodeToString(generated.machineId, Base64.NO_WRAP), + "X-Apple-I-MD", + Base64.encodeToString(generated.oneTimePassword, Base64.NO_WRAP), + "X-Apple-I-MD-RINFO", Long.toString(generated.routing)); + cachedAt = now; + } catch (final Exception e) { + Log.w(TAG, "could not produce Anisette data", e); + this.unavailableReason = String.valueOf(e.getMessage()); + return ""; + } + } + final String value = cached.get(name); + return value == null ? "" : value; + } + } + + @Override + public void recordSessionProvenance(boolean establishedLocally) { + preferences().edit().putBoolean(KEY_SESSION_WAS_LOCAL, establishedLocally).apply(); + Log.i(TAG, "session established with " + (establishedLocally ? "the emulated Mac" : "remote") + + " Anisette"); + } + + @Override + public boolean wasSessionEstablishedLocally() { + return preferences().getBoolean(KEY_SESSION_WAS_LOCAL, false); + } + + @Override + public String hardwareProfileJson() { + return MacIdentity.hardwareJson(); + } + + @Override + public String deviceIdsJson() { + final MacIdentity current = currentIdentity(); + try { + return new JSONObject() + .put("uid", current.localUserUuid()) + .put("devid", current.deviceId()) + .toString(); + } catch (final JSONException e) { + throw new IllegalStateException("could not describe the emulated Mac", e); + } + } + + @Override + public String serial() { + return currentIdentity().serial(); + } + + /** + * What CloudKit is told this device is, as an uppercase UUID: the emulated Mac's host UUID. + * + *

Called from Python's {@code icloud_bridge._forCloudKit}. FindMy.py's CloudKit client + * parses the account's device id as a UUID, and a Mac's is not one. Not on + * {@link AnisetteSource}: no other source has a device id that needs it. + */ + public String cloudKitDeviceUuid() { + final String h = currentIdentity().hostUuid().toUpperCase(Locale.ROOT); + return h.substring(0, 8) + "-" + h.substring(8, 12) + "-" + h.substring(12, 16) + "-" + + h.substring(16, 20) + "-" + h.substring(20); + } + + @Override + public String describe() { + synchronized (LOCK) { + if (identity == null) { + return "emulated Mac not ready: " + this.unavailableReason; + } + return "ready as an emulated Mac, device " + identity.deviceId(); + } + } + + private SharedPreferences preferences() { + return this.context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi.java new file mode 100644 index 00000000..a12e3634 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi.java @@ -0,0 +1,161 @@ +package dev.wander.android.opentagviewer.anisette; + +import java.io.Closeable; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Apple's macOS CoreADI, running under an x86_64 emulator in {@code libmacadi.so}. + * + *

Why a Mac, on an Android phone. Since 2026-10-12 Apple issues the Find My token + * ({@code searchPartyToken}) only to a sign-in whose Anisette machine was minted by a Mac or an + * iPhone, and since about 2026-10-20 its edge refuses any client that says it is Android. The + * Android libraries {@link LocalAnisette} loads mint Android machines, so they can no longer + * reach Find My at all. This runs the Mac library instead - the one in iTunes 12.8.3 for Mac - + * which mints Mac machines. See sacca97/os-findmy-e, which this is ported from, for the evidence. + * + *

One call is one emulated session. The native side puts the emulator back to its + * post-load state before each of {@link #headers} and {@link #provisioningBegin}, so nothing + * builds up however long the app runs. Provisioning is the exception: + * {@link #provisioningBegin} and {@link #provisioningFinish} share one session and nothing may run + * between them. + * + *

Not thread-safe. Callers hold their own lock; {@link EmulatedMacAnisette} does. + */ +public final class MacCoreAdi implements Closeable { + + /** The one build this knows how to drive: iTunes 12.8.3, CoreADI 4.19, x86_64 slice. */ + public static final String BUILD_SHA256 = + "2eafc3c79c7f41a8afbfbeaaf23ee5dab5ad5bda55ec8e54dbc0ddf1d20ce630"; + + /** + * Where {@code _vdfut768ig}, the library's single dispatcher, sits in that build. + * + *

Tied to {@link #BUILD_SHA256}: the dispatcher's offset is a property of those exact bytes, + * and a different build at this offset would run arbitrary code as if it were the dispatcher. + */ + static final long DISPATCH_OFFSET = 0x979A0L; + + /** Apple's "no account" pseudo-DSID, which machine-level Anisette is always asked for. */ + public static final long ANONYMOUS_DS_ID = -2L; + + /** The library's own answer that this machine has never been provisioned. */ + public static final class NotProvisionedException extends IllegalStateException { + public NotProvisionedException(String message) { + super(message); + } + } + + /** One Anisette header set. */ + public static final class Headers { + /** {@code X-Apple-I-MD-M}, raw. */ + public final byte[] machineId; + /** {@code X-Apple-I-MD}, raw. */ + public final byte[] oneTimePassword; + /** {@code X-Apple-I-MD-RINFO}. */ + public final long routing; + + Headers(byte[] machineId, byte[] oneTimePassword, long routing) { + this.machineId = machineId; + this.oneTimePassword = oneTimePassword; + this.routing = routing; + } + } + + static { + System.loadLibrary("macadi"); + } + + private long handle; + + /** + * Load the library's bytes into a fresh emulator. + * + *

The caller has already checked them against {@link #BUILD_SHA256}; + * {@link MacCoreAdiFetcher} will not hand over anything else. + */ + public MacCoreAdi(byte[] image) { + this.handle = nativeOpen(image, DISPATCH_OFFSET); + } + + /** What the library hashes into the machine id. Fixed per machine; see {@link MacIdentity}. */ + public void setHostUuid(byte[] uuid) { + nativeSetHostUuid(live(), uuid); + } + + /** Tests only. A frozen clock makes the OTP a constant, which Apple refuses within a minute. */ + void setFrozenTime(long unixSeconds) { + nativeSetFrozenTime(live(), unixSeconds); + } + + public void storePut(String name, byte[] contents) { + nativeStorePut(live(), name, contents); + } + + /** The library's provisioning state, by file name. Persist it after every call. */ + public Map storeFiles() { + final Object[] flat = nativeStoreFiles(live()); + final Map out = new LinkedHashMap<>(); + for (int i = 0; i + 1 < flat.length; i += 2) { + out.put(new String((byte[]) flat[i], StandardCharsets.UTF_8), (byte[]) flat[i + 1]); + } + return out; + } + + /** @throws NotProvisionedException if this machine has never been provisioned */ + public Headers headers(long dsId) { + final long[] routing = new long[1]; + final byte[][] both = nativeHeaders(live(), dsId, routing); + return new Headers(both[0], both[1], routing[0]); + } + + /** + * Turn Apple's {@code spim} into the {@code cpim} to send back. + * + * @param sessionOut receives the session id {@link #provisioningFinish} needs + */ + public byte[] provisioningBegin(long dsId, byte[] spim, int[] sessionOut) { + return nativeProvisioningBegin(live(), dsId, spim, sessionOut); + } + + public void provisioningFinish(long dsId, int session, byte[] persistentTokenMetadata, + byte[] trustKey, long routing) { + nativeProvisioningFinish(live(), dsId, session, persistentTokenMetadata, trustKey, routing); + } + + @Override + public void close() { + if (this.handle != 0) { + nativeClose(this.handle); + this.handle = 0; + } + } + + private long live() { + if (this.handle == 0) { + throw new IllegalStateException("the emulated Mac has been closed"); + } + return this.handle; + } + + private static native long nativeOpen(byte[] image, long dispatch); + + private static native void nativeClose(long handle); + + private static native void nativeSetHostUuid(long handle, byte[] uuid); + + private static native void nativeSetFrozenTime(long handle, long secs); + + private static native void nativeStorePut(long handle, String name, byte[] data); + + private static native Object[] nativeStoreFiles(long handle); + + private static native byte[][] nativeHeaders(long handle, long dsId, long[] routingOut); + + private static native byte[] nativeProvisioningBegin( + long handle, long dsId, byte[] spim, int[] sessionOut); + + private static native void nativeProvisioningFinish( + long handle, long dsId, int session, byte[] ptm, byte[] tk, long routing); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdiFetcher.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdiFetcher.java new file mode 100644 index 00000000..84893284 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdiFetcher.java @@ -0,0 +1,301 @@ +package dev.wander.android.opentagviewer.anisette; + +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.zip.GZIPInputStream; + +/** + * Apple's macOS CoreADI, fetched from Apple: 3.5 MB of a 291 MB installer, in one request. + * + *

Where it is. The library ships in iTunes 12.8.3 for Mac, which Apple still serves + * (KB DL1977). It sits several layers down - the disk image's HFS partition, then + * {@code Install iTunes.pkg}, then {@code CoreADI.pkg/Payload}, a gzipped cpio archive. But + * the image is a fixed file, and in it the payload happens to be stored uncompressed and in one + * piece, so its position is a constant: bytes {@link #PAYLOAD_OFFSET} to + * {@code PAYLOAD_OFFSET + PAYLOAD_LENGTH}. {@code scripts/locate_mac_coreadi.py} derives those + * numbers from the image and checks them, and can be rerun by anybody who doubts them. + * + *

Nothing is trusted that is not pinned. The payload is checked against + * {@link #PAYLOAD_SHA256} before it is unpacked, and the library against + * {@link MacCoreAdi#BUILD_SHA256} before it is written - so a server that answered with + * something else, or ignored the range, produces an error rather than a library. + * + *

Apple's code is never redistributed by this project: it is fetched, at runtime, from Apple. + */ +public final class MacCoreAdiFetcher { + private static final String TAG = "MacCoreAdiFetcher"; + + public static final String DMG_URL = "https://secure-appldnld.apple.com/itunes12/" + + "041-56365-20210413-79A5AC6D-6B0A-46EC-B399-5A381183E3BE/iTunes12.8.3.dmg"; + + static final long PAYLOAD_OFFSET = 184_678_769L; + static final int PAYLOAD_LENGTH = 3_471_595; + static final String PAYLOAD_SHA256 = + "8a6c068c95036326eb2d2885fa60912ed0f7229d0939ae92934a70a3bb6cf025"; + + /** The library inside the payload. Apple's cpio names carry a leading {@code ./}. */ + static final String FRAMEWORK_PATH = + "System/Library/PrivateFrameworks/CoreADI.framework/Versions/A/CoreADI"; + + private static final int CPU_TYPE_X86_64 = 0x01000007; + + private MacCoreAdiFetcher() { + } + + /** Thrown when what came back is not the library this app knows how to run. */ + public static final class FetchException extends Exception { + public FetchException(String message) { + super(message); + } + + public FetchException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * Make sure {@code target} holds the library, fetching it if not. + * + * @return the library's bytes + */ + public static byte[] ensure(File target) throws FetchException { + return ensure(target, DMG_URL); + } + + /** As {@link #ensure(File)}, from {@code url}. Tests serve a fixture here. */ + static byte[] ensure(File target, String url) throws FetchException { + if (target.isFile()) { + try { + final byte[] existing = readFile(target); + if (MacCoreAdi.BUILD_SHA256.equals(sha256(existing))) { + return existing; + } + Log.w(TAG, "the stored library is not the expected build; fetching it again"); + } catch (final IOException e) { + Log.w(TAG, "could not read the stored library; fetching it again", e); + } + } + + final byte[] payload = fetchPayload(url); + final byte[] library = extract(payload); + write(target, library); + Log.i(TAG, String.format("fetched Apple's macOS CoreADI (%.1f MB downloaded)", + payload.length / 1e6)); + return library; + } + + private static byte[] fetchPayload(String url) throws FetchException { + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(30_000); + connection.setReadTimeout(60_000); + connection.setRequestProperty("Range", "bytes=" + PAYLOAD_OFFSET + "-" + + (PAYLOAD_OFFSET + PAYLOAD_LENGTH - 1)); + + // Anything but a partial response means the range was not honoured, and reading on + // would download the whole 291 MB image to throw nearly all of it away. + final int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_PARTIAL) { + throw new FetchException("Apple answered HTTP " + status + + " to a range request for the library; expected 206"); + } + + final byte[] payload = readAtMost(connection.getInputStream(), PAYLOAD_LENGTH + 1); + if (payload.length != PAYLOAD_LENGTH) { + throw new FetchException("expected " + PAYLOAD_LENGTH + " bytes of the installer," + + " got " + payload.length); + } + return payload; + } catch (final IOException e) { + throw new FetchException("could not download the library from Apple: " + + e.getClass().getSimpleName() + ": " + e.getMessage(), e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + /** + * The x86_64 library out of the installer's payload. No network; pure bytes in, bytes out. + * + * @throws FetchException if either the payload or the library is not the pinned one + */ + static byte[] extract(byte[] payload) throws FetchException { + final String payloadHash = sha256(payload); + if (!PAYLOAD_SHA256.equals(payloadHash)) { + throw new FetchException("the installer bytes are not the expected ones (sha256 " + + payloadHash + ")"); + } + + final byte[] cpio; + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(payload))) { + cpio = readAtMost(in, 64 << 20); + } catch (final IOException e) { + throw new FetchException("could not decompress the installer payload", e); + } + + final byte[] framework = fileFromCpio(cpio, FRAMEWORK_PATH); + final byte[] library = slice(framework, CPU_TYPE_X86_64); + + final String libraryHash = sha256(library); + if (!MacCoreAdi.BUILD_SHA256.equals(libraryHash)) { + throw new FetchException("the library is not the expected build (sha256 " + + libraryHash + ")"); + } + return library; + } + + /** + * One file's contents from an "odc" (old ASCII) cpio archive, which is what Apple's package + * payloads are. + * + *

Each entry: the magic {@code 070707}, then dev, ino, mode, uid, gid, nlink, rdev as six + * octal digits each, mtime as eleven, namesize as six and filesize as eleven - 76 bytes - then + * the name with its NUL, then the data. No padding. + */ + static byte[] fileFromCpio(byte[] cpio, String wanted) throws FetchException { + int pos = 0; + while (pos + 76 <= cpio.length) { + final String magic = new String(cpio, pos, 6, StandardCharsets.US_ASCII); + if (!"070707".equals(magic)) { + throw new FetchException("not an odc cpio archive at offset " + pos); + } + final int nameSize = (int) octal(cpio, pos + 59, 6); + final long fileSize = octal(cpio, pos + 65, 11); + final int nameStart = pos + 76; + if (nameSize < 1 || nameStart + nameSize > cpio.length) { + throw new FetchException("truncated cpio entry at offset " + pos); + } + String name = new String(cpio, nameStart, nameSize - 1, StandardCharsets.UTF_8); + final int dataStart = nameStart + nameSize; + if (fileSize < 0 || dataStart + fileSize > cpio.length) { + throw new FetchException("truncated cpio data for " + name); + } + if ("TRAILER!!!".equals(name)) { + break; + } + if (name.startsWith("./")) { + name = name.substring(2); + } + // A hard link's twins carry no data; the entry that matters is the one that does. + if (wanted.equals(name) && fileSize > 0) { + final byte[] out = new byte[(int) fileSize]; + System.arraycopy(cpio, dataStart, out, 0, out.length); + return out; + } + pos = dataStart + (int) fileSize; + } + throw new FetchException(wanted + " is not in the installer payload"); + } + + /** The slice for {@code cpuType} out of a Mach-O universal binary. */ + static byte[] slice(byte[] fat, int cpuType) throws FetchException { + if (fat.length < 8 || be32(fat, 0) != 0xCAFEBABE) { + throw new FetchException("the library is not a universal binary"); + } + final int count = be32(fat, 4); + for (int i = 0; i < count; i++) { + final int entry = 8 + 20 * i; + if (entry + 20 > fat.length) { + break; + } + if (be32(fat, entry) == cpuType) { + final int offset = be32(fat, entry + 8); + final int size = be32(fat, entry + 12); + if (offset < 0 || size < 0 || (long) offset + size > fat.length) { + throw new FetchException("the library's slice lies outside the file"); + } + final byte[] out = new byte[size]; + System.arraycopy(fat, offset, out, 0, size); + return out; + } + } + throw new FetchException("the library has no x86_64 slice"); + } + + private static void write(File target, byte[] library) throws FetchException { + final File dir = target.getParentFile(); + if (dir != null && !dir.isDirectory() && !dir.mkdirs()) { + throw new FetchException("could not create " + dir); + } + // Written beside and renamed over, so a process killed mid-write never leaves a + // truncated library that the hash check would then reject on every launch until cleared. + final File partial = new File(target.getPath() + ".partial"); + try (FileOutputStream out = new FileOutputStream(partial)) { + out.write(library); + out.getFD().sync(); + } catch (final IOException e) { + throw new FetchException("could not store the library", e); + } + if (!partial.renameTo(target)) { + throw new FetchException("could not store the library at " + target); + } + } + + /** + * A whole file. Not {@code java.nio.file.Files}, which is API 26 and which core library + * desugaring does not cover - minSdk is 24. + */ + static byte[] readFile(File file) throws IOException { + try (InputStream in = new FileInputStream(file)) { + return readAtMost(in, Integer.MAX_VALUE - 1); + } + } + + private static long octal(byte[] b, int offset, int length) throws FetchException { + long v = 0; + for (int i = 0; i < length; i++) { + final int c = b[offset + i]; + if (c < '0' || c > '7') { + throw new FetchException("bad octal field in the cpio archive"); + } + v = v * 8 + (c - '0'); + } + return v; + } + + private static int be32(byte[] b, int offset) { + return ((b[offset] & 0xFF) << 24) | ((b[offset + 1] & 0xFF) << 16) + | ((b[offset + 2] & 0xFF) << 8) | (b[offset + 3] & 0xFF); + } + + private static byte[] readAtMost(InputStream in, int limit) throws IOException { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + if (out.size() > limit) { + throw new IOException("more data than expected"); + } + } + return out.toByteArray(); + } + + static String sha256(byte[] data) { + try { + final byte[] digest = MessageDigest.getInstance("SHA-256").digest(data); + final StringBuilder hex = new StringBuilder(digest.length * 2); + for (final byte b : digest) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is always available", e); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacIdentity.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacIdentity.java new file mode 100644 index 00000000..4c219b54 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacIdentity.java @@ -0,0 +1,205 @@ +package dev.wander.android.opentagviewer.anisette; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Locale; + +/** + * Who the emulated Mac says it is - every value Apple sees about the machine, in one place. + * + *

Rule 11 applies with extra force. The library hashes {@link #hostUuid()} into the + * machine id it mints, and Apple binds the provisioning to the device id, local user id and serial + * presented with it. So these are generated once, stored beside the library's own state + * ({@code identity.json} next to {@code store/}), and never regenerated: a second set is a second + * Mac on the user's Apple account, and the session established with the first stops working. + * + *

The shapes are a real Mac's, and the values are the ones that have been seen to + * work. They follow sacca97/os-findmy-e's {@code mac_anisette.py} and {@code gsa.py}, which + * is the configuration Apple has been observed to issue a Find My token to. Where that source is + * inconsistent with itself - provisioning sends Darwin 22.3.0 and the login 22.2.0 - it is copied + * rather than corrected, because the consistent version is the untested one. + * + *

The serial is this app's own {@code 0PENTAGV} serial, so the device-list entry stays + * recognisable (rule 11). Apple has not been seen to check serials at all. + */ +public final class MacIdentity { + + static final String MODEL = "MacBookPro13,2"; + static final String OS_NAME = "macOS"; + static final String OS_VERSION = "13.1"; + static final String OS_BUILD = "22C65"; + static final String CFNETWORK = "1404.0.5"; + + /** What the sign-in reports, through FindMy.py's {@code DeviceIdentity}. */ + static final String LOGIN_DARWIN = "22.2.0"; + + /** {@code X-MMe-Client-Info} for provisioning. FindMy.py composes the same string at login. */ + static final String CLIENT_INFO = "<" + MODEL + "> <" + OS_NAME + ";" + OS_VERSION + ";" + + OS_BUILD + "> "; + + /** The provisioning requests' User-Agent - Darwin 22.3.0 here, as in the reference. */ + static final String PROVISIONING_USER_AGENT = "akd/1.0 CFNetwork/" + CFNETWORK + + " Darwin/22.3.0"; + + /** + * A Mac's {@code X-Mme-Device-Id} is its provisioning UDID - eight hex, a dash, sixteen hex - + * and not its hardware UUID. + */ + private static final String UDID_PREFIX = "00006000"; + + private static final String FILE = "identity.json"; + + private final String deviceId; + private final String localUserUuid; + private final String hostUuid; + private final String serial; + private final long routing; + private final long provisionedAtMillis; + + private MacIdentity(String deviceId, String localUserUuid, String hostUuid, String serial, + long routing, long provisionedAtMillis) { + this.deviceId = deviceId; + this.localUserUuid = localUserUuid; + this.hostUuid = hostUuid; + this.serial = serial; + this.routing = routing; + this.provisionedAtMillis = provisionedAtMillis; + } + + /** A fresh, unprovisioned machine. Call once, {@link #save}, never call again. */ + static MacIdentity generate() { + final SecureRandom random = new SecureRandom(); + return new MacIdentity( + UDID_PREFIX + "-" + hex(random, 8).toUpperCase(Locale.ROOT), + hex(random, 16).toUpperCase(Locale.ROOT), + hex(random, 16), + AdiDeviceIdentity.generateSerial(random), + 0, + 0); + } + + /** The stored identity, or null if this install has never had one. */ + static MacIdentity load(File directory) throws IOException { + final File file = new File(directory, FILE); + if (!file.isFile()) { + return null; + } + try { + final JSONObject json = new JSONObject( + new String(MacCoreAdiFetcher.readFile(file), StandardCharsets.UTF_8)); + return new MacIdentity( + json.getString("deviceId"), + json.getString("localUserUuid"), + json.getString("hostUuid"), + json.getString("serial"), + json.optLong("routing", 0), + json.optLong("provisionedAtMillis", 0)); + } catch (final JSONException e) { + throw new IOException("the emulated Mac's identity is unreadable", e); + } + } + + void save(File directory) throws IOException { + if (!directory.isDirectory() && !directory.mkdirs()) { + throw new IOException("could not create " + directory); + } + final String text; + try { + text = new JSONObject() + .put("deviceId", this.deviceId) + .put("localUserUuid", this.localUserUuid) + .put("hostUuid", this.hostUuid) + .put("serial", this.serial) + .put("routing", this.routing) + .put("provisionedAtMillis", this.provisionedAtMillis) + .toString(2); + } catch (final JSONException e) { + throw new IOException("could not describe the emulated Mac", e); + } + final File partial = new File(directory, FILE + ".partial"); + try (FileOutputStream out = new FileOutputStream(partial)) { + out.write(text.getBytes(StandardCharsets.UTF_8)); + out.getFD().sync(); + } + if (!partial.renameTo(new File(directory, FILE))) { + throw new IOException("could not store the emulated Mac's identity"); + } + } + + /** This identity, provisioned, with the routing Apple assigned it. */ + MacIdentity provisioned(long assignedRouting, long atMillis) { + return new MacIdentity(this.deviceId, this.localUserUuid, this.hostUuid, this.serial, + assignedRouting, atMillis); + } + + /** {@code X-Mme-Device-Id}. */ + public String deviceId() { + return this.deviceId; + } + + /** + * The local user id. Sent raw at provisioning and as {@code base64} of this at login, by + * FindMy.py - which is what the reference does, and what Apple accepted. + */ + public String localUserUuid() { + return this.localUserUuid; + } + + /** What {@code gethostuuid} returns inside the emulator, as 32 hex characters. */ + public String hostUuid() { + return this.hostUuid; + } + + byte[] hostUuidBytes() { + final byte[] out = new byte[16]; + for (int i = 0; i < 16; i++) { + out[i] = (byte) Integer.parseInt(this.hostUuid.substring(i * 2, i * 2 + 2), 16); + } + return out; + } + + public String serial() { + return this.serial; + } + + /** {@code X-Apple-I-MD-RINFO}, as Apple assigned it at provisioning. 0 before that. */ + public long routing() { + return this.routing; + } + + public long provisionedAtMillis() { + return this.provisionedAtMillis; + } + + /** The six values FindMy.py's {@code DeviceIdentity.from_json} takes. See rule 11. */ + static String hardwareJson() { + try { + return new JSONObject() + .put("model", MODEL) + .put("os_name", OS_NAME) + .put("os_version", OS_VERSION) + .put("os_build", OS_BUILD) + .put("cfnetwork", CFNETWORK) + .put("darwin", LOGIN_DARWIN) + .toString(); + } catch (final JSONException e) { + throw new IllegalStateException("could not describe the emulated Mac", e); + } + } + + private static String hex(SecureRandom random, int bytes) { + final byte[] buffer = new byte[bytes]; + random.nextBytes(buffer); + final StringBuilder out = new StringBuilder(bytes * 2); + for (final byte b : buffer) { + out.append(String.format("%02x", b)); + } + return out.toString(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacProvisioning.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacProvisioning.java new file mode 100644 index 00000000..4c3cc0f3 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacProvisioning.java @@ -0,0 +1,218 @@ +package dev.wander.android.opentagviewer.anisette; + +import android.util.Base64; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +/** + * Registering the emulated Mac with Apple: the same three requests as {@link AdiProvisioning}, + * presented the way a Mac's {@code akd} presents them. + * + *

    + *
  1. GET the URL bag
  2. + *
  3. POST {@code midStartProvisioning}, which answers the server's metadata (spim)
  4. + *
  5. POST {@code midFinishProvisioning} with what the library made of it (cpim), which + * answers a persistent token, a trust key and the machine's routing number
  6. + *
+ * + *

The headers are copied from sacca97/os-findmy-e {@code tools/machoadi/gsa.py}, the + * configuration Apple has been seen to accept, and not tidied. + * + *

Apple allows one provisioning per device id. A second answers HTTP 433 / -29002 + * whatever else changes, so a failure after {@code midFinishProvisioning} succeeded cannot be + * retried with the same identity. That is why the caller saves the store and the identity the + * moment this returns. + */ +final class MacProvisioning { + private static final String TAG = "MacProvisioning"; + + private static final String URL_BAG = "https://gsa.apple.com/grandslam/GsService2/lookup"; + + private static final String START_REQUEST = + "\n" + + "\n" + + "" + + "HeaderRequest" + + ""; + + private static final String FINISH_REQUEST = + "\n" + + "\n" + + "" + + "Header" + + "Requestcpim%s" + + ""; + + private final MacIdentity identity; + private final MacCoreAdi adi; + + MacProvisioning(MacIdentity identity, MacCoreAdi adi) { + this.identity = identity; + this.adi = adi; + } + + /** @return the routing number Apple assigned the machine */ + long provision() throws AdiProvisioning.ProvisioningException { + final Map urls = loadUrlBag(); + final String startUrl = urls.get("midStartProvisioning"); + final String finishUrl = urls.get("midFinishProvisioning"); + if (startUrl == null || finishUrl == null) { + throw new AdiProvisioning.ProvisioningException( + "the URL bag has no provisioning endpoints. Got: " + urls.keySet()); + } + + final Object started = parse(request(startUrl, START_REQUEST), "midStartProvisioning"); + final byte[] spim = decode(started, "spim"); + + final int[] session = new int[1]; + final byte[] cpim; + try { + cpim = this.adi.provisioningBegin(MacCoreAdi.ANONYMOUS_DS_ID, spim, session); + } catch (final IllegalStateException e) { + throw new AdiProvisioning.ProvisioningException( + "the emulated Mac could not start provisioning: " + e.getMessage(), e); + } + + final Object finished = parse(request(finishUrl, String.format( + FINISH_REQUEST, Base64.encodeToString(cpim, Base64.NO_WRAP))), + "midFinishProvisioning"); + final byte[] persistentToken = decode(finished, "ptm"); + final byte[] trustKey = decode(finished, "tk"); + final String routingText = SimplePlist.string(finished, "Response", "X-Apple-I-MD-RINFO"); + long routing = 0; + if (routingText != null && !routingText.isEmpty()) { + try { + routing = Long.parseLong(routingText.trim()); + } catch (final NumberFormatException e) { + throw new AdiProvisioning.ProvisioningException( + "Apple's routing number is not a number: " + routingText); + } + } + + try { + this.adi.provisioningFinish(MacCoreAdi.ANONYMOUS_DS_ID, session[0], persistentToken, + trustKey, routing); + } catch (final IllegalStateException e) { + throw new AdiProvisioning.ProvisioningException( + "the emulated Mac could not finish provisioning: " + e.getMessage(), e); + } + Log.i(TAG, "the emulated Mac is provisioned, routing " + routing); + return routing; + } + + private Map loadUrlBag() throws AdiProvisioning.ProvisioningException { + final Object plist = parse(request(URL_BAG, null), "the URL bag"); + if (!(plist instanceof Map)) { + throw new AdiProvisioning.ProvisioningException("the URL bag is not a dictionary"); + } + final Object urls = ((Map) plist).get("urls"); + if (!(urls instanceof Map) || ((Map) urls).isEmpty()) { + throw new AdiProvisioning.ProvisioningException("the URL bag came back empty"); + } + final Map out = new HashMap<>(); + for (final Map.Entry entry : ((Map) urls).entrySet()) { + if (entry.getValue() instanceof String) { + out.put(String.valueOf(entry.getKey()), (String) entry.getValue()); + } + } + return out; + } + + private static Object parse(String body, String what) + throws AdiProvisioning.ProvisioningException { + try { + return SimplePlist.parse(body); + } catch (final Exception e) { + throw new AdiProvisioning.ProvisioningException("could not read " + what, e); + } + } + + private static byte[] decode(Object plist, String key) + throws AdiProvisioning.ProvisioningException { + final String value = SimplePlist.string(plist, "Response", key); + if (value == null) { + throw new AdiProvisioning.ProvisioningException("Apple's response has no Response/" + + key + " - the request was refused, or the protocol has changed"); + } + return Base64.decode(value, Base64.DEFAULT); + } + + private String request(String url, String body) throws AdiProvisioning.ProvisioningException { + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(30_000); + connection.setReadTimeout(30_000); + connection.setRequestProperty("User-Agent", MacIdentity.PROVISIONING_USER_AGENT); + // AuthKit really does label an XML plist body as a form. + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + connection.setRequestProperty("Connection", "keep-alive"); + connection.setRequestProperty("Accept", "*/*"); + connection.setRequestProperty("X-Mme-Device-Id", this.identity.deviceId()); + connection.setRequestProperty("X-MMe-Client-Info", MacIdentity.CLIENT_INFO); + connection.setRequestProperty("X-Apple-I-MD-LU", this.identity.localUserUuid()); + connection.setRequestProperty("X-Apple-Client-App-Name", "Setup"); + connection.setRequestProperty("X-Apple-I-SRL-NO", this.identity.serial()); + + if (body != null) { + connection.setRequestProperty("X-Apple-I-Client-Time", now()); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8)); + } + + final int status = connection.getResponseCode(); + final String response = readAll(status >= 400 + ? connection.getErrorStream() : connection.getInputStream()); + if (status >= 400) { + throw new AdiProvisioning.ProvisioningException( + "Apple returned HTTP " + status + " for " + url + ": " + response); + } + return response; + } catch (final AdiProvisioning.ProvisioningException e) { + throw e; + } catch (final IOException e) { + throw new AdiProvisioning.ProvisioningException("could not reach " + url + ": " + + e.getClass().getSimpleName() + ": " + e.getMessage(), e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + /** ISO 8601, whole seconds. Apple rejects anything with milliseconds on it. */ + private static String now() { + final SimpleDateFormat format = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + return format.format(new Date()); + } + + private static String readAll(InputStream stream) throws IOException { + if (stream == null) { + return ""; + } + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final byte[] buffer = new byte[8192]; + int read; + while ((read = stream.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toString("UTF-8"); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java index e92fec1f..62c3ca76 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java @@ -13,6 +13,7 @@ import java.util.function.Supplier; import dev.wander.android.opentagviewer.anisette.AnisetteSource; +import dev.wander.android.opentagviewer.anisette.EmulatedMacAnisette; import dev.wander.android.opentagviewer.anisette.LocalAnisette; import dev.wander.android.opentagviewer.ble.AccessorySoundTrigger; import dev.wander.android.opentagviewer.ble.BleAccessorySoundTrigger; @@ -54,8 +55,12 @@ private AppDependencies() {} * How to build Anisette for a given settings object. A factory rather than an instance * because the real one needs a Context and the current settings, and neither exists when * this class is loaded. + * + *

The emulated Mac, not {@link LocalAnisette}. The Android libraries that one runs + * mint Android machines, which Apple no longer lets reach Find My - see + * {@link EmulatedMacAnisette}. */ - private static AnisetteFactory anisetteFactory = LocalAnisette::new; + private static AnisetteFactory anisetteFactory = EmulatedMacAnisette::new; /** Builds the Anisette source for a screen, given where it is running and who is signed in. */ public interface AnisetteFactory { @@ -293,7 +298,7 @@ public static void replaceAnisette(final Function @VisibleForTesting public static void reset() { authService = new PythonAppleAuthService(); - anisetteFactory = LocalAnisette::new; + anisetteFactory = EmulatedMacAnisette::new; serverTesterFactory = AnisetteServerTesterService::new; hardwareDescriber = new ChaquopyHardwareDescriber(); accessoryMacResolver = new ChaquopyAccessoryMacResolver(); diff --git a/app/src/main/python/icloud_bridge.py b/app/src/main/python/icloud_bridge.py index 2b85beb8..1665251a 100644 --- a/app/src/main/python/icloud_bridge.py +++ b/app/src/main/python/icloud_bridge.py @@ -40,6 +40,7 @@ import sys import time import traceback +import uuid from typing import Any import identity as app_identity @@ -423,7 +424,7 @@ def open(self) -> str: try: client = self._awaiting( - lambda: icloud.open_client(self._async, self._identity), + lambda: icloud.open_client(_forCloudKit(self._async), self._identity), "opening the Find My client") self._awaiting(client.__aenter__, "starting the keychain session") self._client = client @@ -890,6 +891,54 @@ def _plist(mapping: Any) -> str | None: return plistlib.dumps(dict(mapping), fmt=plistlib.FMT_XML).decode("utf-8") +class _CloudKitDeviceView: + """ + The account, except that `device_uuid` is a UUID - for the keychain and CloudKit clients only. + + FindMy.py's CloudKit client does `uuid.UUID(account.device_uuid)` for the device identifier it + sends. An emulated Mac's device id is a Mac provisioning UDID, `00006000-` and sixteen hex, + which is not a UUID, so opening the client failed with "badly formed hexadecimal UUID string" + before anything was sent. CloudKit's identifier is a different field from `X-Mme-Device-Id`, + so this answers it with another stable value and forwards everything else to the real + account - Grand Slam, tokens and PET renewals keep the real device id. After + sacca97/os-findmy-e `tag_keys.CloudKitIdentity`, which read tags this way. + """ + + def __init__(self, account: Any, deviceUuid: str) -> None: + self._account = account + self._deviceUuid = deviceUuid + + @property + def device_uuid(self) -> str: + return self._deviceUuid + + def __getattr__(self, name: str) -> Any: + return getattr(self._account, name) + + +def _forCloudKit(asyncAccount: Any) -> Any: + """`asyncAccount`, or a view of it whose device id CloudKit can parse.""" + deviceId = getattr(asyncAccount, "device_uuid", None) + if not isinstance(deviceId, str): + return asyncAccount + try: + uuid.UUID(deviceId) + return asyncAccount + except ValueError: + pass + + # The emulated Mac's host UUID when Java can say, as os-findmy-e used; otherwise one derived + # from the device id, which is as stable as the device id is. + bridge = getattr(getattr(asyncAccount, "_anisette", None), "_bridge", None) + ask = getattr(bridge, "cloudKitDeviceUuid", None) + if ask is not None: + stable = str(ask()) + else: + stable = str(uuid.uuid5(uuid.NAMESPACE_URL, f"opentagviewer:device:{deviceId}")).upper() + print(f"The device id is not a UUID; CloudKit is told {stable} instead") + return _CloudKitDeviceView(asyncAccount, stable) + + def openSession(account: Any) -> ICloudSession | None: """ Start an iCloud conversation on the account the app is already signed in with. diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 5bab609f..8e2a51fd 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -206,11 +206,33 @@ def __init__(self, bridge: Any, fallbackServerUrl: str, **identityKwargs: Any) - @property def otp(self) -> str: - return str(self._bridge.otp()) + return self._said("one-time password", str(self._bridge.otp())) @property def machine(self) -> str: - return str(self._bridge.machine()) + return self._said("machine id", str(self._bridge.machine())) + + def _said(self, what: str, value: str) -> str: + """Pass the value on, saying why when Java had none. + + Empty is still what goes to Apple - the contract is not to throw into a login - but + Apple's answer to it is only `ec -80009`, which names no cause. The reason is in Java. + """ + if not value: + print(f"Local Anisette produced no {what}: {self._bridge.unavailableReason()}") + return value + + @property + def router(self) -> str: + """`X-Apple-I-MD-RINFO`, from Java rather than FindMy.py's Android constant. + + The emulated Mac is provisioned under a routing number Apple assigns, and has to present + that one. A bridge without the method - an older fake in a test - keeps the library's. + """ + router = getattr(self._bridge, "router", None) + if router is None: + return super().router + return str(router()) def to_json(self, dst=None, /): """Deliberately the remote mapping - see the class docstring. @@ -318,6 +340,7 @@ def loginSync(email: str, password: str, anisetteServerUrl: str, timeout=LOGIN_TIMEOUT_SECONDS, **app_identity.deviceIdsForNewSession(localAnisette), ) + _treatHsc409AsSecondFactor(acc) state = acc.login(email, password) @@ -759,6 +782,38 @@ def acceptTerms(account: AppleAccount, pageId: str) -> str: }) +def _treatHsc409AsSecondFactor(acc: AppleAccount) -> None: + """Read Grand Slam's new way of asking for a second factor as asking for one. + + Since 2026-10-12, a password Apple accepts but wants a code for is answered at SRP `complete` + with `hsc` 409 and **no `au`** - the field the pinned FindMy.py reads to decide that 2FA is + needed. Seeing no `au`, the library concludes the sign-in is complete, skips the code, and the + `mobileme` step after it fails with `status=1`. Reported in malmeloo/FindMy.py#278 and on + parawanderer/OpenTagViewer#248; the remedy, setting `au` to `trustedDeviceSecondaryAuth`, is + the one sacca97/os-findmy-e signed in with. + + Wraps the instance's `_gsa_request`, which `_gsa_authenticate` looks up on the instance. If a + later FindMy.py renames it, this says so and changes nothing, and the 2FA bug comes back. + """ + inner = getattr(acc, "_asyncacc", None) + original = getattr(inner, "_gsa_request", None) if inner is not None else None + if inner is None or original is None: + print("FindMy's account internals have changed; hsc 409 will not be read as 2FA") + return + + async def _gsa_request(parameters: Any, *args: Any, **kwargs: Any) -> Any: + response = await original(parameters, *args, **kwargs) + status = response.get("Status") if isinstance(response, dict) else None + if (isinstance(parameters, dict) and parameters.get("o") == "complete" + and isinstance(status, dict) and status.get("hsc") == 409 + and not status.get("au")): + print("Grand Slam answered hsc 409 with no au: treating it as a second factor") + status["au"] = "trustedDeviceSecondaryAuth" + return response + + inner._gsa_request = _gsa_request + + def _preferLocalAnisette(acc: AppleAccount, localAnisette: Any) -> None: """Swap a restored account's anisette provider for the local one, if it is usable. @@ -839,6 +894,8 @@ def getAccount( acc = AppleAccount.from_json(data) _preferLocalAnisette(acc, localAnisette) + # A restored session re-authenticates on its own after a 401, through the same exchange. + _treatHsc409AsSecondFactor(acc) print(f"Restored account, login state: {acc.login_state}") diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/MacCoreAdiFetcherTest.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/MacCoreAdiFetcherTest.java new file mode 100644 index 00000000..6856e268 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/MacCoreAdiFetcherTest.java @@ -0,0 +1,174 @@ +package dev.wander.android.opentagviewer.anisette; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * Getting Apple's macOS CoreADI out of the iTunes installer, without Apple's bytes. + * + *

The real payload is Apple's and is not in the repository, so the pinned hashes cannot be + * satisfied here; {@code scripts/locate_mac_coreadi.py} checks those against the real image. What + * this covers is everything between the hashes - the cpio walk, the universal-binary slice - and + * that a server which ignores the range is refused before the 291 MB arrive. + */ +public class MacCoreAdiFetcherTest { + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + /** One "odc" cpio entry, as Apple's package payloads write them. */ + private static void entry(ByteArrayOutputStream out, String name, byte[] data) { + final byte[] nameBytes = (name + "\0").getBytes(StandardCharsets.UTF_8); + final String header = "070707" + + "000001" + "000002" + "100644" + "000000" + "000000" + "000001" + "000000" + + String.format("%011o", 0) + + String.format("%06o", nameBytes.length) + + String.format("%011o", data.length); + out.write(header.getBytes(StandardCharsets.US_ASCII), 0, header.length()); + out.write(nameBytes, 0, nameBytes.length); + out.write(data, 0, data.length); + } + + private static byte[] cpio(Object... namesAndData) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (int i = 0; i < namesAndData.length; i += 2) { + entry(out, (String) namesAndData[i], (byte[]) namesAndData[i + 1]); + } + entry(out, "TRAILER!!!", new byte[0]); + return out.toByteArray(); + } + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.US_ASCII); + } + + @Test + public void theLibraryIsFoundUnderItsDotSlashName() throws Exception { + final byte[] archive = cpio( + "./System/Library/PrivateFrameworks/CoreADI.framework/Versions/A/adid", bytes("adid"), + "./" + MacCoreAdiFetcher.FRAMEWORK_PATH, bytes("the library")); + + assertArrayEquals(bytes("the library"), + MacCoreAdiFetcher.fileFromCpio(archive, MacCoreAdiFetcher.FRAMEWORK_PATH)); + } + + @Test + public void aHardLinkTwinWithNoDataIsPassedOver() throws Exception { + final byte[] archive = cpio( + "./" + MacCoreAdiFetcher.FRAMEWORK_PATH, new byte[0], + "./" + MacCoreAdiFetcher.FRAMEWORK_PATH, bytes("the data-bearing one")); + + assertArrayEquals(bytes("the data-bearing one"), + MacCoreAdiFetcher.fileFromCpio(archive, MacCoreAdiFetcher.FRAMEWORK_PATH)); + } + + @Test + public void aPayloadWithoutTheLibrarySaysSo() { + final byte[] archive = cpio("./something/else", bytes("x")); + + final MacCoreAdiFetcher.FetchException thrown = assertThrows( + MacCoreAdiFetcher.FetchException.class, + () -> MacCoreAdiFetcher.fileFromCpio(archive, MacCoreAdiFetcher.FRAMEWORK_PATH)); + assertTrue(thrown.getMessage(), thrown.getMessage().contains("is not in the installer")); + } + + @Test + public void anArchiveThatIsNotOdcIsRefused() { + final byte[] newc = new byte[206]; + Arrays.fill(newc, (byte) '0'); + System.arraycopy(bytes("070701"), 0, newc, 0, 6); + + assertThrows(MacCoreAdiFetcher.FetchException.class, + () -> MacCoreAdiFetcher.fileFromCpio(newc, MacCoreAdiFetcher.FRAMEWORK_PATH)); + } + + /** A universal binary with an i386 slice and an x86_64 one, in that order, as Apple's is. */ + private static byte[] fat(byte[] i386, byte[] x8664) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final int first = 8 + 2 * 20; + writeBe32(out, 0xCAFEBABE); + writeBe32(out, 2); + for (final int[] arch : new int[][]{ + {0x00000007, first, i386.length}, + {0x01000007, first + i386.length, x8664.length}}) { + writeBe32(out, arch[0]); + writeBe32(out, 3); + writeBe32(out, arch[1]); + writeBe32(out, arch[2]); + writeBe32(out, 0); + } + out.write(i386, 0, i386.length); + out.write(x8664, 0, x8664.length); + return out.toByteArray(); + } + + private static void writeBe32(ByteArrayOutputStream out, int v) { + out.write(v >>> 24); + out.write(v >>> 16); + out.write(v >>> 8); + out.write(v); + } + + @Test + public void theX8664SliceIsTheOneTaken() throws Exception { + final byte[] universal = fat(bytes("thirty-two bit"), bytes("sixty-four bit")); + + assertArrayEquals(bytes("sixty-four bit"), MacCoreAdiFetcher.slice(universal, 0x01000007)); + } + + @Test + public void aThinBinaryIsNotMistakenForAUniversalOne() { + assertThrows(MacCoreAdiFetcher.FetchException.class, + () -> MacCoreAdiFetcher.slice(bytes("not a fat header"), 0x01000007)); + } + + @Test + public void bytesThatAreNotThePinnedPayloadAreNeverUnpacked() { + final MacCoreAdiFetcher.FetchException thrown = assertThrows( + MacCoreAdiFetcher.FetchException.class, + () -> MacCoreAdiFetcher.extract(bytes("anything at all"))); + assertTrue(thrown.getMessage(), thrown.getMessage().contains("not the expected ones")); + } + + @Test + public void aServerThatIgnoresTheRangeIsRefusedRatherThanRead() throws Exception { + try (FakeApkServer server = FakeApkServer.ignoringRanges(new byte[64 * 1024])) { + final File target = new File(this.folder.getRoot(), "CoreADI"); + + final MacCoreAdiFetcher.FetchException thrown = assertThrows( + MacCoreAdiFetcher.FetchException.class, + () -> MacCoreAdiFetcher.ensure(target, server.url())); + + assertTrue(thrown.getMessage(), thrown.getMessage().contains("expected 206")); + assertTrue("nothing should have been written", !target.exists()); + } + } + + @Test + public void aStoredFileOfTheWrongBuildIsFetchedAgainRatherThanUsed() throws Exception { + final File target = new File(this.folder.getRoot(), "CoreADI"); + try (FileOutputStream out = new FileOutputStream(target)) { + out.write(bytes("some other build")); + } + try (FakeApkServer server = FakeApkServer.ignoringRanges(new byte[16])) { + final MacCoreAdiFetcher.FetchException thrown = assertThrows( + MacCoreAdiFetcher.FetchException.class, + () -> MacCoreAdiFetcher.ensure(target, server.url())); + // Refused by the server's answer, which means it went to the server: the stored + // file of the wrong build was not handed out. + assertTrue(thrown.getMessage(), thrown.getMessage().contains("expected 206")); + assertArrayEquals(bytes("some other build"), MacCoreAdiFetcher.readFile(target)); + } + } +} diff --git a/app/src/test/python/test_emulated_mac.py b/app/src/test/python/test_emulated_mac.py new file mode 100644 index 00000000..d8d8b313 --- /dev/null +++ b/app/src/test/python/test_emulated_mac.py @@ -0,0 +1,154 @@ +""" +The two Python halves of signing in as the emulated Mac. + +**Against the real `AppleAccount`**, with only Grand Slam's answers replaced: the 409 shim works +by replacing `_gsa_request` on the instance, and a fake account would keep that name after a +rename (rule 3). + +**Not covered here:** that the library, given `au`, then asks for a code. That decision is in +`_gsa_authenticate` after the SRP exchange, which needs Apple's half to reach. It is the line +`if au in ("secondaryAuth", "trustedDeviceSecondaryAuth")` in the pinned `account.py`. +""" + +from __future__ import annotations + +import asyncio +import uuid +from collections.abc import Iterator +from types import SimpleNamespace +from typing import Any + +import pytest + +from findmy.reports import AppleAccount, RemoteAnisetteProvider + +import icloud_bridge +import main + + +class Bridge: + """What Java hands Python as `localAnisette`, as far as these tests need.""" + + def __init__(self, router: str | None = None) -> None: + if router is not None: + self.router = lambda: router + + def otp(self) -> str: + return "b3Rw" + + def machine(self) -> str: + return "bWFjaGluZQ==" + + +class TestTheRoutingNumber: + def test_it_is_the_one_java_reports(self) -> None: + provider = main.LocalAnisetteProvider(Bridge(router="12345678"), "https://example.invalid") + assert provider.router == "12345678" + + def test_a_bridge_without_one_keeps_the_librarys(self) -> None: + provider = main.LocalAnisetteProvider(Bridge(), "https://example.invalid") + assert provider.router == RemoteAnisetteProvider("https://example.invalid").router + + +@pytest.fixture +def account() -> Iterator[AppleAccount]: + made = AppleAccount(RemoteAnisetteProvider("https://example.invalid")) + yield made + main.closeAccount(made) + + +def answersFromGrandSlam(account: AppleAccount, status: dict[str, Any]) -> list[dict]: + """ + Make the account's Grand Slam exchange answer every request with `status`, without SRP: the + password check itself needs Apple's half, and the shim only reads what comes back. + """ + asked: list[dict] = [] + + async def gsa_request(parameters: dict[str, Any]) -> dict[str, Any]: + asked.append(parameters) + return {"Status": dict(status)} + + account._asyncacc._gsa_request = gsa_request + return asked + + +class TestHsc409WithNoAu: + def test_a_complete_answered_409_without_au_is_read_as_a_second_factor( + self, account: AppleAccount) -> None: + asked = answersFromGrandSlam(account, {"ec": 0, "hsc": 409}) + main._treatHsc409AsSecondFactor(account) + + response = asyncio.run(account._asyncacc._gsa_request({"o": "complete"})) + + assert asked == [{"o": "complete"}] + assert response["Status"]["au"] == "trustedDeviceSecondaryAuth" + + @pytest.mark.parametrize("status", [ + {"ec": 0, "hsc": 200}, + {"ec": 0, "hsc": 409, "au": "secondaryAuth"}, + ]) + def test_anything_else_is_left_as_apple_sent_it( + self, account: AppleAccount, status: dict) -> None: + answersFromGrandSlam(account, status) + main._treatHsc409AsSecondFactor(account) + + response = asyncio.run(account._asyncacc._gsa_request({"o": "complete"})) + + assert response["Status"] == status + + def test_an_init_answered_409_is_not_touched(self, account: AppleAccount) -> None: + answersFromGrandSlam(account, {"ec": 0, "hsc": 409}) + main._treatHsc409AsSecondFactor(account) + + response = asyncio.run(account._asyncacc._gsa_request({"o": "init"})) + + assert "au" not in response["Status"] + + +MAC_DEVICE_ID = "00006000-0123456789ABCDEF" + + +class TestWhatCloudKitIsToldTheDeviceIs: + def test_a_device_id_that_is_already_a_uuid_is_passed_through_untouched(self) -> None: + account = SimpleNamespace(device_uuid="9F1C2D3E-4B5A-4C6D-8E7F-001122334455") + assert icloud_bridge._forCloudKit(account) is account + + def test_a_mac_device_id_gets_the_host_uuid_java_reports(self) -> None: + bridge = SimpleNamespace(cloudKitDeviceUuid=lambda: "00112233-4455-6677-8899-AABBCCDDEEFF") + account = SimpleNamespace(device_uuid=MAC_DEVICE_ID, + _anisette=SimpleNamespace(_bridge=bridge), serial="0PENTAGVTEST") + + view = icloud_bridge._forCloudKit(account) + + assert view.device_uuid == "00112233-4455-6677-8899-AABBCCDDEEFF" + # What the CloudKit client does with it, and what failed before. + assert uuid.UUID(view.device_uuid).hex.upper() == "00112233445566778899AABBCCDDEEFF" + # Everything else is the real account's. + assert view.serial == "0PENTAGVTEST" + + def test_without_a_bridge_it_is_derived_and_stable(self) -> None: + first = icloud_bridge._forCloudKit(SimpleNamespace(device_uuid=MAC_DEVICE_ID)) + again = icloud_bridge._forCloudKit(SimpleNamespace(device_uuid=MAC_DEVICE_ID)) + + uuid.UUID(first.device_uuid) + assert first.device_uuid == again.device_uuid + + def test_a_method_reached_through_the_view_acts_on_the_real_account(self) -> None: + """request_pet swaps login state on `self` - it must be the account's self, not the view's.""" + class Account: + device_uuid = MAC_DEVICE_ID + state = "before" + + def change(self) -> None: + self.state = "after" + + real = Account() + icloud_bridge._forCloudKit(real).change() + assert real.state == "after" + + +class TestWhenTheInternalsMove: + def test_an_account_without_gsa_request_is_left_alone(self, capsys) -> None: + account = SimpleNamespace(_asyncacc=SimpleNamespace()) + main._treatHsc409AsSecondFactor(account) # type: ignore[arg-type] + assert "hsc 409 will not be read as 2FA" in capsys.readouterr().out From 420d9ac083bb3ab2c1c59b58948148d76ddcd59f Mon Sep 17 00:00:00 2001 From: Rob Lazzurs Date: Wed, 21 Oct 2026 22:11:47 +0000 Subject: [PATCH 3/9] Document the emulated Mac: rule 21, the host check and its workflow Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012JDrrt9ff7WwPepHs6B2Gx --- AGENTS.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- CONTRIBUTING.md | 21 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 16ca0ec1..b1228754 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ python -m venv .venv && .venv/bin/pip install "FindMy==" the dependency tree resolve; every method raises. The stub is not a step toward enabling `aniLocal`, and never will be. - **Unicorn ships as native code, GPL-2.0 and all, and that was decided rather than missed.** - The emulated Mac (#274, in review) runs it in `libmacadi.so`. Before replacing it with a permissive + The emulated Mac (rule 21) runs it in `libmacadi.so`. Before replacing it with a permissive emulator for licence reasons, read [docs/emulated-mac-emulator-choice.md](./docs/emulated-mac-emulator-choice.md): a replacement is invisible to users **only if it produces the same bytes**, because the emulator is not part @@ -702,6 +702,49 @@ already sent you, and then reasoning from a guess while you wait. had a phone number blacked out by hand, and the pixels either side of it were not. Quote the error text, never the surrounding screen, and never re-upload the image. +### 21. Anisette comes from an emulated Mac, and it is one machine for the life of the install + +Since 2026-10-12 Apple issues the Find My token (`searchPartyToken`) only to a sign-in whose +Anisette machine was minted by a Mac or an iPhone; an Android-minted one gets `mmeAuthToken` alone +and every report fetch answers 401 ([malmeloo/FindMy.py#278](https://github.com/malmeloo/FindMy.py/issues/278)). +From about 2026-10-20 the edge also answers 503 to any `X-MMe-Client-Info` whose OS token is +`Android`, whatever else is sent ([#261](https://github.com/parawanderer/OpenTagViewer/pull/261)). +So `LocalAnisette`'s Android libraries can no longer reach Find My by any combination of headers. + +`EmulatedMacAnisette` runs Apple's **macOS** CoreADI - the x86_64 build in iTunes 12.8.3 - under +Unicorn, in `libmacadi.so` (`app/src/main/cpp/macadi/`, ported from sacca97/os-findmy-e; see its +`NOTICE.md`). It is what `AppDependencies` hands out. + +- **The library is fetched from Apple, never shipped.** One HTTP range request: in that exact disk + image the package payload is stored uncompressed and contiguous, so its offset is a constant. + `scripts/locate_mac_coreadi.py` re-derives and checks every constant, reading them from the Java + sources. Both the payload and the library are pinned by SHA-256, and the dispatcher offset + (`0x979A0`) is only valid for that one build. +- **One identity, kept forever** - rule 11, with more at stake. `files/anisette/mac/identity.json` + and `store/` belong together: the library hashes the host UUID into the machine id, Apple binds + the provisioning to the device id, and the Find My token only works alongside Anisette from the + machine that obtained it. Apple will provision a device id **once** (a second attempt answers + HTTP 433 / -29002), so a lost store means a new machine and a new device-list entry. +- **Never leave `adi.pb` in the store.** Provisioning writes the legacy whole-machine blob beside + the per-user one, and while it exists the library says "not provisioned". A process that + provisioned and then asked for headers sent Apple an empty machine id, which surfaces as + **`ec -80009`, "MID is invalid"** - and looked like an emulation fault until a restart, which + never reloads the file, made it go away. `CoreAdi::beginSession` drops it before every session. +- **A Mac's device id is not a UUID**, and FindMy.py's CloudKit client parses it as one. The + keychain and CloudKit clients get `icloud_bridge._forCloudKit`'s view of the account instead, + which answers `device_uuid` with the host UUID and forwards everything else. Anything new that + hands the account to CloudKit has to go through it. +- **Never freeze the emulated clock outside a byte-for-byte comparison.** A constant OTP is + accepted for about a minute and then refused. +- **Grand Slam now asks for a second factor with `hsc` 409 and no `au`**, which the pinned + FindMy.py reads as "no 2FA needed". `main._treatHsc409AsSecondFactor` fills `au` in. +- **The APK carries GPL-2.0 code** (Unicorn). Keep it in `libmacadi.so`, not in + `libopentagviewer.so`. + +The emulator suite cannot cover any of this - it swaps the Anisette source for a fake. What does is +`macadi-host.yml` (the library loads in the ported emulator), the JVM tests on the fetcher, the +Python tests in `test_emulated_mac.py`, and a real sign-in on a phone. + --- ## Building and testing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4f7dcbf..92aa69cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -646,6 +646,26 @@ static constructor running. No provisioning and nothing sent to Apple beyond dow libraries. See [`app/src/test/adi-on-bionic/README.md`](app/src/test/adi-on-bionic/README.md) for how it works and what it measured. +### The emulated Mac, on the host + +```bash +python scripts/locate_mac_coreadi.py --remote --out /tmp/CoreADI.x86_64 +cmake -S app/src/test/macadi-host -B build/macadi && cmake --build build/macadi --parallel +build/macadi/macadi_cli /tmp/CoreADI.x86_64 979a0 /nonexistent 00112233445566778899aabbccddeeff -1 --load-only +``` + +Since 2026-10-12 Apple gives the Find My token only to a sign-in whose Anisette machine was +minted by a Mac or an iPhone, so the app runs Apple's **macOS** CoreADI under an x86_64 emulator +(`app/src/main/cpp/macadi/`, see `EmulatedMacAnisette`). This builds the same native core for the +machine you are on, through the same `MacAdi.cmake`, and loads Apple's library into it. The first +line downloads 3.5 MB from Apple with exactly the range request the app makes and checks it +against the pinned hashes; `--dmg iTunes12.8.3.dmg` does the same against a full copy of the +installer and shows which of its chunks the range spans. + +Given a provisioned store directory and its host UUID instead of `--load-only`, `macadi_cli` +prints one header set. Pass a fixed Unix time as the fifth argument to compare its output byte +for byte against another implementation; never use a fixed clock for anything sent to Apple. + ### Which Python each tree targets There are three, and they are not the same: @@ -898,6 +918,7 @@ run regardless: each AES entry carries a fresh random salt. | `native-stubs.yml` | `app/src/main/cpp/**`, `app/src/test/cpp/**` changes | Builds the stand-ins for Apple's two stubbed libraries on x86_64 and arm64 Linux and checks what they export and return. Seconds, no Android | | `adi-on-bionic.yml` | `app/src/main/cpp/**`, `adi-libraries.json`, `AdiFunction.java`, its own directory | Loads and initialises Apple's real ADI libraries on x86_64 and arm64 Linux under Android 9 and Android 14 bionic, with no emulator, and checks the pre-#232 stub still fails. About a minute cold, seconds warm | | `check-gsa-edge.yml` | daily, and every PR to `main` | Asks Apple's edge whether it still lets the app's and the exporter's sign-in and provisioning requests through, with their exact headers and no account. A scheduled failure opens an issue. Seconds | +| `macadi-host.yml` | `app/src/main/cpp/macadi/**`, `app/src/test/macadi-host/**`, `MacCoreAdi*.java`, `scripts/locate_mac_coreadi.py` | Fetches Apple's macOS CoreADI with the app's own range request, checks both pinned hashes, builds the emulated Mac's native core for the host and has it load the library. Nothing sent to Apple beyond the download. A few minutes, mostly building Unicorn | | `waiting-on-reporter.yml` | a comment on an issue, daily | Removes the `waiting on reporter` label when the issue's author replies. Daily, comments once on any labelled issue quiet for 14 days, and again only after somebody else has commented. Never closes anything. Both triggers run the copy on `main`, so a PR cannot exercise it; `workflow_dispatch` defaults to a dry run that prints what it would post | The instrumented job needs KVM on the runner; the workflow enables it first. It runs the same From 2130d0e13581583e7d7331d13d86bc4aabe8a770 Mon Sep 17 00:00:00 2001 From: Rob Lazzurs Date: Wed, 4 Nov 2026 00:15:10 +0000 Subject: [PATCH 4/9] Keep restored sessions on their own machine, and the emulated Mac in its own process A session LocalAnisette signed in keeps LocalAnisette, and nothing is provisioned to restore it. AppDependencies made EmulatedMacAnisette the source for every caller, so a restore provisioned a new Mac at app start and paired its X-Apple-I-MD-M with the account's Android device id. Per #248 that session's token only works with Anisette from the Android machine, so a working session would start answering 401. AnisetteForSession now chooses: the emulated Mac for a fresh sign-in, and for a restore whichever machine signed the session in. SessionProvenance records that in LocalAnisette's own preferences file, reading the sessionEstablishedLocally flag every older install already has and adding sessionMachine=emulatedMac beside it for the Mac's sessions. The separate anisette-emulated-mac file is gone, so the machine-change warning works again for upgraders. A Mac session is restored as Purpose.RESTORE, which never creates or provisions a machine: a lost store falls back to remote with the warning. libmacadi.so now loads only in a :macadi process. MacAdiService hosts the emulator and RemoteMacAdi calls it over binder, so a SIGSEGV from Unicorn or Apple's code kills that process rather than the sign-in screen (rule 4, #232), and the GPL-2.0 emulator is a separate program from the rest of the APK. A death during a call is recorded per app version and library build, and the Mac is not tried again until one of them changes. OpenAirTagApplication skips its start-up in that process. The native layer is behind MacAdi, so the logic is tested on the JVM: EmulatedMacAnisette's state machine, MacIdentity's shapes and round trip, MacProvisioning's requests against a local FakeGrandSlam socket, and the restore choice for an install with only LocalAnisette's flag. TheEmulatedMacInItsOwnProcessTest checks the process boundary on a device, including that the app process never maps libmacadi.so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MJtmGkNZpfzFGSN1BZ82a --- .../TheEmulatedMacInItsOwnProcessTest.java | 105 +++++ app/src/main/AndroidManifest.xml | 13 + app/src/main/cpp/macadi/jni.cpp | 2 +- .../opentagviewer/OpenAirTagApplication.java | 8 +- .../anisette/AnisetteForSession.java | 63 +++ .../opentagviewer/anisette/Base64Codec.java | 31 ++ .../anisette/EmulatedMacAnisette.java | 353 +++++++++++---- .../opentagviewer/anisette/LocalAnisette.java | 14 +- .../opentagviewer/anisette/MacAdi.java | 97 ++++ .../opentagviewer/anisette/MacAdiService.java | 211 +++++++++ .../opentagviewer/anisette/MacCoreAdi.java | 50 +-- .../anisette/MacProvisioning.java | 43 +- .../opentagviewer/anisette/RemoteMacAdi.java | 302 +++++++++++++ .../anisette/SessionProvenance.java | 113 +++++ .../opentagviewer/python/AppDependencies.java | 13 +- .../anisette/EmulatedMacAnisetteTest.java | 422 ++++++++++++++++++ .../opentagviewer/anisette/FakeGrandSlam.java | 139 ++++++ .../opentagviewer/anisette/FakeMacAdi.java | 125 ++++++ .../anisette/MacIdentityTest.java | 128 ++++++ .../anisette/MacProvisioningTest.java | 182 ++++++++ .../anisette/MemoryProvenance.java | 41 ++ ...SessionOnTheMachineThatSignedItInTest.java | 86 ++++ .../opentagviewer/anisette/TestPlists.java | 113 +++++ 23 files changed, 2507 insertions(+), 147 deletions(-) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/TheEmulatedMacInItsOwnProcessTest.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/AnisetteForSession.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/Base64Codec.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/MacAdi.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/MacAdiService.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/RemoteMacAdi.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/anisette/SessionProvenance.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisetteTest.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/FakeGrandSlam.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/FakeMacAdi.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/MacIdentityTest.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/MacProvisioningTest.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/MemoryProvenance.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/RestoringASessionOnTheMachineThatSignedItInTest.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/TestPlists.java diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/TheEmulatedMacInItsOwnProcessTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/TheEmulatedMacInItsOwnProcessTest.java new file mode 100644 index 00000000..d5c48fb9 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/TheEmulatedMacInItsOwnProcessTest.java @@ -0,0 +1,105 @@ +package dev.wander.android.opentagviewer.anisette; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +/** + * The emulated Mac across the real process boundary: {@code libmacadi.so} in {@code :macadi}, the + * app talking to it over binder (AGENTS.md rules 4 and 21). + * + *

What has to hold, and cannot be checked on the JVM: Apple's real macOS CoreADI loads under + * Unicorn over there and answers "not provisioned"; the app process never maps + * {@code libmacadi.so} at all (which is also what keeps the GPL code out of it); a library that is + * not the pinned build is refused over there; and a native death over there reaches the app as + * {@link MacAdi.EmulatorDiedException}, with the app still running to read it. + * + *

Nothing is sent to Apple but the one range request {@link MacCoreAdiFetcher} makes for the + * library, as {@code ApplesRealLibraryOnThisDeviceTest} does for the Android one. Provisioning, + * which would register a machine, is never run. + * + *

One death in this class, for the reason {@code ApplesLibraryInASeparateProcessTest} + * gives: a process that crashes twice within a minute earns a "keeps stopping" dialog. + */ +@RunWith(AndroidJUnit4.class) +public class TheEmulatedMacInItsOwnProcessTest { + + private static File library; + + private final Context context = getInstrumentation().getTargetContext(); + + @BeforeClass + public static void fetchApplesMacLibrary() throws Exception { + final Context context = getInstrumentation().getTargetContext(); + library = new File(context.getCacheDir(), "emulated-mac-test/CoreADI-4.19-x86_64"); + MacCoreAdiFetcher.ensure(library); + } + + private static boolean thisProcessHasMapped(final String name) throws Exception { + return new String(Files.readAllBytes(new File("/proc/self/maps").toPath()), + StandardCharsets.UTF_8).contains(name); + } + + @Test + public void applesLibraryLoadsOverThereAndThisProcessNeverMapsTheEmulator() throws Exception { + final RemoteMacAdi adi = RemoteMacAdi.open(this.context, library); + try { + adi.setHostUuid(MacIdentity.generate().hostUuidBytes()); + assertThrows(MacAdi.NotProvisionedException.class, + () -> adi.headers(MacAdi.ANONYMOUS_DS_ID)); + assertFalse("adi.pb is the library's; storeFiles still answers", + adi.storeFiles() == null); + } finally { + adi.close(); + } + + assertFalse("libmacadi.so belongs to :macadi, and must never be mapped into the app", + thisProcessHasMapped("libmacadi.so")); + } + + @Test + public void aLibraryThatIsNotThePinnedBuildIsRefusedOverThere() throws Exception { + final File impostor = new File(this.context.getCacheDir(), "emulated-mac-test/impostor"); + impostor.getParentFile().mkdirs(); + try (FileOutputStream out = new FileOutputStream(impostor)) { + out.write("not Apple's library".getBytes(StandardCharsets.UTF_8)); + } + + final IllegalStateException refused = assertThrows(IllegalStateException.class, + () -> RemoteMacAdi.open(this.context, impostor)); + + assertTrue(refused.getMessage(), refused.getMessage().contains("not the pinned")); + assertFalse(refused instanceof MacAdi.EmulatorDiedException); + } + + @Test + public void aNativeDeathOverThereIsReportedHereAndThisProcessCarriesOn() throws Exception { + final RemoteMacAdi doomed = RemoteMacAdi.open(this.context, library); + + assertThrows(MacAdi.EmulatorDiedException.class, doomed::dieForATest); + assertTrue(doomed.isGone()); + + // Still here, which is the point, and the emulator can be started again afterwards. + final RemoteMacAdi again = RemoteMacAdi.open(this.context, library); + try { + assertThrows(MacAdi.NotProvisionedException.class, + () -> again.headers(MacAdi.ANONYMOUS_DS_ID)); + } finally { + again.close(); + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ac5c7b20..b98c338f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -143,6 +143,19 @@ android:exported="false" android:process=":adiprobe" /> + + + (handle); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java index cb3d3431..9110d021 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java @@ -12,6 +12,7 @@ import com.google.android.material.color.DynamicColors; import dev.wander.android.opentagviewer.anisette.AppleLibraryProbeService; +import dev.wander.android.opentagviewer.anisette.MacAdiService; import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; import dev.wander.android.opentagviewer.db.repo.model.UserSettings; @@ -35,7 +36,8 @@ public void onCreate() { // like any other process of the app, and it exists to load one library and possibly die. // Starting Python there would unpack Chaquopy's assets from two processes at once, and // resuming the background scan would start a second scanner. See AppleLibraryProbeService. - if (isTheAppleLibraryProbe()) { + // The emulated Mac's process is the same: it exists to run one emulator. See MacAdiService. + if (isTheAppleLibraryProbe() || isTheEmulatedMac()) { return; } @@ -56,6 +58,10 @@ private boolean isTheAppleLibraryProbe() { return this.processName().endsWith(AppleLibraryProbeService.PROCESS_SUFFIX); } + private boolean isTheEmulatedMac() { + return this.processName().endsWith(MacAdiService.PROCESS_SUFFIX); + } + private String processName() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { return Application.getProcessName(); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/AnisetteForSession.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/AnisetteForSession.java new file mode 100644 index 00000000..c5354b83 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/AnisetteForSession.java @@ -0,0 +1,63 @@ +package dev.wander.android.opentagviewer.anisette; + +import android.content.Context; + +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; + +/** + * Which Anisette a caller gets: the emulated Mac for a fresh sign-in, and for a restored session + * whichever machine signed it in. + * + *

A restored session keeps its machine, and nothing is provisioned to restore it. Per + * #248 a Find My token only works alongside Anisette from the machine that obtained it. Every + * session signed in before the emulated Mac existed belongs to {@link LocalAnisette}'s Android + * machine (or to a remote server), and some of those still fetch reports today. Handing them the + * emulated Mac would download CoreADI and register a new Mac at app start, then pair the restored + * account's Android {@code X-Mme-Device-Id} with that Mac's {@code X-Apple-I-MD-M} - a session + * that worked, made to answer 401. So they keep {@link LocalAnisette}, exactly as before. + * + *

Only a session {@link SessionProvenance} says the emulated Mac signed in is restored onto + * it, and then in {@link EmulatedMacAnisette.Purpose#RESTORE}, which never provisions. + * + *

{@code hasExistingSession} is the same flag {@link UserSettings#usesLocalAnisette} takes: + * false only on the sign-in screen, where nobody is signed in. + */ +public final class AnisetteForSession { + + /** What {@link #choose} picks. */ + enum Choice { + /** A fresh sign-in: the emulated Mac, which may provision. */ + EMULATED_MAC_FOR_SIGN_IN, + /** A session the emulated Mac signed in: the same Mac, never provisioned again. */ + EMULATED_MAC_FOR_RESTORE, + /** Every other session: the Android machine that signed it in, or its server. */ + ANDROID + } + + private AnisetteForSession() { + } + + static Choice choose(final boolean hasExistingSession, final SessionProvenance provenance) { + if (!hasExistingSession) { + return Choice.EMULATED_MAC_FOR_SIGN_IN; + } + return provenance.signedInAsEmulatedMac() + ? Choice.EMULATED_MAC_FOR_RESTORE : Choice.ANDROID; + } + + /** {@code AppDependencies}' factory. */ + public static AnisetteSource create(final Context context, final UserSettings settings, + final boolean hasExistingSession) { + switch (choose(hasExistingSession, SessionProvenance.of(context))) { + case EMULATED_MAC_FOR_SIGN_IN: + return new EmulatedMacAnisette(context, settings, + EmulatedMacAnisette.Purpose.SIGN_IN); + case EMULATED_MAC_FOR_RESTORE: + return new EmulatedMacAnisette(context, settings, + EmulatedMacAnisette.Purpose.RESTORE); + case ANDROID: + default: + return new LocalAnisette(context, settings, hasExistingSession); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/Base64Codec.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/Base64Codec.java new file mode 100644 index 00000000..a9a0d353 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/Base64Codec.java @@ -0,0 +1,31 @@ +package dev.wander.android.opentagviewer.anisette; + +import android.util.Base64; + +/** + * Base64, as the emulated Mac's Java side needs it, behind an interface the JVM tests can fill. + * + *

{@code android.util.Base64} is a stub that returns null on the JVM + * ({@code isReturnDefaultValues}), and {@code java.util.Base64} needs API 26 where this app's + * {@code minSdk} is 24. So the app uses {@link #ANDROID} and the tests pass {@code java.util}'s. + */ +interface Base64Codec { + + /** No line breaks, which is what every header and plist field here wants. */ + String encode(byte[] data); + + /** Lenient about whitespace, as Apple's plists wrap their base64. */ + byte[] decode(String text); + + Base64Codec ANDROID = new Base64Codec() { + @Override + public String encode(final byte[] data) { + return Base64.encodeToString(data, Base64.NO_WRAP); + } + + @Override + public byte[] decode(final String text) { + return Base64.decode(text, Base64.DEFAULT); + } + }; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisette.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisette.java index 37fa58ca..b80d4fee 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisette.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisette.java @@ -2,9 +2,9 @@ import android.content.Context; import android.content.SharedPreferences; -import android.util.Base64; import android.util.Log; +import dev.wander.android.opentagviewer.BuildConfig; import dev.wander.android.opentagviewer.db.repo.model.UserSettings; import org.json.JSONException; @@ -19,11 +19,17 @@ /** * Anisette from an emulated Mac on this device: Apple's macOS CoreADI under an x86_64 emulator. * - *

Why this replaced {@link LocalAnisette}. That one runs Apple's Android libraries, - * which mint Android machines. Since 2026-10-12 Apple issues the Find My token only to a sign-in - * made with a Mac- or iPhone-minted machine, and from about 2026-10-20 its edge refuses any client - * that admits to being Android - so an Android-minted machine can no longer sign in to Find My by - * any combination of headers. A Mac-minted one can, and this mints them. See {@link MacCoreAdi}. + *

Why fresh sign-ins use this rather than {@link LocalAnisette}. That one runs Apple's + * Android libraries, which mint Android machines. Since 2026-10-12 Apple issues the Find My token + * only to a sign-in made with a Mac- or iPhone-minted machine, and from about 2026-10-20 its edge + * refuses any client that admits to being Android - so an Android-minted machine can no longer + * sign in to Find My by any combination of headers. A Mac-minted one can, and this mints them. + * + *

Sessions {@link LocalAnisette} signed in stay on it. {@link AnisetteForSession} decides, + * from {@link SessionProvenance}. This class is only handed a restored session that it signed in + * itself, as {@link Purpose#RESTORE}, and then it never provisions: a Mac that has lost its + * provisioning cannot be the machine that obtained the session's token, so registering a new one + * would only add a device-list entry to a session that is going to fail anyway. * *

The machine must be the same one for the life of the session, and not only at * sign-in: the Find My token only works alongside Anisette from the machine that obtained it @@ -31,89 +37,221 @@ * stored under {@code files/anisette/mac/}, and the same identity answers before and after it is * ready. * - *

Once per process. The emulator, the library and the identity are static, for the same - * reason {@link LocalAnisette}'s are: {@code AppDependencies} builds a new source for every caller, - * and two emulators over one store would each think they owned it. + *

The emulator runs in another process. {@link RemoteMacAdi} reaches + * {@link MacAdiService} in {@code :macadi}; nothing here loads native code. A native fault there + * reaches this class as {@link MacAdi.EmulatorDiedException}, is recorded per app version and + * library build, and from then on this answers "unavailable" without trying - the same contract + * {@link TryItElsewhereFirst} keeps for {@link LocalAnisette}. + * + *

Once per process. The emulator connection and the identity are {@link Shared}, for the + * same reason {@link LocalAnisette}'s are: {@code AppDependencies} builds a new source for every + * caller, and two emulators over one store would each think they owned it. + * + *

Every collaborator can be replaced, so the state machine below is tested on the JVM + * (AGENTS.md rule 13) - see {@code EmulatedMacAnisetteTest}. */ public final class EmulatedMacAnisette implements AnisetteSource { private static final String TAG = "EmulatedMacAnisette"; - private static final String PREFERENCES = "anisette-emulated-mac"; - private static final String KEY_SESSION_WAS_LOCAL = "sessionEstablishedLocally"; + /** Why this was asked for. See {@link AnisetteForSession}. */ + public enum Purpose { + /** Nobody is signed in: may create and provision a Mac. */ + SIGN_IN, + /** Restoring a session this Mac signed in: never creates or provisions anything. */ + RESTORE + } + + /** + * In {@link LocalAnisette#PREFERENCES}, beside its own crash records: the attempt (app version, + * library build) whose emulator process died during a call. + */ + static final String KEY_EMULATOR_DIED = "emulatedMacDied"; + + /** What {@link #unavailableReason()} says once the emulator has died on this device. */ + public static final String CRASHES_HERE_REASON = + "The emulated Mac crashed its process on this device, so a remote Anisette server is" + + " used instead"; /** How long one generated set is reused. See {@code LocalAnisette.CACHE_FOR_MS}. */ private static final long CACHE_FOR_MS = 5_000; - /** Guards everything static below, and every call into the emulator. */ - private static final Object LOCK = new Object(); - private static MacCoreAdi adi; - private static MacIdentity identity; - private static Map cached; - private static long cachedAt; + /** Gets CoreADI onto disk, checked against its pinned hash, and says where. */ + interface Library { + File ensure() throws Exception; + } + + /** Registers a machine with Apple and returns the routing number Apple assigned it. */ + interface Provisioner { + long provision(MacIdentity identity, MacAdi adi) throws Exception; + } - private final Context context; + /** Everything that is one per process. Guarded by {@link #lock}. */ + static final class Shared { + final Object lock = new Object(); + MacAdi adi; + MacIdentity identity; + Map cached; + long cachedAt; + } + + private static final Shared SHARED = new Shared(); + + private final File directory; private final UserSettings settings; - private final boolean hasExistingSession; + private final Purpose purpose; + private final SessionProvenance provenance; + private final MacAdi.Opener opener; + private final Library library; + private final Provisioner provisioner; + private final NativeLoadGuard.Store deaths; + private final String attempt; + private final Base64Codec base64; + private final Shared shared; private volatile String unavailableReason; - public EmulatedMacAnisette(Context context, UserSettings settings, boolean hasExistingSession) { - this.context = context.getApplicationContext(); + public EmulatedMacAnisette(Context context, UserSettings settings, Purpose purpose) { + this(directoryFor(context.getApplicationContext()), settings, purpose, + SessionProvenance.of(context), + RemoteMacAdi.opener(context), + libraryIn(directoryFor(context.getApplicationContext())), + (identity, adi) -> new MacProvisioning(identity, adi).provision(), + deathStore(context.getApplicationContext()), + NativeLoadGuard.attemptFor(BuildConfig.VERSION_CODE, "macadi", + MacCoreAdi.BUILD_SHA256), + Base64Codec.ANDROID, + SHARED); + } + + EmulatedMacAnisette(File directory, UserSettings settings, Purpose purpose, + SessionProvenance provenance, MacAdi.Opener opener, Library library, + Provisioner provisioner, NativeLoadGuard.Store deaths, String attempt, + Base64Codec base64, Shared shared) { + this.directory = directory; this.settings = settings; - this.hasExistingSession = hasExistingSession; + this.purpose = purpose; + this.provenance = provenance; + this.opener = opener; + this.library = library; + this.provisioner = provisioner; + this.deaths = deaths; + this.attempt = attempt; + this.base64 = base64; + this.shared = shared; + } + + private static File directoryFor(Context context) { + return new File(context.getFilesDir(), "anisette/mac"); } - private File directory() { - return new File(this.context.getFilesDir(), "anisette/mac"); + private static Library libraryIn(File directory) { + return () -> { + final File target = new File(directory, "CoreADI-4.19-x86_64"); + MacCoreAdiFetcher.ensure(target); + return target; + }; + } + + /** {@code commit()}, never {@code apply()}: see {@link NativeLoadGuard.Store}. */ + private static NativeLoadGuard.Store deathStore(Context context) { + final SharedPreferences preferences = + context.getSharedPreferences(LocalAnisette.PREFERENCES, Context.MODE_PRIVATE); + return new NativeLoadGuard.Store() { + @Override + public String get() { + return preferences.getString(KEY_EMULATOR_DIED, null); + } + + @Override + public void set(String value) { + if (!preferences.edit().putString(KEY_EMULATOR_DIED, value).commit()) { + Log.w(TAG, "could not record that the emulated Mac crashed"); + } + } + + @Override + public void clear() { + preferences.edit().remove(KEY_EMULATOR_DIED).commit(); + } + }; } private File storeDirectory() { - return new File(directory(), "store"); + return new File(this.directory, "store"); } @Override public boolean ensureReady() { - synchronized (LOCK) { - if (adi != null) { - return true; + synchronized (this.shared.lock) { + if (this.shared.adi != null) { + if (!this.shared.adi.isGone()) { + return true; + } + // Gone between calls, which is not a fault: open a new one from the store on disk. + this.shared.adi = null; + this.shared.cached = null; } - if (!this.settings.usesLocalAnisette(this.hasExistingSession)) { + if (!this.settings.usesLocalAnisette(this.purpose == Purpose.RESTORE)) { this.unavailableReason = "a remote Anisette server is selected in Settings"; return false; } + if (this.purpose == Purpose.RESTORE && !this.provenance.establishedLocally()) { + // Signed in through a server when the emulated Mac was unavailable. That server's + // machine holds the session now, and moving it onto this one is a different + // machine to Apple. + this.unavailableReason = "this session was signed in through a remote Anisette" + + " server, and stays on it"; + return false; + } + if (emulatorDiedHereBefore()) { + this.unavailableReason = CRASHES_HERE_REASON; + return false; + } - MacCoreAdi opened = null; + MacAdi opened = null; try { - final byte[] library = MacCoreAdiFetcher.ensure( - new File(directory(), "CoreADI-4.19-x86_64")); + final File image = this.library.ensure(); - MacIdentity current = loadOrCreateIdentity(); - opened = open(library, current); + MacIdentity current = this.purpose == Purpose.RESTORE + ? storedIdentity() : loadOrCreateIdentity(); + opened = open(image, current); if (current.provisionedAtMillis() == 0) { + if (this.purpose == Purpose.RESTORE) { + throw new IllegalStateException("the emulated Mac that signed this session" + + " in was never provisioned; sign in again to register it"); + } current = provision(opened, current); } else if (!isProvisioned(opened)) { + if (this.purpose == Purpose.RESTORE) { + throw new IllegalStateException("the emulated Mac has lost its provisioning," + + " so it is no longer the machine this session was signed in with;" + + " sign in again"); + } // The identity says provisioned but the store has lost it. Apple will not // provision the same device id twice (HTTP 433), so this is a new machine: // a second device-list entry, which is better than never signing in again. Log.w(TAG, "the emulated Mac's store no longer holds its provisioning;" + " registering a new machine"); opened.close(); + opened = null; current = MacIdentity.generate(); - current.save(directory()); + current.save(this.directory); clearStore(); - opened = open(library, current); + opened = open(image, current); current = provision(opened, current); } - adi = opened; - identity = current; - cached = null; + this.shared.adi = opened; + this.shared.identity = current; + this.shared.cached = null; this.unavailableReason = null; Log.i(TAG, "ready as an emulated Mac, device " + current.deviceId()); return true; + } catch (final MacAdi.EmulatorDiedException e) { + recordEmulatorDeath(e); + this.unavailableReason = CRASHES_HERE_REASON; + return false; } catch (final Exception | LinkageError e) { - // LinkageError too: an UnsatisfiedLinkError loading libmacadi.so is the likeliest - // failure on a device nobody has tried, and it must fall back, not crash sign-in. if (opened != null) { opened.close(); } @@ -125,25 +263,56 @@ public boolean ensureReady() { } } - private MacCoreAdi open(byte[] library, MacIdentity current) throws IOException { - final MacCoreAdi opened = new MacCoreAdi(library); - opened.setHostUuid(current.hostUuidBytes()); - final File[] stored = storeDirectory().listFiles(); - if (stored != null) { - for (final File file : stored) { - if (file.isFile() && !file.getName().endsWith(".partial")) { - opened.storePut(file.getName(), MacCoreAdiFetcher.readFile(file)); + /** + * Whether this app version's emulator has already died during a call on this device. A record + * from another version or library build is stale, and cleared, so an update tries again. + */ + private boolean emulatorDiedHereBefore() { + final String recorded = this.deaths.get(); + if (recorded == null) { + return false; + } + if (recorded.equals(this.attempt)) { + return true; + } + this.deaths.clear(); + return false; + } + + private void recordEmulatorDeath(MacAdi.EmulatorDiedException e) { + Log.w(TAG, "the emulated Mac's process died during a call; using a remote Anisette server" + + " until the app or the library changes", e); + this.deaths.set(this.attempt); + this.shared.adi = null; + this.shared.cached = null; + } + + private MacAdi open(File image, MacIdentity current) throws IOException { + final MacAdi opened = this.opener.open(image); + try { + opened.setHostUuid(current.hostUuidBytes()); + final File[] stored = storeDirectory().listFiles(); + if (stored != null) { + for (final File file : stored) { + if (file.isFile() && !file.getName().endsWith(".partial")) { + opened.storePut(file.getName(), MacCoreAdiFetcher.readFile(file)); + } } } + return opened; + } catch (final IOException | RuntimeException e) { + if (!(e instanceof MacAdi.EmulatorDiedException)) { + opened.close(); + } + throw e; } - return opened; } - private static boolean isProvisioned(MacCoreAdi opened) { + private static boolean isProvisioned(MacAdi opened) { try { - opened.headers(MacCoreAdi.ANONYMOUS_DS_ID); + opened.headers(MacAdi.ANONYMOUS_DS_ID); return true; - } catch (final MacCoreAdi.NotProvisionedException e) { + } catch (final MacAdi.NotProvisionedException e) { return false; } } @@ -156,15 +325,15 @@ private static boolean isProvisioned(MacCoreAdi opened) { * library refuses to provision again, and the failure is visible - rather than the other way * round, an identity marked provisioned over an empty store, which costs a second device. */ - private MacIdentity provision(MacCoreAdi opened, MacIdentity current) throws Exception { - final long routing = new MacProvisioning(current, opened).provision(); + private MacIdentity provision(MacAdi opened, MacIdentity current) throws Exception { + final long routing = this.provisioner.provision(current, opened); saveStore(opened); final MacIdentity done = current.provisioned(routing, System.currentTimeMillis()); - done.save(directory()); + done.save(this.directory); return done; } - private void saveStore(MacCoreAdi opened) throws IOException { + private void saveStore(MacAdi opened) throws IOException { final File dir = storeDirectory(); if (!dir.isDirectory() && !dir.mkdirs()) { throw new IOException("could not create " + dir); @@ -198,21 +367,36 @@ private void clearStore() { } } + /** The stored identity, for a restore, which must not create one. */ + private MacIdentity storedIdentity() throws IOException { + synchronized (this.shared.lock) { + if (this.shared.identity != null) { + return this.shared.identity; + } + final MacIdentity stored = MacIdentity.load(this.directory); + if (stored == null) { + throw new IllegalStateException("this install has no emulated Mac, so it cannot be" + + " the machine this session was signed in with; sign in again"); + } + return stored; + } + } + /** * The identity, read or - on a fresh install - created and saved. Never provisions: that * needs the library, and this has to answer without it. */ private MacIdentity loadOrCreateIdentity() throws IOException { - synchronized (LOCK) { - if (identity != null) { - return identity; + synchronized (this.shared.lock) { + if (this.shared.identity != null) { + return this.shared.identity; } - final MacIdentity stored = MacIdentity.load(directory()); + final MacIdentity stored = MacIdentity.load(this.directory); if (stored != null) { return stored; } final MacIdentity fresh = MacIdentity.generate(); - fresh.save(directory()); + fresh.save(this.directory); Log.i(TAG, "generated a new emulated Mac identity - this must now be kept"); return fresh; } @@ -260,43 +444,50 @@ private String header(String name) { if (!ensureReady()) { return ""; } - synchronized (LOCK) { + synchronized (this.shared.lock) { final long now = System.currentTimeMillis(); - if (cached == null || now - cachedAt > CACHE_FOR_MS) { + if (this.shared.cached == null || now - this.shared.cachedAt > CACHE_FOR_MS) { try { - final MacCoreAdi.Headers generated = - adi.headers(MacCoreAdi.ANONYMOUS_DS_ID); + final MacAdi.Headers generated = + this.shared.adi.headers(MacAdi.ANONYMOUS_DS_ID); // RequestOTP rewrites the store, and a store that goes back in time is a // machine Apple has seen move backwards. - saveStore(adi); - cached = Map.of( - "X-Apple-I-MD-M", - Base64.encodeToString(generated.machineId, Base64.NO_WRAP), - "X-Apple-I-MD", - Base64.encodeToString(generated.oneTimePassword, Base64.NO_WRAP), + saveStore(this.shared.adi); + this.shared.cached = Map.of( + "X-Apple-I-MD-M", this.base64.encode(generated.machineId), + "X-Apple-I-MD", this.base64.encode(generated.oneTimePassword), "X-Apple-I-MD-RINFO", Long.toString(generated.routing)); - cachedAt = now; + this.shared.cachedAt = now; + } catch (final MacAdi.EmulatorDiedException e) { + recordEmulatorDeath(e); + this.unavailableReason = CRASHES_HERE_REASON; + return ""; } catch (final Exception e) { Log.w(TAG, "could not produce Anisette data", e); this.unavailableReason = String.valueOf(e.getMessage()); return ""; } } - final String value = cached.get(name); + final String value = this.shared.cached.get(name); return value == null ? "" : value; } } + /** + * Recorded in {@link SessionProvenance}, in {@link LocalAnisette}'s file, as a session the + * emulated Mac signed in - so the next restore comes back here rather than to the Android + * machine, and the Android machine's sessions keep reading as its own. + */ @Override public void recordSessionProvenance(boolean establishedLocally) { - preferences().edit().putBoolean(KEY_SESSION_WAS_LOCAL, establishedLocally).apply(); + this.provenance.recordEmulatedMacSession(establishedLocally); Log.i(TAG, "session established with " + (establishedLocally ? "the emulated Mac" : "remote") + " Anisette"); } @Override public boolean wasSessionEstablishedLocally() { - return preferences().getBoolean(KEY_SESSION_WAS_LOCAL, false); + return this.provenance.establishedLocally(); } @Override @@ -337,15 +528,11 @@ public String cloudKitDeviceUuid() { @Override public String describe() { - synchronized (LOCK) { - if (identity == null) { + synchronized (this.shared.lock) { + if (this.shared.identity == null) { return "emulated Mac not ready: " + this.unavailableReason; } - return "ready as an emulated Mac, device " + identity.deviceId(); + return "ready as an emulated Mac, device " + this.shared.identity.deviceId(); } } - - private SharedPreferences preferences() { - return this.context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); - } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/LocalAnisette.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/LocalAnisette.java index dfad9798..f9cf0e66 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/anisette/LocalAnisette.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/LocalAnisette.java @@ -563,8 +563,6 @@ public synchronized String unavailableReason() { return this.unavailableReason; } - private static final String KEY_SESSION_WAS_LOCAL = "sessionEstablishedLocally"; - /** * Record which kind of Anisette established the current session. * @@ -577,14 +575,13 @@ public synchronized String unavailableReason() { * you may need to sign in again" and leaving them in a 2FA loop with no explanation. * *

Written on every successful login, not only local ones, so it cannot go stale when - * somebody signs in again over a remote server. + * somebody signs in again over a remote server. The record is {@link SessionProvenance}, in + * this class's preferences under the key it has always had, so a restore can tell this + * machine's sessions from the emulated Mac's. */ @Override public synchronized void recordSessionProvenance(boolean establishedLocally) { - this.context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) - .edit() - .putBoolean(KEY_SESSION_WAS_LOCAL, establishedLocally) - .apply(); + SessionProvenance.of(this.context).recordAndroidSession(establishedLocally); Log.i(TAG, "session established with " + (establishedLocally ? "local" : "remote") + " Anisette"); } @@ -592,8 +589,7 @@ public synchronized void recordSessionProvenance(boolean establishedLocally) { /** Whether the stored session was established with local Anisette. */ @Override public synchronized boolean wasSessionEstablishedLocally() { - return this.context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) - .getBoolean(KEY_SESSION_WAS_LOCAL, false); + return SessionProvenance.of(this.context).establishedLocally(); } /** diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacAdi.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacAdi.java new file mode 100644 index 00000000..bb6b70af --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacAdi.java @@ -0,0 +1,97 @@ +package dev.wander.android.opentagviewer.anisette; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.util.Map; + +/** + * The emulated Mac's CoreADI, as {@link EmulatedMacAnisette} uses it: whatever runs it. + * + *

Two implementations, and the app process only ever holds one of them. + * {@link MacCoreAdi} is the JNI face of {@code libmacadi.so}, and it is loaded only in the + * {@code :macadi} process, by {@link MacAdiService}. The app talks to it through + * {@link RemoteMacAdi}, over binder. So a native crash in Unicorn or in Apple's code kills that + * process and not the app (AGENTS.md rule 4, #232), and the GPL-2.0 emulator never shares a + * process with the Apache-2.0 libraries or the map SDKs (docs/emulated-mac-emulator-choice.md). + * + *

An interface so the logic above it can be tested on the JVM (rule 13): the state + * machine in {@link EmulatedMacAnisette} and the requests in {@link MacProvisioning} need neither + * the emulator nor a device to check. + * + *

The semantics are {@link MacCoreAdi}'s; see it for what each call does. + */ +interface MacAdi extends Closeable { + + /** Apple's "no account" pseudo-DSID, which machine-level Anisette is always asked for. */ + long ANONYMOUS_DS_ID = -2L; + + /** The library's own answer that this machine has never been provisioned. */ + final class NotProvisionedException extends IllegalStateException { + public NotProvisionedException(String message) { + super(message); + } + } + + /** + * The process running the emulator died during a call. Not something to retry in this + * process, and recorded so the next launch does not walk into it again. + */ + final class EmulatorDiedException extends IllegalStateException { + public EmulatorDiedException(String message, Throwable cause) { + super(message, cause); + } + } + + /** One Anisette header set. */ + final class Headers { + /** {@code X-Apple-I-MD-M}, raw. */ + public final byte[] machineId; + /** {@code X-Apple-I-MD}, raw. */ + public final byte[] oneTimePassword; + /** {@code X-Apple-I-MD-RINFO}. */ + public final long routing; + + public Headers(byte[] machineId, byte[] oneTimePassword, long routing) { + this.machineId = machineId; + this.oneTimePassword = oneTimePassword; + this.routing = routing; + } + } + + /** Starts an emulator over a library already checked against {@link MacCoreAdi#BUILD_SHA256}. */ + interface Opener { + MacAdi open(File library) throws IOException; + } + + void setHostUuid(byte[] uuid); + + void storePut(String name, byte[] contents); + + /** The library's provisioning state, by file name. Persist it after every call. */ + Map storeFiles(); + + /** @throws NotProvisionedException if this machine has never been provisioned */ + Headers headers(long dsId); + + /** + * Turn Apple's {@code spim} into the {@code cpim} to send back. + * + * @param sessionOut receives the session id {@link #provisioningFinish} needs + */ + byte[] provisioningBegin(long dsId, byte[] spim, int[] sessionOut); + + void provisioningFinish(long dsId, int session, byte[] persistentTokenMetadata, + byte[] trustKey, long routing); + + /** + * Whether whatever runs the emulator has gone away between calls, so this one is useless and + * a new one should be opened. Not a fault: see {@link RemoteMacAdi}. + */ + default boolean isGone() { + return false; + } + + @Override + void close(); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacAdiService.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacAdiService.java new file mode 100644 index 00000000..a482b4a7 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacAdiService.java @@ -0,0 +1,211 @@ +package dev.wander.android.opentagviewer.anisette; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; +import android.os.Parcel; +import android.os.Process; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; +import android.util.Log; + +import java.io.File; +import java.util.Map; + +/** + * The emulated Mac, in a process of its own: {@code libmacadi.so} is loaded here and nowhere else. + * + *

Declared with {@code android:process=":macadi"}. Two reasons, either sufficient: + * + *

    + *
  • A native crash here is not the app's. Unicorn and Apple's emulated code can fault + * in ways no {@code catch} sees - #232 made the app unopenable that way (AGENTS.md rule 4). + * If this process dies, the call in flight fails with {@link android.os.DeadObjectException} + * in the app, {@link EmulatedMacAnisette} records it and falls back to a remote server, and + * the sign-in screen stays up.
  • + *
  • The GPL code is a separate program. Unicorn is GPL-2.0; this process links it, and + * the app process, with the Apache-2.0 libraries and the map SDKs, never does. They talk + * over binder. See docs/emulated-mac-emulator-choice.md and {@code NOTICE.md}.
  • + *
+ * + *

The protocol is a handful of transaction codes on a plain {@link Binder}, so it needs no AIDL + * build step. {@link RemoteMacAdi} is the other end. One emulator per process; the app holds one + * connection at a time ({@link EmulatedMacAnisette}'s state is static), so a second {@link #OPEN} + * replaces the first. + * + *

The library is passed as a path, not as bytes: it is 3.4 MB and a binder transaction is + * capped at about 1 MB. It is checked against {@link MacCoreAdi#BUILD_SHA256} again here, because + * the dispatcher offset is only meaningful for those exact bytes. + */ +public final class MacAdiService extends Service { + private static final String TAG = "MacAdiService"; + + /** The process name after the package, as declared in the manifest. */ + public static final String PROCESS_SUFFIX = ":macadi"; + + static final String DESCRIPTOR = "dev.wander.android.opentagviewer.anisette.MacAdi"; + + static final int OPEN = IBinder.FIRST_CALL_TRANSACTION; + static final int SET_HOST_UUID = OPEN + 1; + static final int STORE_PUT = OPEN + 2; + static final int STORE_FILES = OPEN + 3; + static final int HEADERS = OPEN + 4; + static final int PROVISIONING_BEGIN = OPEN + 5; + static final int PROVISIONING_FINISH = OPEN + 6; + static final int CLOSE = OPEN + 7; + + /** + * Tests only. Die by a signal, mid-call, as a fault in the emulator would. Here and not + * in a fake because the thing under test is the real process boundary. Nothing outside the app + * can send it: the service is not exported. + */ + static final int DIE_FOR_A_TEST = OPEN + 99; + + static final int OK = 0; + static final int NOT_PROVISIONED = 1; + static final int FAILED = 2; + + private final Object lock = new Object(); + private MacCoreAdi adi; + + @Override + public IBinder onBind(final Intent intent) { + return new Binder() { + @Override + protected boolean onTransact(final int code, final Parcel data, final Parcel reply, + final int flags) { + if (code == INTERFACE_TRANSACTION) { + reply.writeString(DESCRIPTOR); + return true; + } + if (code < OPEN || code > DIE_FOR_A_TEST) { + return false; + } + data.enforceInterface(DESCRIPTOR); + synchronized (MacAdiService.this.lock) { + try { + handle(code, data, reply); + } catch (final MacAdi.NotProvisionedException e) { + reply.setDataPosition(0); + reply.setDataSize(0); + reply.writeInt(NOT_PROVISIONED); + reply.writeString(String.valueOf(e.getMessage())); + } catch (final Exception | LinkageError e) { + Log.w(TAG, "the emulated Mac refused a call", e); + reply.setDataPosition(0); + reply.setDataSize(0); + reply.writeInt(FAILED); + reply.writeString(e.getMessage() == null ? e.toString() : e.getMessage()); + } + } + return true; + } + }; + } + + private void handle(final int code, final Parcel data, final Parcel reply) throws Exception { + switch (code) { + case OPEN: { + final byte[] image = MacCoreAdiFetcher.readFile(new File(data.readString())); + final String sha = MacCoreAdiFetcher.sha256(image); + if (!MacCoreAdi.BUILD_SHA256.equals(sha)) { + throw new IllegalStateException("not the pinned CoreADI build: " + sha); + } + if (this.adi != null) { + this.adi.close(); + this.adi = null; + } + this.adi = new MacCoreAdi(image); + reply.writeInt(OK); + return; + } + case SET_HOST_UUID: + open().setHostUuid(data.createByteArray()); + reply.writeInt(OK); + return; + case STORE_PUT: + open().storePut(data.readString(), data.createByteArray()); + reply.writeInt(OK); + return; + case STORE_FILES: { + final Map files = open().storeFiles(); + reply.writeInt(OK); + reply.writeInt(files.size()); + for (final Map.Entry file : files.entrySet()) { + reply.writeString(file.getKey()); + reply.writeByteArray(file.getValue()); + } + return; + } + case HEADERS: { + final MacAdi.Headers headers = open().headers(data.readLong()); + reply.writeInt(OK); + reply.writeByteArray(headers.machineId); + reply.writeByteArray(headers.oneTimePassword); + reply.writeLong(headers.routing); + return; + } + case PROVISIONING_BEGIN: { + final long dsId = data.readLong(); + final byte[] spim = data.createByteArray(); + final int[] session = new int[1]; + final byte[] cpim = open().provisioningBegin(dsId, spim, session); + reply.writeInt(OK); + reply.writeByteArray(cpim); + reply.writeInt(session[0]); + return; + } + case PROVISIONING_FINISH: + open().provisioningFinish(data.readLong(), data.readInt(), data.createByteArray(), + data.createByteArray(), data.readLong()); + reply.writeInt(OK); + return; + case CLOSE: + if (this.adi != null) { + this.adi.close(); + this.adi = null; + } + reply.writeInt(OK); + return; + case DIE_FOR_A_TEST: + dieLikeACrash(); + return; + default: + throw new IllegalStateException("unknown call " + code); + } + } + + private MacCoreAdi open() { + if (this.adi == null) { + throw new IllegalStateException("the emulated Mac has not been opened"); + } + return this.adi; + } + + /** SIGSEGV, as a fault in Unicorn or Apple's code would be, and never returns. */ + private static void dieLikeACrash() { + try { + Os.kill(Os.getpid(), OsConstants.SIGSEGV); + } catch (final ErrnoException e) { + Process.killProcess(Process.myPid()); + } + // The signal can land on another thread; this one must not get as far as replying. + while (true) { + try { + Thread.sleep(10_000); + } catch (final InterruptedException ignored) { + // Keep waiting to die. + } + Process.killProcess(Process.myPid()); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + // Nobody is bound, and the emulator is megabytes the system would keep cached for nothing. + Process.killProcess(Process.myPid()); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi.java index a12e3634..d7a371b0 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi.java @@ -1,6 +1,5 @@ package dev.wander.android.opentagviewer.anisette; -import java.io.Closeable; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; @@ -21,9 +20,15 @@ * {@link #provisioningBegin} and {@link #provisioningFinish} share one session and nothing may run * between them. * - *

Not thread-safe. Callers hold their own lock; {@link EmulatedMacAnisette} does. + *

Only ever loaded in the {@code :macadi} process, by {@link MacAdiService}. Touching this + * class loads {@code libmacadi.so}, which is Unicorn (GPL-2.0) and Apple's code under emulation, and + * neither may share a process with the app: a native crash there would take the app with it + * (AGENTS.md rule 4), and the GPL code would share a program with the Apache-2.0 libraries and the + * map SDKs. The app reaches it through {@link RemoteMacAdi}. + * + *

Not thread-safe. Callers hold their own lock; {@link MacAdiService} does. */ -public final class MacCoreAdi implements Closeable { +final class MacCoreAdi implements MacAdi { /** The one build this knows how to drive: iTunes 12.8.3, CoreADI 4.19, x86_64 slice. */ public static final String BUILD_SHA256 = @@ -37,32 +42,6 @@ public final class MacCoreAdi implements Closeable { */ static final long DISPATCH_OFFSET = 0x979A0L; - /** Apple's "no account" pseudo-DSID, which machine-level Anisette is always asked for. */ - public static final long ANONYMOUS_DS_ID = -2L; - - /** The library's own answer that this machine has never been provisioned. */ - public static final class NotProvisionedException extends IllegalStateException { - public NotProvisionedException(String message) { - super(message); - } - } - - /** One Anisette header set. */ - public static final class Headers { - /** {@code X-Apple-I-MD-M}, raw. */ - public final byte[] machineId; - /** {@code X-Apple-I-MD}, raw. */ - public final byte[] oneTimePassword; - /** {@code X-Apple-I-MD-RINFO}. */ - public final long routing; - - Headers(byte[] machineId, byte[] oneTimePassword, long routing) { - this.machineId = machineId; - this.oneTimePassword = oneTimePassword; - this.routing = routing; - } - } - static { System.loadLibrary("macadi"); } @@ -80,6 +59,7 @@ public MacCoreAdi(byte[] image) { } /** What the library hashes into the machine id. Fixed per machine; see {@link MacIdentity}. */ + @Override public void setHostUuid(byte[] uuid) { nativeSetHostUuid(live(), uuid); } @@ -89,11 +69,12 @@ void setFrozenTime(long unixSeconds) { nativeSetFrozenTime(live(), unixSeconds); } + @Override public void storePut(String name, byte[] contents) { nativeStorePut(live(), name, contents); } - /** The library's provisioning state, by file name. Persist it after every call. */ + @Override public Map storeFiles() { final Object[] flat = nativeStoreFiles(live()); final Map out = new LinkedHashMap<>(); @@ -103,22 +84,19 @@ public Map storeFiles() { return out; } - /** @throws NotProvisionedException if this machine has never been provisioned */ + @Override public Headers headers(long dsId) { final long[] routing = new long[1]; final byte[][] both = nativeHeaders(live(), dsId, routing); return new Headers(both[0], both[1], routing[0]); } - /** - * Turn Apple's {@code spim} into the {@code cpim} to send back. - * - * @param sessionOut receives the session id {@link #provisioningFinish} needs - */ + @Override public byte[] provisioningBegin(long dsId, byte[] spim, int[] sessionOut) { return nativeProvisioningBegin(live(), dsId, spim, sessionOut); } + @Override public void provisioningFinish(long dsId, int session, byte[] persistentTokenMetadata, byte[] trustKey, long routing) { nativeProvisioningFinish(live(), dsId, session, persistentTokenMetadata, trustKey, routing); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacProvisioning.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacProvisioning.java index 4c3cc0f3..1c7d89cb 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacProvisioning.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacProvisioning.java @@ -1,6 +1,5 @@ package dev.wander.android.opentagviewer.anisette; -import android.util.Base64; import android.util.Log; import java.io.ByteArrayOutputStream; @@ -38,7 +37,7 @@ final class MacProvisioning { private static final String TAG = "MacProvisioning"; - private static final String URL_BAG = "https://gsa.apple.com/grandslam/GsService2/lookup"; + static final String URL_BAG = "https://gsa.apple.com/grandslam/GsService2/lookup"; private static final String START_REQUEST = "\n" @@ -58,11 +57,31 @@ final class MacProvisioning { + ""; private final MacIdentity identity; - private final MacCoreAdi adi; + private final MacAdi adi; + private final String urlBag; + private final Base64Codec base64; + private final PlistParser plists; + + /** + * Reads a plist document. {@link SimplePlist#parse} in the app; on the JVM, where + * {@code android.util.Xml} is a stub, the tests supply one. + */ + interface PlistParser { + Object parse(String xml) throws Exception; + } + + MacProvisioning(MacIdentity identity, MacAdi adi) { + this(identity, adi, URL_BAG, Base64Codec.ANDROID, SimplePlist::parse); + } - MacProvisioning(MacIdentity identity, MacCoreAdi adi) { + /** @param urlBag where to ask for the provisioning endpoints; a local fake in the JVM tests */ + MacProvisioning(MacIdentity identity, MacAdi adi, String urlBag, Base64Codec base64, + PlistParser plists) { this.identity = identity; this.adi = adi; + this.urlBag = urlBag; + this.base64 = base64; + this.plists = plists; } /** @return the routing number Apple assigned the machine */ @@ -81,14 +100,14 @@ long provision() throws AdiProvisioning.ProvisioningException { final int[] session = new int[1]; final byte[] cpim; try { - cpim = this.adi.provisioningBegin(MacCoreAdi.ANONYMOUS_DS_ID, spim, session); + cpim = this.adi.provisioningBegin(MacAdi.ANONYMOUS_DS_ID, spim, session); } catch (final IllegalStateException e) { throw new AdiProvisioning.ProvisioningException( "the emulated Mac could not start provisioning: " + e.getMessage(), e); } final Object finished = parse(request(finishUrl, String.format( - FINISH_REQUEST, Base64.encodeToString(cpim, Base64.NO_WRAP))), + FINISH_REQUEST, this.base64.encode(cpim))), "midFinishProvisioning"); final byte[] persistentToken = decode(finished, "ptm"); final byte[] trustKey = decode(finished, "tk"); @@ -104,7 +123,7 @@ long provision() throws AdiProvisioning.ProvisioningException { } try { - this.adi.provisioningFinish(MacCoreAdi.ANONYMOUS_DS_ID, session[0], persistentToken, + this.adi.provisioningFinish(MacAdi.ANONYMOUS_DS_ID, session[0], persistentToken, trustKey, routing); } catch (final IllegalStateException e) { throw new AdiProvisioning.ProvisioningException( @@ -115,7 +134,7 @@ long provision() throws AdiProvisioning.ProvisioningException { } private Map loadUrlBag() throws AdiProvisioning.ProvisioningException { - final Object plist = parse(request(URL_BAG, null), "the URL bag"); + final Object plist = parse(request(this.urlBag, null), "the URL bag"); if (!(plist instanceof Map)) { throw new AdiProvisioning.ProvisioningException("the URL bag is not a dictionary"); } @@ -132,23 +151,23 @@ private Map loadUrlBag() throws AdiProvisioning.ProvisioningExce return out; } - private static Object parse(String body, String what) + private Object parse(String body, String what) throws AdiProvisioning.ProvisioningException { try { - return SimplePlist.parse(body); + return this.plists.parse(body); } catch (final Exception e) { throw new AdiProvisioning.ProvisioningException("could not read " + what, e); } } - private static byte[] decode(Object plist, String key) + private byte[] decode(Object plist, String key) throws AdiProvisioning.ProvisioningException { final String value = SimplePlist.string(plist, "Response", key); if (value == null) { throw new AdiProvisioning.ProvisioningException("Apple's response has no Response/" + key + " - the request was refused, or the protocol has changed"); } - return Base64.decode(value, Base64.DEFAULT); + return this.base64.decode(value); } private String request(String url, String body) throws AdiProvisioning.ProvisioningException { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/RemoteMacAdi.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/RemoteMacAdi.java new file mode 100644 index 00000000..87da5a43 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/RemoteMacAdi.java @@ -0,0 +1,302 @@ +package dev.wander.android.opentagviewer.anisette; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.DeadObjectException; +import android.os.IBinder; +import android.os.Looper; +import android.os.Parcel; +import android.os.RemoteException; +import android.util.Log; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * The emulated Mac as the app process sees it: {@link MacAdiService}, in {@code :macadi}, over + * binder. Nothing here loads native code. + * + *

A death is reported, not suffered. If the other process dies during a call - a fault + * in Unicorn or in Apple's emulated code - that call throws {@link MacAdi.EmulatorDiedException} + * and the app carries on. One that dies between calls (the system reclaiming memory, say) is not + * evidence of a fault: the next call throws a plain {@link IllegalStateException} and + * {@link #isGone} says so, so the caller opens a new one rather than writing the Mac off. + * + *

Unbound the moment the other process dies. A bound service whose process died is + * restarted by the system while the binding stands, which here would mean faulting again. + * + *

Blocks. Never call it on the main thread: the binding's callbacks arrive there, so + * waiting on it would wait for itself until the timeout. + */ +final class RemoteMacAdi implements MacAdi { + private static final String TAG = "RemoteMacAdi"; + + /** How long to wait for the other process to start. Generous: an old phone is slow at it. */ + static final long CONNECT_TIMEOUT_SECONDS = 20; + + private final Context context; + private final ServiceConnection connection; + private final IBinder binder; + private volatile boolean gone; + private volatile boolean unbound; + + private RemoteMacAdi(final Context context, final ServiceConnection connection, + final IBinder binder) { + this.context = context; + this.connection = connection; + this.binder = binder; + } + + /** {@link EmulatedMacAnisette}'s opener in the app. */ + static MacAdi.Opener opener(final Context context) { + return library -> open(context.getApplicationContext(), library); + } + + /** Start {@code :macadi} if it is not running, connect, and load {@code library} there. */ + static RemoteMacAdi open(final Context context, final File library) throws IOException { + if (Looper.myLooper() == Looper.getMainLooper()) { + throw new IllegalStateException("the emulated Mac cannot be reached from the main thread"); + } + + final AtomicReference connected = new AtomicReference<>(); + final AtomicReference self = new AtomicReference<>(); + final CountDownLatch ready = new CountDownLatch(1); + + final ServiceConnection connection = new ServiceConnection() { + @Override + public void onServiceConnected(final ComponentName name, final IBinder service) { + connected.set(service); + ready.countDown(); + } + + @Override + public void onServiceDisconnected(final ComponentName name) { + final RemoteMacAdi opened = self.get(); + if (opened != null) { + opened.onDeath(); + } + } + + @Override + public void onBindingDied(final ComponentName name) { + onServiceDisconnected(name); + } + + @Override + public void onNullBinding(final ComponentName name) { + ready.countDown(); + } + }; + + final boolean bound; + try { + bound = context.bindService(new Intent(context, MacAdiService.class), connection, + Context.BIND_AUTO_CREATE); + } catch (final RuntimeException e) { + throw new IOException("could not start the emulated Mac's process", e); + } + if (!bound) { + throw new IOException("could not start the emulated Mac's process"); + } + + try { + if (!ready.await(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) || connected.get() == null) { + unbindQuietly(context, connection); + throw new IOException("the emulated Mac's process did not start within " + + CONNECT_TIMEOUT_SECONDS + "s"); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + unbindQuietly(context, connection); + throw new IOException("interrupted while starting the emulated Mac's process", e); + } + + final RemoteMacAdi opened = new RemoteMacAdi(context, connection, connected.get()); + self.set(opened); + try { + opened.binder.linkToDeath(opened::onDeath, 0); + } catch (final RemoteException alreadyDead) { + opened.onDeath(); + throw new MacAdi.EmulatorDiedException( + "the emulated Mac's process died as it started", alreadyDead); + } + + Parcel reply = null; + try { + reply = opened.call(MacAdiService.OPEN, data -> data.writeString(library.getAbsolutePath())); + } catch (final RuntimeException e) { + if (!(e instanceof MacAdi.EmulatorDiedException)) { + opened.close(); + } + throw e; + } finally { + if (reply != null) { + reply.recycle(); + } + } + return opened; + } + + /** Whether the other process has gone, so this connection is useless. */ + @Override + public boolean isGone() { + return this.gone; + } + + private void onDeath() { + this.gone = true; + Log.w(TAG, "the emulated Mac's process has gone"); + unbindOnce(); + } + + private interface Writer { + void write(Parcel data); + } + + /** One call; the reply is positioned after the status. The caller recycles it. */ + private Parcel call(final int code, final Writer writer) { + if (this.gone) { + throw new IllegalStateException("the emulated Mac's process has gone"); + } + final Parcel data = Parcel.obtain(); + final Parcel reply = Parcel.obtain(); + boolean handedOver = false; + try { + data.writeInterfaceToken(MacAdiService.DESCRIPTOR); + writer.write(data); + try { + this.binder.transact(code, data, reply, 0); + } catch (final DeadObjectException e) { + onDeath(); + throw new MacAdi.EmulatorDiedException( + "the emulated Mac's process died during a call", e); + } catch (final RemoteException e) { + throw new IllegalStateException("could not reach the emulated Mac", e); + } + + final int status = reply.readInt(); + if (status == MacAdiService.NOT_PROVISIONED) { + throw new MacAdi.NotProvisionedException(reply.readString()); + } + if (status != MacAdiService.OK) { + throw new IllegalStateException(reply.readString()); + } + handedOver = true; + return reply; + } finally { + data.recycle(); + if (!handedOver) { + reply.recycle(); + } + } + } + + private void simple(final int code, final Writer writer) { + call(code, writer).recycle(); + } + + @Override + public void setHostUuid(final byte[] uuid) { + simple(MacAdiService.SET_HOST_UUID, data -> data.writeByteArray(uuid)); + } + + @Override + public void storePut(final String name, final byte[] contents) { + simple(MacAdiService.STORE_PUT, data -> { + data.writeString(name); + data.writeByteArray(contents); + }); + } + + @Override + public Map storeFiles() { + final Parcel reply = call(MacAdiService.STORE_FILES, data -> { }); + try { + final int count = reply.readInt(); + final Map out = new LinkedHashMap<>(); + for (int i = 0; i < count; i++) { + out.put(reply.readString(), reply.createByteArray()); + } + return out; + } finally { + reply.recycle(); + } + } + + @Override + public Headers headers(final long dsId) { + final Parcel reply = call(MacAdiService.HEADERS, data -> data.writeLong(dsId)); + try { + return new Headers(reply.createByteArray(), reply.createByteArray(), reply.readLong()); + } finally { + reply.recycle(); + } + } + + @Override + public byte[] provisioningBegin(final long dsId, final byte[] spim, final int[] sessionOut) { + final Parcel reply = call(MacAdiService.PROVISIONING_BEGIN, data -> { + data.writeLong(dsId); + data.writeByteArray(spim); + }); + try { + final byte[] cpim = reply.createByteArray(); + sessionOut[0] = reply.readInt(); + return cpim; + } finally { + reply.recycle(); + } + } + + @Override + public void provisioningFinish(final long dsId, final int session, + final byte[] persistentTokenMetadata, final byte[] trustKey, + final long routing) { + simple(MacAdiService.PROVISIONING_FINISH, data -> { + data.writeLong(dsId); + data.writeInt(session); + data.writeByteArray(persistentTokenMetadata); + data.writeByteArray(trustKey); + data.writeLong(routing); + }); + } + + /** Tests only. See {@link MacAdiService#DIE_FOR_A_TEST}. */ + void dieForATest() { + simple(MacAdiService.DIE_FOR_A_TEST, data -> { }); + } + + @Override + public void close() { + if (!this.gone) { + try { + simple(MacAdiService.CLOSE, data -> { }); + } catch (final RuntimeException e) { + Log.w(TAG, "could not close the emulated Mac cleanly", e); + } + } + unbindOnce(); + } + + private synchronized void unbindOnce() { + if (!this.unbound) { + this.unbound = true; + unbindQuietly(this.context, this.connection); + } + } + + private static void unbindQuietly(final Context context, final ServiceConnection connection) { + try { + context.unbindService(connection); + } catch (final IllegalArgumentException alreadyGone) { + // Nothing left to release. + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/SessionProvenance.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/SessionProvenance.java new file mode 100644 index 00000000..81902a15 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/SessionProvenance.java @@ -0,0 +1,113 @@ +package dev.wander.android.opentagviewer.anisette; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +/** + * Which Anisette established the signed-in session, and so which one a restore must keep using. + * + *

One record, in the file {@link LocalAnisette} has always written it to. Every install + * signed in before the emulated Mac existed has {@code sessionEstablishedLocally} in + * {@link LocalAnisette#PREFERENCES} and nothing else, and that has to go on meaning what it meant: + * "this session belongs to the Android machine {@link LocalAnisette} provisioned" (or to a remote + * server, when false). The emulated Mac's sessions add one key beside it, {@link #KEY_MACHINE}. + * Its absence is meaningful in the same way {@link LocalAnisette#KEY_HARDWARE}'s is: a session + * from before there was a choice. + * + *

Why a restore cares. Per #248 a Find My token only works alongside Anisette from the + * machine that obtained it. A session the Android machine signed in must stay on the Android + * machine, and must not have a new Mac provisioned underneath it at app start; a session the Mac + * signed in must stay on the Mac. See {@link AnisetteForSession}. + * + *

Plain Java behind {@link Store}, so the decision is tested on the JVM (AGENTS.md rule 13). + */ +public final class SessionProvenance { + private static final String TAG = "SessionProvenance"; + + /** {@link LocalAnisette}'s key, unchanged since before the emulated Mac. */ + static final String KEY_ESTABLISHED_LOCALLY = "sessionEstablishedLocally"; + + /** + * Which local machine the session was signed in as: {@link #EMULATED_MAC}, or absent for + * {@link LocalAnisette}'s Android machine and for every session from before this key. + */ + static final String KEY_MACHINE = "sessionMachine"; + + /** {@link #KEY_MACHINE} for a sign-in made through {@link EmulatedMacAnisette}. */ + static final String EMULATED_MAC = "emulatedMac"; + + /** Where the record lives. {@code commit()}, not {@code apply()}: the next launch reads it. */ + interface Store { + boolean getBoolean(String key); + + String getString(String key); + + /** Write both, durably, in one edit. A null machine removes the key. */ + void put(boolean establishedLocally, String machine); + } + + private final Store store; + + SessionProvenance(final Store store) { + this.store = store; + } + + /** The record in {@link LocalAnisette#PREFERENCES}, where it has always been. */ + public static SessionProvenance of(final Context context) { + final SharedPreferences preferences = context.getApplicationContext() + .getSharedPreferences(LocalAnisette.PREFERENCES, Context.MODE_PRIVATE); + return new SessionProvenance(new Store() { + @Override + public boolean getBoolean(final String key) { + return preferences.getBoolean(key, false); + } + + @Override + public String getString(final String key) { + return preferences.getString(key, null); + } + + @Override + public void put(final boolean establishedLocally, final String machine) { + final SharedPreferences.Editor edit = preferences.edit() + .putBoolean(KEY_ESTABLISHED_LOCALLY, establishedLocally); + if (machine == null) { + edit.remove(KEY_MACHINE); + } else { + edit.putString(KEY_MACHINE, machine); + } + if (!edit.commit()) { + Log.w(TAG, "could not record which Anisette established this session"); + } + } + }); + } + + /** + * A sign-in made through {@link LocalAnisette}: locally on its Android machine, or through a + * server when that was unavailable. Clears {@link #KEY_MACHINE}, so the record cannot claim + * a Mac for a session the Mac never saw. + */ + void recordAndroidSession(final boolean establishedLocally) { + this.store.put(establishedLocally, null); + } + + /** + * A sign-in made through {@link EmulatedMacAnisette}. Recorded as the Mac's whether or not it + * fell back to a server, because it presented the Mac's device ids and hardware either way. + */ + void recordEmulatedMacSession(final boolean establishedLocally) { + this.store.put(establishedLocally, EMULATED_MAC); + } + + /** Whether local Anisette, of either kind, produced the session. */ + public boolean establishedLocally() { + return this.store.getBoolean(KEY_ESTABLISHED_LOCALLY); + } + + /** Whether the session was signed in through the emulated Mac. False for every older one. */ + public boolean signedInAsEmulatedMac() { + return EMULATED_MAC.equals(this.store.getString(KEY_MACHINE)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java index 62c3ca76..4f34d8a2 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java @@ -12,6 +12,7 @@ import java.util.function.Function; import java.util.function.Supplier; +import dev.wander.android.opentagviewer.anisette.AnisetteForSession; import dev.wander.android.opentagviewer.anisette.AnisetteSource; import dev.wander.android.opentagviewer.anisette.EmulatedMacAnisette; import dev.wander.android.opentagviewer.anisette.LocalAnisette; @@ -56,11 +57,13 @@ private AppDependencies() {} * because the real one needs a Context and the current settings, and neither exists when * this class is loaded. * - *

The emulated Mac, not {@link LocalAnisette}. The Android libraries that one runs - * mint Android machines, which Apple no longer lets reach Find My - see - * {@link EmulatedMacAnisette}. + *

The emulated Mac for a fresh sign-in, and the machine that signed it in for a + * restore. The Android libraries {@link LocalAnisette} runs mint Android machines, which + * Apple no longer lets sign in to Find My, so new sign-ins get {@link EmulatedMacAnisette}. + * A session {@link LocalAnisette} established keeps it and is never moved onto a new machine: + * see {@link AnisetteForSession}. */ - private static AnisetteFactory anisetteFactory = EmulatedMacAnisette::new; + private static AnisetteFactory anisetteFactory = AnisetteForSession::create; /** Builds the Anisette source for a screen, given where it is running and who is signed in. */ public interface AnisetteFactory { @@ -298,7 +301,7 @@ public static void replaceAnisette(final Function @VisibleForTesting public static void reset() { authService = new PythonAppleAuthService(); - anisetteFactory = EmulatedMacAnisette::new; + anisetteFactory = AnisetteForSession::create; serverTesterFactory = AnisetteServerTesterService::new; hardwareDescriber = new ChaquopyHardwareDescriber(); accessoryMacResolver = new ChaquopyAccessoryMacResolver(); diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisetteTest.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisetteTest.java new file mode 100644 index 00000000..b4d0cc65 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/EmulatedMacAnisetteTest.java @@ -0,0 +1,422 @@ +package dev.wander.android.opentagviewer.anisette; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; + +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; + +/** + * {@link EmulatedMacAnisette}'s state machine, with the emulator, Apple and the disk image faked. + * + *

What it pins, because each one costs the user a device-list entry or a working session when it + * goes wrong (AGENTS.md rule 21): a fresh install provisions once and keeps that machine; a store + * that lost its provisioning registers a new machine on a sign-in and never on a restore; a + * failure anywhere falls back to a remote server; {@code adi.pb} never reaches disk; and an + * emulator that died in its process is not walked into again. + */ +public class EmulatedMacAnisetteTest { + + private static final String ATTEMPT = "1/macadi/build"; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private File directory; + private File library; + private FakeMacAdi.Opener opener; + private MemoryProvenance provenanceFile; + private MemoryDeaths deaths; + private List provisioned; + private Exception provisioningFails; + + /** {@link NativeLoadGuard.Store} in memory. */ + private static final class MemoryDeaths implements NativeLoadGuard.Store { + String value; + + @Override + public String get() { + return this.value; + } + + @Override + public void set(final String attempt) { + this.value = attempt; + } + + @Override + public void clear() { + this.value = null; + } + } + + @Before + public void anInstall() throws Exception { + this.directory = this.folder.newFolder("anisette", "mac"); + this.library = new File(this.directory, "CoreADI-4.19-x86_64"); + this.opener = new FakeMacAdi.Opener(); + this.provenanceFile = new MemoryProvenance(); + this.deaths = new MemoryDeaths(); + this.provisioned = new ArrayList<>(); + } + + private static UserSettings local() { + return UserSettings.builder().anisetteMode(UserSettings.ANISETTE_LOCAL).build(); + } + + /** A new process: nothing shared with any earlier one but the disk. */ + private EmulatedMacAnisette inANewProcess(final EmulatedMacAnisette.Purpose purpose) { + return in(new EmulatedMacAnisette.Shared(), purpose, local()); + } + + private EmulatedMacAnisette in(final EmulatedMacAnisette.Shared shared, + final EmulatedMacAnisette.Purpose purpose, + final UserSettings settings) { + return new EmulatedMacAnisette(this.directory, settings, purpose, + this.provenanceFile.provenance(), this.opener, () -> this.library, + (identity, adi) -> { + if (this.provisioningFails != null) { + throw this.provisioningFails; + } + this.provisioned.add(identity); + adi.provisioningFinish(MacAdi.ANONYMOUS_DS_ID, 1, FakeMacAdi.bytes("ptm"), + FakeMacAdi.bytes("tk"), 17106176L); + return 17106176L; + }, + this.deaths, ATTEMPT, TestPlists.BASE64, shared); + } + + private File store() { + return new File(this.directory, "store"); + } + + private List storedFiles() { + final String[] names = store().list(); + final List out = names == null ? new ArrayList<>() : new ArrayList<>(Arrays.asList(names)); + out.sort(null); + return out; + } + + private MacIdentity identity() throws IOException { + return MacIdentity.load(this.directory); + } + + // ----- first sign-in ----------------------------------------------------------------------- + + @Test + public void aFreshInstallCreatesProvisionsAndKeepsOneMachine() throws Exception { + final EmulatedMacAnisette mac = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + + assertTrue(mac.unavailableReason(), mac.ensureReady()); + + assertEquals(1, this.provisioned.size()); + final MacIdentity saved = identity(); + assertEquals(this.provisioned.get(0).deviceId(), saved.deviceId()); + assertTrue(saved.provisionedAtMillis() > 0); + assertEquals(17106176L, saved.routing()); + assertEquals(List.of(FakeMacAdi.PROVISIONED_FILE), storedFiles()); + assertArrayEquals(saved.hostUuidBytes(), this.opener.last().hostUuid); + assertEquals(List.of(this.library), this.opener.libraries); + } + + @Test + public void askingAgainInTheSameProcessDoesNothingAgain() { + final EmulatedMacAnisette.Shared shared = new EmulatedMacAnisette.Shared(); + assertTrue(in(shared, EmulatedMacAnisette.Purpose.SIGN_IN, local()).ensureReady()); + assertTrue(in(shared, EmulatedMacAnisette.Purpose.SIGN_IN, local()).ensureReady()); + + assertEquals(1, this.opener.opened.size()); + assertEquals(1, this.provisioned.size()); + } + + @Test + public void theNextLaunchIsTheSameMachineAndProvisionsNothing() throws Exception { + assertTrue(inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN).ensureReady()); + final String deviceId = identity().deviceId(); + + final EmulatedMacAnisette later = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + assertTrue(later.unavailableReason(), later.ensureReady()); + + assertEquals(1, this.provisioned.size()); + assertEquals(deviceId, identity().deviceId()); + assertTrue(this.opener.last().calls.contains("storePut " + FakeMacAdi.PROVISIONED_FILE)); + assertEquals(deviceId, new JSONObject(later.deviceIdsJson()).getString("devid")); + } + + @Test + public void adiPbNeverReachesTheDisk() { + final EmulatedMacAnisette mac = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + assertTrue(mac.ensureReady()); + assertFalse(mac.otp().isEmpty()); + + assertTrue("the fake did write it", this.opener.last().store.containsKey("adi.pb")); + assertFalse(storedFiles().contains("adi.pb")); + } + + @Test + public void theHeadersAreTheLibrarysAndTheStoreIsSavedAfterThem() { + final EmulatedMacAnisette mac = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + + assertEquals(Base64.getEncoder().encodeToString(FakeMacAdi.bytes("otp")), mac.otp()); + assertEquals(Base64.getEncoder().encodeToString(FakeMacAdi.bytes("mid")), mac.machine()); + assertEquals("17106176", mac.router()); + // RequestOTP advanced the store, and what is on disk is the advanced one. + assertEquals("provisioned+", new String( + readQuietly(new File(store(), FakeMacAdi.PROVISIONED_FILE)))); + } + + // ----- a store that lost its provisioning --------------------------------------------------- + + private void loseTheStore() { + for (final File file : store().listFiles()) { + assertTrue(file.delete()); + } + } + + @Test + public void aLostStoreOnASignInRegistersANewMachine() throws Exception { + assertTrue(inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN).ensureReady()); + final String first = identity().deviceId(); + loseTheStore(); + + assertTrue(inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN).ensureReady()); + + assertEquals(2, this.provisioned.size()); + assertNotEquals(first, identity().deviceId()); + assertEquals(identity().deviceId(), this.provisioned.get(1).deviceId()); + assertTrue("the old emulator is let go", this.opener.opened.get(1).closed); + } + + @Test + public void aLostStoreOnARestoreRegistersNothing() throws Exception { + assertTrue(inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN).ensureReady()); + this.provenanceFile.provenance().recordEmulatedMacSession(true); + final String deviceId = identity().deviceId(); + loseTheStore(); + + final EmulatedMacAnisette restore = inANewProcess(EmulatedMacAnisette.Purpose.RESTORE); + + assertFalse(restore.ensureReady()); + assertTrue(restore.unavailableReason(), restore.unavailableReason().contains("sign in again")); + assertEquals(1, this.provisioned.size()); + assertEquals(deviceId, identity().deviceId()); + assertTrue("so the machine change is reported", restore.isChangingMachineIdentity()); + } + + // ----- restores ----------------------------------------------------------------------------- + + @Test + public void aRestoreOfTheMacsSessionUsesTheSameMac() throws Exception { + assertTrue(inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN).ensureReady()); + this.provenanceFile.provenance().recordEmulatedMacSession(true); + + final EmulatedMacAnisette restore = inANewProcess(EmulatedMacAnisette.Purpose.RESTORE); + + assertTrue(restore.unavailableReason(), restore.ensureReady()); + assertEquals(1, this.provisioned.size()); + assertFalse(restore.isChangingMachineIdentity()); + } + + @Test + public void aRestoreOnAnInstallWithNoMacCreatesNone() throws Exception { + this.provenanceFile.provenance().recordEmulatedMacSession(true); + + final EmulatedMacAnisette restore = inANewProcess(EmulatedMacAnisette.Purpose.RESTORE); + + assertFalse(restore.ensureReady()); + assertNull(identity()); + assertTrue(this.provisioned.isEmpty()); + assertTrue(this.opener.opened.isEmpty()); + } + + @Test + public void aRestoreOfAnUnprovisionedMacProvisionsNothing() throws Exception { + MacIdentity.generate().save(this.directory); + this.provenanceFile.provenance().recordEmulatedMacSession(true); + + final EmulatedMacAnisette restore = inANewProcess(EmulatedMacAnisette.Purpose.RESTORE); + + assertFalse(restore.ensureReady()); + assertTrue(this.provisioned.isEmpty()); + assertTrue(this.opener.last().closed); + } + + @Test + public void aSessionTheMacFellBackToAServerForStaysOnTheServer() throws Exception { + assertTrue(inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN).ensureReady()); + this.provenanceFile.provenance().recordEmulatedMacSession(false); + final int opened = this.opener.opened.size(); + + final EmulatedMacAnisette restore = inANewProcess(EmulatedMacAnisette.Purpose.RESTORE); + + assertFalse(restore.ensureReady()); + assertEquals(opened, this.opener.opened.size()); + assertFalse("it was never local, so nothing changed", restore.isChangingMachineIdentity()); + } + + // ----- falling back ------------------------------------------------------------------------- + + @Test + public void aRemoteServerChosenInSettingsTouchesNothing() { + final EmulatedMacAnisette mac = in(new EmulatedMacAnisette.Shared(), + EmulatedMacAnisette.Purpose.SIGN_IN, + UserSettings.builder().anisetteMode(UserSettings.ANISETTE_REMOTE).build()); + + assertFalse(mac.ensureReady()); + assertTrue(mac.unavailableReason().contains("remote")); + assertTrue(this.opener.opened.isEmpty()); + assertEquals("", mac.otp()); + } + + @Test + public void anEmulatorThatWillNotStartFallsBack() { + this.opener.failWith = new IOException("could not start the emulated Mac's process"); + final EmulatedMacAnisette mac = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + + assertFalse(mac.ensureReady()); + assertEquals("could not start the emulated Mac's process", mac.unavailableReason()); + assertEquals("", mac.machine()); + } + + @Test + public void aRefusedProvisioningFallsBackAndLeavesTheMachineUnprovisioned() throws Exception { + this.provisioningFails = new AdiProvisioning.ProvisioningException("Apple returned HTTP 433"); + final EmulatedMacAnisette mac = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + + assertFalse(mac.ensureReady()); + assertEquals("Apple returned HTTP 433", mac.unavailableReason()); + assertEquals(0, identity().provisionedAtMillis()); + assertTrue(this.opener.last().closed); + assertTrue(storedFiles().isEmpty()); + } + + @Test + public void aFailureIsRetriedOnTheNextAskAndNotCached() { + this.provisioningFails = new AdiProvisioning.ProvisioningException("offline"); + final EmulatedMacAnisette.Shared shared = new EmulatedMacAnisette.Shared(); + assertFalse(in(shared, EmulatedMacAnisette.Purpose.SIGN_IN, local()).ensureReady()); + + this.provisioningFails = null; + assertTrue(in(shared, EmulatedMacAnisette.Purpose.SIGN_IN, local()).ensureReady()); + } + + // ----- the emulator's process dying --------------------------------------------------------- + + @Test + public void anEmulatorThatDiesIsRecordedAndNotTriedAgain() { + assertTrue(inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN).ensureReady()); + // The next launch asks the library whether it is provisioned, and the process dies. + this.opener.configure = adi -> adi.failHeadersWith = new MacAdi.EmulatorDiedException( + "the emulated Mac's process died during a call", null); + final EmulatedMacAnisette next = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + + assertFalse(next.ensureReady()); + assertEquals(EmulatedMacAnisette.CRASHES_HERE_REASON, next.unavailableReason()); + assertEquals(ATTEMPT, this.deaths.value); + + final int opened = this.opener.opened.size(); + final EmulatedMacAnisette after = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + assertFalse(after.ensureReady()); + assertEquals(EmulatedMacAnisette.CRASHES_HERE_REASON, after.unavailableReason()); + assertEquals("not opened again", opened, this.opener.opened.size()); + } + + @Test + public void aDeathMidSessionIsRecordedAndTheHeadersAreEmpty() { + final EmulatedMacAnisette mac = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + assertTrue(mac.ensureReady()); + this.opener.last().failHeadersWith = new MacAdi.EmulatorDiedException("died", null); + + assertEquals("", mac.otp()); + assertEquals(ATTEMPT, this.deaths.value); + assertFalse(mac.ensureReady()); + } + + @Test + public void aRecordFromAnotherVersionIsClearedAndTriedAgain() { + this.deaths.value = "0/macadi/older-build"; + + assertTrue(inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN).ensureReady()); + assertNull(this.deaths.value); + } + + @Test + public void aProcessThatWentAwayBetweenCallsIsReopenedNotWrittenOff() { + final EmulatedMacAnisette.Shared shared = new EmulatedMacAnisette.Shared(); + assertTrue(in(shared, EmulatedMacAnisette.Purpose.SIGN_IN, local()).ensureReady()); + this.opener.last().gone = true; + + final EmulatedMacAnisette again = in(shared, EmulatedMacAnisette.Purpose.SIGN_IN, local()); + assertTrue(again.unavailableReason(), again.ensureReady()); + + assertEquals(2, this.opener.opened.size()); + assertEquals(1, this.provisioned.size()); + assertNull(this.deaths.value); + } + + // ----- provenance --------------------------------------------------------------------------- + + @Test + public void aSignInIsRecordedAsTheMacsInLocalAnisettesFile() { + final EmulatedMacAnisette mac = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + + mac.recordSessionProvenance(true); + + assertTrue(mac.wasSessionEstablishedLocally()); + assertEquals("emulatedMac", this.provenanceFile.values.get("sessionMachine")); + assertEquals(true, this.provenanceFile.values.get("sessionEstablishedLocally")); + } + + @Test + public void theProvenanceLocalAnisetteWroteIsReadNotLost() { + // An install from before this change: only LocalAnisette's key. + this.provenanceFile.values.put("sessionEstablishedLocally", true); + this.opener.failWith = new IOException("offline"); + + final EmulatedMacAnisette mac = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + + assertTrue(mac.wasSessionEstablishedLocally()); + assertTrue("so the machine change is reported", mac.isChangingMachineIdentity()); + } + + @Test + public void theIdsAnswerBeforeTheMacIsReadyAndAreTheOnesItProvisionsWith() throws Exception { + final EmulatedMacAnisette mac = inANewProcess(EmulatedMacAnisette.Purpose.SIGN_IN); + final JSONObject ids = new JSONObject(mac.deviceIdsJson()); + final String serial = mac.serial(); + + assertTrue(mac.ensureReady()); + + assertEquals(ids.getString("devid"), this.provisioned.get(0).deviceId()); + assertEquals(ids.getString("uid"), this.provisioned.get(0).localUserUuid()); + assertEquals(serial, this.provisioned.get(0).serial()); + assertNotNull(mac.cloudKitDeviceUuid()); + assertTrue(mac.cloudKitDeviceUuid().matches( + "[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}")); + } + + private static byte[] readQuietly(final File file) { + try { + return MacCoreAdiFetcher.readFile(file); + } catch (final IOException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/FakeGrandSlam.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/FakeGrandSlam.java new file mode 100644 index 00000000..74c39bc6 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/FakeGrandSlam.java @@ -0,0 +1,139 @@ +package dev.wander.android.opentagviewer.anisette; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A stand-in for Apple's provisioning endpoints: answers by path, and keeps every request. + * + *

The same idea as {@link FakeApkServer}, for the other conversation: a {@code ServerSocket} on + * {@code 127.0.0.1} and a few lines of HTTP, so the requests {@link MacProvisioning} sends can be + * read back exactly as they went on the wire (AGENTS.md rule 13). One request per connection, + * answered with {@code Connection: close}. + */ +final class FakeGrandSlam implements Closeable { + + /** One request as it arrived. Header names are lower-cased. */ + static final class Request { + final String method; + final String path; + final Map headers; + final String body; + + Request(final String method, final String path, final Map headers, + final String body) { + this.method = method; + this.path = path; + this.headers = headers; + this.body = body; + } + } + + /** What to answer on one path. */ + static final class Answer { + final int status; + final String body; + + Answer(final int status, final String body) { + this.status = status; + this.body = body; + } + } + + private final ServerSocket socket; + private final Map answers = new HashMap<>(); + private final List requests = new ArrayList<>(); + private final AtomicBoolean stopped = new AtomicBoolean(false); + + FakeGrandSlam() throws IOException { + this.socket = new ServerSocket(0, 0, InetAddress.getByName("127.0.0.1")); + final Thread thread = new Thread(this::serve, "FakeGrandSlam"); + thread.setDaemon(true); + thread.start(); + } + + String url(final String path) { + return "http://127.0.0.1:" + this.socket.getLocalPort() + path; + } + + synchronized FakeGrandSlam answer(final String path, final int status, final String body) { + this.answers.put(path, new Answer(status, body)); + return this; + } + + synchronized List requests() { + return new ArrayList<>(this.requests); + } + + private void serve() { + while (!this.stopped.get()) { + try (Socket client = this.socket.accept()) { + handle(client); + } catch (final IOException e) { + if (this.stopped.get()) { + return; + } + } + } + } + + private void handle(final Socket client) throws IOException { + final InputStream in = client.getInputStream(); + final String[] start = readLine(in).split(" "); + final Map headers = new HashMap<>(); + String line; + while (!(line = readLine(in)).isEmpty()) { + final int colon = line.indexOf(':'); + headers.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT), + line.substring(colon + 1).trim()); + } + final int length = Integer.parseInt(headers.getOrDefault("content-length", "0")); + final byte[] body = in.readNBytes(length); + + final Answer answer; + synchronized (this) { + this.requests.add(new Request(start[0], start[1], headers, + new String(body, StandardCharsets.UTF_8))); + answer = this.answers.getOrDefault(start[1], new Answer(404, "not here")); + } + + final byte[] payload = answer.body.getBytes(StandardCharsets.UTF_8); + final OutputStream out = client.getOutputStream(); + out.write(("HTTP/1.1 " + answer.status + " X\r\n" + + "Content-Type: text/xml\r\n" + + "Content-Length: " + payload.length + "\r\n" + + "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + out.write(payload); + out.flush(); + } + + private static String readLine(final InputStream in) throws IOException { + final ByteArrayOutputStream line = new ByteArrayOutputStream(); + int b; + while ((b = in.read()) != -1 && b != '\n') { + if (b != '\r') { + line.write(b); + } + } + return line.toString(StandardCharsets.US_ASCII.name()); + } + + @Override + public void close() throws IOException { + this.stopped.set(true); + this.socket.close(); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/FakeMacAdi.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/FakeMacAdi.java new file mode 100644 index 00000000..4b06d4b5 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/FakeMacAdi.java @@ -0,0 +1,125 @@ +package dev.wander.android.opentagviewer.anisette; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.io.IOException; +import java.util.function.Consumer; + +/** + * The emulated Mac's CoreADI with no emulator: a store that behaves the way the library's does. + * + *

It is "provisioned" exactly when its store holds {@link #PROVISIONED_FILE}, which is what the + * real one comes down to - provisioning writes per-user state, and a store that lost it is a + * machine that has to be registered again. It also writes {@code adi.pb} whenever it is asked + * for anything, as the real library does, so a test can check that file never reaches disk. + */ +final class FakeMacAdi implements MacAdi { + + static final String PROVISIONED_FILE = "2a4d5f.pb"; + + final Map store = new LinkedHashMap<>(); + final List calls = new ArrayList<>(); + byte[] hostUuid; + boolean closed; + boolean gone; + long routingAssigned; + + /** When set, the next call to this method throws it. */ + RuntimeException failHeadersWith; + + @Override + public void setHostUuid(final byte[] uuid) { + this.calls.add("setHostUuid"); + this.hostUuid = uuid.clone(); + } + + @Override + public void storePut(final String name, final byte[] contents) { + this.calls.add("storePut " + name); + this.store.put(name, contents.clone()); + } + + @Override + public Map storeFiles() { + return new LinkedHashMap<>(this.store); + } + + @Override + public Headers headers(final long dsId) { + this.calls.add("headers"); + if (this.failHeadersWith != null) { + final RuntimeException e = this.failHeadersWith; + this.failHeadersWith = null; + throw e; + } + this.store.put("adi.pb", bytes("legacy whole-machine blob")); + if (!this.store.containsKey(PROVISIONED_FILE)) { + throw new NotProvisionedException("ADIOTPRequest returned -45061"); + } + // RequestOTP advances the store, which is why the caller saves it after every call. + final byte[] state = this.store.get(PROVISIONED_FILE); + this.store.put(PROVISIONED_FILE, (new String(state, StandardCharsets.UTF_8) + "+") + .getBytes(StandardCharsets.UTF_8)); + return new Headers(bytes("mid"), bytes("otp"), this.routingAssigned); + } + + @Override + public byte[] provisioningBegin(final long dsId, final byte[] spim, final int[] sessionOut) { + this.calls.add("provisioningBegin " + new String(spim, StandardCharsets.UTF_8)); + sessionOut[0] = 42; + return bytes("cpim for " + new String(spim, StandardCharsets.UTF_8)); + } + + @Override + public void provisioningFinish(final long dsId, final int session, final byte[] ptm, + final byte[] tk, final long routing) { + this.calls.add("provisioningFinish " + session + " " + new String(ptm, StandardCharsets.UTF_8) + + " " + new String(tk, StandardCharsets.UTF_8) + " " + routing); + this.routingAssigned = routing; + this.store.put(PROVISIONED_FILE, bytes("provisioned")); + this.store.put("adi.pb", bytes("legacy whole-machine blob")); + } + + @Override + public boolean isGone() { + return this.gone; + } + + @Override + public void close() { + this.closed = true; + } + + static byte[] bytes(final String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + /** Opens a new {@link FakeMacAdi} each time, and keeps them all. */ + static final class Opener implements MacAdi.Opener { + final List opened = new ArrayList<>(); + final List libraries = new ArrayList<>(); + IOException failWith; + /** Applied to each new one before it is handed over. */ + Consumer configure = adi -> { }; + + @Override + public MacAdi open(final File library) throws IOException { + if (this.failWith != null) { + throw this.failWith; + } + this.libraries.add(library); + final FakeMacAdi adi = new FakeMacAdi(); + this.configure.accept(adi); + this.opened.add(adi); + return adi; + } + + FakeMacAdi last() { + return this.opened.get(this.opened.size() - 1); + } + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/MacIdentityTest.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/MacIdentityTest.java new file mode 100644 index 00000000..1695aff7 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/MacIdentityTest.java @@ -0,0 +1,128 @@ +package dev.wander.android.opentagviewer.anisette; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.json.JSONObject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.io.IOException; + +/** + * The emulated Mac's identity: the shapes Apple is sent, and that a saved one comes back unchanged. + * + *

Losing or regenerating any of these is a second Mac on the user's Apple account, so the round + * trip is the thing worth pinning (AGENTS.md rules 11 and 21). + */ +public class MacIdentityTest { + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void aFreshMachineHasTheShapesAMacSends() { + final MacIdentity mac = MacIdentity.generate(); + + assertTrue(mac.deviceId(), mac.deviceId().matches("00006000-[0-9A-F]{16}")); + assertTrue(mac.localUserUuid(), mac.localUserUuid().matches("[0-9A-F]{32}")); + assertTrue(mac.hostUuid(), mac.hostUuid().matches("[0-9a-f]{32}")); + assertTrue(mac.serial(), mac.serial().matches("0PENTAGV[0-9A-Z]{4}")); + assertEquals(0, mac.routing()); + assertEquals(0, mac.provisionedAtMillis()); + } + + @Test + public void twoFreshMachinesAreTwoMachines() { + final MacIdentity one = MacIdentity.generate(); + final MacIdentity two = MacIdentity.generate(); + + assertNotEquals(one.deviceId(), two.deviceId()); + assertNotEquals(one.localUserUuid(), two.localUserUuid()); + assertNotEquals(one.hostUuid(), two.hostUuid()); + } + + @Test + public void aSavedMachineLoadsBackExactly() throws Exception { + final MacIdentity saved = MacIdentity.generate().provisioned(17106176L, 1_790_000_000_000L); + saved.save(this.folder.getRoot()); + + final MacIdentity loaded = MacIdentity.load(this.folder.getRoot()); + + assertEquals(saved.deviceId(), loaded.deviceId()); + assertEquals(saved.localUserUuid(), loaded.localUserUuid()); + assertEquals(saved.hostUuid(), loaded.hostUuid()); + assertEquals(saved.serial(), loaded.serial()); + assertEquals(17106176L, loaded.routing()); + assertEquals(1_790_000_000_000L, loaded.provisionedAtMillis()); + assertArrayEquals(saved.hostUuidBytes(), loaded.hostUuidBytes()); + } + + @Test + public void provisioningKeepsEveryIdAndAddsOnlyTheRouting() { + final MacIdentity fresh = MacIdentity.generate(); + final MacIdentity done = fresh.provisioned(123L, 456L); + + assertEquals(fresh.deviceId(), done.deviceId()); + assertEquals(fresh.localUserUuid(), done.localUserUuid()); + assertEquals(fresh.hostUuid(), done.hostUuid()); + assertEquals(fresh.serial(), done.serial()); + assertEquals(123L, done.routing()); + assertEquals(456L, done.provisionedAtMillis()); + } + + @Test + public void savingLeavesNoPartialFileBehind() throws Exception { + MacIdentity.generate().save(this.folder.getRoot()); + + assertTrue(new File(this.folder.getRoot(), "identity.json").isFile()); + assertEquals(1, this.folder.getRoot().listFiles().length); + } + + @Test + public void anInstallWithNoneHasNone() throws Exception { + assertNull(MacIdentity.load(this.folder.getRoot())); + } + + @Test + public void anUnreadableOneIsAnErrorAndNotAFreshMachine() throws Exception { + try (FileOutputStream out = new FileOutputStream(this.folder.newFile("identity.json"))) { + out.write("{\"deviceId\": ".getBytes(StandardCharsets.UTF_8)); + } + + assertThrows(IOException.class, () -> MacIdentity.load(this.folder.getRoot())); + } + + @Test + public void theHostUuidIsSixteenBytesOfTheHex() { + final MacIdentity mac = MacIdentity.generate(); + final StringBuilder hex = new StringBuilder(); + for (final byte b : mac.hostUuidBytes()) { + hex.append(String.format("%02x", b)); + } + assertEquals(mac.hostUuid(), hex.toString()); + } + + @Test + public void theHardwareFindMyPyIsGivenIsTheMacProvisioningDescribes() throws Exception { + final JSONObject hardware = new JSONObject(MacIdentity.hardwareJson()); + + assertEquals(6, hardware.length()); + assertEquals(MacIdentity.MODEL, hardware.getString("model")); + assertEquals(MacIdentity.OS_NAME, hardware.getString("os_name")); + assertEquals(MacIdentity.OS_VERSION, hardware.getString("os_version")); + assertEquals(MacIdentity.OS_BUILD, hardware.getString("os_build")); + assertEquals(MacIdentity.CFNETWORK, hardware.getString("cfnetwork")); + assertEquals(MacIdentity.LOGIN_DARWIN, hardware.getString("darwin")); + assertEquals(" ", + MacIdentity.CLIENT_INFO); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/MacProvisioningTest.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/MacProvisioningTest.java new file mode 100644 index 00000000..35e1a87c --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/MacProvisioningTest.java @@ -0,0 +1,182 @@ +package dev.wander.android.opentagviewer.anisette; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The three requests that register the emulated Mac with Apple, as they go on the wire. + * + *

Served by {@link FakeGrandSlam} on {@code 127.0.0.1}, so what is checked is what was sent - + * every header os-findmy-e's {@code gsa.py} was seen to be accepted with, both bodies, and that + * Apple's answers reach the library intact (AGENTS.md rule 13). Apple allows one provisioning per + * device id, so this is the only way to look at these requests more than once. + */ +public class MacProvisioningTest { + + private FakeGrandSlam apple; + private MacIdentity identity; + private FakeMacAdi adi; + + @Before + public void startApple() throws Exception { + this.apple = new FakeGrandSlam(); + this.identity = MacIdentity.generate(); + this.adi = new FakeMacAdi(); + } + + @After + public void stopApple() throws Exception { + this.apple.close(); + } + + private static String b64(final String s) { + return Base64.getEncoder().encodeToString(FakeMacAdi.bytes(s)); + } + + private void appleAnswersEverything() { + final Map urls = new LinkedHashMap<>(); + urls.put("midStartProvisioning", this.apple.url("/start")); + urls.put("midFinishProvisioning", this.apple.url("/finish")); + this.apple.answer("/lookup", 200, TestPlists.dict(Map.of("urls", urls))); + this.apple.answer("/start", 200, TestPlists.dict(Map.of("Response", + Map.of("spim", b64("the spim"))))); + this.apple.answer("/finish", 200, TestPlists.dict(Map.of("Response", Map.of( + "ptm", b64("the ptm"), + "tk", b64("the tk"), + "X-Apple-I-MD-RINFO", "17106176")))); + } + + private long provision() throws Exception { + return new MacProvisioning(this.identity, this.adi, this.apple.url("/lookup"), + TestPlists.BASE64, TestPlists::parse).provision(); + } + + @Test + public void itAsksForTheBagThenStartsThenFinishes() throws Exception { + appleAnswersEverything(); + + assertEquals(17106176L, provision()); + + final List sent = this.apple.requests(); + assertEquals(3, sent.size()); + assertEquals("GET /lookup", sent.get(0).method + " " + sent.get(0).path); + assertEquals("POST /start", sent.get(1).method + " " + sent.get(1).path); + assertEquals("POST /finish", sent.get(2).method + " " + sent.get(2).path); + } + + @Test + public void everyRequestPresentsTheSameMac() throws Exception { + appleAnswersEverything(); + provision(); + + for (final FakeGrandSlam.Request request : this.apple.requests()) { + final Map h = request.headers; + assertEquals(request.path, "akd/1.0 CFNetwork/1404.0.5 Darwin/22.3.0", h.get("user-agent")); + assertEquals(request.path, MacIdentity.CLIENT_INFO, h.get("x-mme-client-info")); + assertEquals(request.path, this.identity.deviceId(), h.get("x-mme-device-id")); + // Raw here; FindMy.py base64-encodes it at login. See MacIdentity#localUserUuid. + assertEquals(request.path, this.identity.localUserUuid(), h.get("x-apple-i-md-lu")); + assertEquals(request.path, this.identity.serial(), h.get("x-apple-i-srl-no")); + assertEquals(request.path, "Setup", h.get("x-apple-client-app-name")); + assertEquals(request.path, "application/x-www-form-urlencoded", h.get("content-type")); + } + } + + @Test + public void onlyThePostsCarryAClientTimeAndItHasNoMilliseconds() throws Exception { + appleAnswersEverything(); + provision(); + + final List sent = this.apple.requests(); + assertNull(sent.get(0).headers.get("x-apple-i-client-time")); + for (final FakeGrandSlam.Request post : sent.subList(1, 3)) { + final String time = post.headers.get("x-apple-i-client-time"); + assertTrue(post.path + ": " + time, + time.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z")); + } + } + + @Test + public void startSendsAnEmptyRequestAndFinishSendsTheLibrarysCpim() throws Exception { + appleAnswersEverything(); + provision(); + + final List sent = this.apple.requests(); + assertTrue(sent.get(1).body, sent.get(1).body.endsWith( + "HeaderRequest" + + "")); + assertTrue(sent.get(2).body, sent.get(2).body.contains( + "Requestcpim" + b64("cpim for the spim") + + "")); + } + + @Test + public void applesAnswersReachTheLibraryInOneSession() throws Exception { + appleAnswersEverything(); + provision(); + + assertEquals(List.of("provisioningBegin the spim", + "provisioningFinish 42 the ptm the tk 17106176"), this.adi.calls); + } + + @Test + public void aRefusalSaysWhatAppleSaid() { + appleAnswersEverything(); + this.apple.answer("/finish", 433, "-29002"); + + final AdiProvisioning.ProvisioningException refused = + assertThrows(AdiProvisioning.ProvisioningException.class, this::provision); + + assertTrue(refused.getMessage(), refused.getMessage().contains("HTTP 433")); + assertTrue(refused.getMessage(), refused.getMessage().contains("-29002")); + assertFalse("the library must not be told it finished", + this.adi.calls.stream().anyMatch(c -> c.startsWith("provisioningFinish"))); + } + + @Test + public void aBagWithoutTheEndpointsStopsBeforeAnythingIsSent() { + this.apple.answer("/lookup", 200, TestPlists.dict(Map.of("urls", + Map.of("somethingElse", this.apple.url("/x"))))); + + final AdiProvisioning.ProvisioningException refused = + assertThrows(AdiProvisioning.ProvisioningException.class, this::provision); + + assertTrue(refused.getMessage(), refused.getMessage().contains("no provisioning endpoints")); + assertEquals(1, this.apple.requests().size()); + assertTrue(this.adi.calls.isEmpty()); + } + + @Test + public void aFinishWithoutATrustKeyIsRefusedAndNotHalfApplied() { + appleAnswersEverything(); + this.apple.answer("/finish", 200, TestPlists.dict(Map.of("Response", + Map.of("ptm", b64("the ptm"))))); + + final AdiProvisioning.ProvisioningException refused = + assertThrows(AdiProvisioning.ProvisioningException.class, this::provision); + + assertTrue(refused.getMessage(), refused.getMessage().contains("Response/tk")); + assertEquals(List.of("provisioningBegin the spim"), this.adi.calls); + } + + @Test + public void aRoutingNumberThatIsNotANumberIsRefused() { + appleAnswersEverything(); + this.apple.answer("/finish", 200, TestPlists.dict(Map.of("Response", Map.of( + "ptm", b64("the ptm"), "tk", b64("the tk"), "X-Apple-I-MD-RINFO", "seventeen")))); + + assertThrows(AdiProvisioning.ProvisioningException.class, this::provision); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/MemoryProvenance.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/MemoryProvenance.java new file mode 100644 index 00000000..c774b5f0 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/MemoryProvenance.java @@ -0,0 +1,41 @@ +package dev.wander.android.opentagviewer.anisette; + +import java.util.HashMap; +import java.util.Map; + +/** + * {@link SessionProvenance}'s store as a map: what is in {@code LocalAnisette.PREFERENCES}, by key. + * + *

Tests write the keys by name, the way an install from before the emulated Mac has them, rather + * than through {@link SessionProvenance}, so "an older install" means the file such an install + * actually has. + */ +final class MemoryProvenance implements SessionProvenance.Store { + final Map values = new HashMap<>(); + + @Override + public boolean getBoolean(final String key) { + final Object value = this.values.get(key); + return value instanceof Boolean && (Boolean) value; + } + + @Override + public String getString(final String key) { + final Object value = this.values.get(key); + return value instanceof String ? (String) value : null; + } + + @Override + public void put(final boolean establishedLocally, final String machine) { + this.values.put(SessionProvenance.KEY_ESTABLISHED_LOCALLY, establishedLocally); + if (machine == null) { + this.values.remove(SessionProvenance.KEY_MACHINE); + } else { + this.values.put(SessionProvenance.KEY_MACHINE, machine); + } + } + + SessionProvenance provenance() { + return new SessionProvenance(this); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/RestoringASessionOnTheMachineThatSignedItInTest.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/RestoringASessionOnTheMachineThatSignedItInTest.java new file mode 100644 index 00000000..5cede9ae --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/RestoringASessionOnTheMachineThatSignedItInTest.java @@ -0,0 +1,86 @@ +package dev.wander.android.opentagviewer.anisette; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Which Anisette each caller gets: a fresh sign-in the emulated Mac, a restore the machine that + * signed the session in. + * + *

The case this exists for is the upgrade. A session {@link LocalAnisette} established before + * the emulated Mac existed still fetches reports, its token is bound to the Android machine (#248), + * and its install has nothing but {@code sessionEstablishedLocally} in + * {@code LocalAnisette.PREFERENCES}. It must stay on {@link LocalAnisette}, and nothing may be + * provisioned to restore it. + */ +public class RestoringASessionOnTheMachineThatSignedItInTest { + + private static AnisetteForSession.Choice restoring(final MemoryProvenance file) { + return AnisetteForSession.choose(true, file.provenance()); + } + + @Test + public void aSessionLocalAnisetteSignedInBeforeTheUpgradeKeepsLocalAnisette() { + // Exactly what an older install has on disk: LocalAnisette's key, and nothing beside it. + final MemoryProvenance olderInstall = new MemoryProvenance(); + olderInstall.values.put("sessionEstablishedLocally", true); + + assertEquals(AnisetteForSession.Choice.ANDROID, restoring(olderInstall)); + assertTrue("the provenance LocalAnisette wrote must still read as local", + olderInstall.provenance().establishedLocally()); + } + + @Test + public void aSessionSignedInThroughAServerBeforeTheUpgradeKeepsLocalAnisettesHandling() { + final MemoryProvenance olderInstall = new MemoryProvenance(); + olderInstall.values.put("sessionEstablishedLocally", false); + + assertEquals(AnisetteForSession.Choice.ANDROID, restoring(olderInstall)); + } + + @Test + public void anInstallThatNeverRecordedAnythingKeepsLocalAnisette() { + assertEquals(AnisetteForSession.Choice.ANDROID, restoring(new MemoryProvenance())); + } + + @Test + public void aSessionTheEmulatedMacSignedInIsRestoredOntoIt() { + final MemoryProvenance file = new MemoryProvenance(); + file.provenance().recordEmulatedMacSession(true); + + assertEquals(AnisetteForSession.Choice.EMULATED_MAC_FOR_RESTORE, restoring(file)); + } + + @Test + public void aFreshSignInGetsTheEmulatedMacWhateverCameBefore() { + final MemoryProvenance olderInstall = new MemoryProvenance(); + olderInstall.values.put("sessionEstablishedLocally", true); + + assertEquals(AnisetteForSession.Choice.EMULATED_MAC_FOR_SIGN_IN, + AnisetteForSession.choose(false, olderInstall.provenance())); + } + + @Test + public void theMacRecordsInTheFileLocalAnisetteAlwaysUsed() { + final MemoryProvenance file = new MemoryProvenance(); + file.provenance().recordEmulatedMacSession(true); + + assertEquals(true, file.values.get("sessionEstablishedLocally")); + assertEquals("emulatedMac", file.values.get("sessionMachine")); + } + + @Test + public void signingInAgainThroughLocalAnisetteClearsTheMac() { + final MemoryProvenance file = new MemoryProvenance(); + file.provenance().recordEmulatedMacSession(true); + file.provenance().recordAndroidSession(true); + + assertNull(file.values.get("sessionMachine")); + assertFalse(file.provenance().signedInAsEmulatedMac()); + assertEquals(AnisetteForSession.Choice.ANDROID, restoring(file)); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/TestPlists.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/TestPlists.java new file mode 100644 index 00000000..cd4d7cdd --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/TestPlists.java @@ -0,0 +1,113 @@ +package dev.wander.android.opentagviewer.anisette; + +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.util.Base64; + +/** + * {@link SimplePlist#parse}'s shapes, from the JDK's DOM parser. + * + *

{@code SimplePlist} uses {@code android.util.Xml}, which is a stub on the JVM. This reads the + * same documents into the same maps and strings, so {@link SimplePlist#string} works on the result + * unchanged and what is under test is {@link MacProvisioning}, not a parser. + */ +final class TestPlists { + + private TestPlists() { + } + + static Object parse(final String xml) throws Exception { + final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + // Apple's DOCTYPE points at a URL; nothing here should fetch it. + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + final Element plist = factory.newDocumentBuilder() + .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))) + .getDocumentElement(); + for (final Element child : children(plist)) { + return value(child); + } + throw new IllegalArgumentException("no value in the plist"); + } + + private static Object value(final Element element) { + switch (element.getTagName()) { + case "dict": { + final Map out = new HashMap<>(); + final List items = children(element); + for (int i = 0; i + 1 < items.size(); i += 2) { + out.put(items.get(i).getTextContent(), value(items.get(i + 1))); + } + return out; + } + case "array": { + final List out = new ArrayList<>(); + for (final Element child : children(element)) { + out.add(value(child)); + } + return out; + } + case "true": + case "false": + return "true".equals(element.getTagName()); + default: + return element.getTextContent(); + } + } + + private static List children(final Element parent) { + final List out = new ArrayList<>(); + final NodeList nodes = parent.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + if (nodes.item(i).getNodeType() == Node.ELEMENT_NODE) { + out.add((Element) nodes.item(i)); + } + } + return out; + } + + /** A plist dictionary of strings, with one nested level, as Apple's answers are written. */ + static String dict(final Map entries) { + return "\n" + + "\n" + + "" + body(entries) + ""; + } + + private static String body(final Map entries) { + final StringBuilder out = new StringBuilder(""); + for (final Map.Entry entry : entries.entrySet()) { + out.append("").append(entry.getKey()).append(""); + if (entry.getValue() instanceof Map) { + @SuppressWarnings("unchecked") + final Map nested = (Map) entry.getValue(); + out.append(body(nested)); + } else { + out.append("").append(entry.getValue()).append(""); + } + } + return out.append("").toString(); + } + + /** {@code java.util.Base64}, for the tests; the app's is {@code android.util.Base64}. */ + static final Base64Codec BASE64 = new Base64Codec() { + @Override + public String encode(final byte[] data) { + return Base64.getEncoder().encodeToString(data); + } + + @Override + public byte[] decode(final String text) { + return Base64.getMimeDecoder().decode(text); + } + }; +} From da1a784e7bf957f70927bebf0a094f673f165b44 Mon Sep 17 00:00:00 2001 From: Rob Lazzurs Date: Wed, 4 Nov 2026 00:15:26 +0000 Subject: [PATCH 5/9] Carry Unicorn's GPL-2.0 notice in the APK, and its source on each release libmacadi.so links Unicorn 2.1.4, which is GPL-2.0. The APK now carries Unicorn's own licence files (COPYING, its QEMU licence, LGPL2, GLIB) under assets/licences/unicorn/, with a NOTICE.txt naming the exact source tarball and hash and saying the code runs in its own process. UnicornNoticeTest fails if that notice stops matching MacAdi.cmake. The source is fetched by FetchContent rather than being in the tag, so build-release.yml now downloads the same tarball, checks it against the pinned hash, and attaches it to the release beside the APK. The root NOTICE gains Unicorn and os-findmy-e entries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MJtmGkNZpfzFGSN1BZ82a --- .github/workflows/build-release.yml | 19 +- NOTICE | 25 + app/src/main/assets/licences/unicorn/COPYING | 339 ++++++++++++ .../assets/licences/unicorn/COPYING.LGPL2 | 481 +++++++++++++++++ .../main/assets/licences/unicorn/COPYING_GLIB | 482 ++++++++++++++++++ .../main/assets/licences/unicorn/NOTICE.txt | 35 ++ .../main/assets/licences/unicorn/qemu-LICENSE | 27 + app/src/main/cpp/macadi/NOTICE.md | 12 + .../anisette/UnicornNoticeTest.java | 49 ++ 9 files changed, 1468 insertions(+), 1 deletion(-) create mode 100644 app/src/main/assets/licences/unicorn/COPYING create mode 100644 app/src/main/assets/licences/unicorn/COPYING.LGPL2 create mode 100644 app/src/main/assets/licences/unicorn/COPYING_GLIB create mode 100644 app/src/main/assets/licences/unicorn/NOTICE.txt create mode 100644 app/src/main/assets/licences/unicorn/qemu-LICENSE create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/anisette/UnicornNoticeTest.java diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index a0efed89..a653b9c1 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -156,11 +156,28 @@ jobs: # here, and fails with 403 "Resource not accessible by integration" after the APK has # already been built and signed. That is exactly how the 1.0.5 exporter release went out # with no assets attached; this workflow had the same wiring and was never re-run. + # The APK carries libmacadi.so, which links Unicorn (GPL-2.0). GPL-2.0 section 3 asks for the + # corresponding source to be offered from the same place as the binary, and FetchContent + # downloads Unicorn's at build time rather than it being in this repository's tag. So the + # exact tarball the build used goes on the release beside the APK, checked against the hash + # MacAdi.cmake pins, and the APK's assets/licences/unicorn/NOTICE.txt says where it is. + - name: Fetch Unicorn's source, as the APK was built from it + if: github.event_name == 'release' + run: | + url=$(sed -n 's/^ *URL \(https:[^ ]*unicorn[^ ]*\.tar\.gz\)$/\1/p' app/src/main/cpp/macadi/MacAdi.cmake) + sha=$(sed -n 's/^ *URL_HASH SHA256=\([0-9a-f]*\).*/\1/p' app/src/main/cpp/macadi/MacAdi.cmake) + test -n "$url" && test -n "$sha" + curl -sSfL "$url" -o "unicorn-$(basename "$url")" + echo "$sha unicorn-$(basename "$url")" | sha256sum -c - + echo "UNICORN_SOURCE=unicorn-$(basename "$url")" >> "$GITHUB_ENV" + - name: Upload APK Release Asset to GitHub Release uses: softprops/action-gh-release@v2 if: github.event_name == 'release' with: - files: ${{ env.main_project_module }}/build/outputs/apk/release/${{ env.app_name }}-${{ steps.extract_version.outputs.APP_VERSION }}.apk + files: | + ${{ env.main_project_module }}/build/outputs/apk/release/${{ env.app_name }}-${{ steps.extract_version.outputs.APP_VERSION }}.apk + ${{ env.UNICORN_SOURCE }} name: ${{ env.app_name }} Android App ${{ steps.extract_version.outputs.APP_VERSION }} token: ${{ secrets.ANDROID_BUILD_RELEASE_GITHUB_TOKEN }} diff --git a/NOTICE b/NOTICE index 9610b0e3..a0409d13 100644 --- a/NOTICE +++ b/NOTICE @@ -46,3 +46,28 @@ What is derived, and where it lives: Apache-2.0 terms continue to apply to the derived portion above. AirGuard ships no NOTICE file of its own, so there is none to propagate here. + +-------------------------------------------------------------------------------- + +Unicorn (https://github.com/unicorn-engine/unicorn), version 2.1.4 +Licensed under the GNU General Public License, version 2 + +Linked into libmacadi.so, the emulated Mac that signs in to Find My, which the +APK carries and runs in a process of its own (":macadi"), reached from the rest of +the app over binder. Nothing else in the APK contains it. Unicorn is fetched from +source at build time, pinned by SHA-256 in app/src/main/cpp/macadi/MacAdi.cmake. + +The APK carries Unicorn's licence files and a notice saying where the +corresponding source is, under assets/licences/unicorn/. Each release that ships +libmacadi.so has the exact Unicorn source tarball attached beside the APK. + +-------------------------------------------------------------------------------- + +sacca97/os-findmy-e (https://github.com/sacca97/os-findmy-e) +Copyright (c) 2026 sacca +Licensed under the MIT License + +What is derived, and where it lives: app/src/main/cpp/macadi/{macho,vm,core_adi}.* +are a C++ port of its native/adi-core, and the provisioning requests and machine +identity in anisette/MacProvisioning.java and anisette/MacIdentity.java follow its +tools/machoadi. The full licence text is in app/src/main/cpp/macadi/NOTICE.md. diff --git a/app/src/main/assets/licences/unicorn/COPYING b/app/src/main/assets/licences/unicorn/COPYING new file mode 100644 index 00000000..00ccfbb6 --- /dev/null +++ b/app/src/main/assets/licences/unicorn/COPYING @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/app/src/main/assets/licences/unicorn/COPYING.LGPL2 b/app/src/main/assets/licences/unicorn/COPYING.LGPL2 new file mode 100644 index 00000000..5bc8fb2c --- /dev/null +++ b/app/src/main/assets/licences/unicorn/COPYING.LGPL2 @@ -0,0 +1,481 @@ + GNU LIBRARY GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1991 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the library GPL. It is + numbered 2 because it goes with version 2 of the ordinary GPL.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Library General Public License, applies to some +specially designated Free Software Foundation software, and to any +other libraries whose authors decide to use it. You can use it for +your libraries, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if +you distribute copies of the library, or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link a program with the library, you must provide +complete object files to the recipients so that they can relink them +with the library, after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + Our method of protecting your rights has two steps: (1) copyright +the library, and (2) offer you this license which gives you legal +permission to copy, distribute and/or modify the library. + + Also, for each distributor's protection, we want to make certain +that everyone understands that there is no warranty for this free +library. If the library is modified by someone else and passed on, we +want its recipients to know that what they have is not the original +version, so that any problems introduced by others will not reflect on +the original authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that companies distributing free +software will individually obtain patent licenses, thus in effect +transforming the program into proprietary software. To prevent this, +we have made it clear that any patent must be licensed for everyone's +free use or not licensed at all. + + Most GNU software, including some libraries, is covered by the ordinary +GNU General Public License, which was designed for utility programs. This +license, the GNU Library General Public License, applies to certain +designated libraries. This license is quite different from the ordinary +one; be sure to read it in full, and don't assume that anything in it is +the same as in the ordinary license. + + The reason we have a separate public license for some libraries is that +they blur the distinction we usually make between modifying or adding to a +program and simply using it. Linking a program with a library, without +changing the library, is in some sense simply using the library, and is +analogous to running a utility program or application program. However, in +a textual and legal sense, the linked executable is a combined work, a +derivative of the original library, and the ordinary General Public License +treats it as such. + + Because of this blurred distinction, using the ordinary General +Public License for libraries did not effectively promote software +sharing, because most developers did not use the libraries. We +concluded that weaker conditions might promote sharing better. + + However, unrestricted linking of non-free programs would deprive the +users of those programs of all benefit from the free status of the +libraries themselves. This Library General Public License is intended to +permit developers of non-free programs to use free libraries, while +preserving your freedom as a user of such programs to change the free +libraries that are incorporated in them. (We have not seen how to achieve +this as regards changes in header files, but we have achieved it as regards +changes in the actual functions of the Library.) The hope is that this +will lead to faster development of free libraries. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, while the latter only +works together with the library. + + Note that it is possible for a library to be covered by the ordinary +General Public License rather than by this special one. + + GNU LIBRARY GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library which +contains a notice placed by the copyright holder or other authorized +party saying it may be distributed under the terms of this Library +General Public License (also called "this License"). Each licensee is +addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also compile or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + c) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + d) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the source code distributed need not include anything that is normally +distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Library General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/app/src/main/assets/licences/unicorn/COPYING_GLIB b/app/src/main/assets/licences/unicorn/COPYING_GLIB new file mode 100644 index 00000000..bf50f20d --- /dev/null +++ b/app/src/main/assets/licences/unicorn/COPYING_GLIB @@ -0,0 +1,482 @@ + GNU LIBRARY GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1991 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the library GPL. It is + numbered 2 because it goes with version 2 of the ordinary GPL.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Library General Public License, applies to some +specially designated Free Software Foundation software, and to any +other libraries whose authors decide to use it. You can use it for +your libraries, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if +you distribute copies of the library, or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link a program with the library, you must provide +complete object files to the recipients so that they can relink them +with the library, after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + Our method of protecting your rights has two steps: (1) copyright +the library, and (2) offer you this license which gives you legal +permission to copy, distribute and/or modify the library. + + Also, for each distributor's protection, we want to make certain +that everyone understands that there is no warranty for this free +library. If the library is modified by someone else and passed on, we +want its recipients to know that what they have is not the original +version, so that any problems introduced by others will not reflect on +the original authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that companies distributing free +software will individually obtain patent licenses, thus in effect +transforming the program into proprietary software. To prevent this, +we have made it clear that any patent must be licensed for everyone's +free use or not licensed at all. + + Most GNU software, including some libraries, is covered by the ordinary +GNU General Public License, which was designed for utility programs. This +license, the GNU Library General Public License, applies to certain +designated libraries. This license is quite different from the ordinary +one; be sure to read it in full, and don't assume that anything in it is +the same as in the ordinary license. + + The reason we have a separate public license for some libraries is that +they blur the distinction we usually make between modifying or adding to a +program and simply using it. Linking a program with a library, without +changing the library, is in some sense simply using the library, and is +analogous to running a utility program or application program. However, in +a textual and legal sense, the linked executable is a combined work, a +derivative of the original library, and the ordinary General Public License +treats it as such. + + Because of this blurred distinction, using the ordinary General +Public License for libraries did not effectively promote software +sharing, because most developers did not use the libraries. We +concluded that weaker conditions might promote sharing better. + + However, unrestricted linking of non-free programs would deprive the +users of those programs of all benefit from the free status of the +libraries themselves. This Library General Public License is intended to +permit developers of non-free programs to use free libraries, while +preserving your freedom as a user of such programs to change the free +libraries that are incorporated in them. (We have not seen how to achieve +this as regards changes in header files, but we have achieved it as regards +changes in the actual functions of the Library.) The hope is that this +will lead to faster development of free libraries. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, while the latter only +works together with the library. + + Note that it is possible for a library to be covered by the ordinary +General Public License rather than by this special one. + + GNU LIBRARY GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library which +contains a notice placed by the copyright holder or other authorized +party saying it may be distributed under the terms of this Library +General Public License (also called "this License"). Each licensee is +addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also compile or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + c) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + d) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the source code distributed need not include anything that is normally +distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Library General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., 59 Temple Place - Suite 330, + Boston, MA 02111-1307 USA. + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/app/src/main/assets/licences/unicorn/NOTICE.txt b/app/src/main/assets/licences/unicorn/NOTICE.txt new file mode 100644 index 00000000..a8826ab0 --- /dev/null +++ b/app/src/main/assets/licences/unicorn/NOTICE.txt @@ -0,0 +1,35 @@ +Unicorn 2.1.4, in lib//libmacadi.so +========================================= + +This APK contains libmacadi.so, the emulated Mac that signs in to Find My. It links +Unicorn (https://github.com/unicorn-engine/unicorn), a CPU emulator built on QEMU, +which is licensed under the GNU General Public License, version 2. The licence is in +COPYING beside this file. Parts of Unicorn's QEMU code carry their own GPL-2.0 +compatible licences; see qemu-LICENSE, COPYING.LGPL2 and COPYING_GLIB, which are +Unicorn's own files at the 2.1.4 tag. + +libmacadi.so runs in its own process (":macadi") and talks to the rest of the app +over Android's binder. It is the only part of this APK that contains Unicorn. + +Corresponding source +-------------------- + + * Unicorn 2.1.4, exactly as built: + https://github.com/unicorn-engine/unicorn/archive/refs/tags/2.1.4.tar.gz + SHA-256 ea8863f095a0136388694e5a6063afd9bb7650e30243dd6251af59c5ce5601f4 + The same file is attached to every OpenTagViewer release on GitHub that ships + libmacadi.so, beside the APK. + + * The rest of libmacadi.so, and how it is built: app/src/main/cpp/macadi/ and + app/src/main/cpp/CMakeLists.txt in https://github.com/parawanderer/OpenTagViewer, + at the release's tag. + +Other code in libmacadi.so +-------------------------- + +macho.cpp, vm.cpp and core_adi.cpp are a C++ port of sacca97/os-findmy-e's +native/adi-core (https://github.com/sacca97/os-findmy-e), MIT License, +Copyright (c) 2026 sacca. Its full notice is in app/src/main/cpp/macadi/NOTICE.md +in the repository above. + +Apple's CoreADI is not in this APK. The app downloads it from Apple at runtime. diff --git a/app/src/main/assets/licences/unicorn/qemu-LICENSE b/app/src/main/assets/licences/unicorn/qemu-LICENSE new file mode 100644 index 00000000..f19b0184 --- /dev/null +++ b/app/src/main/assets/licences/unicorn/qemu-LICENSE @@ -0,0 +1,27 @@ +The QEMU distribution includes both the QEMU emulator and +various firmware files. These are separate programs that are +distributed together for our users' convenience, and they have +separate licenses. + +The following points clarify the license of the QEMU emulator: + +1) The QEMU emulator as a whole is released under the GNU General +Public License, version 2. + +2) Parts of the QEMU emulator have specific licenses which are compatible +with the GNU General Public License, version 2. Hence each source file +contains its own licensing information. Source files with no licensing +information are released under the GNU General Public License, version +2 or (at your option) any later version. + +As of July 2013, contributions under version 2 of the GNU General Public +License (and no later version) are only accepted for the following files +or directories: bsd-user/, linux-user/, hw/vfio/, hw/xen/xen_pt*. + +3) The Tiny Code Generator (TCG) is mostly under the BSD or MIT licenses; + but some parts may be GPLv2 or other licenses. Again, see the + specific licensing information in each source file. + +4) QEMU is a trademark of Fabrice Bellard. + +Fabrice Bellard and the QEMU team diff --git a/app/src/main/cpp/macadi/NOTICE.md b/app/src/main/cpp/macadi/NOTICE.md index 935ebfd4..9a89363c 100644 --- a/app/src/main/cpp/macadi/NOTICE.md +++ b/app/src/main/cpp/macadi/NOTICE.md @@ -37,6 +37,18 @@ SOFTWARE. (`MacAdi.cmake`, pinned by SHA-256) and linked into `libmacadi.so`. It is licensed under **GPL-2.0**, so an APK containing `libmacadi.so` contains GPL-2.0 code. +What that obliges, and where each part is done: + +- **The licence and a notice travel with the APK**: `app/src/main/assets/licences/unicorn/` holds + Unicorn's own `COPYING` (GPL-2.0), its QEMU licence files, and `NOTICE.txt`, which says where the + corresponding source is. `UnicornNoticeTest` fails if the version or hash there stops matching + `MacAdi.cmake`. +- **The corresponding source is offered from the same place as the binary**: `build-release.yml` + attaches the exact Unicorn tarball, checked against the pinned hash, to the release. +- **It is a separate program**: `libmacadi.so` is loaded only in the `:macadi` process, by + `MacAdiService`, and the app reaches it over binder (`RemoteMacAdi`). See + `docs/emulated-mac-emulator-choice.md`. + ## Apple's CoreADI Not part of this repository or of any build. The app downloads it at runtime from Apple's own diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/UnicornNoticeTest.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/UnicornNoticeTest.java new file mode 100644 index 00000000..1fbb81d1 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/UnicornNoticeTest.java @@ -0,0 +1,49 @@ +package dev.wander.android.opentagviewer.anisette; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The GPL-2.0 notice the APK carries for Unicorn, and that it names the Unicorn the build uses. + * + *

Nothing else notices a stale one: bump Unicorn in {@code MacAdi.cmake} and the APK goes on + * telling people where to find the source of a version it no longer contains. + */ +public class UnicornNoticeTest { + + // Unit tests run from the module directory. + private static final File NOTICES = new File("src/main/assets/licences/unicorn"); + + private static String read(final File file) throws Exception { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + + @Test + public void theApkCarriesTheGplAndUnicornsOwnLicenceFiles() throws Exception { + assertTrue(read(new File(NOTICES, "COPYING")).contains("GNU GENERAL PUBLIC LICENSE\n" + + "\t\t Version 2, June 1991")); + for (final String name : new String[] {"COPYING.LGPL2", "COPYING_GLIB", "qemu-LICENSE"}) { + assertTrue(name, new File(NOTICES, name).length() > 0); + } + } + + @Test + public void theNoticeNamesTheUnicornTheBuildFetches() throws Exception { + final String cmake = read(new File("src/main/cpp/macadi/MacAdi.cmake")); + final Matcher url = Pattern.compile("URL (https://\\S+unicorn\\S+\\.tar\\.gz)").matcher(cmake); + final Matcher sha = Pattern.compile("URL_HASH SHA256=([0-9a-f]{64})").matcher(cmake); + assertTrue(url.find()); + assertTrue(sha.find()); + + final String notice = read(new File(NOTICES, "NOTICE.txt")); + assertTrue("NOTICE.txt does not give " + url.group(1), notice.contains(url.group(1))); + assertTrue("NOTICE.txt does not give " + sha.group(1), notice.contains(sha.group(1))); + } +} From c73a064f132480ce1c15d24cd21d71b982dd5d4b Mon Sep 17 00:00:00 2001 From: Rob Lazzurs Date: Wed, 4 Nov 2026 00:15:26 +0000 Subject: [PATCH 6/9] Compare the C++ port with os-findmy-e's adi-core byte for byte compare-libmacadi.sh builds adi-core's adi-diff (the driver run.sh uses for Unicorn against icicle) and the port's new macadi_diff, runs both over the same offline CoreADI calls with a frozen clock and a fixed host UUID, and diffs the output. macadi-host.yml runs it on every change to the port. To make that possible CoreAdi exposes the raw calls adi-core has (begin_session, IsMachineProvisioned, RequestOTP, GetIDMSRouting, ProvisioningStart) and the VM's files. headers() and provisioningBegin() are now built from those same calls, so the comparison covers the code the app runs. Result on x86_64 Linux: all 20 lines identical, with the port on Unicorn 2.1.4 and adi-core on unicorn-engine 2.1.5. Checked for teeth by breaking vm.cpp on purpose: a stray byte in created files turns six lines red, while changing arc4random_buf or truncating write changes nothing, because the offline calls never reach them. Both drivers take an optional provisioned store for the OTP path, which has not been run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MJtmGkNZpfzFGSN1BZ82a --- .github/workflows/macadi-host.yml | 25 +++ CONTRIBUTING.md | 19 ++- app/src/main/cpp/macadi/core_adi.cpp | 73 ++++++--- app/src/main/cpp/macadi/core_adi.h | 32 +++- app/src/test/macadi-host/CMakeLists.txt | 4 + app/src/test/macadi-host/macadi_diff.cpp | 154 ++++++++++++++++++ app/src/test/macadi-host/sha256.h | 108 ++++++++++++ docs/emulated-mac-emulator-choice.md | 26 ++- scripts/adi-emulator-diff/adi-diff.rs | 16 ++ .../adi-emulator-diff/compare-libmacadi.sh | 59 +++++++ 10 files changed, 484 insertions(+), 32 deletions(-) create mode 100644 app/src/test/macadi-host/macadi_diff.cpp create mode 100644 app/src/test/macadi-host/sha256.h create mode 100755 scripts/adi-emulator-diff/compare-libmacadi.sh diff --git a/.github/workflows/macadi-host.yml b/.github/workflows/macadi-host.yml index fccb0121..d7d0eba8 100644 --- a/.github/workflows/macadi-host.yml +++ b/.github/workflows/macadi-host.yml @@ -15,6 +15,7 @@ on: - "app/src/test/macadi-host/**" - "app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi*.java" - "scripts/locate_mac_coreadi.py" + - "scripts/adi-emulator-diff/**" - ".github/workflows/macadi-host.yml" push: branches: [ "main" ] @@ -23,6 +24,7 @@ on: - "app/src/test/macadi-host/**" - "app/src/main/java/dev/wander/android/opentagviewer/anisette/MacCoreAdi*.java" - "scripts/locate_mac_coreadi.py" + - "scripts/adi-emulator-diff/**" - ".github/workflows/macadi-host.yml" workflow_dispatch: @@ -53,3 +55,26 @@ jobs: 00112233445566778899aabbccddeeff -1 --load-only) echo "$out" test "$out" = '{"loaded": true, "provisioned": false}' + + # The C++ port against the Rust it was ported from, byte for byte: os-findmy-e's adi-core and + # app/src/test/macadi-host/macadi_diff.cpp make the same offline CoreADI calls with a frozen + # clock and a fixed host UUID, and the outputs must be identical. A difference is a port bug a + # successful sign-in would not show. Nothing is sent to Apple beyond the library download. + against-adi-core: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Fetch Apple's macOS CoreADI the way the app does, and check both hashes + run: python scripts/locate_mac_coreadi.py --remote --out "$RUNNER_TEMP/CoreADI.x86_64" + + # unicorn-engine-sys runs bindgen, which needs libclang. + - name: Install libclang + run: sudo apt-get update -qq && sudo apt-get install -y -qq libclang-dev + + - name: Compare libmacadi with adi-core + run: scripts/adi-emulator-diff/compare-libmacadi.sh "$RUNNER_TEMP/CoreADI.x86_64" "" "$RUNNER_TEMP/diff" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92aa69cc..15aa160e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -666,6 +666,20 @@ Given a provisioned store directory and its host UUID instead of `--load-only`, prints one header set. Pass a fixed Unix time as the fifth argument to compare its output byte for byte against another implementation; never use a fixed clock for anything sent to Apple. +#### Against os-findmy-e's Rust, byte for byte + +```bash +scripts/adi-emulator-diff/compare-libmacadi.sh /tmp/CoreADI.x86_64 [provisioned store dir] +``` + +Builds os-findmy-e's `adi-core` (pinned commit) and this port's `macadi_diff`, has both make the same +offline CoreADI calls with a frozen clock and a fixed host UUID, and diffs the output; it exits +non-zero on any difference. Needs git, cargo, cmake and libclang (`unicorn-engine-sys` runs bindgen). +`macadi-host.yml` runs it on every change to the port. Without a store, the calls stop short of the +OTP path; a **copy** of a provisioned store (`files/anisette/mac/store/` from an install, through +`adb shell run-as`) takes both drivers through one header session on that machine as well, still +offline. + ### Which Python each tree targets There are three, and they are not the same: @@ -883,7 +897,8 @@ scripts/adi-emulator-diff/run.sh /tmp/CoreADI.x86_64 ``` Runs Apple's macOS CoreADI under Unicorn and under icicle with the same inputs and diffs the -output; it exits non-zero on any difference. Unlike the scripts above it does need the network, +output; it exits non-zero on any difference. (`compare-libmacadi.sh`, above, uses the same driver +to compare this repository's C++ port with the Rust it came from.) Unlike the scripts above it does need the network, to clone three repositories at pinned commits, but it sends nothing to Apple. Nobody needs to run it routinely; it exists for whoever wants to replace Unicorn. Why, what it showed and what it does not cover: [docs/emulated-mac-emulator-choice.md](docs/emulated-mac-emulator-choice.md). @@ -918,7 +933,7 @@ run regardless: each AES entry carries a fresh random salt. | `native-stubs.yml` | `app/src/main/cpp/**`, `app/src/test/cpp/**` changes | Builds the stand-ins for Apple's two stubbed libraries on x86_64 and arm64 Linux and checks what they export and return. Seconds, no Android | | `adi-on-bionic.yml` | `app/src/main/cpp/**`, `adi-libraries.json`, `AdiFunction.java`, its own directory | Loads and initialises Apple's real ADI libraries on x86_64 and arm64 Linux under Android 9 and Android 14 bionic, with no emulator, and checks the pre-#232 stub still fails. About a minute cold, seconds warm | | `check-gsa-edge.yml` | daily, and every PR to `main` | Asks Apple's edge whether it still lets the app's and the exporter's sign-in and provisioning requests through, with their exact headers and no account. A scheduled failure opens an issue. Seconds | -| `macadi-host.yml` | `app/src/main/cpp/macadi/**`, `app/src/test/macadi-host/**`, `MacCoreAdi*.java`, `scripts/locate_mac_coreadi.py` | Fetches Apple's macOS CoreADI with the app's own range request, checks both pinned hashes, builds the emulated Mac's native core for the host and has it load the library. Nothing sent to Apple beyond the download. A few minutes, mostly building Unicorn | +| `macadi-host.yml` | `app/src/main/cpp/macadi/**`, `app/src/test/macadi-host/**`, `MacCoreAdi*.java`, `scripts/locate_mac_coreadi.py`, `scripts/adi-emulator-diff/**` | Fetches Apple's macOS CoreADI with the app's own range request, checks both pinned hashes, builds the emulated Mac's native core for the host and has it load the library; a second job runs `compare-libmacadi.sh`, the port against os-findmy-e's `adi-core` byte for byte. Nothing sent to Apple beyond the download. A few minutes each, mostly building Unicorn | | `waiting-on-reporter.yml` | a comment on an issue, daily | Removes the `waiting on reporter` label when the issue's author replies. Daily, comments once on any labelled issue quiet for 14 days, and again only after somebody else has commented. Never closes anything. Both triggers run the copy on `main`, so a PR cannot exercise it; `workflow_dispatch` defaults to a dry run that prints what it would post | The instrumented job needs KVM on the runner; the workflow enables it first. It runs the same diff --git a/app/src/main/cpp/macadi/core_adi.cpp b/app/src/main/cpp/macadi/core_adi.cpp index 755cd86b..cc2c2e64 100644 --- a/app/src/main/cpp/macadi/core_adi.cpp +++ b/app/src/main/cpp/macadi/core_adi.cpp @@ -96,8 +96,12 @@ void CoreAdi::beginSession() { // headers in the same process would, and sent Apple an empty machine id (ec -80009). So it // goes before every session, not only on load. vm_.files().erase(std::string(STORE_DIR) + "/" + LEGACY_STORE); + rawBeginSession(); +} + +CoreAdi::Reply CoreAdi::rawBeginSession() { vm_.resetSession(); - initialize(); + return initialize(); } CoreAdi::Reply CoreAdi::initialize() { @@ -110,6 +114,40 @@ CoreAdi::Reply CoreAdi::isMachineProvisioned(int64_t dsId) { return call(IS_MACHINE_PROVISIONED, dsIdMessage(dsId)); } +CoreAdi::Otp CoreAdi::requestOtp(int64_t dsId) { + vm_.scratchReset(); + const uint64_t midP = cell(0), midL = cell(0), otpP = cell(0), otpL = cell(0); + std::vector m = dsIdMessage(dsId); + for (const uint64_t v : {midP, midL, otpP, otpL}) be64(m, v); + const Reply reply = call(REQUEST_OTP, m); + Otp out{reply, slice(midP, midL), slice(otpP, otpL)}; + // The library hands back pointers into its own storage; release them. + if (!out.mid.empty() && !out.otp.empty()) storageDispose(vm_.readU64(midP)); + return out; +} + +std::pair CoreAdi::getRouting(int64_t dsId) { + vm_.scratchReset(); + const uint64_t routingCell = cell(0); + std::vector r = dsIdMessage(dsId); + be64(r, routingCell); + const Reply reply = call(GET_IDMS_ROUTING, r); + return {reply, vm_.readU64(routingCell)}; +} + +CoreAdi::ProvisioningStart CoreAdi::provisioningStart(int64_t dsId, + const std::vector &spim) { + vm_.scratchReset(); + const uint64_t spimBuf = input(spim); + const uint64_t cpimP = cell(0), cpimL = cell(0), session = cell(0); + std::vector m = dsIdMessage(dsId); + be64(m, spimBuf); + be32(m, static_cast(spim.size())); + for (const uint64_t v : {cpimP, cpimL, session}) be64(m, v); + const Reply reply = call(PROVISIONING_START, m); + return {reply, slice(cpimP, cpimL), vm_.readU32(session)}; +} + CoreAdi::Reply CoreAdi::storageDispose(uint64_t ptr) { std::vector m; be64(m, ptr); @@ -144,23 +182,12 @@ Headers CoreAdi::headers(int64_t dsId) { beginSession(); if (isMachineProvisioned(dsId).ret != 0) throw NotProvisioned(); - vm_.scratchReset(); - const uint64_t midP = cell(0), midL = cell(0), otpP = cell(0), otpL = cell(0); - std::vector m = dsIdMessage(dsId); - for (const uint64_t v : {midP, midL, otpP, otpL}) be64(m, v); - const Reply otpReply = call(REQUEST_OTP, m); - Headers h{slice(midP, midL), slice(otpP, otpL), 0}; - // The library hands back pointers into its own storage; release them. - if (!h.mid.empty() && !h.otp.empty()) storageDispose(vm_.readU64(midP)); - check("RequestOTP", otpReply); + const Otp otp = requestOtp(dsId); + check("RequestOTP", otp.reply); - vm_.scratchReset(); - const uint64_t routingCell = cell(0); - std::vector r = dsIdMessage(dsId); - be64(r, routingCell); - check("GetIDMSRouting", call(GET_IDMS_ROUTING, r)); - h.routing = vm_.readU64(routingCell); - return h; + const std::pair routing = getRouting(dsId); + check("GetIDMSRouting", routing.first); + return Headers{otp.mid, otp.otp, routing.second}; } std::pair, uint32_t> CoreAdi::provisioningBegin( @@ -168,15 +195,9 @@ std::pair, uint32_t> CoreAdi::provisioningBegin( beginSession(); if (isMachineProvisioned(dsId).ret == 0) throw AlreadyProvisioned(); - vm_.scratchReset(); - const uint64_t spimBuf = input(spim); - const uint64_t cpimP = cell(0), cpimL = cell(0), session = cell(0); - std::vector m = dsIdMessage(dsId); - be64(m, spimBuf); - be32(m, static_cast(spim.size())); - for (const uint64_t v : {cpimP, cpimL, session}) be64(m, v); - check("ProvisioningStart", call(PROVISIONING_START, m)); - return {slice(cpimP, cpimL), vm_.readU32(session)}; + const ProvisioningStart started = provisioningStart(dsId, spim); + check("ProvisioningStart", started.reply); + return {started.cpim, started.session}; } void CoreAdi::provisioningFinish(int64_t dsId, uint32_t session, const std::vector &ptm, diff --git a/app/src/main/cpp/macadi/core_adi.h b/app/src/main/cpp/macadi/core_adi.h index 88618ac6..6e74ca84 100644 --- a/app/src/main/cpp/macadi/core_adi.h +++ b/app/src/main/cpp/macadi/core_adi.h @@ -13,6 +13,7 @@ #pragma once #include +#include #include #include #include @@ -55,12 +56,40 @@ class CoreAdi { std::pair, std::vector> sync(int64_t dsId, const std::vector &sim); -private: + // ---- The raw calls, one dispatcher call each, for scripts/adi-emulator-diff/ ---------------- + // + // These are the operations os-findmy-e's adi-core exposes, with the same inputs and the same + // outputs, so its offline driver can be replayed here call for call and the two printed side by + // side (app/src/test/macadi-host/macadi_diff.cpp). The high-level calls above are built from + // them, so the comparison covers the code the app runs. struct Reply { int32_t rc; // the dispatcher's status int32_t ret; // the operation's own, negative on failure }; + struct Otp { + Reply reply; + std::vector mid; + std::vector otp; + }; + struct ProvisioningStart { + Reply reply; + std::vector cpim; + uint32_t session; + }; + + // adi-core's begin_session: back to the post-load state, then Initialize. Unlike the app's + // beginSession it leaves adi.pb in place, as adi-core does - see beginSession for why the app + // removes it. + Reply rawBeginSession(); + Reply isMachineProvisioned(int64_t dsId); + Otp requestOtp(int64_t dsId); + std::pair getRouting(int64_t dsId); + ProvisioningStart provisioningStart(int64_t dsId, const std::vector &spim); + + // Every file in the virtual filesystem, by full path, as adi-core's Vm::files(). + const std::map> &vmFiles() { return vm_.files(); } +private: void beginSession(); Reply call(uint32_t code, const std::vector &message); uint64_t cell(uint64_t v); @@ -69,7 +98,6 @@ class CoreAdi { static void check(const char *op, Reply r); Reply initialize(); - Reply isMachineProvisioned(int64_t dsId); Reply storageDispose(uint64_t ptr); Reply setRouting(int64_t dsId, uint64_t routing); diff --git a/app/src/test/macadi-host/CMakeLists.txt b/app/src/test/macadi-host/CMakeLists.txt index 920aa8b7..4df223fe 100644 --- a/app/src/test/macadi-host/CMakeLists.txt +++ b/app/src/test/macadi-host/CMakeLists.txt @@ -9,3 +9,7 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/../../main/cpp/macadi/MacAdi.cmake) add_executable(macadi_cli macadi_cli.cpp) target_link_libraries(macadi_cli PRIVATE macadi_core) + +# scripts/adi-emulator-diff/compare-libmacadi.sh: this port against os-findmy-e's adi-core. +add_executable(macadi_diff macadi_diff.cpp) +target_link_libraries(macadi_diff PRIVATE macadi_core) diff --git a/app/src/test/macadi-host/macadi_diff.cpp b/app/src/test/macadi-host/macadi_diff.cpp new file mode 100644 index 00000000..9277d43e --- /dev/null +++ b/app/src/test/macadi-host/macadi_diff.cpp @@ -0,0 +1,154 @@ +// scripts/adi-emulator-diff/adi-diff.rs, replayed against this repository's C++ port. +// +// macadi_diff [provisioned store dir] +// +// Makes the same calls as os-findmy-e's adi-core driver, with the same frozen clock, host UUID +// and synthetic spims, and prints the same lines in the same format - including Rust's {:?} for +// the replies - so `diff` between the two outputs is the whole comparison. Sends nothing anywhere. +// +// With a store directory (the files under files/anisette/mac/store/ of a provisioned install, or +// any provisioned adi-core store) it then also runs one header session on that machine with the +// clock still frozen: the OTP path, which an unprovisioned machine never reaches. adi-diff.rs +// takes the same optional argument. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core_adi.h" +#include "error.h" +#include "sha256.h" + +namespace { + +std::vector readFile(const std::string &path) { + std::ifstream in(path, std::ios::binary); + if (!in) throw macadi::Error("cannot read " + path); + return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; +} + +std::string hex(const std::vector &b, size_t limit = SIZE_MAX) { + static const char *digits = "0123456789abcdef"; + std::string out; + for (size_t i = 0; i < b.size() && i < limit; i++) { + out.push_back(digits[b[i] >> 4]); + out.push_back(digits[b[i] & 15]); + } + return out; +} + +// Rust's {:?} of adi_core::Reply. +std::string reply(const macadi::CoreAdi::Reply &r) { + return "Reply { rc: " + std::to_string(r.rc) + ", ret: " + std::to_string(r.ret) + " }"; +} + +// adi-diff.rs's store_sha: every file in the VM, path then length (u64 LE) then bytes, in the +// map's order (BTreeMap there, std::map here: both sort by bytes). +std::string storeSha(macadi::CoreAdi &adi) { + hostcheck::Sha256 h; + for (const auto &f : adi.vmFiles()) { + h.update(reinterpret_cast(f.first.data()), f.first.size()); + uint8_t len[8]; + uint64_t n = f.second.size(); + for (int i = 0; i < 8; i++) len[i] = static_cast(n >> (8 * i)); + h.update(len, 8); + h.update(f.second.data(), f.second.size()); + } + return hex(h.digest(), 8); +} + +// A failure the Rust driver would print as Err(...). Never expected; any of these is a diff. +template +void line(const std::string &label, F body) { + try { + std::printf("%s %s\n", label.c_str(), body().c_str()); + } catch (const std::exception &e) { + std::printf("%s Err(%s)\n", label.c_str(), e.what()); + } +} + +} // namespace + +int main(int argc, char **argv) { + if (argc < 3) { + std::fprintf(stderr, "usage: %s coreadi dispatch_hex [store_dir]\n", argv[0]); + return 2; + } + try { + const std::vector image = readFile(argv[1]); + macadi::CoreAdi adi(image, std::strtoull(argv[2], nullptr, 16)); + const uint8_t uuid[16] = {0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}; + adi.setHostUuid(uuid); + adi.setFrozenTime(1790000000); + + line("begin_session", [&] { return "Ok(" + reply(adi.rawBeginSession()) + ")"; }); + line("is_machine_provisioned", [&] { return "Ok(" + reply(adi.isMachineProvisioned(-2)) + ")"; }); + line("request_otp(unprovisioned)", [&] { + const auto o = adi.requestOtp(-2); + return "Ok((" + reply(o.reply) + ", \"" + hex(o.mid) + "\", \"" + hex(o.otp) + "\"))"; + }); + line("get_routing", [&] { + const auto r = adi.getRouting(-2); + return "Ok((" + reply(r.first) + ", " + std::to_string(r.second) + "))"; + }); + std::printf("store %s\n", storeSha(adi).c_str()); + + uint64_t seed = 0x9E3779B97F4A7C15ull; + const size_t lengths[] = {0, 16, 256, 1024, 4096}; + for (size_t i = 0; i < 5; i++) { + const size_t len = lengths[i]; + std::vector spim; + for (size_t j = 0; j < len; j++) { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + spim.push_back(static_cast(seed)); + } + line("begin_session", [&] { return "Ok(" + reply(adi.rawBeginSession()) + ")"; }); + const std::string label = "provisioning_start[" + std::to_string(i) + "] len=" + + std::to_string(len); + try { + const auto p = adi.provisioningStart(-2, spim); + std::printf("%s %s session=%u cpim(%zu)=%s\n", label.c_str(), reply(p.reply).c_str(), + p.session, p.cpim.size(), hex(hostcheck::sha256(p.cpim), 16).c_str()); + } catch (const std::exception &e) { + std::printf("%s error %s\n", label.c_str(), e.what()); + } + std::printf("store %s\n", storeSha(adi).c_str()); + } + + if (argc > 3) { + // A provisioned machine, then one header session on it: the OTP path. + DIR *dir = opendir(argv[3]); + if (dir == nullptr) throw macadi::Error(std::string("cannot open store ") + argv[3]); + while (const dirent *e = readdir(dir)) { + const std::string name = e->d_name; + if (name == "." || name == "..") continue; + adi.storePut(name, readFile(std::string(argv[3]) + "/" + name)); + } + closedir(dir); + line("provisioned begin_session", [&] { return "Ok(" + reply(adi.rawBeginSession()) + ")"; }); + line("provisioned is_machine_provisioned", + [&] { return "Ok(" + reply(adi.isMachineProvisioned(-2)) + ")"; }); + line("provisioned request_otp", [&] { + const auto o = adi.requestOtp(-2); + return "Ok((" + reply(o.reply) + ", \"" + hex(o.mid) + "\", \"" + hex(o.otp) + "\"))"; + }); + line("provisioned get_routing", [&] { + const auto r = adi.getRouting(-2); + return "Ok((" + reply(r.first) + ", " + std::to_string(r.second) + "))"; + }); + std::printf("provisioned store %s\n", storeSha(adi).c_str()); + } + return 0; + } catch (const std::exception &e) { + std::fprintf(stderr, "error: %s\n", e.what()); + return 1; + } +} diff --git a/app/src/test/macadi-host/sha256.h b/app/src/test/macadi-host/sha256.h new file mode 100644 index 00000000..37065e57 --- /dev/null +++ b/app/src/test/macadi-host/sha256.h @@ -0,0 +1,108 @@ +// SHA-256 for the host checks only, so macadi_diff needs nothing beyond the C++ standard library. +// FIPS 180-4, straightforwardly; not constant-time and not for anything secret. +#pragma once + +#include +#include +#include +#include + +namespace hostcheck { + +class Sha256 { +public: + Sha256() { reset(); } + + void update(const uint8_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + block_[fill_++] = data[i]; + if (fill_ == 64) { + compress(); + fill_ = 0; + } + } + bits_ += static_cast(len) * 8; + } + + std::vector digest() { + const uint64_t bits = bits_; + const uint8_t one = 0x80, zero = 0; + update(&one, 1); + while (fill_ != 56) update(&zero, 1); + uint8_t len[8]; + for (int i = 0; i < 8; i++) len[i] = static_cast(bits >> (56 - 8 * i)); + update(len, 8); + std::vector out(32); + for (int i = 0; i < 8; i++) { + for (int j = 0; j < 4; j++) out[i * 4 + j] = static_cast(h_[i] >> (24 - 8 * j)); + } + reset(); + return out; + } + +private: + static uint32_t rotr(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); } + + void reset() { + static const uint32_t init[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; + std::memcpy(h_, init, sizeof(h_)); + fill_ = 0; + bits_ = 0; + } + + void compress() { + static const uint32_t k[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2}; + uint32_t w[64]; + for (int i = 0; i < 16; i++) { + w[i] = (static_cast(block_[i * 4]) << 24) | + (static_cast(block_[i * 4 + 1]) << 16) | + (static_cast(block_[i * 4 + 2]) << 8) | block_[i * 4 + 3]; + } + for (int i = 16; i < 64; i++) { + const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + uint32_t a = h_[0], b = h_[1], c = h_[2], d = h_[3], e = h_[4], f = h_[5], g = h_[6], + h = h_[7]; + for (int i = 0; i < 64; i++) { + const uint32_t t1 = h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + + k[i] + w[i]; + const uint32_t t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c)); + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + h_[0] += a; h_[1] += b; h_[2] += c; h_[3] += d; + h_[4] += e; h_[5] += f; h_[6] += g; h_[7] += h; + } + + uint32_t h_[8]; + uint8_t block_[64]; + size_t fill_; + uint64_t bits_; +}; + +inline std::vector sha256(const std::vector &data) { + Sha256 h; + h.update(data.data(), data.size()); + return h.digest(); +} + +} // namespace hostcheck diff --git a/docs/emulated-mac-emulator-choice.md b/docs/emulated-mac-emulator-choice.md index 7c43c828..b9c8c65d 100644 --- a/docs/emulated-mac-emulator-choice.md +++ b/docs/emulated-mac-emulator-choice.md @@ -68,13 +68,35 @@ the hash of every file the library wrote. would have to carry them or a precompiled form. - **#274's C++ port is a third implementation.** This compares os-findmy-e's Rust under two emulators; it says nothing about whether `app/src/main/cpp/macadi/` matches either. The same - driver pointed at `libmacadi.so` would. + driver pointed at `libmacadi.so` would, and now does - see below. + +## The C++ port against the Rust + +`scripts/adi-emulator-diff/compare-libmacadi.sh` replays `adi-diff.rs`'s calls through +`app/src/test/macadi-host/macadi_diff.cpp`, which drives the port's `CoreAdi` and prints the same +lines, and diffs that against `adi-core` under Unicorn. `macadi-host.yml` runs it on every change to +the port. + +Result on x86_64 Linux, 2026-11-04, the same CoreADI 4.19: all 20 lines **byte-identical** - every +return code, and the hash of every file the library wrote. That is with the port on Unicorn 2.1.4 +(`MacAdi.cmake`) and `adi-core` on the `unicorn-engine` crate 2.1.5 (its `Cargo.lock`), so the patch +release between them makes no difference to these calls either. + +**How much that covers, measured rather than assumed.** The diff was checked for teeth by breaking +the port on purpose. Creating files with one stray byte in `vm.cpp`'s `open` stub turned six `store` +lines red. Changing every byte `arc4random_buf` returns, or truncating every `write`, changed +nothing: the offline calls never reach either. So this pins the loader, the dispatcher, the request +framing and the filesystem paths `Initialize` takes, and says nothing yet about the code a real OTP +or a real provisioning runs. Both drivers take an optional provisioned store directory for that, +which runs one header session on that machine with the clock frozen; it has not been run, because no +provisioned store was available offline. ## Running it ```bash python scripts/locate_mac_coreadi.py --remote --out /tmp/CoreADI.x86_64 # from #274 -scripts/adi-emulator-diff/run.sh /tmp/CoreADI.x86_64 +scripts/adi-emulator-diff/run.sh /tmp/CoreADI.x86_64 # Unicorn against icicle +scripts/adi-emulator-diff/compare-libmacadi.sh /tmp/CoreADI.x86_64 # the port against adi-core ``` Needs git, cargo and cmake. It clones os-findmy-e, icicle and a sparse Ghidra at pinned commits diff --git a/scripts/adi-emulator-diff/adi-diff.rs b/scripts/adi-emulator-diff/adi-diff.rs index 309548fe..e6824838 100644 --- a/scripts/adi-emulator-diff/adi-diff.rs +++ b/scripts/adi-emulator-diff/adi-diff.rs @@ -65,6 +65,22 @@ fn run() { } println!("store {}", store_sha(&mut adi)); } + + // Optional: a provisioned store, then one header session on it with the clock still frozen. + // That is the OTP path, which every call above stops short of. compare-libmacadi.sh passes it + // through; macadi_diff.cpp prints the same lines. + if let Some(store) = std::env::args().nth(2) { + for entry in std::fs::read_dir(&store).unwrap() { + let entry = entry.unwrap(); + let name = entry.file_name().to_string_lossy().into_owned(); + adi.store_put(&name, &std::fs::read(entry.path()).unwrap()); + } + println!("provisioned begin_session {:?}", adi.begin_session()); + println!("provisioned is_machine_provisioned {:?}", adi.is_machine_provisioned(-2)); + println!("provisioned request_otp {:?}", adi.request_otp(-2).map(|o| (o.reply, hex(&o.mid), hex(&o.otp)))); + println!("provisioned get_routing {:?}", adi.get_routing(-2)); + println!("provisioned store {}", store_sha(&mut adi)); + } eprintln!("calls {:?}", t.elapsed()); #[cfg(feature = "icicle")] eprintln!("guest instructions {}", adi.vm().icount()); diff --git a/scripts/adi-emulator-diff/compare-libmacadi.sh b/scripts/adi-emulator-diff/compare-libmacadi.sh new file mode 100755 index 00000000..444405f0 --- /dev/null +++ b/scripts/adi-emulator-diff/compare-libmacadi.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Run os-findmy-e's Rust adi-core and this repository's C++ port (app/src/main/cpp/macadi/, which +# is libmacadi.so) through the same offline CoreADI calls, and diff the output byte for byte. +# +# scripts/adi-emulator-diff/compare-libmacadi.sh [provisioned store dir] [work dir] +# +# The calls, the frozen clock, the host UUID and the synthetic spims are adi-diff.rs's, the driver +# run.sh uses to compare Unicorn with icicle; app/src/test/macadi-host/macadi_diff.cpp replays the +# same sequence against the port and prints the same lines. A difference is a port bug, of the +# kind a single successful sign-in would not show. +# +# With a provisioned store directory both also run one header session on that machine with the +# clock frozen, which reaches the OTP path. Use a copy, and never a store an app is still using: +# nothing here writes it, but it is the machine's identity. Without one, nothing is sent anywhere +# and nothing needs Apple but the library itself: +# python scripts/locate_mac_coreadi.py --remote --out /tmp/CoreADI.x86_64 +# +# Needs git, cargo, cmake and a C++17 compiler. +set -euo pipefail + +OS_FINDMY_E=374d83854f657a7fdca45f813387d99d491db0e2 +DISPATCH=979a0 + +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +dylib="$(cd "$(dirname "${1:?usage: compare-libmacadi.sh [store dir] [work dir]}")" && pwd)/$(basename "$1")" +store="" +if [ -n "${2:-}" ]; then + store="$(cd "$2" && pwd)" +fi +work="${3:-${TMPDIR:-/tmp}/adi-emulator-diff-libmacadi}" +mkdir -p "$work" && cd "$work" + +if [ ! -d os-findmy-e ]; then + git init -q os-findmy-e + git -C os-findmy-e remote add origin https://github.com/sacca97/os-findmy-e +fi +git -C os-findmy-e fetch -q --depth 1 --filter=blob:none origin "$OS_FINDMY_E" +git -C os-findmy-e checkout -q FETCH_HEAD +git -C os-findmy-e checkout -q -- native/adi-core + +core=os-findmy-e/native/adi-core +mkdir -p "$core/src/bin" +cp "$here/adi-diff.rs" "$core/src/bin/" +(cd "$core" && cargo build -q --release --locked --bin adi-diff --target-dir target-unicorn) + +cmake -S "$repo/app/src/test/macadi-host" -B macadi-host -DCMAKE_BUILD_TYPE=Release > /dev/null +cmake --build macadi-host --parallel --target macadi_diff > /dev/null + +"$core/target-unicorn/release/adi-diff" "$dylib" ${store:+"$store"} > adi-core.txt +macadi-host/macadi_diff "$dylib" "$DISPATCH" ${store:+"$store"} > libmacadi.txt + +cat adi-core.txt +if diff -u adi-core.txt libmacadi.txt; then + echo "libmacadi: identical to adi-core ($(wc -l < adi-core.txt) lines)" +else + echo "libmacadi: DIFFERS from adi-core" >&2 + exit 1 +fi From f67616d5719f95a01dc0241054e88e32cab904ab Mon Sep 17 00:00:00 2001 From: Rob Lazzurs Date: Wed, 4 Nov 2026 00:15:26 +0000 Subject: [PATCH 7/9] Step the hsc 409 wrapper aside once FindMy.py reads it itself The fix belongs in FindMy.py's _gsa_authenticate, where malmeloo/FindMy.py#279 adds _normalize_2fa_status; the same change is prepared for parawanderer/FindMy.py. Until the pin moves, _treatHsc409AsSecondFactor keeps wrapping _gsa_request, but returns without touching the account when the pinned FindMy.py has the function. test_the_pinned_findmy_does_not_yet fails on the pin move, which is the cue to delete the wrapper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MJtmGkNZpfzFGSN1BZ82a --- app/src/main/python/main.py | 18 ++++++++++++++++-- app/src/test/python/test_emulated_mac.py | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 8e2a51fd..61efafcb 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -31,6 +31,11 @@ ) import identity as app_identity +from findmy.reports import account as _findmy_account + +_FINDMY_READS_HSC_409 = hasattr(_findmy_account, "_normalize_2fa_status") +"""Whether the pinned FindMy.py reads `hsc` 409 as a second factor itself. See +`_treatHsc409AsSecondFactor`.""" class TwoFactorMethods(Enum): @@ -792,9 +797,18 @@ def _treatHsc409AsSecondFactor(acc: AppleAccount) -> None: parawanderer/OpenTagViewer#248; the remedy, setting `au` to `trustedDeviceSecondaryAuth`, is the one sacca97/os-findmy-e signed in with. - Wraps the instance's `_gsa_request`, which `_gsa_authenticate` looks up on the instance. If a - later FindMy.py renames it, this says so and changes nothing, and the 2FA bug comes back. + **A stopgap until the pin moves.** The fix belongs in FindMy.py's `_gsa_authenticate`, which is + where malmeloo/FindMy.py#279 puts it, as `_normalize_2fa_status`; the same change is proposed + for parawanderer/FindMy.py. A FindMy.py that has it is left alone here, so moving the pin (rule + 14) makes this a no-op, and the next change deletes it. + + Until then it wraps the instance's `_gsa_request`, which `_gsa_authenticate` looks up on the + instance. If a later FindMy.py renames it, this says so and changes nothing, and the 2FA bug + comes back. """ + if _FINDMY_READS_HSC_409: + return + inner = getattr(acc, "_asyncacc", None) original = getattr(inner, "_gsa_request", None) if inner is not None else None if inner is None or original is None: diff --git a/app/src/test/python/test_emulated_mac.py b/app/src/test/python/test_emulated_mac.py index d8d8b313..6e6a7fe1 100644 --- a/app/src/test/python/test_emulated_mac.py +++ b/app/src/test/python/test_emulated_mac.py @@ -152,3 +152,24 @@ def test_an_account_without_gsa_request_is_left_alone(self, capsys) -> None: account = SimpleNamespace(_asyncacc=SimpleNamespace()) main._treatHsc409AsSecondFactor(account) # type: ignore[arg-type] assert "hsc 409 will not be read as 2FA" in capsys.readouterr().out + + +class TestWhenFindMyReadsTheHsc409Itself: + """The pin moving to a FindMy.py with `_normalize_2fa_status` makes the stopgap a no-op.""" + + def test_the_account_is_left_alone( + self, account: AppleAccount, monkeypatch: pytest.MonkeyPatch) -> None: + asked = answersFromGrandSlam(account, {"ec": 0, "hsc": 409}) + wrapped = account._asyncacc._gsa_request + monkeypatch.setattr(main, "_FINDMY_READS_HSC_409", True) + + main._treatHsc409AsSecondFactor(account) + + assert account._asyncacc._gsa_request is wrapped + response = asyncio.run(account._asyncacc._gsa_request({"o": "complete"})) + assert asked == [{"o": "complete"}] + assert "au" not in response["Status"] + + def test_the_pinned_findmy_does_not_yet(self) -> None: + """When this fails, the pin has moved: delete `_treatHsc409AsSecondFactor` and this class.""" + assert not main._FINDMY_READS_HSC_409 From 17bf34850c6e977d76c566cede19e2ada6684a08 Mon Sep 17 00:00:00 2001 From: Rob Lazzurs Date: Wed, 4 Nov 2026 00:15:26 +0000 Subject: [PATCH 8/9] Note that MacBookPro13,2 never ran macOS 13.1, and why it is kept The 2016 13-inch MacBook Pro stops at macOS 12, so the pair is not a real release, which rule 11 asks for. These are the values os-findmy-e was observed to get searchPartyToken with, and changing either is a new machine to Apple, so they stay until a consistent pair is shown to work. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MJtmGkNZpfzFGSN1BZ82a --- .../android/opentagviewer/anisette/MacIdentity.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacIdentity.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacIdentity.java index 4c219b54..a46e4757 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacIdentity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/MacIdentity.java @@ -25,6 +25,14 @@ * inconsistent with itself - provisioning sends Darwin 22.3.0 and the login 22.2.0 - it is copied * rather than corrected, because the consistent version is the untested one. * + *

The model and the OS do not describe a real Mac either, and that is kept on purpose. + * {@code MacBookPro13,2} is the 2016 13-inch MacBook Pro, whose last supported release is macOS 12 + * Monterey, so it never ran macOS 13.1 (22C65). AGENTS.md rule 11 asks for a model, OS, build, + * CFNetwork and Darwin that describe one real release, and this is the exception: these are the + * exact values os-findmy-e was observed to receive {@code searchPartyToken} with, and a corrected + * pair is a configuration nobody has seen Apple accept. Changing either later is a new machine to + * Apple (a second device-list entry), so it waits for evidence that a consistent pair works. + * *

The serial is this app's own {@code 0PENTAGV} serial, so the device-list entry stays * recognisable (rule 11). Apple has not been seen to check serials at all. */ From 6100a00d8529b67cbf6d899fc28ed2e9d5c05837 Mon Sep 17 00:00:00 2001 From: Rob Lazzurs Date: Wed, 4 Nov 2026 00:15:26 +0000 Subject: [PATCH 9/9] Rule 21: restored sessions keep their machine, and :macadi holds Unicorn Records what the review taught: never move an existing session onto the emulated Mac, where provenance lives, why libmacadi.so loads only in :macadi, the GPL obligations and where each is met, the byte-for-byte check, and that the hsc 409 wrapper goes when the FindMy.py pin moves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MJtmGkNZpfzFGSN1BZ82a --- AGENTS.md | 50 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b1228754..d02e9557 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ python -m venv .venv && .venv/bin/pip install "FindMy==" [docs/emulated-mac-emulator-choice.md](./docs/emulated-mac-emulator-choice.md): a replacement is invisible to users **only if it produces the same bytes**, because the emulator is not part of the machine identity but its output is. `scripts/adi-emulator-diff/` is the check, and it has - not yet been run over the OTP path. The same doc says what the GPL obliges: a notice, and the + not yet been run over the OTP path (both drivers take a provisioned store for that). The same doc says what the GPL obliges: a notice, and the library kept in its own process. - **The app does run ADI locally, by a different route.** `app/src/main/java/.../anisette/` loads Apple's real Android ADI libraries in-process and produces Anisette here, falling @@ -713,7 +713,29 @@ So `LocalAnisette`'s Android libraries can no longer reach Find My by any combin `EmulatedMacAnisette` runs Apple's **macOS** CoreADI - the x86_64 build in iTunes 12.8.3 - under Unicorn, in `libmacadi.so` (`app/src/main/cpp/macadi/`, ported from sacca97/os-findmy-e; see its -`NOTICE.md`). It is what `AppDependencies` hands out. +`NOTICE.md`). **A fresh sign-in gets it; a restored session keeps the machine that signed it in.** + +- **Never move an existing session onto the emulated Mac.** A session `LocalAnisette` signed in + before 2026-10-12 still fetches reports, and its token only works with Anisette from the Android + machine that obtained it (#248). Handing it the emulated Mac at restore downloads CoreADI, + provisions a new Mac at app start, and sends that Mac's `X-Apple-I-MD-M` beside the account's + Android `X-Mme-Device-Id` - a working session made to answer 401. `AnisetteForSession` decides, + from `SessionProvenance`: `LocalAnisette` for every session but the emulated Mac's own, and + `EmulatedMacAnisette.Purpose.RESTORE` for those, which **never provisions** - a Mac that lost its + store is not the machine that holds the token. `RestoringASessionOnTheMachineThatSignedItInTest` + pins it. +- **Provenance has one home**: `sessionEstablishedLocally` in `LocalAnisette.PREFERENCES`, the key + every older install already has, plus `sessionMachine = emulatedMac` beside it for the Mac's + sessions. A new preferences file reads "nothing recorded" for every upgrader, which silently + turned off the machine-change warning in `_preferLocalAnisette`. +- **`libmacadi.so` is loaded only in `:macadi`.** `MacAdiService` hosts the emulator and the app + reaches it over binder through `RemoteMacAdi`; nothing in the app process may touch `MacCoreAdi`, + whose static initialiser loads the library. Two reasons: a SIGSEGV from Unicorn or Apple's code + is a native crash no `catch` sees (rule 4, #232), and the GPL-2.0 emulator must stay a separate + program from the Apache-2.0 libraries and the map SDKs. A death during a call arrives as + `MacAdi.EmulatorDiedException`, is recorded per app version and library build, and the Mac is not + tried again until one of those changes. `OpenAirTagApplication` returns early in that process, + as it does in `:adiprobe`. `TheEmulatedMacInItsOwnProcessTest` checks the boundary for real. - **The library is fetched from Apple, never shipped.** One HTTP range request: in that exact disk image the package payload is stored uncompressed and contiguous, so its offset is a constant. @@ -737,13 +759,25 @@ Unicorn, in `libmacadi.so` (`app/src/main/cpp/macadi/`, ported from sacca97/os-f - **Never freeze the emulated clock outside a byte-for-byte comparison.** A constant OTP is accepted for about a minute and then refused. - **Grand Slam now asks for a second factor with `hsc` 409 and no `au`**, which the pinned - FindMy.py reads as "no 2FA needed". `main._treatHsc409AsSecondFactor` fills `au` in. + FindMy.py reads as "no 2FA needed". The fix belongs in FindMy.py's `_gsa_authenticate` + (`_normalize_2fa_status`, as malmeloo/FindMy.py#279 has it). Until the pin reaches a commit with + it, `main._treatHsc409AsSecondFactor` fills `au` in by wrapping `_gsa_request`, and it steps aside + by itself once FindMy.py has the function. `test_the_pinned_findmy_does_not_yet` fails on the + pin move, which is the cue to delete the wrapper. - **The APK carries GPL-2.0 code** (Unicorn). Keep it in `libmacadi.so`, not in - `libopentagviewer.so`. - -The emulator suite cannot cover any of this - it swaps the Anisette source for a fake. What does is -`macadi-host.yml` (the library loads in the ported emulator), the JVM tests on the fetcher, the -Python tests in `test_emulated_mac.py`, and a real sign-in on a phone. + `libopentagviewer.so`, and keep its obligations met: `assets/licences/unicorn/` carries the + licence files and a notice naming the exact source (`UnicornNoticeTest` keeps it in step with + `MacAdi.cmake`), and `build-release.yml` attaches that source tarball to every release. +- **The C++ port is checked against os-findmy-e's Rust byte for byte**, offline: + `scripts/adi-emulator-diff/compare-libmacadi.sh`, in `macadi-host.yml`. Change `vm.cpp`, + `core_adi.cpp` or `macho.cpp` and that job is what says whether the port still matches. + +The emulator suite's sign-in tests cannot cover any of this - they swap the Anisette source for a +fake. What does: the JVM tests (`EmulatedMacAnisetteTest`, `MacIdentityTest`, +`MacProvisioningTest` against `FakeGrandSlam`, `RestoringASessionOnTheMachineThatSignedItInTest`, +the fetcher's), `macadi-host.yml` (the library loads in the ported emulator, and matches adi-core), +`TheEmulatedMacInItsOwnProcessTest` on the device, the Python tests in `test_emulated_mac.py`, and a +real sign-in on a phone. ---