Browse Source

Support for multiple keys with daily rotation

pull/2731/head
Stefan Alfredsson 3 days ago
committed by Stefan Alfredsson
parent
commit
a1d2d90c44
  1. 169
      docs/findmy.md
  2. 2
      examples/simple_repeater/MyMesh.cpp
  3. 5
      examples/simple_repeater/main.cpp
  4. 253
      src/helpers/nrf52/FindMyBeacon.cpp
  5. 59
      src/helpers/nrf52/FindMyBeacon.h
  6. 109
      tools/findmy/genkeys.py

169
docs/findmy.md

@ -21,11 +21,10 @@ requires the matching **private** key, which you keep off-device on your own mac
## 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.
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:
@ -39,23 +38,59 @@ builds. It is intended for always-on roles (repeater/sensor) where BLE is otherw
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
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:
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
```
| 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 |
It writes to `mytag/`:
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*.)
| 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
@ -63,20 +98,55 @@ Configure over the local USB serial console (115200 baud) or remotely from the M
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.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.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)
```
- `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).
- 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
@ -88,26 +158,41 @@ reboot
## 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
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>`.
- **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.
`-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.

2
examples/simple_repeater/MyMesh.cpp

@ -1262,7 +1262,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
strcpy(reply, "OK - Discover sent");
}
#ifdef WITH_FINDMY_BEACON
} else if (findmy_beacon.handleCommand(command, reply)) {
} else if (findmy_beacon.handleCommand(sender_timestamp, command, reply)) {
// FindMy beacon command handled (set/get findmy ...)
#endif
} else{

5
examples/simple_repeater/main.cpp

@ -96,7 +96,7 @@ void setup() {
the_mesh.begin(fs);
#ifdef WITH_FINDMY_BEACON
findmy_beacon.begin();
findmy_beacon.begin(rtc_clock);
if (findmy_beacon.isRunning()) Serial.println("FindMy beacon started");
#endif
@ -156,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

253
src/helpers/nrf52/FindMyBeacon.cpp

@ -6,9 +6,11 @@
#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"
@ -20,18 +22,67 @@ using namespace Adafruit_LittleFS_Namespace;
#define FINDMY_ADV_INTERVAL 3200
#endif
#define FINDMY_FILE "/findmy"
// 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;
memset(_key, 0, sizeof(_key));
_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(_key, sizeof(_key));
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;
}
@ -40,38 +91,28 @@ 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(_key, sizeof(_key));
f.write((uint8_t *)&_count, sizeof(_count));
for (uint16_t i = 0; i < _count; i++) {
f.write(_keys[i], 28);
}
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);
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];
sd_ble_gap_addr_set(&addr);
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];
@ -82,20 +123,60 @@ void FindMyBeacon::begin(int8_t tx_dbm) {
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
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();
}
Bluefruit.Advertising.stop();
sd_ble_gap_addr_set(&addr);
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
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() {
@ -105,31 +186,101 @@ void FindMyBeacon::stop() {
}
}
bool FindMyBeacon::handleCommand(const char* command, char* reply) {
// 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) {
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);
}
// 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();
strcpy(reply, _enabled ? "OK - on, reboot to apply" : "OK - off, reboot to apply");
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) {
// 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]);
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;

59
src/helpers/nrf52/FindMyBeacon.h

@ -2,39 +2,72 @@
#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: 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.
// 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.
// 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
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" into _enabled/_key
void save(); // write "/findmy"
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 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);
// 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 "set findmy.key <b64>", "set findmy on|off", "get findmy".
// 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(const char* command, char* reply);
bool handleCommand(uint32_t sender_timestamp, const char* command, char* reply);
};
// Single shared instance (defined in FindMyBeacon.cpp).

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()
Loading…
Cancel
Save