diff --git a/docs/findmy.md b/docs/findmy.md new file mode 100644 index 0000000000..3e67f982fc --- /dev/null +++ b/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 `) | + +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 append a key in the next free slot +set findmy.key set/replace the key in slot (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 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 ` 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 ` 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 ` 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 `) +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=`. +- **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. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 096907494b..23ee0cfaaf 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(sender_timestamp, 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 2ce056f521..29d02152c2 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(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 diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 17265f0455..76b47de270 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 17065cf443..21cf58c067 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -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; diff --git a/src/helpers/nrf52/FindMyBeacon.cpp b/src/helpers/nrf52/FindMyBeacon.cpp new file mode 100644 index 0000000000..0d70a72cd0 --- /dev/null +++ b/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 +#include +#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 + +// 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 + const char* p = &command[15]; + char* end; + long index = strtol(p, &end, 10); + if (end == p || *end != ' ') { strcpy(reply, "Error: usage: set findmy.key "); 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 diff --git a/src/helpers/nrf52/FindMyBeacon.h b/src/helpers/nrf52/FindMyBeacon.h new file mode 100644 index 0000000000..002dc849da --- /dev/null +++ b/src/helpers/nrf52/FindMyBeacon.h @@ -0,0 +1,74 @@ +#pragma once + +#include + +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 append a key in the next free slot + // set findmy.key set/replace slot (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 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; diff --git a/tools/findmy/genkeys.py b/tools/findmy/genkeys.py new file mode 100644 index 0000000000..31fb2ed108 --- /dev/null +++ b/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 ` per slot, + then `set findmy on`. Paste/pipe into the node's serial console. + keys/.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 --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() diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index 5df77f95cb..f0db6cec84 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 2bbba31463..13392f1674 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 =