Skip to content

Latest commit

 

History

60 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

intro

See the locations of your OpenHaystack trackers - on the desktop, and on Android.

where what you run needs a server?
macOS / Linux desktop this repo (./run.sh) no
Android OpenTagViewer + tools/export_opentagviewer.py no
iOS nothing viable - see docs/LLD.md §7 -

Both apps talk to Apple directly with their own copy of the keys. Nothing runs in the background, and there is no service to keep alive.

Design notes: docs/PRD.md (why we did not write a mobile app), docs/LLD.md (how the Android path works end to end).

omg. how to use it?

./run.sh

If a trackers.json exists next to app.py, the UI seeds its tracker list from it on first load, so the map is populated without pasting anything. Otherwise click Trackers and paste the configuration:

[
    {
        "name": "microbit",
        "key_id": "DIf2Od7NcEfYHsFVQTC/xTUFecr3J8B0KoPfJHsXRQM=",
        "advertisement_key": "f9dQjCafB68gi9ZKLH/iQsN9tScDX+zXF4BfZGDqoyA=",
        "private_key": "7b1MPYzazUCpDzTUDO9RM2h+2VytFGe0Sdua8A=="
    }
]

Then tracker positions has to be displayed on the map.

Seeding is deliberately limited to the local (eel) backend. The REST backend never ships private keys to a browser it does not control.

The web UI never asks for a password. Authentication is a one-time Apple ID login done in a terminal - see sign in to Apple (once) below. Do that before the first run, or the UI will just tell you to.

Existing trackers keep working: the private_key values you already flashed are unchanged, so no re-flashing is required by this migration.

see your tags on Android

There is no Android app in this repo and there is not going to be one. Instead we convert trackers.json into an import bundle for OpenTagViewer (MIT), which runs the whole Find My stack on the phone - no server, no Mac, no tunnel. It already supports self-generated OpenHaystack tags, so no upstream changes are needed. The reasoning is in docs/PRD.md.

Verified working on a real device with real tags.

1. build the bundle

The bundle is written by OpenTagViewer's own exporter rather than a reimplementation here, because the format has details you would not guess. That code cannot be pip installed (upstream sets package = false), so point the tool at a clone:

git clone --depth 1 https://github.com/parawanderer/OpenTagViewer.git /tmp/otv
pipenv run python tools/export_opentagviewer.py trackers.json \
    --exporter-path /tmp/otv/python \
    -o ~/openhaystack-tags.zip

Set $OPENHAYSTACK_OTV_EXPORTER to that python/ directory to skip --exporter-path every time.

argument what it does
trackers tracker list to convert. Default trackers.json.
--exporter-path PATH OpenTagViewer's python/ directory. Defaults to $OPENHAYSTACK_OTV_EXPORTER.
-o, --output where to write the bundle. Default opentagviewer-import.zip.

What comes out - one zip, a few hundred bytes:

openhaystack-tags.zip
├── OPENTAGVIEWER.yml                         # format version 0.0.3
└── CustomAccessories/
    ├── openhaystack-<id>.json                # one per tracker
    └── ...

Each accessory is {type: "custom_rolling_key_accessory", identifier, name, private_keys: [...]}. Import the zip, not the JSON - upstream's importer reads it through ZipInputStream and there is no single-file path. The manifest is mandatory: its version is what tells the importer the bundle may contain self-generated tags.

The file is created mode 0600 via os.open, with the mode applied at creation time rather than chmod-ed afterwards, so there is never a moment where a file full of private keys is world-readable.

2. install the app

Download the APK from the releases page (~103 MB, universal) and tap it; Android will ask you to allow installs from that source. Or over USB, with Developer options -> USB debugging enabled:

adb install ~/Downloads/OpenTagViewer-1.1.1.apk
adb push ~/openhaystack-tags.zip /sdcard/Download/

Android only - there is no desktop build of OpenTagViewer. On the desktop you use this repo.

3. import

  1. Open the app and sign in with your Apple ID. Prefer the trusted-device 2FA code; the SMS path has an open upstream bug (#236).
  2. First launch downloads Apple's ADI libraries (~11 MB, over the network, from Apple's own CDN). They are never bundled into the APK.
  3. Import openhaystack-tags.zip. Your tags appear on the map.

Then delete the bundle from both the phone and the computer. It contains private keys in the clear, and once imported it has no further use.

rm ~/openhaystack-tags.zip
adb shell rm /sdcard/Download/openhaystack-tags.zip

A tag with no recent reports shows up empty. That means nobody's iPhone has been near it inside the window - it is not an import failure. Confirm with tools/scan.py (see scanning over Bluetooth), which needs no Apple account at all.

Both apps read the same keys independently, so importing changes nothing on the desktop side.

sign in to Apple (once)

Locations come from Apple's Find My network via FindMy.py (FindMy==0.10.2, imported as findmy). It needs an authenticated Apple ID session. Anisette data is produced locally by FindMy.py's LocalAnisetteProvider - there is no Docker container and no third-party anisette server involved.

Run this once, interactively:

pipenv run python tools/findmy_login.py

It prompts for your Apple ID, then the password (read with getpass: not echoed, not logged, not written anywhere), then walks you through 2FA (trusted-device code or SMS). On success it prints Signed in as ... and saves the session.

usage: tools/findmy_login.py [-h] [--account-file ACCOUNT_FILE] [--force]
                             [--new-device-identity] [--apple-id APPLE_ID]
argument what it does
--apple-id APPLE_ID Apple ID (email). Prompted for when omitted. The password is always prompted for.
--account-file ACCOUNT_FILE Where to store the session. Default: $FINDMY_ACCOUNT_FILE, else $XDG_CONFIG_HOME/openhaystack-web/account.json, else ~/.config/openhaystack-web/account.json.
--force Log in again even when a valid session already exists. Without it the script detects the existing session, prints Nothing to do. and exits 0. It reuses the stored device identity, so it does not register another device.
--new-device-identity Throws the stored device identity away and mints a new one, which makes Apple register another device on your Apple ID. This is the expensive action that causes Account limit reached. Only for the case where Apple has blacklisted the current identity, and only after cleaning up at https://account.apple.com/account/manage/section/devices.
--2fa-method N Pick the 2FA method up front, by its number in the printed list, instead of being asked. With one method available it is chosen automatically either way.
--code-file PATH Read the 6-digit code from a file instead of the terminal, polling until it appears. The file is deleted as soon as it is read, so a code cannot be replayed. For unattended runs.

device identity, and why a failed login is now free

Apple registers a device per uid/devid pair it sees at the final com.apple.mobileme step. FindMy.py invents both at random for every fresh AppleAccount, so an interrupted or rejected login used to burn a device slot and then throw the identity away - each retry made the problem worse until Apple answered com.apple.mobileme login failed with status 1: Account limit reached.

The login tool now writes the device identity to the session file even when the login fails (identity only: no username, no password, login state LOGGED_OUT), and every later attempt - including --force - restores and reuses it. Retrying costs zero extra device slots.

If you do hit Account limit reached, the tool prints the cleanup steps: go to https://account.apple.com/account/manage/section/devices and remove the phantom entries. They appear as a "MacBook Pro" running macOS 13.4.1 with a blank/zero serial number, because that is the identity FindMy.py sends (<MacBookPro18,3> <Mac OS X;13.4.1;22F8> with X-Apple-I-SRL-NO: "0", from findmy/reports/anisette.py). Leave your real devices alone.

read this before you log in

These are real constraints of Apple's GSA ("grandslam") authentication, not conservative guesses:

  1. App-specific passwords do NOT work. GSA predates them. You must use the real Apple ID password plus interactive two-factor authentication. There is no headless/unattended way to do this first login.
  2. Every login with a new device identity registers another device on your Apple ID. These accumulate, and an Apple ID with too many of them starts refusing sign-ins (Account limit reached). Log in once and reuse the saved session; that is why tools/findmy_login.py refuses to re-login unless you pass --force. Retries are safe: the device identity is saved even when a login fails and is reused by every later attempt, including --force. Only --new-device-identity costs you a device slot.
  3. Brand-new Apple IDs are sometimes rejected with an "account score not high enough" error. Nothing in this repo can work around that; use an established Apple ID.

the session file

path what
~/.config/openhaystack-web/account.json the Apple account session or just the saved device identity, written mode 0600 in a 0700 directory
~/.config/openhaystack-web/anisette-libs.bin cached anisette libraries, so later starts load locally instead of re-downloading

Overrides: $FINDMY_ACCOUNT_FILE for the session file, $FINDMY_ANISETTE_LIBS for the libs cache; $XDG_CONFIG_HOME is honoured for the directory.

account.json is a live Apple credential - treat it like ~/.ssh/id_rsa. It is gitignored and must never be committed. If it is lost or corrupt, the app says so and you re-run the login tool.

It contains your Apple ID password in plaintext. That is not this repo's choice: findmy's AppleAccount.to_json() serializes account.password as-is, and the library needs it to re-authenticate. This is why the file is only ever written through the atomic 0600 writer in lib/findmy_backend.py, inside a 0700 directory. Do not copy it to a backup, a shared drive, or a paste bin.

The file has two shapes, distinguished by login.state:

login.state meaning contains a password?
3 (LOGGED_IN) a usable session; the app runs yes
0 (LOGGED_OUT) device identity only, saved after a failed login no (account.username/password are null)

has_account() / load_account() only accept LOGGED_IN. An identity-only file still raises AccountNotConfiguredError telling you to run tools/findmy_login.py, and /api/v1/health keeps reporting account_configured: false - the app never mistakes a kept identity for a session.

for developers

install requirements

brew install python3
python3 -m pip install pipenv
pipenv sync

Python 3.10 or newer is required (FindMy==0.10.2 is pinned to python_version >= '3.10' and python_version < '3.15'); CI uses 3.12.

pipenv sync installs exactly what Pipfile.lock pins (with hash verification) - eel is pinned to ==0.18.2 and FindMy to ==0.10.2, so don't replace it with a plain pipenv install unless you intend to move those pins. ./run.sh does this for you on the first run.

Run the app:

pipenv run python3 app.py

openocd is only needed if you are going to flash a MCU:

brew install openocd

run the tests

pipenv run python -m unittest discover -s tests

74 tests, no network and no Apple credentials required (the Find My calls are mocked). Nothing in tests/ imports objc or touches the macOS keychain, so the suite runs on Linux as well as macOS - CI runs it on ubuntu-latest.

Two of them are skipped unless $OPENHAYSTACK_OTV_EXPORTER points at a OpenTagViewer clone: they assert the real bundle contents, which needs upstream's exporter. Everything else about the export tool is tested without it.

generate keys

pipenv run ./tools/keygen.py

Generates a new airtag keys, use an advertisement_key for the next ste

{
    "key_id": "DIf2Od7NcEfYHsFVQTC/xTUFecr3J8B0KoPfJHsXRQM=",
    "advertisement_key": "f9dQjCafB68gi9ZKLH/iQsN9tScDX+zXF4BfZGDqoyA=",
    "private_key": "7b1MPYzazUCpDzTUDO9RM2h+2VytFGe0Sdua8A=="
}

connect MCU to STLink

flashing

pipenv run ./tools/flash.py --advertisement-key=$KEY

Wait for ** Programming Finished **.

It should output like this

Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
For bug reports, read
	http://openocd.org/doc/doxygen/bugs.html
WARNING: interface/stlink-v2.cfg is deprecated, please switch to interface/stlink.cfg
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
Info : clock speed 1000 kHz
Info : STLINK V2J25S4 (API v2) VID:PID 0483:3748
Info : Target voltage: 3.234442
Info : [nrf51.cpu] Cortex-M0 r0p0 processor detected
Info : [nrf51.cpu] target has 4 breakpoints, 2 watchpoints
Info : starting gdb server for nrf51.cpu on 3333
Info : Listening on port 3333 for gdb connections
[nrf51.cpu] halted due to debug-request, current mode: Handler HardFault
xPSR: 0x41000003 pc: 0x00000c7a msp: 0x20003fd0
Info : nRF51822-QFAA(build code: H0) 256kB Flash, 16kB RAM
Info : Mass erase completed.
Info : A reset or power cycle is required if the flash was protected before.
[nrf51.cpu] halted due to debug-request, current mode: Thread
xPSR: 0xc1000000 pc: 0xfffffffe msp: 0xfffffffc
** Programming Started **
Warn : Adding extra erase range, 0x0000374c .. 0x000037ff
** Programming Finished **
** Verify Started **
** Verified OK **
[nrf51.cpu] halted due to debug-request, current mode: Thread
xPSR: 0xc1000000 pc: 0x00000c3c msp: 0x20004000
** Programming Started **
Warn : Adding extra erase range, 0x0000374c .. 0x000037ff
** Programming Finished **

test that it works

To get locations of the tracker Put keys in array into "trackers.json" as in examble below It needs time to advertise the tracker by the nearby iPhones, wait about 10m

pipenv run ./tools/locations.py                      # reads ./trackers.json
pipenv run ./tools/locations.py trackers.json        # or pass the path
pipenv run ./tools/locations.py --config trackers.json
pipenv run ./tools/locations.py trackers.json --hours 6
pipenv run ./tools/locations.py trackers.json --format raw
argument default what it does
CONFIG (positional, optional) trackers.json path to the trackers JSON
--config CONFIG trackers.json same thing; passing both forms exits with code 2
--hours HOURS 24 how far back to look for reports, must be > 0
--format {json,raw} json json prints {name: [{lat, lng, accuracy, reported_at}]}, raw prints the same mapping as a python repr

scanning over Bluetooth, without an Apple account

tools/scan.py listens for tags broadcasting in radio range of this machine. It needs no Apple ID, no iCloud and no network, so it is the way to check that a tag is powered and advertising before blaming the Find My lookup.

pipenv run ./tools/scan.py                       # reads ./trackers.json, scans 30s
pipenv run ./tools/scan.py trackers.json --seconds 90
pipenv run ./tools/scan.py --all                 # also list other Find My devices

It reports presence and rough distance (from RSSI), not coordinates. Range is roughly 10-50 m. A tag that is separated from its owner broadcasts its full advertisement key and can be matched against trackers.json by name; a tag that still considers itself near its owner truncates that key and is only counted as an unidentifiable Find My device. On macOS the terminal needs Bluetooth permission (System Settings -> Privacy & Security -> Bluetooth).

The default output is JSON; --format raw prints the python repr of the same {name: [...]} mapping. The config file may be a single JSON object instead of a list - it is wrapped into a one-element list.

trackers.json holds tracker private keys and is gitignored - don't commit it.

trackers.json schema

Validation lives in lib/trackers.py and is shared by the eel app and the REST backend.

field required rules
private_key yes base64 P-224 private key. Everything else is derived from it (findmy.KeyPair.from_b64). An unparseable value is rejected by index, never echoing the key material.
key_id no derived from private_key as KeyPair.hashed_adv_key_b64. If you do supply it and it disagrees with the private key, the app raises a loud ValueError rather than silently querying a different tag.
advertisement_key no advisory; checked against KeyPair.adv_key_b64 the same way when present.
name no falls back to the derived key_id. It is the key of the results object, so unnamed trackers would otherwise all collide.

Keypairs you flashed before the FindMy.py migration are still correct: KeyPair.from_b64(private_key).adv_key_b64 and .hashed_adv_key_b64 reproduce exactly the advertisement_key and key_id already on your tags. You do not need to re-flash anything.

trackers.json example

[
	{
		"name": "My tracker",
		"key_id": "DIf2Od7NcEfYHsFVQTC/xTUFecr3J8B0KoPfJHsXRQM=",
		"advertisement_key": "f9dQjCafB68gi9ZKLH/iQsN9tScDX+zXF4BfZGDqoyA=",
		"private_key": "7b1MPYzazUCpDzTUDO9RM2h+2VytFGe0Sdua8A=="
	}
]

the --hours window

--hours (and the hours argument of lib.trackers.get_tracker_locations, default 24) bounds how far back reports are returned. FindMy.py 0.10.2 has no server-side time window, so Apple is asked for the full history for each key and the cutoff is applied client side on LocationReport.timestamp; results are returned sorted oldest-first. tools/locations.py rejects a value <= 0 with exit code 2; in the library a None or non-positive value means "no filtering".

firmware

The firmware defines the status LED on GPIO P0.17. Connect your LED (with a suitable series resistor) to that pin, or change the STATUS_LED_PIN definition in firmware/src/main.c if you want to use a different pin

references

Low-power consumption firmare

how to build a openhaystack tracker

  1. Building Nordic NRF51822 Airtag tutorial
  2. How to generate a key pair and flash a firmware to the MCU

optional REST backend

app.py (eel) talks to python directly. If that's not available the web UI falls back to a REST backend, backend/app.py (sanic):

pipenv run python backend/app.py

No credentials are passed on the command line. The server restores the same ~/.config/openhaystack-web/account.json session that tools/findmy_login.py wrote, once per process, and reuses that one AppleAccount for every request - re-provisioning anisette on each request is what gets an Apple ID rate-limited.

A missing or broken session does not stop the server from starting: it logs a warning and retries lazily, so a login performed while the server is running is picked up without a restart.

Endpoints:

endpoint what
POST /api/v1/locations body {"trackers": [...]}, returns {name: [{lat, lng, accuracy, reported_at}]} over a fixed 24 hour window
GET /api/v1/health always 200; account_configured / account_session_loaded tell you whether anyone has logged in yet

Status codes from POST /api/v1/locations: 400 for a malformed body or an invalid/inconsistent tracker list, 503 when no Apple session is configured or the saved one is unusable (run the login tool; no restart needed), 502 when the upstream Find My lookup fails. Error bodies are deliberately generic - the detail goes to the server log, because the underlying exceptions embed the account file path and therefore the local username.

env var default meaning
BIND_HOST (or HOST) 127.0.0.1 address to bind
PORT 8000 port to bind
ALLOWED_ORIGINS http://localhost:<port>,http://127.0.0.1:<port> (and :8000) comma separated CORS origins
FINDMY_ACCOUNT_FILE $XDG_CONFIG_HOME/openhaystack-web/account.json session file to restore
FINDMY_ANISETTE_LIBS next to the account file anisette libs cache

⚠️ The endpoint is unauthenticated. It accepts tracker private keys and returns physical location history to anyone who can reach it. Keep it on loopback; binding BIND_HOST to anything else publishes your trackers to the network (the server logs a warning when you do).

Files used

path mode what
~/.config/openhaystack-web/account.json 0600 in a 0700 dir Apple ID session from tools/findmy_login.py. A live credential. Gitignored; never commit it. Override with $FINDMY_ACCOUNT_FILE.
~/.config/openhaystack-web/anisette-libs.bin 0600 in a 0700 dir cached anisette libraries, avoids a download on every start. Override with $FINDMY_ANISETTE_LIBS.
./trackers.json - tracker private keys. Gitignored; never commit it.
./opentagviewer-import.zip 0600 Android import bundle from tools/export_opentagviewer.py, named by -o. Contains private keys. Delete it after importing. Gitignored.

$XDG_CONFIG_HOME is honoured for the config directory. Writes are atomic (mkstemp -> chmod 0600 -> fsync -> os.replace), so an interrupted write can never leave a half-written or world-readable credential.

There is no longer any macOS keychain, iCloud token cache, or 12-hour expiry: the Apple session is reused until Apple invalidates it, at which point the app tells you to re-run tools/findmy_login.py.

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages