BLE 协议规范
为您自己的控制器——您的板子、您的单片机——加上 Entrixy BLE 支持所需的一切。我们现成的固件做的正是这件事;下面给出精确的协议说明和测试向量,方便您逐字节自检。
1. 密码学原语
- HMAC-SHA256 ——签名与派生。截断到指定长度时,取结果的前 N 个字节。
- HKDF-SHA256 (RFC 5869)——密钥派生。若
salt = null,则使用 32 字节的全零向量。 - X25519 (RFC 7748)——配对时的 ECDH。私钥需做 clamp 处理:
k[0] &= 0xF8; k[31] = (k[31] & 0x7F) | 0x40。
参考实现:mbedTLS(MBEDTLS_ECP_DP_CURVE25519)在 ESP 上,Android 上用 BouncyCastle——两端结果一致。
2. GATT:服务与特征值
所有 UUID 都是 128 位,基于 656e7472-7869-7900-XXXX-426c45000001 (ASCII 的 “entrixy”)。
| UUID 后缀 | 属性 | 用途 |
|---|---|---|
0000 | — | 服务 UUID(完整: 656e7472-7869-7900-0000-426c45000001) |
0001 NONCE | READ | 8 字节随机数(遗留方式;现代客户端从广播中取随机数,见 §3) |
0002 FIRE | WRITE | 开启指令:24 字节(所有者)或 47 字节(访客) |
0003 TIME | WRITE | 校时:所有者 32 字节,访客 55 字节 |
0004 WIFI | WRITE | 用于 NTP 的 SSID 和密码(可选) |
0005 RESULT | READ+NOTIFY | 每次写入后返回一个字节的结果码(§9) |
0006 REVOKE | WRITE | 所有者对单个访客的签名决定,23 字节 |
00f0 PAIR_PUB | READ | 仅在配对模式下:控制器 32 字节的 X25519 公钥(小端) |
00f1 PAIR_DONE | WRITE | 仅在配对模式下:36 字节 [phone_pub 32B][device_id 4B LE] |
"entrixy" 与服务 UUID 被放进 scan-response,而不是主包:128 位 UUID 加上厂商数据放不进主包的 31 字节。Android 的被动扫描只能看到主包,所以过滤是基于厂商数据做的(company id 0x00E0).3. GAP 广播
每隔 sleep_interval 秒,控制器就用厂商专有数据广播一次自己的存在。company ID 为 0x00E0 (在链路上为 E0 00)。该数据块共 23 字节 =2 字节 company ID 加 21 字节数值:
offset size field [0..1] 2 Company ID = E0 00 [2..5] 4 device_id (LE) [6..9] 4 counter (LE, monotonic, anti-replay, survives deep sleep) [10..17] 8 auth_hmac = HMAC(owner_secret, mac_in)[0..7] [18] 1 sleep_interval_s (plaintext) [19..22] 4 opts_cipher (battery, status, fw, hw — encrypted)
签名覆盖 13 字节 (不含 company ID):
mac_in = device_id(4) || counter(4) || sleep(1) || opts_cipher(4) auth_hmac = HMAC-SHA256(owner_secret, mac_in)[0..7]
auth_hmac 充当 nonce ,用于无需往返即可立即开启:没有 owner_secret 的人只会把它们看成随机数,而所有者或访客能算出签名并立刻触发。控制器会保留自己最近签发的 16 个随机数构成的环形缓冲。选项(opts)——XOR 密钥流
ks = HMAC-SHA256(owner_secret, "ks" || device_id(4 LE) || counter(4 LE))[0..3] opts_cipher[i] = opts_plain[i] XOR ks[i] opts_plain[0] = battery % [1] = status [2] = fw (major=(b>>4)&0xF, minor=b&0xF) [3] = hw
双稳态模式
对于双稳态驱动装置,数值后面再追加三个字节,共 26 字节 (见 §8)。
4. 配对
新控制器会自行进入 30–90 秒的配对模式(否则按键进入;长按 5 秒为恢复出厂设置)。整个交换在本地完成,不经服务器:
- 手机读取
PAIR_PUB(00f0)——控制器 32 字节的公钥。 - 手机生成自己的 X25519 密钥对,
shared = X25519(phone_priv, esp_pub)。 - 双方派生出同一个密钥:
owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
- 手机写入
PAIR_DONE(00f1):[phone_pub 32B][device_id 4B LE]。 由手机生成 device_id,控制器把它保存下来。
owner_secret 绝不会经过空中传输。没有服务器参与:device_id 是应用自己生成的,所以它当然知道。5. 所有者开启(owner fire)
写入 FIRE (0002), 24 字节:
[nonce 8B][ HMAC-SHA256(owner_secret, nonce)[0..15] 16B ]
nonce ——8 字节的 auth_hmac ,取自最近一次广播。控制器在环形缓冲中核对该随机数,校验签名,给继电器一个脉冲,然后作废该随机数。
6. 访客开启(guest fire)
访客并不知道 owner_secret。所有者通过服务器签发一个凭据包: token, owner_sig, guest_key。写入 FIRE, 47 字节: [token 7B][owner_sig 16B][nonce 8B][guest_sig 16B]。
| 字段 | 长度 | 公式 |
|---|---|---|
token | 7B | [bleId 2B LE][valid_until 4B LE unix-sec][flags 1B] |
owner_sig | 16B | HMAC-SHA256(owner_secret, token)[0..15] |
nonce | 8B | 取自广播的新鲜随机数 |
guest_sig | 16B | HMAC-SHA256(guest_key, nonce)[0..15] |
guest_key = HKDF-SHA256(salt=null, ikm=owner_secret, info="guest" || bleId(2B LE), L=32)
控制器:随机数在环内 → owner_sig 用它自己的 owner_secret → 访客不在撤销列表中 → valid_until 对照自己的时钟 → 派生出 guest_key → guest_sig。
控制器保存的唯一列表是所有者的撤销记录(§7)。
7. 时间同步
访客的有效期需要时钟。所有者和持有有效通行证的访客都可以校时。每次写入都带有控制器发出的一次性随机数,控制器用后即作废:在空中录下的写入,之后无法重放来把时钟拨回。
随机数来自读取 NONCE (0001)或最新的广播。写入 TIME (0003):
owner, 32 bytes: [epoch_ms 8B LE][nonce 8B][ HMAC-SHA256(owner_secret, "T" || epoch_ms || nonce)[0..15] ] guest, 55 bytes: [token 7B][owner_sig 16B][epoch_ms 8B LE][nonce 8B] [ HMAC-SHA256(guest_key, "T" || token || epoch_ms || nonce)[0..15] ]
访客需要与开门相同的通行证(§6):由所有者签名、未被撤销、当前有效,并且在其提议的时间也有效。被撤销的访客无法把时钟推到其他人通行证的有效期之后。
访客写入的时间还受到棘轮(不能倒退)、向前上限(每次写入不超过 +24 小时)和“先开门”规则的保护。所有者不受限制。外部 RTC 和 NTP 均为可选。
吊销访客
所有者对单个访客的决定,用控制器自身的 owner_secret。写入 REVOKE (0006),23 字节:
[guest_id 2B LE][version 4B LE][revoked 1B: 1 revoked, 0 allowed] [ HMAC-SHA256(owner_secret, "rev" || first 7 bytes)[0..15] ]
控制器为每位访客保存最新版本,只接受版本更高的记录,因此重放旧的“允许”无法让被撤销的访客恢复访问。被撤销的访客既不能开门(0x08),也不能写入时间。
任何手机都可以写入这条记录:所有者的,或者恰好在门口的访客的。控制器相信的是签名而不是写入者,因此撤销会随第一个靠近的人到达门锁,而不必等访客的通行证过期。重新配对会清空该列表。
8. 状态与事件(K_state)
K_state = HKDF-SHA256(salt=null, ikm=owner_secret, info="ble-state-v1", L=16) [..21] state_enc = state_byte XOR K_state[counter & 15] // bit0: 1=open, 0=closed [22..23] state_mac = HMAC-SHA256(K_state, counter(4B LE) || state_enc)[0..1] // 2 bytes
两个字节的标签是有意为之:状态字节只驱动“开/关”指示,不授予任何权限,而广播每多一个字节,都要在每次唤醒时消耗电量。该标签防的是空口上的损坏,不是伪造者——16 位几秒即可穷举。凡是决定访问权限的东西,都不该走这条路。
控制器自身的事件
状态字节的其他位携带控制器自身发生的事情,以便附近的手机把它写进所有者的日志。控制器没有互联网:只有当手机收到广播时,事件才会进入日志。
state_byte: bit 0 1 = open, 0 = closed bits 1..3 number of the last event, 0..7, wrapping bits 4..5 its type: 0 none, 1 exit button, 2 limit switch, 3 auto-close bit 6 1 = on its way: the limit switch has not confirmed the press yet, bit 0 is where it goes bit 7 1 = no position (a momentary lock that sends the byte only for its events)
手机会为每个控制器记住最后看到的编号。类型不为零的新编号就是一个事件;编号之差表示无人在附近时错过了多少个。手机第一次看到的编号是起点,而不是事件。控制器在休眠和断电后都会保留编号。弹簧锁只有在配有出门按钮时才发送此数据块。
有限位开关时,按下后锁处于运行中(第 6 位),直到限位开关确认。若控制器设定的行程时间内限位开关未确认,控制器以它为准,并发送一个类型为 0 的新事件编号:故障,电机未到位。此时第 0 位表示锁的真实位置:打开即表示未能关闭。
对这种按压的回复是 0x32 或 0x33:锁正在前往打开或关闭的位置。按下的手机随后跟踪广播,直到位置稳定或收到失败。
9. 响应码(RESULT,特征值 0005)
| 代码 | 取值 | 代码 | 取值 |
|---|---|---|---|
0x00 | FIRE OK | 0x10 | TIME OK (owner) |
0x30 | FIRE OK — 已开 | 0x11 | TIME OK (guest) |
0x31 | FIRE OK — 已关 | 0x12 | TIME 长度不对 |
0x32 | FIRE OK — 正在打开 | ||
0x33 | FIRE OK — 正在关闭 | ||
0x01 | 随机数已过期或不在环内 | 0x13 | TIME 的 HMAC 不匹配 |
0x02 | 有效期已过 | 0x15 | TIME 回拨了(棘轮) |
0x03 | owner_sig 无效 | 0x16 | TIME 超过了 +24 小时上限 |
0x04 | guest_sig 无效 | 0x17 | TIME 之前没有近期的开启动作 |
0x05 | 长度不对 | 0x20 | WIFI OK |
0x06 | HKDF failed | 0x21 | WIFI 格式不对 |
0x07 | 所有者的 HMAC 无效 | 0x22 | WIFI 的 HMAC 不匹配 |
0x08 | 该访客在此控制器上已被撤销 | 0x19 | TIME 随机数已过期或不在环中 |
0x40 | REVOKE 已保存 | 0x1a | 来自没有有效通行证的访客的 TIME |
0x41 | REVOKE 版本不比已有的新 | 0x42 | REVOKE 长度或签名错误 |
10. 测试向量
确定性的已知答案向量:跑一遍您的实现,逐字节对比。所有数值均为十六进制,字节序与链路上一致。整条链路贯通:配对 → owner_secret → 其余一切。
Pairing (X25519 → owner_secret)
esp_priv (clamped) = 0002030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f60 phone_priv (clamped) = 201f1e1d1c1b1a191817161514131211100f0e0d0c0b0a090807060504030241 esp_pub = 07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c phone_pub = 0d799600f6ffaee2e121e6b8f7a05dc66874b51db3102d0d71f799a09cb4c461 shared_z = 53126e95ac6e407e8a412fdf82c87f1be45a2251edf9422ad00df2e83aaebd19 owner_secret = 3609fa67bd15cf2bbaecdd5305feea0d48e1f21d714d01b29082a5fec459c64b = HKDF(salt="entrixy-pair-v1", ikm=shared_z, info="owner-secret", L=32)
Advertise (did=0x0A0B0C0D, counter=7, sleep=1, opts=batt100/status1/fw v2.3/hw5)
ks[0..3] = ee0a8499 opts_cipher = 8a0ba79c auth_hmac = 1f2d8012178a6e06 (= nonce) mfg (23B) = e0000d0c0b0a070000001f2d8012178a6e06018a0ba79c
Owner fire (24B)
fire = 1f2d8012178a6e06eaafaae40500580a774b62be6e9b6aa2
= nonce || HMAC(owner_secret, nonce)[0..15]
Guest fire (47B) — bleId=0x0042, valid_until=0x6890ABCD, flags=0x01
token (7B) = 4200cdab906801 owner_sig (16B) = faa3a99a76dfba0521fb4eb4bd508559 = HMAC(owner_secret, token)[0..15] guest_key (32B) = 248b57d94ccafeb3cba598c6b81fc18617c1b54da1386ca1ed99bcd5dd012ff5 = HKDF(salt=null, ikm=owner_secret, info="guest"||bleId_LE, L=32) guest_sig (16B) = 7a6e02ae269827b78b7c49840c40476e = HMAC(guest_key, nonce)[0..15] fire (47B) = 4200cdab906801faa3a99a76dfba0521fb4eb4bd5085591f2d8012178a6e067a6e02ae269827b78b7c49840c40476e
Time from the owner (32B), epoch_ms=1750000000000, nonce as above
time = 00dc2074970100001f2d8012178a6e06bfb4645605d42a5287cd56ce9a61e6df
= [epoch_ms 8B LE][nonce 8B][ HMAC(owner_secret, "T" || epoch_ms || nonce)[0..15] ]
Time from a guest (55B), the same pass, epoch_ms and nonce
time = 4200cdab906801faa3a99a76dfba0521fb4eb4bd50855900dc2074970100001f2d8012178a6e06d92cc82b145083302bc8e3f97b2080cb
= token || owner_sig || epoch_ms || nonce || HMAC(guest_key, "T" || token || epoch_ms || nonce)[0..15]
Revoke (23B), guest_id=0x0042, version=1750000000, revoked=1
revoke = 420080e14e6801a1a54f560a1cc74d91cb985e9f62b2a6
= guest_id || version || revoked || HMAC(owner_secret, "rev" || guest_id || version || revoked)[0..15]
K_state (16B)
K_state = 19bcfc41f61a103d79e9967c21403a93
= HKDF(salt=null, ikm=owner_secret, info="ble-state-v1", L=16)
参考代码与联系方式
两端的可用实现见 esp32-example/entrixy-ble (MIT 许可)。要构建 .bin 以适配您自己的板子,请使用 浏览器配置器。联网传输方式见 socket 协议。这在开放架构中的位置: /open。OEM 咨询: hello@entrixy.com。