Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions docs/findmy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# FindMy / OpenHaystack Beacon

This document describes the experimental FindMy locator beacon for nRF52 MeshCore nodes.

## Purpose

When enabled, a node advertises an Apple [FindMy](https://support.apple.com/find-my) /
[OpenHaystack](https://github.com/seemoo-lab/openhaystack) "offline finding" beacon alongside
its normal mesh duties. Nearby iPhones anonymously relay its encrypted location to Apple's
servers, letting you locate a deployed node through the global Find My network — no extra
infrastructure, GPS, or cellular needed.

This is particularly useful for repeater nodes. To maximise coverage they are often deployed in
exposed, unattended, hard-to-reach spots — rooftops, towers, hilltops, remote solar sites — which
makes them especially prone to going missing, whether through theft, weather, a failed mount, or
simply being forgotten. A FindMy beacon gives you a way to locate (or recover) such a node, or at
least get a last-known position, without having to physically visit the site.

The node only ever broadcasts a **public** advertising key. Decrypting the location reports
requires the matching **private** key, which you keep off-device on your own machine.

## Supported hardware

nRF52840 boards only (Adafruit Bluefruit BLE stack), but the implementation is easily ported to
ESP32 and other BLE-capable chips. The challenge is concurrent BLE usage, i.e. companion nodes.
**TODO / future work:** check whether the FindMy advertisement can be sent while a companion is
disconnected from the phone, and disabled again when it reconnects.

Pre-defined build environments:

| Environment | Board |
|-------------|-------|
| `RAK_4631_repeater_findmy` | RAK4631 / RAK WisMesh (repeater, incl. Solar Repeater Mini) |
| `LilyGo_T-Echo_repeater_findmy` | LilyGo T-Echo |

The feature is gated behind the `WITH_FINDMY_BEACON` build flag, so it adds nothing to other
builds. It is intended for always-on roles (repeater/sensor) where BLE is otherwise unused. It
is **not** for the phone-companion firmware, which needs BLE for its own link — a node can be a
FindMy beacon or a phone companion, not both at once.

## Key rotation

The node stores up to **365 advertising keys** and picks the active one from its clock, where
`now_utc` is the current UTC time in Unix-epoch seconds (so `now_utc / 86400` is the day number):

slot = (now_utc / 86400) % count

so keys rotate **daily** and cycle every `count` days — the way an AirTag rotates. `count` is
whatever you provision, from **1 (a single static key)** up to 365. The server (holding the
matching private keys) derives the same per-day slot, so it always knows which key to query.

> **Rotation requires the node's clock to be set.** The slot is derived from UTC time, so the
> node must know the real date/time for it to pick — and stay in sync on — the right slot. Until
> the clock is set (it reads a time at/after the firmware build date) the node advertises **slot
> 0** and does not rotate; it begins rotating once the time is set (via the companion app, the
> `time`/`clock` CLI, or GPS). On boards without a battery-backed RTC the clock resets on every
> power loss, so either fit an RTC module or use a single static key (`count = 1`), which needs
> no clock. See [Notes and caveats](#notes-and-caveats).

Why rotate: genuine AirTags rotate their key daily and Apple's network is tuned for that. A
single never-changing key is more easily treated as anomalous and may have its reports throttled
over time. Rotating daily (e.g. `count = 365`, regenerated yearly or left to wrap) keeps it
looking normal. A single static key still works and is simplest; the trade-off is reduced
long-term reliability and that the node is linkable across the cycle when keys repeat.

Each fetch only needs the last ~7 keys (Apple keeps reports about a week), regardless of `count`.

## Generating keys

Use the integrated generator, which derives the whole set deterministically from one random seed
so it can be regenerated later:

```
python3 tools/findmy/genkeys.py --count 365 --out mytag # needs: pip install cryptography
```

It writes to `mytag/`:

| File | Where it goes | Purpose |
|------|---------------|---------|
| `provision.txt` | the **node** (paste/pipe into serial) | `set findmy.add …` lines + `set findmy on` |
| `keys/*.keys` | your **server** | per-slot Private / Advertisement / Hashed keys for macless-haystack |
| `seed.txt` | keep **safe** | regenerates every key (`--seed <hex>`) |

Each key is a NIST P-224 keypair; only the **advertisement** (public, 28-byte) key goes on the
node, encoded into the BLE advert and MAC. The **private** key and **hashed adv key** stay on
your server (to decrypt reports and to query Apple). Never load a private key onto the device —
it would be broadcast publicly; note it is also 28 bytes, so the firmware cannot reject it by
length.

You can also generate keys with stock [macless-haystack](https://github.com/dchristl/macless-haystack)
/ [OpenHaystack](https://github.com/seemoo-lab/openhaystack) tooling and load the advertisement
keys by hand with the commands below.

## Node configuration

Configure over the local USB serial console (115200 baud) or remotely from the MeshCore app's
Command Line tab when logged in as admin. Configuration is stored in `/findmy` on the node's
internal filesystem (independent of the normal node preferences).

Commands:

```
set findmy.add <base64> append a key in the next free slot
set findmy.key <index> <base64> set/replace the key in slot <index> (0..364)
set findmy.clear erase all keys
set findmy on | off enable / disable the beacon
get findmy status: enabled, key count, current slot, MAC
get findmy.key <index> print the (public) advertisement key for a slot
get findmy.keys list all keys (local serial console only)
```

- Use `set findmy.add` to build the list without tracking indices — it appends at the current
count and replies `OK - appended slot N (M keys)`. A single key (`set findmy.add …` once, then
`set findmy on`) is the static case.
- `set findmy.key <i>` is the explicit form: it **replaces** slot `i` (for rotating one key out)
or appends when `i == count`; slots stay **contiguous from 0**, so a gap (`i > count`) is
rejected.
- Keys must decode to exactly 28 bytes, else `Error: decoded N bytes, expected 28`.
- Keys are public, so they can be read back: `get findmy.key <i>` returns one slot's base64 key
(works remotely), and `get findmy.keys` dumps the whole list to the USB serial console.
- `get findmy` reports e.g. `> on, 365 keys, slot 142, mac DF:41:B5:D7:F3:BF, clock set`. It
never echoes a key. The MAC is `key[0]|0xC0 : key[1] : … : key[5]` for the active slot. For a
rotating set (`count > 1`) it also shows the **clock state** — `clock set`, or
`CLOCK NOT SET - no rotation` if the node's time isn't valid yet.
- **Automatic daily rotation requires the node's time to be set.** Since the slot is derived from
UTC, with `count > 1` the keys only advance once the clock is set; until then the node stays on
slot 0. `set findmy on` warns if the clock isn't set, and `get findmy` shows it — use the
`clock` / `time <epoch>` commands (or the companion app / GPS) to set it. A single static key
(`count = 1`) needs no clock.
- Provisioning/enable changes apply at boot, so `reboot` (or power cycle) after them; on boot the
node prints `FindMy beacon started`. Daily rotation after that is automatic and needs **no
reboot** — the MAC and payload change live at the day boundary.

### Provisioning a full key set (USB)

The generator's `provision.txt` is a ready-to-send script. Pipe it into the node's serial port
(it starts with `set findmy.clear` and ends with `set findmy on`), then reboot:

```
while read l; do echo "$l"; sleep 0.2; done < mytag/provision.txt > /dev/ttyACM0
```

### Configuring over the air (OTA)

All commands also work remotely from the MeshCore app's admin Command Line, since each is one
short text packet. This is ideal for **rotating out a single slot** (`set findmy.key <i> <b64>`)
or toggling the beacon on a deployed node. Bulk-loading hundreds of keys over LoRa is impractical
— do the initial full provisioning over USB and use OTA for tweaks.

### Verifying

1. **On air:** scan with a BLE tool (e.g. nRF Connect). You should see a non-connectable
advertiser at the address `get findmy` reported (address type *Random*) carrying Apple
manufacturer data beginning `4C 00 12 19 …`.
2. **Key sanity:** run heystack's `tools/showmac.py` on the advertisement key you loaded — it
must print the same MAC as `get findmy`. A mismatch means the wrong key was loaded.

## Retrieving location data

Use your own server with the **private** keys — the node cannot retrieve anything itself. Import
the generated `keys/*.keys` into [macless-haystack](https://github.com/dchristl/macless-haystack)
and query/decrypt reports with its scripts, e.g. `fetch_reports.py` (a `findmy.py`-style query),
which authenticates to Apple (via Anisette), pulls the encrypted reports for the hashed adv keys,
and decrypts them with the private keys. See the macless-haystack README for setup
(Anisette/Apple-ID requirements) and exact invocation.

With rotation you only need to query the **last ~7 days of slots** (Apple keeps reports about a
week). Compute the current slot as `(now_utc / 86400) % count` and query a couple of slots
either side of it to absorb clock skew between the node and your server.

## Notes and caveats

- **Latency:** Find My is deliberately latency-tolerant. Reports appear only after the node is
near a passing iPhone and can take minutes to a few hours.
- **Power:** continuous BLE advertising keeps the SoftDevice awake, so idle current is higher
than a node with BLE off. The advertising interval defaults to ~2 s; override with
`-D FINDMY_ADV_INTERVAL=<0.625ms-units>`. Rotation itself is cheap: the clock is read only about
once an hour (not every loop), so a hardware RTC isn't polled over I2C continuously. The day
rollover need not be precise; tune with `-D FINDMY_CHECK_INTERVAL_MS=<ms>`.
- **Clock dependency:** rotation relies on the node's UTC clock matching the server's. A day of
skew just shifts which slot is "today" — query a slot either side on the server to absorb it.
Until the clock reads a time at/after the firmware build date it is treated as **unset** and
the node advertises **slot 0** (then switches to the correct slot once time is set). Boards
without a battery-backed RTC (e.g. a bare RAK4631) reset their clock on every power loss and
rely on the companion app / `time` CLI / GPS to set it — for an unattended or solar node that
may power-cycle, fit an RTC module (e.g. RAK12002) or use a single static key (`count = 1`),
which is immune to clock state.
- **Privacy / linkability:** with `count > 1` the node looks like a normally-rotating device, but
keys repeat once the cycle wraps (every `count` days), so it is linkable across that period. A
single static key (`count = 1`) is always linkable and more likely to be throttled long-term.
- **Storage / RAM:** the key table is held in RAM (`FINDMY_MAX_KEYS` × 28 B ≈ 10 KB at the
default 365) and persisted to `/findmy`. Lower `FINDMY_MAX_KEYS` to shrink it if needed.
- **Future work:** true AirTag-style derivation (store a seed + primary public key and derive
each slot's key on-device with P-224 point math) would remove the key table and the yearly
re-provision, at the cost of on-device EC. Today's scheme uses a precomputed per-day list.
- **OTA:** the beacon coexists with `start ota` — triggering a firmware update reuses the
running BLE stack and switches to the DFU advertiser automatically.
8 changes: 8 additions & 0 deletions examples/simple_repeater/MyMesh.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
#include "MyMesh.h"
#include <algorithm>

#ifdef WITH_FINDMY_BEACON
#include <helpers/nrf52/FindMyBeacon.h>
#endif

/* ------------------------------ Config -------------------------------- */

#ifndef LORA_FREQ
Expand Down Expand Up @@ -1257,6 +1261,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
sendNodeDiscoverReq();
strcpy(reply, "OK - Discover sent");
}
#ifdef WITH_FINDMY_BEACON
} else if (findmy_beacon.handleCommand(sender_timestamp, command, reply)) {
// FindMy beacon command handled (set/get findmy ...)
#endif
} else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
}
Expand Down
12 changes: 12 additions & 0 deletions examples/simple_repeater/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
static UITask ui_task(display);
#endif

#ifdef WITH_FINDMY_BEACON
#include <helpers/nrf52/FindMyBeacon.h>
#endif

StdRNG fast_rng;
SimpleMeshTables tables;

Expand Down Expand Up @@ -91,6 +95,11 @@ void setup() {

the_mesh.begin(fs);

#ifdef WITH_FINDMY_BEACON
findmy_beacon.begin(rtc_clock);
if (findmy_beacon.isRunning()) Serial.println("FindMy beacon started");
#endif

#ifdef DISPLAY_CLASS
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
#endif
Expand Down Expand Up @@ -147,6 +156,9 @@ void loop() {

the_mesh.loop();
sensors.loop();
#ifdef WITH_FINDMY_BEACON
findmy_beacon.loop(millis());
#endif
#ifdef DISPLAY_CLASS
ui_task.loop();
#endif
Expand Down
33 changes: 26 additions & 7 deletions src/helpers/NRF52Board.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@

static BLEDfu bledfu;

// Tracks whether Bluefruit.begin() has already run (it is not idempotent).
static bool _bluefruit_begun = false;

bool NRF52Board::beginBluefruitOnce() {
if (!_bluefruit_begun) {
_bluefruit_begun = Bluefruit.begin();
}
return _bluefruit_begun;
}

static void connect_callback(uint16_t conn_handle) {
(void)conn_handle;
MESH_DEBUG_PRINTLN("BLE client connected");
Expand Down Expand Up @@ -317,13 +327,22 @@ bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) {
}

bool NRF52Board::startOTAUpdate(const char *id, char reply[]) {
// Config the peripheral connection with maximum bandwidth
// more SRAM required by SoftDevice
// Note: All config***() function must be called before begin()
Bluefruit.configPrphBandwidth(BANDWIDTH_MAX);
Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16);

Bluefruit.begin(1, 0);
if (!_bluefruit_begun) {
// Config the peripheral connection with maximum bandwidth
// more SRAM required by SoftDevice
// Note: All config***() function must be called before begin()
Bluefruit.configPrphBandwidth(BANDWIDTH_MAX);
Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16);

_bluefruit_begun = Bluefruit.begin(1, 0);
} else {
// Stack already up (e.g. FindMy beacon). Reuse it: stop the beacon advert and
// reset the advertising payload/type so we can advertise the DFU service instead.
Bluefruit.Advertising.stop();
Bluefruit.Advertising.clearData();
Bluefruit.ScanResponse.clearData();
Bluefruit.Advertising.setType(BLE_GAP_ADV_TYPE_CONNECTABLE_SCANNABLE_UNDIRECTED);
}
// Set max power. Accepted values are: -40, -30, -20, -16, -12, -8, -4, 0, 4
Bluefruit.setTxPower(4);
// Set the BLE device name
Expand Down
5 changes: 5 additions & 0 deletions src/helpers/NRF52Board.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ class NRF52Board : public mesh::MainBoard {
virtual void reboot() override { NVIC_SystemReset(); }
virtual bool getBootloaderVersion(char* version, size_t max_len) override;
virtual bool startOTAUpdate(const char *id, char reply[]) override;

// Bring up the Bluefruit/SoftDevice stack exactly once. Bluefruit.begin() has no
// double-init guard, so any feature that needs BLE (FindMy beacon, OTA DFU) must
// route through here instead of calling Bluefruit.begin() directly.
static bool beginBluefruitOnce();
virtual void sleep(uint32_t secs) override;
bool isExternalPowered() override;

Expand Down
Loading