mirror of https://github.com/meshcore-dev/MeshCore
committed by
Stefan Alfredsson
9 changed files with 380 additions and 7 deletions
@ -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 <base64-advertisement-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. |
|||
@ -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 <string.h> |
|||
#include <stdio.h> |
|||
#include <bluefruit.h> |
|||
#include <InternalFileSystem.h> |
|||
#include <base64.hpp> |
|||
#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=<units>.
|
|||
#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 |
|||
@ -0,0 +1,41 @@ |
|||
#pragma once |
|||
|
|||
#include <stdint.h> |
|||
|
|||
// 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 <base64>`, `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 <b64>", "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; |
|||
Loading…
Reference in new issue