diff --git a/docs/findmy.md b/docs/findmy.md new file mode 100644 index 000000000..f830b801e --- /dev/null +++ b/docs/findmy.md @@ -0,0 +1,113 @@ +# 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 with concurrent BLE usage, ie. companion nodes. **TODO/Future work** check if +ble adverisements can be sent when the companion is disconnected from the phone, +and disable when reconnected. + +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. + +## Generating keys + +Keys are generated off-device with the OpenHaystack / macless-haystack tooling — see +[macless-haystack](https://github.com/dchristl/macless-haystack) (or the original +[OpenHaystack](https://github.com/seemoo-lab/openhaystack)). Its `generate_keys.py` produces +three values per key; use them as follows: + +| Value | Where it goes | Purpose | +|-------|---------------|---------| +| **Advertisement key** (public, 28 bytes) | the **node** (`set findmy.key`) | broadcast in the BLE advert + MAC | +| **Private key** | your **server only** | decrypts the location reports | +| **Hashed adv key** | your **server** (lookup id) | used to query Apple for reports | + +Only the advertisement (public) key goes on the node. Never load the private key onto the +device — it would be broadcast publicly and reports would not decrypt. (Note: the private key is +also 28 bytes, so the firmware cannot tell them apart by length — pick the value labelled +*advertisement*.) + +## 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). + +``` +set findmy.key -> OK - reboot to apply +set findmy on -> OK - on, reboot to apply +get findmy -> > on, mac DF:41:B5:D7:F3:BF +reboot +``` + +- `set findmy.key` must decode to exactly 28 bytes, otherwise it reports + `Error: decoded N bytes, expected 28`. +- `get findmy` shows the enabled state and the derived BLE MAC (it never echoes the key). The + MAC is `key[0]|0xC0 : key[1] : key[2] : key[3] : key[4] : key[5]`. +- The beacon starts only at boot, so `reboot` (or a power cycle) is required to apply changes. + On boot the node prints `FindMy beacon started`. +- `set findmy off` then `reboot` stops advertising (the stored key is kept). + +### 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** key — the node cannot retrieve anything itself. With +macless-haystack, query and decrypt reports using 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 key, and decrypts them with the private key. See the +[macless-haystack](https://github.com/dchristl/macless-haystack) README for setup +(Anisette/Apple-ID requirements) and exact invocation. + +## 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>`. +- **Privacy:** the key is static (no rotation), so anyone holding it can track the node. +- **Static key may be rate-limited / blocked (TODO):** genuine AirTags rotate their advertising + key roughly daily, and Apple's network is tuned for that. A single never-changing key can be + treated as anomalous and may have its reports throttled or dropped over time, so long-term + reliability is not guaranteed. **Future work:** add key rotation — store a sequence of keys and + advance the index on a daily schedule (matching AirTag behaviour), and extend the key generator + to emit a batch of keys with daily-incrementing indices that the server side can resolve. +- **OTA:** the beacon coexists with `start ota` — triggering a firmware update reuses the + running BLE stack and switches to the DFU advertiser automatically. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 096907494..cf7b411b5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,6 +1,10 @@ #include "MyMesh.h" #include +#ifdef WITH_FINDMY_BEACON +#include +#endif + /* ------------------------------ Config -------------------------------- */ #ifndef LORA_FREQ @@ -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(command, reply)) { + // FindMy beacon command handled (set/get findmy ...) +#endif } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 2ce056f52..d6f630f25 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -8,6 +8,10 @@ static UITask ui_task(display); #endif +#ifdef WITH_FINDMY_BEACON + #include +#endif + StdRNG fast_rng; SimpleMeshTables tables; @@ -91,6 +95,11 @@ void setup() { the_mesh.begin(fs); +#ifdef WITH_FINDMY_BEACON + findmy_beacon.begin(); + 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 diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 17265f045..76b47de27 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -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"); @@ -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 diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 17065cf44..21cf58c06 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -52,6 +52,11 @@ public: 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; diff --git a/src/helpers/nrf52/FindMyBeacon.cpp b/src/helpers/nrf52/FindMyBeacon.cpp new file mode 100644 index 000000000..8342167ec --- /dev/null +++ b/src/helpers/nrf52/FindMyBeacon.cpp @@ -0,0 +1,138 @@ +#include "FindMyBeacon.h" + +// Gated on the opt-in feature flag so the unit stays inert even if a variant's build_src_filter +// happens to glob helpers/nrf52/*.cpp. +#ifdef WITH_FINDMY_BEACON + +#include +#include +#include +#include +#include +#include "ble_gap.h" +#include "../NRF52Board.h" + +using namespace Adafruit_LittleFS_Namespace; + +// Advertising interval in units of 0.625 ms. ~2s by default to keep idle current low; +// override per-build with -D FINDMY_ADV_INTERVAL=. +#ifndef FINDMY_ADV_INTERVAL +#define FINDMY_ADV_INTERVAL 3200 +#endif + +#define FINDMY_FILE "/findmy" + +FindMyBeacon findmy_beacon; + +bool FindMyBeacon::load() { + _enabled = 0; + memset(_key, 0, sizeof(_key)); + if (!InternalFS.exists(FINDMY_FILE)) return false; + File f = InternalFS.open(FINDMY_FILE); + if (!f) return false; + f.read((uint8_t *)&_enabled, sizeof(_enabled)); + f.read(_key, sizeof(_key)); + f.close(); + return true; +} + +void FindMyBeacon::save() { + InternalFS.remove(FINDMY_FILE); + File f = InternalFS.open(FINDMY_FILE, FILE_O_WRITE); + if (!f) return; + f.write((uint8_t *)&_enabled, sizeof(_enabled)); + f.write(_key, sizeof(_key)); + f.close(); +} + +void FindMyBeacon::begin(int8_t tx_dbm) { + if (_started) return; + + load(); + if (!_enabled) return; + + bool key_set = false; + for (size_t i = 0; i < sizeof(_key); i++) { if (_key[i]) { key_set = true; break; } } + if (!key_set) return; + + // Bring up the SoftDevice/Bluefruit stack (shared one-shot guard - see NRF52Board). + if (!NRF52Board::beginBluefruitOnce()) return; + + Bluefruit.setTxPower(tx_dbm); + + // Static-random BLE address derived from the first 6 key bytes. ble_gap_addr_t.addr is + // little-endian (addr[0] = LSB); the MSB's top two bits mark a static random address. + ble_gap_addr_t addr; + memset(&addr, 0, sizeof(addr)); + addr.addr_type = BLE_GAP_ADDR_TYPE_RANDOM_STATIC; + addr.addr[5] = _key[0] | 0xC0; + addr.addr[4] = _key[1]; + addr.addr[3] = _key[2]; + addr.addr[2] = _key[3]; + addr.addr[1] = _key[4]; + addr.addr[0] = _key[5]; + sd_ble_gap_addr_set(&addr); + + // 31-byte OpenHaystack advertisement payload. + uint8_t adv[31]; + adv[0] = 0x1E; // length: 30 bytes follow + adv[1] = 0xFF; // AD type: manufacturer specific data + adv[2] = 0x4C; // company id: Apple (0x004C), little-endian + adv[3] = 0x00; + adv[4] = 0x12; // Apple payload type: offline finding + adv[5] = 0x19; // length of remaining offline-finding payload (25) + adv[6] = 0x00; // status byte + memcpy(&adv[7], &_key[6], 22); // public key bytes 6..27 + adv[29] = _key[0] >> 6; // top two bits of key[0] + adv[30] = 0x00; // hint + + Bluefruit.Advertising.stop(); + Bluefruit.Advertising.clearData(); + Bluefruit.ScanResponse.clearData(); + Bluefruit.Advertising.setType(BLE_GAP_ADV_TYPE_NONCONNECTABLE_NONSCANNABLE_UNDIRECTED); + Bluefruit.Advertising.setData(adv, sizeof(adv)); + Bluefruit.Advertising.restartOnDisconnect(false); + Bluefruit.Advertising.setInterval(FINDMY_ADV_INTERVAL, FINDMY_ADV_INTERVAL); + Bluefruit.Advertising.setFastTimeout(0); + + _started = Bluefruit.Advertising.start(0); // 0 = advertise forever +} + +void FindMyBeacon::stop() { + if (_started) { + Bluefruit.Advertising.stop(); + _started = false; + } +} + +bool FindMyBeacon::handleCommand(const char* command, char* reply) { + if (memcmp(command, "set findmy.key ", 15) == 0) { + const char* b64 = &command[15]; + uint8_t decoded[40]; // 28-byte key encodes to 40 base64 chars + unsigned int len = decode_base64((unsigned char *)b64, strlen(b64), (unsigned char *)decoded); + if (len == sizeof(_key)) { + memcpy(_key, decoded, sizeof(_key)); + save(); + strcpy(reply, "OK - reboot to apply"); + } else { + sprintf(reply, "Error: decoded %u bytes, expected 28", len); + } + return true; + } + if (memcmp(command, "set findmy ", 11) == 0) { + _enabled = memcmp(&command[11], "on", 2) == 0; + save(); + strcpy(reply, _enabled ? "OK - on, reboot to apply" : "OK - off, reboot to apply"); + return true; + } + if (memcmp(command, "get findmy", 10) == 0) { + // derived static-random MAC is key[0]|0xC0 : key[1] : ... : key[5] + sprintf(reply, "> %s, mac %02X:%02X:%02X:%02X:%02X:%02X", + _enabled ? "on" : "off", + _key[0] | 0xC0, _key[1], _key[2], _key[3], _key[4], _key[5]); + return true; + } + return false; +} + +#endif diff --git a/src/helpers/nrf52/FindMyBeacon.h b/src/helpers/nrf52/FindMyBeacon.h new file mode 100644 index 000000000..bb08046c7 --- /dev/null +++ b/src/helpers/nrf52/FindMyBeacon.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +// Apple FindMy / OpenHaystack locator beacon for nRF52 (Adafruit Bluefruit). +// +// Advertises a static, non-connectable OpenHaystack payload derived from a 28-byte +// advertising public key. The matching private key is held off-device (in the user's +// OpenHaystack / macless-haystack setup) and is required to actually locate the device. +// +// The algorithm is ported from https://github.com/pix/heystack-nrf5x (nRF5 SDK) and +// reimplemented here on the Bluefruit advertising API used by MeshCore. +// +// Self-contained: it persists its own config to "/findmy" on the internal filesystem and +// parses its own "set/get findmy" CLI commands, so it needs no changes to the shared +// NodePrefs/CommonCLI code. Provisioning: `set findmy.key `, `set findmy on`, reboot. +// +// Intended for always-on roles (repeater/sensor) where BLE is otherwise unused. It is not +// meant to run alongside the phone-companion firmware, which needs BLE for its own link. +class FindMyBeacon { + bool _started = false; + uint8_t _enabled = 0; + uint8_t _key[28] = {0}; // OpenHaystack advertising public key + + bool load(); // read "/findmy" into _enabled/_key + void save(); // write "/findmy" + +public: + // Load persisted config and, if enabled with a key set, start advertising. Call once at + // boot after the internal filesystem is mounted. tx_dbm is the advertising TX power. + void begin(int8_t tx_dbm = 4); + void stop(); + bool isRunning() const { return _started; } + + // Handle "set findmy.key ", "set findmy on|off", "get findmy". + // Returns true if the command was recognised (and reply filled), false otherwise. + bool handleCommand(const char* command, char* reply); +}; + +// Single shared instance (defined in FindMyBeacon.cpp). +extern FindMyBeacon findmy_beacon; diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index 5df77f95c..f0db6cec8 100644 --- a/variants/lilygo_techo/platformio.ini +++ b/variants/lilygo_techo/platformio.ini @@ -64,6 +64,25 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +[env:LilyGo_T-Echo_repeater_findmy] +extends = LilyGo_T-Echo +build_src_filter = ${LilyGo_T-Echo.build_src_filter} + + + +<../examples/simple_repeater> +build_flags = + ${LilyGo_T-Echo.build_flags} + -D ADVERT_NAME='"T-Echo Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_FINDMY_BEACON=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = + ${LilyGo_T-Echo.lib_deps} + densaugeo/base64 @ ~1.4.0 + [env:LilyGo_T-Echo_room_server] extends = LilyGo_T-Echo build_src_filter = ${LilyGo_T-Echo.build_src_filter} diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 2bbba3146..13392f167 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -53,6 +53,27 @@ build_src_filter = ${rak4631.build_src_filter} + +<../examples/simple_repeater> +[env:RAK_4631_repeater_findmy] +extends = rak4631 +build_flags = + ${rak4631.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RAK4631 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_FINDMY_BEACON=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak4631.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${rak4631.lib_deps} + densaugeo/base64 @ ~1.4.0 + [env:RAK_4631_repeater_bridge_rs232_serial1] extends = rak4631 build_flags =