Merge pull request #178 from malmeloo/feat/docs

Feat/docs
This commit is contained in:
Mike Almeloo
2025-09-19 14:55:26 +02:00
committed by GitHub
9 changed files with 441 additions and 7 deletions

View File

@@ -24,6 +24,8 @@ extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.inheritance_diagram",
"autoapi.extension",
"sphinx_togglebutton",
"sphinx_design",
]
templates_path = ["_templates"]

View File

@@ -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.

View File

@@ -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")
```

View File

@@ -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 <private_key_base64>
```
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.
```

View File

@@ -0,0 +1,3 @@
# Anisette
TODO

View File

@@ -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

View File

@@ -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 += ")"

View File

@@ -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]

44
uv.lock generated
View File

@@ -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"