mirror of https://github.com/meshcore-dev/MeshCore
committed by
GitHub
10 changed files with 761 additions and 7 deletions
@ -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. |
|||
@ -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 |
|||
@ -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; |
|||
@ -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…
Reference in new issue