OpenHaystack ESP32 固件实战指南:构建与烧录 BLE 广播固件,接入 Apple Find My 网络

  • 物联网
  • 嵌入式
  • 密码学
  • 网络安全

【免费下载链接】openhaystack

Build your own 'AirTags' 🏷 today! Framework for tracking personal Bluetooth devices via Apple's massive Find My network.

项目地址: https://gitcode.com/gh_mirrors/op/openhaystack
点击查看 免费下载

导读

本文以 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) */
};

各字节段的含义如下:

偏移字节含义
00x1eAD 结构长度(30),即后续内容长度
10xffAD 类型:Manufacturer Specific Data(厂商自定义数据)
2–30x4c 0x00公司 ID:Apple(Company ID 0x004C)
4–50x12 0x19Offline Finding 子类型 0x12 与子类型长度 0x19(25)
60x00State 状态字节
7–2822 字节公钥主体(广播密钥的第 6~27 字节)
290x00"First two bits":公钥最高两位
300x00Hint 提示字节

这与客户端 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),按顺序完成:

  1. nvs_flash_init():初始化 NVS 存储;
  2. 释放经典蓝牙控制器内存(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)),仅保留 BLE 模式;
  3. 初始化并启用蓝牙控制器与 Bluedroid 协议栈;
  4. load_key() 读取 28 字节公钥;
  5. set_addr_from_key() + set_payload_from_key() 组装随机地址与广告数据;
  6. 注册 GAP 回调 esp_ble_gap_register_callback(esp_gap_cb);
  7. esp_ble_gap_set_rand_addr() 设置随机地址;
  8. 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 地址内容对应分区
0x1000bootloader.bin二级引导程序(引导程序固定偏移)
0x8000partition-table.bin分区表
0xe000tmp.key(28 字节公钥)key 分区(Type 0x40, SubType 0x00)
0x10000openhaystack.binfactory 应用分区(1M)

6.3 烧录后的注意点

  • 可能需要手动复位:README 特别提示,脚本执行完成后设备可能不会立即开始广播,需要按一下开发板上的 EN/RST 键复位,或重新上电,设备才会进入持续广播状态。
  • 验证广播:复位后可在日志串口观察到 "advertising has started.",也可用 hcitool lescan、手机 nRF Connect 等工具扫描到该随机地址及其 Apple 厂商数据广播。

7. 与 OpenHaystack 客户端应用的集成路径

该固件并非只能命令行烧录——OpenHaystack macOS 应用内置了 ESP32 部署向导,调用链为:

  1. ESP32InstallSheet.swift:应用内选择配件后展示"Flash your ESP32"弹窗,自动枚举 /dev 下的串口(ESP32Controller.findPort()),用户点击目标串口即触发烧录,界面提示"This can take up to 3min"(与 README 中"Deployment can take up to 3 minutes"的说明一致)。
  2. ESP32Controller.swift:把应用 Bundle 内附带的 ESP32 固件目录整体拷贝到临时目录,调用 accessory.getAdvertisementKey().base64EncodedString() 得到公钥,然后以 ["-p", port, key] 作为参数执行 flash_esp32.sh。因此客户端内一键部署走的正是本文第 6 节的同一套脚本与分区布局。
  3. 烧录成功后应用将该配件标记为已部署(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 keykey 分区为空或烧录未包含公钥,确认使用正确的 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.

项目地址: https://gitcode.com/gh_mirrors/op/openhaystack
点击查看 免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值