❮  Entrixy 控制器

BLE 协议规范

为您自己的控制器——您的板子、您的单片机——加上 Entrixy BLE 支持所需的一切。我们现成的固件做的正是这件事;下面给出精确的协议说明和测试向量,方便您逐字节自检。

BLE 控制器 不需要 Entrixy 服务器。它根本不联网:手机直接通过蓝牙与它对话。与主人的绑定在本地完成(X25519 ECDH)。在我们的服务器上注册设备、领取密钥或开通制造商账户,都 不是必需的 ——您可以直接出货空白控制器。参考: esp32-example/entrixy-ble (MIT).

1. 密码学原语

参考实现: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 NONCEREAD8 字节随机数(遗留方式;现代客户端从广播中取随机数,见 §3)
0002 FIREWRITE开启指令:24 字节(所有者)或 47 字节(访客)
0003 TIMEWRITE校时:所有者 32 字节,访客 55 字节
0004 WIFIWRITE用于 NTP 的 SSID 和密码(可选)
0005 RESULTREAD+NOTIFY每次写入后返回一个字节的结果码(§9)
0006 REVOKEWRITE所有者对单个访客的签名决定,23 字节
00f0 PAIR_PUBREAD仅在配对模式下:控制器 32 字节的 X25519 公钥(小端)
00f1 PAIR_DONEWRITE仅在配对模式下: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]
这 8 个字节 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 秒为恢复出厂设置)。整个交换在本地完成,不经服务器:

  1. 手机读取 PAIR_PUB (00f0)——控制器 32 字节的公钥。
  2. 手机生成自己的 X25519 密钥对, shared = X25519(phone_priv, esp_pub)。
  3. 双方派生出同一个密钥:
    owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
  4. 手机写入 PAIR_DONE (00f1): [phone_pub 32B][device_id 4B LE]。 由手机生成 device_id,控制器把它保存下来。
X25519 密钥采用小端序(RFC 7748)。 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]。

字段长度公式
token7B[bleId 2B LE][valid_until 4B LE unix-sec][flags 1B]
owner_sig16BHMAC-SHA256(owner_secret, token)[0..15]
nonce8B取自广播的新鲜随机数
guest_sig16BHMAC-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)

代码取值代码取值
0x00FIRE OK0x10TIME OK (owner)
0x30FIRE OK — 已开0x11TIME OK (guest)
0x31FIRE OK — 已关0x12TIME 长度不对
0x32FIRE OK — 正在打开
0x33FIRE OK — 正在关闭
0x01随机数已过期或不在环内0x13TIME 的 HMAC 不匹配
0x02有效期已过0x15TIME 回拨了(棘轮)
0x03owner_sig 无效0x16TIME 超过了 +24 小时上限
0x04guest_sig 无效0x17TIME 之前没有近期的开启动作
0x05长度不对0x20WIFI OK
0x06HKDF failed0x21WIFI 格式不对
0x07所有者的 HMAC 无效0x22WIFI 的 HMAC 不匹配
0x08该访客在此控制器上已被撤销0x19TIME 随机数已过期或不在环中
0x40REVOKE 已保存0x1a来自没有有效通行证的访客的 TIME
0x41REVOKE 版本不比已有的新0x42REVOKE 长度或签名错误

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。