【免费下载链接】openhaystack
Build your own 'AirTags' 🏷 today! Framework for tracking personal Bluetooth devices via Apple's massive Find My network.
导读
本文以 OpenHaystack 仓库中的 Firmware/ESP32/README.md 为骨架,系统讲解如何为 Espressif ESP32(如 ESP32-WROOM / ESP32-WROVER)构建并烧录 OpenHaystack 固件,使普通 BLE 设备能够以"AirTag 风格"的离线查找(Offline Finding)广播进入 Apple Find My 网络。读完本文,你将掌握:ESP-IDF 环境与固件构建流程、flash_esp32.sh 烧录脚本的完整参数用法、固件源码中广告数据与密钥注入的底层原理,以及该固件与 OpenHaystack 客户端应用之间的集成关系。
1. 固件定位:一个可被 Find My 网络发现的 BLE 广播设备
OpenHaystack 的 ESP32 固件是一个概念验证(Proof-of-Concept,PoC)实现:烧录后,设备会持续向外发送 Bluetooth Low Energy(BLE)广播报文,广播内容包含设备所有者生成的公钥。当附近运行 iOS 13 或更新版本的 iPhone 收到该广播后,会按照苹果离线查找协议,把加密后的位置报告上传到 Apple 的服务器(即 Find My 网络);之后配件所有者即可用保存在 Mac 钥匙串中的私钥取回并解密位置。
从仓库根目录的 README.md 可以看到,OpenHaystack 完整流程分为四步:配对(Pairing)——在 P-224 椭圆曲线上生成公私钥对,公钥部署到配件上;丢失(Losing)——配件以 BLE 广播方式周期性地广播公钥;查找(Finding)——附近的 iPhone 加密位置并上传;搜索(Searching)——用户通过苹果账户认证下载报告并用私钥解密。本固件承担的就是第二步"丢失"环节中的持续广播任务。
⚠️ 免责声明与安全边界:固件目前仅实现单一静态密钥的广播,即设备上始终广播同一个公钥。这意味着运行此固件的设备在物理上可被近距离的其他设备追踪(例如通过扫描 BLE 广播识别其身份)。它不具备 Apple AirTag 那样的"滚动密钥(rolling keys)"防跟踪机制。这一点在 Firmware/ESP32/README.md 中有明确声明,任何用于个人隐私保护的场景都应在理解这一局限的前提下使用。
2. 支持的芯片与前置环境要求
固件面向标准双核 ESP32 芯片设计,不适用于 ESP32-S2(后者 BLE 能力与经典 ESP32 不同)。仓库 README 明确列出了兼容型号:
- 支持的芯片:ESP32-WROOM、ESP32-WROVER(均为经典 ESP32 系列)
- 不支持的芯片:ESP32-S2
构建固件需要以下工具链:
| 依赖 | 说明 |
|---|---|
| ESP-IDF(Espressif IoT Development Framework) | 官方推荐安装最新版本,本固件在 ESP-IDF 4.2 版本上完成测试 |
| Python 3 | 仅部署固件时需要,要求 python3(首选)或 python 在 PATH 中 |
| venv 模块 | 烧录脚本会用其创建 Python 虚拟环境并安装 esptool |
其中 Python 3 与 venv 是 flash_esp32.sh 运行的前置条件——脚本在启动时会对 Python 3 版本、venv 模块可用性逐一做硬性校验,缺失任何一个都会直接退出。
3. 固件工程结构速览
在进入构建之前,先快速了解工程目录布局:
Firmware/ESP32/
├── CMakeLists.txt # 顶层工程配置,声明支持目标 esp32
├── Makefile # 旧版 make 构建入口(PROJECT_NAME: openhaystack-esp32)
├── main/
│ ├── CMakeLists.txt # main 组件编译配置
│ ├── Kconfig.projbuild # 组件 Kconfig(当前为空占位)
│ ├── component.mk # 旧版组件 makefile
│ └── openhaystack_main.c # 固件核心源码(BLE 广播与密钥处理)
├── flash_esp32.sh # 一键烧录脚本(含密钥写入)
├── partitions.csv # 自定义分区表(含 "key" 密钥分区)
├── sdkconfig # 已生成的工程配置(BLE-only、2MB Flash 等)
└── README.md # 本文对应的官方说明文档
顶层 CMakeLists.txt 通过 set(SUPPORTED_TARGETS esp32) 声明仅支持 esp32 目标,并通过 $ENV{IDF_PATH} 引入 ESP-IDF 的 project.cmake,因此必须先在 shell 中完成 ESP-IDF 环境初始化(source $IDF_PATH/export.sh 或使用 idf.py 所在的环境) 才能正常构建。
4. 构建固件:idf.py build
在 ESP-IDF 已加入 $PATH 的前提下,在 Firmware/ESP32 目录内执行:
idf.py build
构建产物为以下三个文件,它们是下一步烧录所必需的:
| 产物路径 | 作用 |
|---|---|
build/bootloader/bootloader.bin | 二级引导程序(second stage bootloader) |
build/partition_table/partition-table.bin | 分区表 |
build/openhaystack.bin | 应用固件本体 |
如果使用旧版 make 流程,仓库也保留了 Makefile(PROJECT_NAME := openhaystack-esp32),供 ESP-IDF 4.2 时代的经典构建方式使用。此外,仓库根目录的顶层 Makefile 也提供了固件构建相关的统一入口,可直接查阅。
4.1 工程配置(sdkconfig)中的关键开关
仓库自带的 sdkconfig 已为 BLE 广播场景预置了完整配置,理解这些选项有助于你按需调整:
CONFIG_BT_ENABLED=y、CONFIG_BT_BLE_ENABLED=y:启用 Bluetooth 与 BLE 协议栈(Bluedroid)。CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y:蓝牙控制器仅运行 BLE 模式,不启用经典蓝牙(BR/EDR)——固件源码中esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)正是与之一致的内存释放操作。CONFIG_BT_CLASSIC_ENABLED未设置:关闭经典蓝牙支持,节省 RAM。CONFIG_PARTITION_TABLE_CUSTOM=y且CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv":使用自定义分区表(详见下文第 7 节)。CONFIG_ESPTOOLPY_FLASHSIZE="2MB":按 2MB Flash 规划。CONFIG_BTDM_CTRL_BLE_MAX_CONN=3、CONFIG_BTDM_CTRL_HCI_MODE_VHCI=y:控制器采用 VHCI(虚拟 HCI)接口对接 Bluedroid 主机栈。
5. 固件核心机制源码剖析
固件全部逻辑集中在 main/openhaystack_main.c(约 160 行),可以拆解为四个部分:广告数据结构、BLE 广播参数、密钥加载、地址与载荷组装。
5.1 31 字节的 Apple 离线查找广播载荷
固件在启动时构造了一段恰好 31 字节的广播数据(BLE 传统广播报文的最大长度):
static uint8_t adv_data[31] = {
0x1e, /* Length (30) */
0xff, /* Manufacturer Specific Data (type 0xff) */
0x4c, 0x00, /* Company ID (Apple) */
0x12, 0x19, /* Offline Finding type and length */
0x00, /* State */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, /* First two bits */
0x00, /* Hint (0x00) */
};
各字节段的含义如下:
| 偏移 | 字节 | 含义 |
|---|---|---|
| 0 | 0x1e | AD 结构长度(30),即后续内容长度 |
| 1 | 0xff | AD 类型:Manufacturer Specific Data(厂商自定义数据) |
| 2–3 | 0x4c 0x00 | 公司 ID:Apple(Company ID 0x004C) |
| 4–5 | 0x12 0x19 | Offline Finding 子类型 0x12 与子类型长度 0x19(25) |
| 6 | 0x00 | State 状态字节 |
| 7–28 | 22 字节 | 公钥主体(广播密钥的第 6~27 字节) |
| 29 | 0x00 | "First two bits":公钥最高两位 |
| 30 | 0x00 | Hint 提示字节 |
这与客户端 Advertisement.swift 中的解析逻辑完全对应:macOS 端从 CBAdvertisementDataManufacturerDataKey 取得 29 字节的厂商数据后,依次校验 Apple 公司 ID(0x4c 0x00)、Offline Finding 子类型(0x12)与子类型长度(0x19),再从偏移 5 处截取 22 字节作为公钥载荷(publicKeyPayloadLength = 22)。也就是说,固件广播的格式与 OpenHaystack 客户端 / 真实苹果配件的解析格式严格一致,这是它能被 iPhone 和客户端识别的前提。
5.2 BLE 广播参数:不可连接、随机地址、1~2 秒周期
广播行为由 esp_ble_adv_params_t 结构体控制:
static esp_ble_adv_params_t ble_adv_params = {
.adv_int_min = 0x0640, // 1s
.adv_int_max = 0x0C80, // 2s
.adv_type = ADV_TYPE_NONCONN_IND,
.own_addr_type = BLE_ADDR_TYPE_RANDOM,
.channel_map = ADV_CHNL_ALL,
.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
};
参数语义如下:
adv_int_min=0x0640(约 1 秒),adv_int_max=0x0C80(约 2 秒):广播间隔按照Time = N * 0.625 ms计算,最小 20 ms、最大 10.24 s 的合法范围内取值。实际间隔在两者之间随机化,既省电又能避免与其他设备周期性撞包。ADV_TYPE_NONCONN_IND:不可连接的广播类型(non-connectable undirected advertising)——配件只"被找到",不建立连接,这与离线查找配件的典型行为一致。BLE_ADDR_TYPE_RANDOM:使用随机地址而非出厂 MAC,且该随机地址由公钥派生(见 5.4)。ADV_CHNL_ALL:在 37/38/39 三个广播信道上发送。ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY:允许任何设备扫描/发起连接请求(广播本身仍不可连接)。
5.3 从密钥分区加载 28 字节公钥
固件启动后通过 load_key() 从 Flash 的 key 分区读取 28 字节公钥:
int load_key(uint8_t *dst, size_t size) {
const esp_partition_t *keypart = esp_partition_find_first(0x40, 0x00, "key");
if (keypart == NULL) {
ESP_LOGE(LOG_TAG, "Could not find key partition");
return 1;
}
esp_err_t status;
status = esp_partition_read(keypart, 0, dst, size);
...
return status;
}
这里 esp_partition_find_first(0x40, 0x00, "key") 查找类型为 0x40、子类型 0x00、名称为 key 的分区——这正是 partitions.csv 中定义的自定义分区:
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
key, 0x40, 0x00, 0xe000, 0x1000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 1M,
key 分区占 4 KB(0x1000),位于 Flash 偏移 0xe000,专门用于存放待广播的 28 字节公钥,由烧录脚本独立写入(见第 7 节)。读取失败时固件会打印 "Could not read the key, stopping." 并终止启动。
5.4 用公钥派生随机地址与广播载荷
这是固件中最核心的密码学细节,通过两个函数把 28 字节公钥"映射"进广播报文与设备地址:
① 随机地址派生(set_addr_from_key):
void set_addr_from_key(esp_bd_addr_t addr, uint8_t *public_key) {
addr[0] = public_key[0] | 0b11000000;
addr[1] = public_key[1];
addr[2] = public_key[2];
addr[3] = public_key[3];
addr[4] = public_key[4];
addr[5] = public_key[5];
}
取公钥前 6 字节构成 BLE 随机地址,其中最高字节的最高两位被强制置为 11(0b11000000),这表示该地址属于静态随机地址(static random address),符合 BLE 规范对随机地址类型的编码要求。
② 广播载荷填充(set_payload_from_key):
void set_payload_from_key(uint8_t *payload, uint8_t *public_key) {
/* copy last 22 bytes */
memcpy(&payload[7], &public_key[6], 22);
/* append two bits of public key */
payload[29] = public_key[0] >> 6;
}
将公钥的第 6~27 字节(共 22 字节)拷贝到载荷的偏移 7 处,同时把公钥最高字节的最高两位(public_key[0] >> 6)写入偏移 29 的 "First two bits" 字节。也就是说,广播中实际承载的密钥信息 = 22 字节主体 + 2 位前缀,与客户端按 28 字节公钥恢复的算法互为逆运算。
③ 启动流程(app_main),按顺序完成:
nvs_flash_init():初始化 NVS 存储;- 释放经典蓝牙控制器内存(
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)),仅保留 BLE 模式; - 初始化并启用蓝牙控制器与 Bluedroid 协议栈;
load_key()读取 28 字节公钥;set_addr_from_key()+set_payload_from_key()组装随机地址与广告数据;- 注册 GAP 回调
esp_ble_gap_register_callback(esp_gap_cb); esp_ble_gap_set_rand_addr()设置随机地址;esp_ble_gap_config_adv_data_raw()提交原始广播数据。
在 GAP 事件回调 esp_gap_cb 中,收到 ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT 后立即调用 esp_ble_gap_start_advertising(&ble_adv_params) 开始广播;ESP_GAP_BLE_ADV_START_COMPLETE_EVT / ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT 则分别记录启动/停止结果。固件启动后会在日志中打印当前使用的随机设备地址(using device address: ...),可用于在抓包工具中核对广播来源。
5.5 28 字节公钥从哪来?
固件读取的 28 字节公钥,正是 OpenHaystack 客户端中 Accessory.swift 的 getAdvertisementKey() 产物:客户端在 P-224 椭圆曲线上生成私钥(保存在 macOS 钥匙串),由私钥派生出压缩格式公钥,去掉开头的格式标志字节后得到恰好 28 字节的"广告密钥(advertisement key)"。该密钥以 Base64 形式传给烧录脚本写入 key 分区。位置报告的取回与解密则依赖配对的私钥(见 DecryptReports.swift),本固件本身不参与任何加解密,只负责广播。
6. 部署固件:flash_esp32.sh 完整用法
构建完成后,使用仓库自带的 flash_esp32.sh 一键完成"固件 + 公钥"的烧录。基础用法:
./flash_esp32.sh -p /dev/yourSerialPort "Base64-encoded advertisement key"
其中第二个位置参数是 Base64 编码的 28 字节广播公钥(即 Accessory.getAdvertisementKey() 输出的字符串,可用 OpenHaystack 应用"部署 ESP32"功能自动获取,也可在客户端密钥导出后自行提取)。
6.1 命令行参数速查表
| 参数 | 默认值 | 说明 |
|---|---|---|
-p, --port <port> | /dev/ttyS0 | 指定串口设备路径,例如 macOS 上为 /dev/cu.usbserial-XXXX,Linux 上通常为 /dev/ttyUSB0 |
-s, --slow | 关闭 | 将烧录波特率从默认的 921600 降为 115200。长 USB 线、劣质 USB 线或慢速 USB 转串口芯片导致烧录失败时使用 |
-v, --venvdir <dir> | 脚本目录下的 venv | 指定 Python 虚拟环境目录(内含 esptool);目录不存在时会自动创建 |
-h, --help | — | 打印帮助信息 |
注:README 文末提到的
./flash-esp32.h --help应为./flash_esp32.sh --help(脚本实际文件名),实际使用以本仓库脚本为准。
6.2 脚本内部执行流程
脚本的执行逻辑可以分为四个阶段,理解它有助于排查烧录问题:
① 参数解析与校验:逐一解析 -p、-s、-v、-h 及位置参数 PUBKEY。若未提供公钥,输出 "Missing public key, call with --help for usage" 并退出;若指定端口不存在(! -e "$PORT"),提示 "$PORT does not exist, please specify a valid serial interface with the -p argument"。
② 构建 Python 虚拟环境:依次探测 python3 → python,校验其版本确实为 Python 3、且 import venv 可用,然后 python3 -m venv 创建虚拟环境,pip install esptool 安装烧录工具。若目录已存在则直接复用。
③ 生成临时密钥文件:将 Base64 公钥解码写入脚本同目录的 tmp.key(若该文件已存在则拒绝覆盖、直接退出,防止误伤旧文件):
echo "$PUBKEY" | python3 -m base64 -d - > "$KEYFILE"
④ 调用 esptool 烧录(后续命令用 set -e 严格失败即停,并用 trap cleanup INT TERM EXIT 保证退出时删除 tmp.key):
# Clear NVM
esptool.py --after no_reset --port "$PORT" \
erase_region 0x9000 0x5000
esptool.py --before no_reset --baud $BAUDRATE --port "$PORT" \
write_flash 0x1000 "$SCRIPT_DIR/build/bootloader/bootloader.bin" \
0x8000 "$SCRIPT_DIR/build/partition_table/partition-table.bin" \
0xe000 "$KEYFILE" \
0x10000 "$SCRIPT_DIR/build/openhaystack.bin"
第一步先擦除 0x9000 起始、长度 0x5000 的 NVS 区域(防止旧配置残留干扰);第二步以默认 921600(或 -s 指定的 115200)波特率写入四个镜像。烧录地址与 partitions.csv 严格对应:
| Flash 地址 | 内容 | 对应分区 |
|---|---|---|
0x1000 | bootloader.bin | 二级引导程序(引导程序固定偏移) |
0x8000 | partition-table.bin | 分区表 |
0xe000 | tmp.key(28 字节公钥) | key 分区(Type 0x40, SubType 0x00) |
0x10000 | openhaystack.bin | factory 应用分区(1M) |
6.3 烧录后的注意点
- 可能需要手动复位:README 特别提示,脚本执行完成后设备可能不会立即开始广播,需要按一下开发板上的 EN/RST 键复位,或重新上电,设备才会进入持续广播状态。
- 验证广播:复位后可在日志串口观察到
"advertising has started.",也可用hcitool lescan、手机 nRF Connect 等工具扫描到该随机地址及其 Apple 厂商数据广播。
7. 与 OpenHaystack 客户端应用的集成路径
该固件并非只能命令行烧录——OpenHaystack macOS 应用内置了 ESP32 部署向导,调用链为:
- ESP32InstallSheet.swift:应用内选择配件后展示"Flash your ESP32"弹窗,自动枚举
/dev下的串口(ESP32Controller.findPort()),用户点击目标串口即触发烧录,界面提示"This can take up to 3min"(与 README 中"Deployment can take up to 3 minutes"的说明一致)。 - ESP32Controller.swift:把应用 Bundle 内附带的
ESP32固件目录整体拷贝到临时目录,调用accessory.getAdvertisementKey().base64EncodedString()得到公钥,然后以["-p", port, key]作为参数执行flash_esp32.sh。因此客户端内一键部署走的正是本文第 6 节的同一套脚本与分区布局。 - 烧录成功后应用将该配件标记为已部署(
isDeployed),之后即可在 Find My 网络中等待位置报告回传。
8. 常见问题排查清单
| 现象 | 可能原因与对策 |
|---|---|
idf.py 命令找不到 | 未激活 ESP-IDF 环境,先执行 source $IDF_PATH/export.sh 或运行 ESP-IDF 的安装脚本 |
烧录报 $PORT does not exist | 串口路径错误或未插入设备;用 -p 明确指定真实串口(如 /dev/ttyUSB0) |
| 高波特率烧录失败 | 换用 -s 降为 115200 baud(长/劣质 USB 线、慢速 USB 转串口芯片常见) |
| 烧录后不广播 | 脚本使用 --after no_reset,需手动复位设备(EN 键)后开始广播 |
日志出现 Could not find key partition / Could not read the key | key 分区为空或烧录未包含公钥,确认使用正确的 flash_esp32.sh(而非仅烧固件) |
| 扫描不到广播 | 检查固件日志中 using device address 与 advertising has started.,确认设备处于 BLE 广播状态 |
9. 小结
OpenHaystack 的 ESP32 固件以极简的代码实现了与苹果离线查找规范一致的 BLE 广播:31 字节厂商广播载荷 + 随机地址派生 + 1~2 秒广播周期,并通过独立 key 分区实现"固件与公钥分离烧录"。配合 ESP-IDF 4.2 的 idf.py build 构建流程与 flash_esp32.sh 一键烧录脚本,任何 ESP32-WROOM / ESP32-WROVER 开发板都能在几分钟内成为一台可被 Find My 网络发现的追踪配件。同时务必牢记其 PoC 局限——静态密钥广播意味着设备可被邻近设备识别,量产级防跟踪能力并不在本固件的当前设计范围内。
【免费下载链接】openhaystack
Build your own 'AirTags' 🏷 today! Framework for tracking personal Bluetooth devices via Apple's massive Find My network.
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



