Browse Source

Merge a1d2d90c44 into e8d3c53ba1

pull/2731/merge
Stefan A 2 days ago
committed by GitHub
parent
commit
f6b8842565
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 198
      docs/findmy.md
  2. 8
      examples/simple_repeater/MyMesh.cpp
  3. 12
      examples/simple_repeater/main.cpp
  4. 33
      src/helpers/NRF52Board.cpp
  5. 5
      src/helpers/NRF52Board.h
  6. 289
      src/helpers/nrf52/FindMyBeacon.cpp
  7. 74
      src/helpers/nrf52/FindMyBeacon.h
  8. 109
      tools/findmy/genkeys.py
  9. 19
      variants/lilygo_techo/platformio.ini
  10. 21
      variants/rak4631/platformio.ini

198
docs/findmy.md

@ -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
examples/simple_repeater/MyMesh.cpp

@ -1,6 +1,10 @@
#include "MyMesh.h"
#include <algorithm>
#ifdef WITH_FINDMY_BEACON
#include <helpers/nrf52/FindMyBeacon.h>
#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(sender_timestamp, command, reply)) {
// FindMy beacon command handled (set/get findmy ...)
#endif
} else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
}

12
examples/simple_repeater/main.cpp

@ -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;
@ -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
@ -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

33
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

5
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;

289
src/helpers/nrf52/FindMyBeacon.cpp

@ -0,0 +1,289 @@
#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 <stdlib.h>
#include <bluefruit.h>
#include <InternalFileSystem.h>
#include <base64.hpp>
#include <Mesh.h>
#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
// How often loop() consults the clock for a day rollover. Daily rotation needs no finer
// precision (the rollover does not have to be exactly at midnight), so check hourly to keep RTC
// reads (I2C) rare. Override with -D FINDMY_CHECK_INTERVAL_MS.
#ifndef FINDMY_CHECK_INTERVAL_MS
#define FINDMY_CHECK_INTERVAL_MS 3600000UL // 1 hour
#endif
// Compile-time Unix epoch of this build, parsed from __DATE__/__TIME__ (build-machine local
// time; day precision is all that matters here). Used as the "is the clock set?" threshold: a
// node with no battery-backed RTC falls back to VolatileRTCClock, which defaults to 15 May 2024
// - necessarily before this build - so an unset clock reads below the threshold and the beacon
// stays on slot 0 until a real time is set. A 1-day margin absorbs build/set/timezone skew.
// Self-updating across releases, so it never goes stale. (Macros, not constexpr, for C++11.)
// __DATE__ = "Mmm dd yyyy" __TIME__ = "hh:mm:ss"
#define FM_MON ( (__DATE__[0]=='J'&&__DATE__[1]=='a') ? 1 \
: (__DATE__[0]=='F') ? 2 \
: (__DATE__[0]=='M'&&__DATE__[2]=='r') ? 3 \
: (__DATE__[0]=='A'&&__DATE__[1]=='p') ? 4 \
: (__DATE__[0]=='M'&&__DATE__[2]=='y') ? 5 \
: (__DATE__[0]=='J'&&__DATE__[2]=='n') ? 6 \
: (__DATE__[0]=='J'&&__DATE__[2]=='l') ? 7 \
: (__DATE__[0]=='A'&&__DATE__[1]=='u') ? 8 \
: (__DATE__[0]=='S') ? 9 \
: (__DATE__[0]=='O') ? 10 \
: (__DATE__[0]=='N') ? 11 : 12 )
#define FM_DAY ( (__DATE__[4]==' ' ? 0 : __DATE__[4]-'0')*10 + (__DATE__[5]-'0') )
#define FM_YEAR ( (__DATE__[7]-'0')*1000 + (__DATE__[8]-'0')*100 + (__DATE__[9]-'0')*10 + (__DATE__[10]-'0') )
#define FM_HMS ( ((__TIME__[0]-'0')*10+(__TIME__[1]-'0'))*3600L + ((__TIME__[3]-'0')*10+(__TIME__[4]-'0'))*60 + ((__TIME__[6]-'0')*10+(__TIME__[7]-'0')) )
// days since 1970-01-01 (Howard Hinnant's days_from_civil), with y' = year - (month<=2)
#define FM_YP (FM_YEAR - (FM_MON <= 2 ? 1 : 0))
#define FM_ERA ((FM_YP >= 0 ? FM_YP : FM_YP-399) / 400)
#define FM_YOE (FM_YP - FM_ERA*400)
#define FM_DOY ((153*(FM_MON + (FM_MON>2 ? -3 : 9)) + 2)/5 + FM_DAY - 1)
#define FM_DOE (FM_YOE*365 + FM_YOE/4 - FM_YOE/100 + FM_DOY)
#define FM_DAYS ((long)FM_ERA*146097 + FM_DOE - 719468)
static const uint32_t FINDMY_MIN_VALID_TIME = (uint32_t)(FM_DAYS*86400L + FM_HMS) - 86400UL;
#define FINDMY_FILE "/findmy"
#define FINDMY_VERSION 1
FindMyBeacon findmy_beacon;
bool FindMyBeacon::load() {
_enabled = 0;
_count = 0;
if (!InternalFS.exists(FINDMY_FILE)) return false;
File f = InternalFS.open(FINDMY_FILE);
if (!f) return false;
uint8_t version = 0;
uint16_t count = 0;
f.read((uint8_t *)&version, sizeof(version));
f.read((uint8_t *)&_enabled, sizeof(_enabled));
f.read((uint8_t *)&count, sizeof(count));
if (version != FINDMY_VERSION) { f.close(); return false; }
if (count > FINDMY_MAX_KEYS) count = FINDMY_MAX_KEYS;
for (uint16_t i = 0; i < count; i++) {
f.read(_keys[i], 28);
}
_count = count;
f.close();
return true;
}
void FindMyBeacon::save() {
InternalFS.remove(FINDMY_FILE);
File f = InternalFS.open(FINDMY_FILE, FILE_O_WRITE);
if (!f) return;
uint8_t version = FINDMY_VERSION;
f.write((uint8_t *)&version, sizeof(version));
f.write((uint8_t *)&_enabled, sizeof(_enabled));
f.write((uint8_t *)&_count, sizeof(_count));
for (uint16_t i = 0; i < _count; i++) {
f.write(_keys[i], 28);
}
f.close();
}
void FindMyBeacon::startAdvertising(const uint8_t key[28]) {
// 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];
// 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
if (!_started) {
// First time: bring up the stack (shared one-shot guard - see NRF52Board) and configure.
if (!NRF52Board::beginBluefruitOnce()) return;
Bluefruit.setTxPower(_tx_dbm);
Bluefruit.Advertising.setType(BLE_GAP_ADV_TYPE_NONCONNECTABLE_NONSCANNABLE_UNDIRECTED);
Bluefruit.Advertising.restartOnDisconnect(false);
Bluefruit.Advertising.setInterval(FINDMY_ADV_INTERVAL, FINDMY_ADV_INTERVAL);
Bluefruit.Advertising.setFastTimeout(0);
} else {
// Rotating to a new key: stop the current advert before re-arming.
Bluefruit.Advertising.stop();
}
sd_ble_gap_addr_set(&addr);
Bluefruit.Advertising.clearData();
Bluefruit.ScanResponse.clearData();
Bluefruit.Advertising.setData(adv, sizeof(adv));
if (Bluefruit.Advertising.start(0)) _started = true; // 0 = advertise forever
}
void FindMyBeacon::begin(mesh::RTCClock& clock, int8_t tx_dbm) {
_clock = &clock;
_now = clock.getCurrentTime();
if (_started) return;
_tx_dbm = tx_dbm;
load();
if (!_enabled || _count == 0) return;
_cur_slot = (_now >= FINDMY_MIN_VALID_TIME) ? (uint16_t)((_now / 86400UL) % _count) : 0;
startAdvertising(_keys[_cur_slot]);
}
void FindMyBeacon::loop(unsigned long now_millis) {
if (!_enabled || _count == 0 || !_clock) return;
// Throttle: consult the clock at most once per FINDMY_CHECK_INTERVAL_MS (millis() is free;
// reading the RTC is not). Unsigned subtraction handles millis() wraparound.
if (_last_check != 0 && (now_millis - _last_check) < FINDMY_CHECK_INTERVAL_MS) return;
_last_check = now_millis;
_now = _clock->getCurrentTime();
if (_now < FINDMY_MIN_VALID_TIME) return; // clock not set yet; keep current slot
uint16_t slot = (uint16_t)((_now / 86400UL) % _count);
if (_started && slot == _cur_slot) return; // no day rollover
_cur_slot = slot;
startAdvertising(_keys[slot]);
}
void FindMyBeacon::stop() {
if (_started) {
Bluefruit.Advertising.stop();
_started = false;
}
}
// Decode a base64 advertising key into _keys[slot]. Returns true on success (28 bytes).
static bool decode_key(const char* b64, uint8_t out[28], char* reply) {
uint8_t decoded[40];
unsigned int len = decode_base64((unsigned char *)b64, strlen(b64), (unsigned char *)decoded);
if (len != 28) { sprintf(reply, "Error: decoded %u bytes, expected 28", len); return false; }
memcpy(out, decoded, 28);
return true;
}
bool FindMyBeacon::handleCommand(uint32_t sender_timestamp, const char* command, char* reply) {
if (memcmp(command, "set findmy.add ", 15) == 0) {
// append in the next free slot
if (_count >= FINDMY_MAX_KEYS) { sprintf(reply, "Error: full (%d keys)", FINDMY_MAX_KEYS); return true; }
if (!decode_key(&command[15], _keys[_count], reply)) return true;
_count++;
save();
sprintf(reply, "OK - appended slot %u (%u keys)", _count - 1, _count);
return true;
}
if (memcmp(command, "set findmy.key ", 15) == 0) {
// set findmy.key <index> <base64>
const char* p = &command[15];
char* end;
long index = strtol(p, &end, 10);
if (end == p || *end != ' ') { strcpy(reply, "Error: usage: set findmy.key <index> <base64>"); return true; }
if (index < 0 || index >= FINDMY_MAX_KEYS) { sprintf(reply, "Error: index 0..%d", FINDMY_MAX_KEYS - 1); return true; }
if (index > _count) { sprintf(reply, "Error: gap - next free slot is %u", _count); return true; }
const char* b64 = end + 1;
while (*b64 == ' ') b64++;
if (!decode_key(b64, _keys[index], reply)) return true;
if (index == _count) _count++; // append
save();
sprintf(reply, "OK - slot %ld set (%u keys)", index, _count);
return true;
}
if (memcmp(command, "set findmy.clear", 16) == 0) {
_count = 0;
_enabled = 0;
memset(_keys, 0, sizeof(_keys));
save();
strcpy(reply, "OK - cleared, reboot to apply");
return true;
}
if (memcmp(command, "set findmy ", 11) == 0) {
_enabled = memcmp(&command[11], "on", 2) == 0;
save();
if (_clock) _now = _clock->getCurrentTime(); // fresh time for the warning below
if (_enabled && _count > 1 && _now < FINDMY_MIN_VALID_TIME) {
// rotation needs a real clock; warn rather than silently sticking on slot 0
strcpy(reply, "OK - on, reboot to apply. WARNING: clock not set - set the node time or "
"keys will not rotate (stays on slot 0)");
} else {
strcpy(reply, _enabled ? "OK - on, reboot to apply" : "OK - off, reboot to apply");
}
return true;
}
if (memcmp(command, "get findmy.keys", 15) == 0) {
// dump all (public) keys to the local serial console; too large for a mesh reply
if (sender_timestamp != 0) { strcpy(reply, "Error: serial console only"); return true; }
Serial.printf("FindMy keys (%u):\n", _count);
for (uint16_t i = 0; i < _count; i++) {
char b64[44];
unsigned int n = encode_base64(_keys[i], 28, (unsigned char *)b64);
b64[n] = 0;
Serial.printf("%u: %s\n", i, b64);
}
reply[0] = 0;
return true;
}
if (memcmp(command, "get findmy.key ", 15) == 0) {
long index = strtol(&command[15], nullptr, 10);
if (index < 0 || index >= _count) { sprintf(reply, "Error: index 0..%d", _count ? _count - 1 : 0); return true; }
char b64[44];
unsigned int n = encode_base64(_keys[index], 28, (unsigned char *)b64);
b64[n] = 0;
sprintf(reply, "> %ld: %s", index, b64);
return true;
}
if (memcmp(command, "get findmy", 10) == 0) {
if (_clock) _now = _clock->getCurrentTime(); // fresh time for the clock state below
if (_count == 0) {
sprintf(reply, "> %s, 0 keys", _enabled ? "on" : "off");
} else {
const uint8_t* k = _keys[_cur_slot];
// derived static-random MAC is key[0]|0xC0 : key[1] : ... : key[5]
int n = sprintf(reply, "> %s, %u keys, slot %u, mac %02X:%02X:%02X:%02X:%02X:%02X",
_enabled ? "on" : "off", _count, _cur_slot,
k[0] | 0xC0, k[1], k[2], k[3], k[4], k[5]);
// for a rotating set, report whether the clock is set (rotation depends on it)
if (_count > 1) {
sprintf(reply + n, ", %s", (_now >= FINDMY_MIN_VALID_TIME)
? "clock set" : "CLOCK NOT SET - no rotation");
}
}
return true;
}
return false;
}
#endif

74
src/helpers/nrf52/FindMyBeacon.h

@ -0,0 +1,74 @@
#pragma once
#include <stdint.h>
namespace mesh { class RTCClock; }
// Maximum number of daily rotation slots (one per day; cycles every `count` days).
#ifndef FINDMY_MAX_KEYS
#define FINDMY_MAX_KEYS 365
#endif
// 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.
//
// Supports daily key rotation: up to FINDMY_MAX_KEYS public keys are stored, and the active
// slot is chosen from the clock as (now_utc / 86400) % count. With count == 1 this reduces to
// a single static key; with more keys it rotates daily and cycles every `count` days, the way an
// AirTag rotates (the server, holding the matching private keys, derives the same per-day slot).
//
// 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: persists its own config to "/findmy" and parses its own "set/get findmy" CLI
// commands, so it needs no changes to the shared NodePrefs/CommonCLI code.
//
// 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;
uint16_t _count = 0; // number of provisioned keys (0..FINDMY_MAX_KEYS)
uint16_t _cur_slot = 0; // currently advertised slot
uint32_t _now = 0; // last UTC time read from the clock
unsigned long _last_check = 0;// millis() of the last clock check (rotation throttle)
mesh::RTCClock* _clock = nullptr;
int8_t _tx_dbm = 4;
uint8_t _keys[FINDMY_MAX_KEYS][28] = {{0}};
bool load(); // read "/findmy"
void save(); // write "/findmy"
void startAdvertising(const uint8_t key[28]); // (re)advertise the given key
public:
// Load persisted config and, if enabled with keys, start advertising the slot for the current
// time. Call once at boot after the internal filesystem is mounted. The clock is read here and
// periodically in loop(); if it is not set yet, slot 0 is used until it is.
void begin(mesh::RTCClock& clock, int8_t tx_dbm = 4);
// Call every main-loop iteration with millis(). To avoid a per-loop RTC read (an I2C
// transaction on boards with a hardware RTC), it only consults the clock about once an hour
// and rotates the key at day boundaries - daily rotation needs no finer precision.
void loop(unsigned long now_millis);
void stop();
bool isRunning() const { return _started; }
// Handle the findmy CLI commands (works over serial and remote admin):
// set findmy.add <base64> append a key in the next free slot
// set findmy.key <index> <base64> set/replace slot <index> (append if index == count)
// set findmy.clear erase all keys
// set findmy on|off enable/disable
// 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 to serial (local console only)
// sender_timestamp is 0 for the local serial console, non-zero for remote admin.
// Returns true if the command was recognised (and reply filled), false otherwise.
bool handleCommand(uint32_t sender_timestamp, const char* command, char* reply);
};
// Single shared instance (defined in FindMyBeacon.cpp).
extern FindMyBeacon findmy_beacon;

109
tools/findmy/genkeys.py

@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Generate FindMy / OpenHaystack advertising keys for the MeshCore nRF52 FindMy beacon.
Keys are derived deterministically from a random 32-byte seed so the whole set can be
regenerated from that seed alone. Each slot is a NIST P-224 (secp224r1) keypair:
d_i = SHA256(seed || "findmy" || i) reduced into [1, n-1] (private scalar)
P_i = d_i * G (public point)
adv = X coordinate of P_i (28 bytes) -> goes on the device
hash = SHA256(adv) -> server lookup id for Apple's network
The device only ever stores the public `adv` keys and picks the active slot from its clock:
slot = (now_utc / 86400) % count
so the keys rotate daily and cycle every `count` days. count == 1 is a single static key.
Outputs (into --out):
seed.txt the master seed (hex) - keep this safe, it regenerates everything
provision.txt CLI script: `set findmy.clear`, one `set findmy.add <b64>` per slot,
then `set findmy on`. Paste/pipe into the node's serial console.
keys/<i>.keys per-slot OpenHaystack/macless-haystack key file (Private/Advertisement/
Hashed adv key) for the server side.
Requires: cryptography (pip install cryptography)
Examples:
python3 genkeys.py --count 365 --out mytag
python3 genkeys.py --count 1 --out mytag # single static key
python3 genkeys.py --count 30 --seed <hex> --out mytag # reproduce from a seed
"""
import argparse
import base64
import hashlib
import os
import sys
try:
from cryptography.hazmat.primitives.asymmetric import ec
except ImportError:
sys.exit("This script needs the 'cryptography' package: pip install cryptography")
# Order of the secp224r1 / NIST P-224 curve.
P224_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFF16A2E0B8F03E13DD29455C5C2A3D
def derive_private_scalar(seed: bytes, index: int) -> int:
"""Deterministic private scalar in [1, n-1] from seed and slot index."""
counter = 0
while True:
h = hashlib.sha256(seed + b"findmy" + index.to_bytes(4, "big") + bytes([counter])).digest()
d = int.from_bytes(h, "big") % P224_N
if d != 0:
return d
counter += 1
def slot_keys(seed: bytes, index: int):
"""Return (private_b64, adv_b64, hashed_b64) for a slot."""
d = derive_private_scalar(seed, index)
priv = ec.derive_private_key(d, ec.SECP224R1())
x = priv.public_key().public_numbers().x
adv = x.to_bytes(28, "big") # advertised public key (X coordinate)
private = d.to_bytes(28, "big")
hashed = hashlib.sha256(adv).digest()
b64 = lambda b: base64.b64encode(b).decode()
return b64(private), b64(adv), b64(hashed)
def main():
ap = argparse.ArgumentParser(description="Generate FindMy keys for the MeshCore nRF52 beacon.")
ap.add_argument("--count", type=int, default=365, help="number of daily slots (1..365)")
ap.add_argument("--seed", help="32-byte master seed as hex (random if omitted)")
ap.add_argument("--out", default="findmy_keys", help="output directory")
args = ap.parse_args()
if not 1 <= args.count <= 365:
sys.exit("--count must be between 1 and 365")
seed = bytes.fromhex(args.seed) if args.seed else os.urandom(32)
os.makedirs(os.path.join(args.out, "keys"), exist_ok=True)
with open(os.path.join(args.out, "seed.txt"), "w") as f:
f.write(seed.hex() + "\n")
with open(os.path.join(args.out, "provision.txt"), "w") as prov:
prov.write("set findmy.clear\n")
for i in range(args.count):
private_b64, adv_b64, hashed_b64 = slot_keys(seed, i)
prov.write(f"set findmy.add {adv_b64}\n")
with open(os.path.join(args.out, "keys", f"{i:03d}.keys"), "w") as kf:
kf.write(f"Private key: {private_b64}\n")
kf.write(f"Advertisement key: {adv_b64}\n")
kf.write(f"Hashed adv key: {hashed_b64}\n")
prov.write("set findmy on\n")
print(f"Generated {args.count} key(s) in '{args.out}/'")
print(f" seed: {args.out}/seed.txt (keep safe - regenerates all keys)")
print(f" device: {args.out}/provision.txt (pipe/paste into the node serial console)")
print(f" server keys: {args.out}/keys/*.keys (import into macless-haystack)")
print()
print("Provision a node over USB, e.g.:")
print(f" while read line; do echo \"$line\"; sleep 0.2; done < {args.out}/provision.txt > /dev/ttyACM0")
print("then `reboot` the node.")
if __name__ == "__main__":
main()

19
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}
+<helpers/nrf52/FindMyBeacon.cpp>
+<../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}

21
variants/rak4631/platformio.ini

@ -53,6 +53,27 @@ build_src_filter = ${rak4631.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../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}
+<helpers/ui/SSD1306Display.cpp>
+<helpers/nrf52/FindMyBeacon.cpp>
+<../examples/simple_repeater>
lib_deps =
${rak4631.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:RAK_4631_repeater_bridge_rs232_serial1]
extends = rak4631
build_flags =

Loading…
Cancel
Save