diff --git a/docs/conf.py b/docs/conf.py index 6041c7b..b90f624 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,6 +24,8 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.inheritance_diagram", "autoapi.extension", + "sphinx_togglebutton", + "sphinx_design", ] templates_path = ["_templates"] diff --git a/docs/getstarted/00-install.md b/docs/getstarted/00-install.md new file mode 100644 index 0000000..74524a8 --- /dev/null +++ b/docs/getstarted/00-install.md @@ -0,0 +1,10 @@ +# Installation + +FindMy.py is available in the standard PyPi repositories. You can install it using the following command: + +```bash +pip install -U findmy +``` + +We highly recommend using a [virtual environment](https://docs.python.org/3/library/venv.html) for your project +if you want to use FindMy.py. This reduces the chance of dependency conflicts. diff --git a/docs/getstarted/01-account.md b/docs/getstarted/01-account.md index 8bfec34..1897f99 100644 --- a/docs/getstarted/01-account.md +++ b/docs/getstarted/01-account.md @@ -1,7 +1,122 @@ # Logging in -Some useful features of this library require an active login session with Apple in order to work correctly. +Most useful features of this library require an active login session with Apple in order to work correctly. The reason for this is that the remote endpoints require authentication to actually retrieve data. This page will guide you through the steps needed to log into an Apple account using FindMy.py. +## Step 0: Account Requirements +FindMy.py requires an **active** Apple Account which has had a device attached to it **at least once**. +It is OK if there are currently no devices signed into the account, as long as a device has signed into +it at least once in the past. Note that this does not have to be a _real_ device: a hackintosh using e.g. +[Docker-OSX](https://github.com/sickcodes/Docker-OSX) may also work for you if the VM is configured correctly. +We do not and will not provide support regarding setting this up. + +Additionally, if you want to track your AirTags, iDevices or other FindMy-compatible 3rd party devices, +the account used for FindMy.py does _not_ have to be the same one as the one that the devices are attached to. +Given the right decryption keys, any Apple account can query the location history of any FindMy device. +However, if you want to track such an official device, you currently must have access to a machine that is +running a compatible version of MacOS in order to extract the decryption keys (see later sections). + +## Step 1: Creating an AppleAccount instance + +The first time we want to sign in, we must manually construct an instance of the [AppleAccount](#findmy.AppleAccount) +class. Creating such a class requires specifying an [Anisette](../technical/15-Anisette.md) provider. Anisette +data is usually generated on-device, and identifies our virtual device when we make a request to Apple's servers. + +There are two different Anisette providers included in FindMy.py: [LocalAnisetteProvider](#findmy.LocalAnisetteProvider) +and [RemoteAnisetteProvider](#findmy.RemoteAnisetteProvider). The local provider is much easier to use, +so we will be utilizing it in this example. + +```python +from findmy import AppleAccount, LocalAnisetteProvider + +ani = LocalAnisetteProvider(libs_path="ani_libs.bin") +account = AppleAccount(ani) +``` + +Note the `libs_path` argument: the local Anisette provider needs to use some proprietary libraries +from Apple, which will be stored in this file. They will be automatically downloaded if the file is missing. +While the argument is technically optional, it is highly recommended to provide it; otherwise, the library +will need to re-download the bundle every time. The size of the bundle is approximately 2,1 MB. + +## Step 2: Logging in + +Logging into an Apple Account is an interactive process: depending on the circumstances, 2FA may or may +not be required, and there are multiple different methods to perform 2FA authentication. FindMy.py supports +both SMS and Trusted Device challenges to pass the 2FA check, but you must handle the sign-in flow manually in your application. + +```{attention} +FindMy.py currently does not support passkey authentication: [#159](https://github.com/malmeloo/FindMy.py/issues/159). +If you use a passkey to secure your Apple Account, you must disable it to use FindMy.py. This is because enabling +passkeys for your account will disable other 2FA mechanisms. +``` + +To start the authentication process, provide your email and password as follows: + +```python +state = account.login(email, password) +``` + +The `state` variable will now contain a [LoginState](#findmy.LoginState). If `value == LoginState.LOGGED_IN`, you're +good! Continue to the next step. If `value == LoginState.REQUIRE_2FA`, we need to pass a 2FA challenge first. +Read on to learn how to do this. + +In order to pass the 2FA challenge, we first need to find out which challenges Apple provides to us. We can use either +one of these challenges to continue the login flow. + +```python +from findmy import LoginState, TrustedDeviceSecondFactorMethod, SmsSecondFactorMethod + +if state == LoginState.REQUIRE_2FA: # Account requires 2FA + methods = account.get_2fa_methods() + + for i, method in enumerate(methods): + if isinstance(method, TrustedDeviceSecondFactorMethod): + print(f"{i} - Trusted Device") + elif isinstance(method, SmsSecondFactorMethod): + print(f"{i} - SMS ({method.phone_number})") + + # example output: + # 0 - Trusted Device + # 1 - SMS (+31 •• ••••••55) + # 2 - SMS (+31 •• ••••••32) +``` + +Depending on your account configuration, you will either get more or fewer 2FA challenge options. +In order to pass one of these challenges, we will first call its `request()` method to request a code +(on a Trusted Device or via SMS), and then use the `submit()` method to submit the code and pass the challenge. + +```python + ind = int(input("Method? > ")) + + method = methods[ind] + method.request() + code = input("Code? > ") + + method.submit(code) +``` + +If all went well, you should now be logged in! + +## Step 3: Saving / restoring the session + +Before we continue to fetching device locations, I first want to talk about properly closing and restoring sessions. +Apple Account sessions are precious, and you shall not create more of them than necessary. Each time we go through the +steps outlined above, a new 'device' is added to your account, and you will need to go through the 2FA flow again. +This is inefficient and simply unnecessary. + +Therefore, once you are done, it is good practice to save the current state of the account to a file, as well as close +any resources that the instance may be holding onto: + +```python +acc.to_json("account.json") + +acc.close() +``` + +Then, if you want to pick up the session again later: + +```python +acc = AppleAccount.from_json("account.json", anisette_libs_path="ani_libs.bin") +``` diff --git a/docs/getstarted/02-fetching.md b/docs/getstarted/02-fetching.md new file mode 100644 index 0000000..717994e --- /dev/null +++ b/docs/getstarted/02-fetching.md @@ -0,0 +1,259 @@ +# Fetching device locations + +```{note} +The steps below assume that you have already obtained an `AppleAccount` instance with a login session attached. +If you don't have this yet, follow the instructions [here](01-account.md) to obtain one. +``` + +## Step 1: Obtaining device information + +In order to fetch location reports for your device, FindMy.py requires the keys that are used to encrypt the location +reports that are uploaded by other Apple devices. Depending on the device you are using, this process can differ somewhat. + +```{tip} +This step can be quite annoying, but don't fret! You only have to do this once for each device you want to track. +Can't figure it out? Join the [Discord server](http://discord.gg/EF6UCG2TF6) and we'll try to help! +``` + +```````{tab-set} +:sync-group: device-type + +``````{tab-item} Official device +:sync: official-device + +If you want to track an official FindMy device (AirTag, iPhone/iPad/Mac, 3rd party 'works with FindMy'), you currently +need access to a device running MacOS. This can be either a real device or a Hackintosh, however, make sure that you are +signed into your Apple account and that the FindMy app is able to track your device. This is a one-time process, so you +can also ask a friend to borrow their Mac. + +Note that not all versions of MacOS are currently supported. Please see the menus below for more details. + +`````{tab-set} + +````{tab-item} MacOS <= 14 +FindMy.py includes a built-in utility that will dump the accessories from your Mac. Note that it will pop up +an interactive password prompt to unlock the keychain; therefore, this utility does **not** work over SSH. + +```bash +python3 -m findmy decrypt --out-dir devices/ +``` + +The above command will write one JSON file for each accessory found on your system to the `devices` directory. +These files are ready to be used with FindMy.py! + +```` + +````{tab-item} MacOS 15 +MacOS 15 may or may not include additional protection for the BeaconStoreKey. You should first try to follow +the instructions for MacOS 14. If these do not work for you, read on. + +If the instructions for MacOS 14 do not work for you, the BeaconStoreKey is likely protected. We will need to +use an additional utility to decrypt a set of 'plist' files. Go and follow the instructions at @pajowu's +[beaconstorekey-extractor](https://github.com/pajowu/beaconstorekey-extractor), then return here. + +Welcome back! **Did you remember to re-enable System Integrity Protection? If not, go do that now!** + +If all went well, you should now have one or multiple decrypted plist files. Hooray! +That was the most difficult part. These plist files are not directly compatible with FindMy.py, +so we'll need to convert them first. +Save this [plist_to_json](https://github.com/malmeloo/FindMy.py/blob/main/examples/plist_to_json.py) +script somewhere on your computer and run it as follows: + +```python +python3 plist_to_json.py path/to/original_file.plist device.json +``` + +This will convert a single plist file into a FindMy.py-compatible JSON file and save it to `device.json`. +Repeat this step for any other plist files you want to convert. + +```{note} +The first time you try to fetch the location of your device, FindMy.py might appear to hang for a bit. +This is because the beaconstorekey-extractor tool does not export key alignment data, so FindMy.py needs +to query a wide range of possible values to find the right alignment to use. The older your tag is, the +longer it will take to do this process. + +If you are physically close to the tag, you can speed this up significantly by using the +[Tag Scanner](https://github.com/malmeloo/FindMy.py/blob/main/examples/scanner.py). This will attempt +to discover your tag via Bluetooth and update its alignment based on the values that it is currently broadcasting. +Make sure to give it your device JSON file as argument! Otherwise, the scanner does not know which tag +to look for. +``` + +```` + +````{tab-item} MacOS 26 +MacOS 26 appears to protect the BeaconStoreKey needed to decrypt the plist records that contain accessory data. +Unlike with MacOS 15, disabling SIP does not appear to fix it. + +If you figure out a way to dump the plist encryption key, please share your findings +[here](https://github.com/malmeloo/FindMy.py/issues/177). +```` + +````{tab-item} I don't have a Mac :( +Unfortunately, FindMy.py currently only supports dumping accessory information from a Mac. +Device encryption keys are stored in your account's keychain, which is only accessible on Apple hardware. +iOS / iPadOS is too limited and does not allow us to access the necessary device secrets. + +A method to join the encrypted keychain circle from non-MacOS hardware has recently been found, +but it takes a lot of time and effort to implement. We are currently considering what the best +way would be to implement this, however, we are not currently actively working on making this happen. +You can follow development on this feature and voice your support in +[this](https://github.com/malmeloo/FindMy.py/issues/173) GitHub issue. +```` + +````` + +`````` + +``````{tab-item} Custom device +:sync: custom-device + +If you built your own FindMy tag (using e.g. [OpenHaystack](https://https://github.com/seemoo-lab/openhaystack), +[macless-haystack](https://github.com/dchristl/macless-haystack), or [one](https://github.com/pix/heystack-nrf5x) +of the [many](https://github.com/hybridgroup/go-haystack) other [available](https://github.com/dakhnod/FakeTag) +projects), it will most likely be broadcasting a static key. In this case, grab the private key that you generated +and create a [KeyPair](#findmy.KeyPair) object as follows: + +````python +# PRIVATE key in base64 format +device = KeyPair.from_b64(...) +```` + +`````{admonition} Don't have a private key yet? +:class: tip dropdown + +If you are setting up your DIY tag and have not generated a private key yet, you can use FindMy.py to do it! + +````python +device = KeyPair.new() +print(device.private_key_b64) +# a6C9bgy4H/bpZ7vGtVBdO3/UyNjan2/3a7UW4w== +```` + +````` + +`````` + +``````` + +## Step 2: Testing your device JSON file (optional) + +At this point, you should be able to fetch location reports for your accessory. FindMy.py includes extensive +example scripts to help you test this. + +`````{tab-set} +:sync-group: device-type + +````{tab-item} Official device +:sync: official-device + +Clone the FindMy.py repository somewhere and enter the `examples/` directory. +Then run the following command: + +```bash +python3 airtag.py path_to_device.json +``` + +The script will ask for your account credentials. If all went well, it will output a location report as follows: + +``` +Last known location: + - LocationReport(hashed_adv_key=..., timestamp=..., lat=..., lon=...) +``` + +```` + +````{tab-item} Custom device +:sync: custom-device + +Clone the FindMy.py repository somewhere and enter the `examples/` directory. +Then run the following command: + +```bash +python3 fetch_reports.py +``` + +The script will ask for your account credentials. If all went well, it will output a location report as follows: + +``` +Last known location: + - LocationReport(hashed_adv_key=..., timestamp=..., lat=..., lon=...) +``` + +```` + +````` + +## Step 3: Fetching location reports + +To fetch location report for a device, you can use the [fetch_location](#findmy.AppleAccount.fetch_location) method +on your [AppleAccount](#findmy.AppleAccount) instance. This method will return either a [LocationReport](#findmy.LocationReport) +if a location is found, or `None` if no location was found. + +```python +location = account.fetch_location(device) +print(location) + +# LocationReport(...) +``` + +If you want to query locations for multiple devices, you can also pass in a list. FindMy.py will then optimize its +request payloads to get the locations in as few queries to Apple servers as possible. In this case, the method will +return a dictionary with the given devices as keys, and the fetch result as value. + +```python +locations = account.fetch_location([device1, device2]) +print(locations) + +# {device1: LocationReport(...), device2: None} +``` + +You can also save location reports to JSON if you want to store them: + +```python +location.to_json("report.json") +``` + +````{caution} +The JSON representation of a location report includes the device's encryption key at that time. +**Sharing this file with someone else will allow them to query location reports for your device.** +You can avoid including the key by setting the `include_key` parameter to `False`, however, +this will save the report in its encrypted format, which means you will have to manually decrypt it again. + +```python +enc_report_json = report.to_json(include_key=False) +report = LocationReport.from_json(enc_report_json) + +print(report.is_decrypted) +# False + +print(report.latitude) +# RuntimeError: Latitude is unavailable while the report is encrypted. + +report.decrypt(key) # key is the `KeyPair` of the device at that time + +print(report.is_decrypted) +# True +``` + +```` + +## Step 4: Saving accessory state to disk + +After fetching, FindMy.py may have made changes to the accessory's internal state. +Saving these changes to the accessory's JSON representation ensures that the process of fetching +the device's location will be as fast and efficient as possible. + +The device's state can be exported to JSON as follows: + +```python +device.to_json("airtag.json") +``` + +```{tip} +As you may have noticed, many objects in FindMy.py can be (de)serialized to and from JSON. +Classes such as [AppleAccount](#findmy.AppleAccount), [LocationReport](#findmy.LocationReport), +[KeyPair](#findmy.KeyPair) and [FindMyAccessory](#findmy.FindMyAccessory) all subclass +[Serializable](#findmy.util.abc.Serializable). Whenever a class in FindMy.py subclasses `Serializable`, +you can save and load its state using the `to_json` and `from_json` methods. +``` diff --git a/docs/technical/15-Anisette.md b/docs/technical/15-Anisette.md new file mode 100644 index 0000000..90e9f9a --- /dev/null +++ b/docs/technical/15-Anisette.md @@ -0,0 +1,3 @@ +# Anisette + +TODO diff --git a/examples/real_airtag.py b/examples/airtag.py similarity index 95% rename from examples/real_airtag.py rename to examples/airtag.py index 310e98b..67b1dfe 100644 --- a/examples/real_airtag.py +++ b/examples/airtag.py @@ -36,11 +36,10 @@ def main(airtag_path: Path) -> int: airtag = FindMyAccessory.from_json(airtag_path) # Step 1: log into an Apple account - print("Logging into account") acc = get_account_sync(STORE_PATH, ANISETTE_SERVER, ANISETTE_LIBS_PATH) + print(f"Logged in as: {acc.account_name} ({acc.first_name} {acc.last_name})") # step 2: fetch reports! - print("Fetching location") location = acc.fetch_location(airtag) # step 3: print 'em diff --git a/findmy/reports/reports.py b/findmy/reports/reports.py index 85173b2..607714f 100644 --- a/findmy/reports/reports.py +++ b/findmy/reports/reports.py @@ -306,10 +306,10 @@ class LocationReport(HasHashedPublicKey, util.abc.Serializable[LocationReportMap def __lt__(self, other: LocationReport) -> bool: """ - Compare against another :meth:`KeyReport`. + Compare against another :meth:`LocationReport`. - A :meth:`KeyReport` is said to be "less than" another :meth:`KeyReport` iff its recorded - timestamp is strictly less than the other report. + A :meth:`LocationReport` is said to be "less than" another :meth:`LocationReport` iff + its recorded timestamp is strictly less than the other report. """ if isinstance(other, LocationReport): return self.timestamp < other.timestamp @@ -318,7 +318,7 @@ class LocationReport(HasHashedPublicKey, util.abc.Serializable[LocationReportMap @override def __repr__(self) -> str: """Human-readable string representation of the location report.""" - msg = f"KeyReport(hashed_adv_key={self.hashed_adv_key_b64}, timestamp={self.timestamp}" + msg = f"LocationReport(hashed_adv_key={self.hashed_adv_key_b64}, timestamp={self.timestamp}" if self.is_decrypted: msg += f", lat={self.latitude}, lon={self.longitude}" msg += ")" diff --git a/pyproject.toml b/pyproject.toml index 03a15b1..e980ecd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ docs = [ "sphinx>=8.2.3,<8.3.0; python_full_version >= '3.11'", "sphinx-autoapi==3.6.0", "sphinx-book-theme>=1.1.4", + "sphinx-design>=0.6.1", + "sphinx-togglebutton>=0.3.2", ] [tool.pyright] diff --git a/uv.lock b/uv.lock index 1ac412c..15497c7 100644 --- a/uv.lock +++ b/uv.lock @@ -608,6 +608,8 @@ docs = [ { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autoapi" }, { name = "sphinx-book-theme" }, + { name = "sphinx-design" }, + { name = "sphinx-togglebutton" }, ] test = [ { name = "pytest" }, @@ -638,6 +640,8 @@ docs = [ { name = "sphinx", marker = "python_full_version >= '3.11'", specifier = ">=8.2.3,<8.3.0" }, { name = "sphinx-autoapi", specifier = "==3.6.0" }, { name = "sphinx-book-theme", specifier = ">=1.1.4" }, + { name = "sphinx-design", specifier = ">=0.6.1" }, + { name = "sphinx-togglebutton", specifier = ">=0.3.2" }, ] test = [{ name = "pytest", specifier = ">=8.3.2,<9.0.0" }] @@ -1670,6 +1674,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/9e/c41d68be04eef5b6202b468e0f90faf0c469f3a03353f2a218fd78279710/sphinx_book_theme-1.1.4-py3-none-any.whl", hash = "sha256:843b3f5c8684640f4a2d01abd298beb66452d1b2394cd9ef5be5ebd5640ea0e1", size = 433952, upload-time = "2025-02-20T16:32:31.009Z" }, ] +[[package]] +name = "sphinx-design" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, +] + +[[package]] +name = "sphinx-togglebutton" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "setuptools" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/df/d151dfbbe588116e450ca7e898750cb218dca6b2e557ced8de6f9bd7242b/sphinx-togglebutton-0.3.2.tar.gz", hash = "sha256:ab0c8b366427b01e4c89802d5d078472c427fa6e9d12d521c34fa0442559dc7a", size = 8324, upload-time = "2022-07-15T12:08:50.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/18/267ce39f29d26cdc7177231428ba823fe5ca94db8c56d1bed69033b364c8/sphinx_togglebutton-0.3.2-py3-none-any.whl", hash = "sha256:9647ba7874b7d1e2d43413d8497153a85edc6ac95a3fea9a75ef9c1e08aaae2b", size = 8249, upload-time = "2022-07-15T12:08:48.8Z" }, +] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" @@ -1835,6 +1870,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, ] +[[package]] +name = "wheel" +version = "0.45.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, +] + [[package]] name = "winrt-runtime" version = "3.2.1"