蓝牙5.4核心规格里,真正让广播链路发生质变的不是PHY速率,而是三个容易被拆开讲的东西:加密广播数据、LE GATT安全级别特征、广播编码选择。我在做一个低功耗蓝牙信标项目时,正好把这三块完整过了一遍,从协议栈底层到业务逻辑踩了不少坑,也把Core Spec 5.4相关章节翻了好多遍。这篇就按我在项目里实际推进的顺序,把这三个特性怎么配合、怎么用、怎么避坑一次说清楚。
这个内容更适合谁看?我的判断是:正在做低功耗蓝牙传感器、电子货架标签、室内定位信标或者任何“不想让广播数据裸奔”的嵌入式开发者,以及那些被蓝牙配对安全等级问题折腾过的协议栈工程师。如果你只是做App层开发,也可以看第二和第三章的连接部分,能帮你少走很多弯路。
1. 蓝牙5.4的这组改动,把广播从"通知"变成了"可用业务"
1.1 广播数据为什么一直处于裸奔状态
传统BLE广播数据的格式非常简单:一个广播包Payload里塞一串AD Structure,每个AD Structure由Length、AD Type、Data三部分组成。问题是,这些东西从射频出去之后,完全是明文。手机上的nRF Connect、LightBlue这类工具打开扫描,任何自定义字段都一览无余。
过去很多开发者怎么处理?要么把广播内容做得让人看不懂,比如用我们自己定义的字节序和位运算把温湿度、电池电压塞进几个字节里。这种做法顶多叫“混淆”,不叫“加密”。只要有心人抓包分析一段时间,格式就能被逆向出来,然后伪造一个一模一样的信标广播,你的网关根本分不清哪个是真设备。
所以在蓝牙5.4之前,广播链路的定位非常尴尬:适合做发现、做通知,但没办法承载真正有业务价值的数据。所有敏感数据都只能走GATT连接。可GATT连接不是万能的,尤其是电池供电、需要秒级上报的设备,连接一次的功耗可能抵得上几十次广播。这也正是5.4加密广播数据(EAD)出现的主要原因:让广播载荷在物理层和链路层的格式不变的前提下,内容做到机密性和完整性保护。
1.2 EAD、GATT安全级别特征、编码选择是一条链路,不是三个孤立功能
我前期看资料时最大的误判,是把这三个特性拆开理解。加密广播数据关注“广播内容怎么加密”,GATT安全级别特征关注“连接通道的安全等级怎么声明”,广播编码选择关注“用哪种PHY发送广播”。看起来毫无关联,实际用起来是一条链路。
以电子货架标签举例。标签平时用可连接广播做存在性发报,价格数据通过加密广播下发,保证顾客用手机扫描也解不出来。但加密广播需要一个SessionKey,这个Key不能直接写在固件里,否则逆向固件就等于破解所有设备。更稳妥的办法是首次部署时,让标签进入GATT连接模式,通过加密连接把SessionKey写进标签。那么问题来了:这条连接本身够不够安全?GATT安全级别特征就是干这个的:App在连接后先读这个特征,看看标签是不是只接受认证加密,再决定要不要继续当前的安全协商流程。
至于广播编码选择,是因为加密广播解出来还需要接收端扫得到。如果标签部署在仓库深处,广播需要传50米,普通1M PHY可能不够,Coded PHY更合适。5.4把“广播用哪种编码”变成可控配置后,我们终于可以在同一个设备上根据广播事件类型灵活切换,而不是为了兼容性把所有广播都绑死在1M PHY上。
1.3 哪些项目最值得吃这波更新
不是所有项目都要立刻上。
我梳理了一下,下面几类项目收益最大:
- 电子货架标签/电子价签:价格数据通过加密广播下发,顾客扫到也不怕,关键还能远程批量更新;
- 工业传感器节点:秒级上报温度、振动、防拆状态,不想给别人抓包分析产线规律;
- 医疗/健康一次性设备:无连接上报数据,但要求隐私合规,广播内容不能明文;
- 防伪溯源标签:动态Token通过EAD广播,扫描端拿到Token后到云端验真,防复制。
如果只是室内定位信标,广播内容只有一个固定ID,那EAD带来的收益有限,重点反而是广播编码选择和功耗的平衡。
2. 加密广播数据(EAD)的包格式和加解密实现
2.1 EAD在AD Structure里的位置
EAD不是一个新广播类型,它本质上就是把一个“加密后的AD Structure列表”再包成一个AD Structure,放进原来的广播Payload里。它在广播包里的位置和普通AD Structure完全一样,头部还是Length和AD Type,只是AD Type变成了加密数据类型(Assigned Numbers里分配了0x31)。
typedef struct { uint8_t length; // 整个EAD结构长度,包含类型、IV、随机数、密文和MIC uint8_t type; // 0x31,Encrypted Data uint8_t iv[4]; // 4字节初始向量 uint8_t randomizer[4]; // 4字节随机数 uint8_t encrypted_data[]; // 密文,长度可变 uint8_t mic[4]; // 4字节消息完整性校验 } ead_ad_structure_t;解密之后的encrypted_data,应该是一段完整的、由普通AD Structure组成的明文列表。举个例子,你可以把原本打算公开广播的0x16 Service Data字段,改成放在EAD里面,扫描端先解密EAD,再按普通AD Structure去解析Service Data。这样做的好处是现有上层解析逻辑不用大改,只要在解析广播包时先做一层EAD解包。
这里有个很容易被忽略的细节:EAD的AD Length字段,长度包含的是从AD Type开始一直到MIC结束的全部字节。有些协议栈在解析时会把MIC当成下一个AD Structure的Length,导致广播包后面出现一截无法解析的数据。所以如果你要自己写解析器,一定要先按EAD结构把IV、Randomizer、密文、MIC拆出来,再把剩余数据当普通AD处理,顺序不能反。
2.2 AES-CCM的SessionKey和Nonce处理
EAD加密算法用的是AES-128-CCM。这是一个带完整性校验的认证加密模式,可以同时保证机密性和完整性。用生活化的方式理解:普通加密像把信塞进信封,别人看不到内容但可以偷换;CCM相当于信封上还贴了一道防伪封条,试图篡改会被发现。
这里最关键的是两样东西:SessionKey和Nonce。
SessionKey是128位对称密钥,广播端和扫描端必须共享。如果不知道SessionKey,即使收到完整EAD包,也解不出任何内容。Nonce是一个加密时使用的唯一值,用来防止同样的明文在不同时刻加密出同样的密文。EAD构造Nonce时会用到IV和Randomizer。我看规范时特别注意了它的要求:每次广播的IV必须变化,Randomizer也必须重新随机生成,不能让两条广播消息共用同一组Nonce。否则攻击者可以通过密文对比做重放或猜测,CCM的安全性就打了折扣。
在BLE里,同一组IV和Randomizer如果被子广播(Subevent)复用,就会引发解密失败。我之前在调试时踩过一次:协议栈为了省电,在同一个广播事件里连续发送几个相同内容的EAD包,结果IV没有递增,扫描端在同一个广播事件中收到重复包,Nonce判定失败,后一个包被当成重放包丢弃。后来把IV按广播事件递增,问题才解决。
2.3 广播端构造EAD的完整流程
我习惯把加密广播当成一个“加壳”流程,顺序如下:
- 准备要广播的业务AD Structure明文列表,例如一个0x16 Service Data字段,里面放温度、批次号、随机Token;
- 生成4字节IV和4字节Randomizer,确保与上次广播不同;
- 用SessionKey和由IV、Randomizer等构造的Nonce调用AES-128-CCM加密,得到密文和MIC;
- 组装EAD AD Structure:先填AD Type=0x31,再依次填IV、Randomizer、密文、MIC;
- 将EAD AD Structure和其他明文AD Structure(如Flags、Manufacturer Specific)一起塞进广播Payload。
伪代码大概是这样的:
uint8_t plaintext[] = { 0x03, 0x16, 0xAA, 0x01, 0x1F, // 业务数据 }; ead_packet_t ead; fill_iv_randomizer(&ead, iv, randomizer); aes_ccm_encrypt(session_key, nonce, plaintext, sizeof(plaintext), ead.encrypted_data, &ead.mic); // 组装完整广播Payload build_ad_payload(adv_data, &adv_len, &ead);实际项目中,业务数据往往还要加一个递增计数器。这个计数器会让每次广播的明文都不同,即使IV和Randomizer被协议栈复用,也能多一层防重放保障。加密本身不解决重放问题,防重放需要应用层参与。
2.4 扫描端解密EAD的流程和失败定位
扫描端解析EAD时,需要主动识别AD Type=0x31,然后按结构拆字段,再用SessionKey进行CCM解密和MIC校验。如果MIC校验失败,就说明密文在传输过程中被篡改,或者SessionKey不对,又或者是Nonce构造方式与广播端不一致。
我整理过一份排查清单,实际调试时特别有用:
- 广播端和扫描端的Nonce构造规则是否一致,字节序是否一致;
- SessionKey是否真的匹配,注意很多SDK会把128位Key打印成32个十六进制字符,大小写不同都会导致解密失败;
- MIC放在EAD结构的最后4字节,如果协议栈自动补齐了广播包的对齐字节,也会导致MIC提取位置偏移;
- 扫描端是否把同一个设备的历史广播包和当前广播包混在一起,造成重放窗口判断错误。
排查思路不要一上来就怀疑算法,先在协议栈日志里对照广播端和扫描端的IV、Randomizer。如果两者相同,却解不出来,问题大概率在Key或Nonce。如果两者不同,那就要看扫描端有没有丢包,或者两个设备的时间基准是否偏差过大。
3. LE GATT安全级别特征:把安全策略变成可查询的元数据
3.1 没有这个特征之前,连接后的安全协商是“黑盒”
GATT连接本身不包含安全协商,配对和加密是独立机制。传统做法是:客户端连上来之后,服务端根据它自己的安全策略决定是否拒绝访问。问题在于,这个安全策略对客户端是个黑盒。客户端只有在发起读写请求被拒绝之后,才知道“哦,原来这个服务需要加密才能读”。
更麻烦的是降级攻击。攻击者可以伪造一个中间设备,让客户端以为服务端只支持无加密连接,从而诱导客户端降级到No Security。如果有一个特征能让服务端把自己支持的安全级别明文写出来,客户端在发起关键请求之前就能先读到,沟通成本会低很多。蓝牙5.4新增的LE GATT安全级别特征,就是干这个的。
3.2 特征值编码与安全模式1 Level 1到Level 4的对应关系
这个特征的值并不复杂,本质上就是告诉客户端“我这台设备最高/最低支持到什么安全级别”。参考BLE传统安全模式,可以这样理解和编码:
| 特征值 | 安全层级 | 含义 |
|---|---|---|
| 0x01 | Security Mode 1 Level 1 | 无加密、无认证 |
| 0x02 | Security Mode 1 Level 2 | 未认证配对+加密 |
| 0x03 | Security Mode 1 Level 3 | 已认证配对+加密 |
| 0x04 | Security Mode 1 Level 4 | LE Secure Connections + 已认证加密 |
需要说明的是,具体特征值定义以Core Spec 5.4原文和SIG分配的UUID为准,不同协议栈可能还有自己的扩展字段。但逻辑上是同一个思路:客户端读到0x04,就知道这台设备要求SC配对;读到0x01,就知道设备完全不设防,后续如果涉及敏感数据,App应该主动拒绝连接或者提示用户。
实际上,规范中这个特征更倾向于“服务器声明自己所支持的级别”。所以服务端实现时,应该返回它能够支持的最高安全级别,而不是当前连接已经达到的级别。客户端拿到之后,要判断的是“当前这条连接是否达到了这个级别”,如果没达到,就需要发起配对或加密请求。
3.3 客户端怎么用这个特征规避降级攻击
连接建立后,客户端第一件事就是查找并读取这个特征。读的时候要注意,特征本身可能受服务端安全策略保护,所以未加密连接上不一定读得到。那怎么办?我的经验是:先用默认方式读一次。如果直接被拒绝,说明服务端要求加密,那就发起配对;配对完成后再读一次,如果读到Level 3或Level 4,确认当前连接已经满足,才去操作其他敏感服务。
这样能防降级攻击吗?能。因为攻击者一旦在中间做代理,就无法同时通过服务端的加密认证。客户端读到特征值之后,还会验证当前连接确实用了SC配对和加密链路,而不是只凭特征值做判断。特征值只是声明,真正决定安全性的还是Pairing流程和加密链路本身。
我之前遇到过一个场景:App连接了一个旧的BLE设备,设备固件版本不支持安全级别特征,读特征时报Attribute Not Found。这时App不能直接判定“这个设备不安全”,因为有些老设备虽然不支持这个特征,但实际配对策略是安全的。所以App要做兼容性处理:特征不存在时,退回原来的安全协商逻辑。
3.4 实现中的几个细节
如果你是设备端固件开发者,添加这个特征有几点要注意。
第一,特征属性一般设置为Read,允许客户端读取,但不要开放Write。安全级别声明如果可写,攻击者可以直接改成No Security,那客户端反而会被误导。
第二,特征对应的服务UUID和特征UUID一定要按照规范分配,不要自己造一个私有UUID放到随便哪个服务里。否则客户端找不到标准服务,这个特征就失去了互操作性意义。
第三,要注意GATT缓存。很多设备端协议栈启动时会给服务端属性赋值,如果这个值在运行过程中会变化(比如设备刚从无加密模式切换成安全模式),要主动发起Service Changed Indication,否则客户端复用缓存可能读不到最新值。
第四,这个特征与EAD的SessionKey分发是天然合作伙伴。设备初次部署时,通过加密GATT连接写SessionKey,App在读安全级别特征确认连接等级后再写Key,可以避免Key在弱安全连接上下发。
4. 广播编码选择:速率、距离、功耗的平衡木
4.1 Coded PHY的纠错和速率折算
蓝牙5.0引入Coded PHY时,大家最关心的是距离。Coded PHY本质上是通过前向纠错(FEC)把有效载荷中的每个bit扩展成多个symbol,接收端可以容忍更多误码。S=8时,物理层速率等效为125kbps,S=2时等效为500kbps。作为对比,传统1M PHY的物理层速率是1Mbps,2M PHY是2Mbps。
速率降下来,距离确实远了。我在开阔场地实测,同样发射功率下,1M PHY广播稳定接收距离大约30米,Coded PHY S=8能到60米以上,环境好的时候接近80米。这很容易让人产生一种误解:无脑用Coded PHY就好了。
问题在于代价。编码后的广播PDU占用空口时间变长,同样长度的广播包,发送时间可能是原来的4倍甚至8倍。广播间隔如果不变,占空比会大幅上升,平均电流跟着往上走。对电池供电设备来说,这可能直接导致续航缩水。所以Coded PHY不是免费的午餐,它是一个典型的速率、距离、功耗三选二问题。
4.2 5.4的Advertising Coding Selection改了什么
在蓝牙5.4之前,广播端也能配置Coded PHY,但很多协议栈把“广播类型”和“PHY类型”绑得过死。比如某个连接事件里,广播者想用1M PHY发一个可连接广播,同时又想用Coded PHY发另一个不可连接广播,配置起来很麻烦,甚至只能在启动广播时选一次。
5.4对广播编码选择做了机制层面的细化,让广播者可以更灵活地指定不同广播事件或广播集对应的编码方式,而不是让链路层默默选择。对于应用层开发者的意义在于:“这个广播应该用125kbps还是1Mbps”终于变成一个可以显式管理的业务参数,而不是只能从SDK示例代码里抄一个固定写法。
我在项目里最直接的使用方式,是把设备配置成双广播事件:一个事件用1M PHY发可连接广播,供部署人员近场调试;另一个事件用Coded PHY发加密的远距离业务广播,供仓库网关扫描。这样既保证了正常业务的距离,又不影响现场调试时的连接速度和兼容性。
4.3 不同项目的编码选择方案
没有万能答案,只能按业务权衡。我按常见项目类型整理了一个参考:
| 场景 | 广播内容 | 推荐编码 | 主要原因 |
|---|---|---|---|
| 室内信标 | 防篡改Beacon ID | 1M PHY | 距离近、时延低、兼容性好 |
| 电子货架标签 | 加密价格和批次 | Coded PHY S=8 | 货架遮挡多,需要穿障碍 |
| 仓储传感器节点 | 加密温湿度 | Coded PHY S=2或S=8 | 距离和功耗平衡 |
| 可穿戴连接广播 | 连接握手信息 | 1M PHY | 手机兼容性优先 |
| 大批量PAwR响应 | 节点状态响应 | Coded或1M由拓扑决定 | 避免子广播事件之间的干扰 |
里面的逻辑很简单:静态场景、长时间部署、距离要求高的,优先Coded PHY;交互性强、需要频繁连接、周围手机设备多的,优先1M PHY;如果是点对点高速传输或音频这类高吞吐场景,才考虑2M PHY,不过广播里2M PHY用得比较少。
4.4 实测对比:125kbps与1M PHY的信号表现
我在一个约2000平米的仓库做过一次对比测试。设备放在货架中段,网关放在通道尽头。1M PHY广播时,走到距离约35米的位置,丢包率开始明显上升,有时连续几个广播周期都收不到;切换成Coded PHY S=8后,同一位置基本能稳定收到,走到50米以外才开始出现零星丢包。
代价也很直观。用电流分析仪测试,同样10字节业务载荷、100ms广播间隔,1M PHY平均电流约60uA,Coded PHY S=8直接到160uA左右。这个数值受设备射频前端和协议栈实现影响很大,但趋势是确定的。后来我把广播间隔从100ms调到200ms,平均电流降回到100uA以内,距离优势仍然保留。项目上如果续航压力大,记得把广播间隔和Coded PHY放在一起调,不要单独看PHY参数。
另外要提醒一点:扫描端也要同步配置Coded PHY。很多手机App底层扫描用的还是1M PHY,广播端即使发了Coded广播也扫不到。如果是Android平台,ScanSettings里要把PHY类型配成LE_CODED;如果是自研网关,扫描参数的PHY字段别漏。
5. 落地蓝牙5.4安全广播时的避坑清单
5.1 协议栈和芯片支持情况要先查
蓝牙5.4是规范版本,但芯片和协议栈不一定把所有新特性都暴露给上层。EAD的API、GATT安全级别特征的支持、Advertising Coding Selection的配置入口,在不同厂商SDK里差别很大。我调研时会先看三件事:芯片是否声明了对应的HCI Command/Option支持,协议栈Release Note是否提到EAD,SDK里是否有EAD示例或预编译库。
如果SDK没有现成API,不要硬改HCI层。EAD涉及密钥管理和Nonce构造,自己做容易出错,而且不同协议栈对广播包内存管理的处理方式也不一样,强行封装很容易踩内存溢出。建议优先找Nordic、TI、Silicon Labs等主流厂商的最新SDK,确认版本再动手。
5.2 SessionKey分发的“鸡生蛋”问题
EAD是加密广播,但它不能凭空变出密钥。广播信道本身没有安全通道,SessionKey必须通过其他途径预先共享。常见的做法有三种:
- 首次连接时通过GATT加密连接写Key;
- 通过带外方式注入,比如扫码、NFC配对、出厂预置;
- 使用云端证书体系,设备连接云端后拉取Key。
如果你走GATT分发Key这条路,就绕不开前面提到的LE GATT安全级别特征。正确流程应该是:连接建立,读安全级别特征,确认当前连接达到预期级别,再在这个加密链路上写SessionKey。顺序不能乱。我有一次图省事,直接在无加密连接上把Key写进设备,结果测试时抓包发现Key以明文出现在GATT Write Request里,整个EAD方案形同虚设。
5.3 安全级别特征与广播间隔的时序问题
设备端如果同时开着可连接广播和EAD广播,App连接设备后,可能会先收到一堆EAD广播,再读到GATT服务列表。这时候安全级别特征还没读到,App如果先对敏感服务发起读写请求,很可能被安全策略拒绝。
优化方法是把安全级别特征放到GATT服务列表前面,让客户端在Discover All Primary Services时较早看到它。另一个做法是App侧在连接后不急于操作业务服务,先读这个特征。设备端和App端配合好,用户就不会遇到“明明连接成功了,但下面所有按钮都是灰色”的尴尬。
5.4 编码选择对扫描功耗的反向影响
这是我和网关团队反复磨合才意识到的问题。大家容易盯着广播端,觉得Coded PHY只有广播端在增加功耗,扫描端不就开个接收窗口嘛。实际不是。Coded PHY的接收需要扫描端在125kbps模式下持续监听,接收窗口要比1M PHY长很多,因为每个bit都被扩展了。网关如果同时跟踪几十个Coded广播标签,接收功耗和CPU占用都会明显上升。
在低功耗大规模部署项目里,必须把广播端和扫描端当成一个整体来设计。可以把部分节点配置成Coded PHY,关键网关周围节点用1M PHY,避免所有节点都在一个慢速信道上挤牙膏。5.4的编码选择如果利用得好,是可以动态切换的:业务高峰期用Coded PHY保证覆盖,业务低峰期切回1M PHY省电。
6. 把这三个特性用起来之后的实际感受
6.1 一次完整的调试过程
我在电子货架标签项目里,完整跑通过一次“加密广播+安全级别特征+Coded PHY”的链路。设备上电后,先用1M PHY发一个普通可连接广播,App连接设备,读取LE GATT安全级别特征,确认Level 4后通过加密通道写入SessionKey。写成功后,标签切换到Coded PHY,开始发送EAD广播。网关在Coded PHY上扫描,解密EAD得到价格数据和批次号,整个流程跑通。
最头疼的问题发生在解密的最后一个环节:网关偶尔会把同一个标签的旧广播包和新广播包混在一起。EAD广播包到达网关的时间有抖动,网关的接收队列里同时存在两个相同IV的包,后一个包被判成重放,解密失败。解决方式不是改加密参数,而是在应用层维护一个“最近广播时间戳”窗口,只有时间戳比上次更新的包才进入解密流程。
6.2 后续可以扩展的方向
这套方案真正落地后,我反而觉得它最大的价值不是“加密”,而是让广播数据变成了一个可编程的安全业务通道。后续我准备继续做三件事:一是把EAD和PAwR响应结合起来,让大批量节点不仅能广播,还能安全地回答网关的查询;二是在固件升级场景里用加密广播下发Bootloader版本信息,防止设备被恶意降级;三是把GATT安全级别特征和设备的证书状态绑定,让客户端在连接后迅速判断设备是否还在有效期内。
最后说一个我自己调试时的习惯:不要一上来就全量加密广播。先用明文广播把所有逻辑调通,包括SessionKey的正确性、扫描端能否解析、Coded PHY的信号覆盖情况,最后一步再切到EAD。加密只是加壳,如果里面的业务逻辑还没验证,排查问题时会多一层干扰。等整条链路稳定了,再放心地把广播壳子换成EAD,你会发现蓝牙5.4这套组合拳,确实比想象中好用。