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