用小LED练习敏感写入,先限定服务边界
这个节点有两类信息:任何连接者可读/订阅的公开状态,以及必须通过安全门才能读写的配置。配置只有通知间隔1..60秒与一个限流LED命令;没有Wi-Fi密码、门锁或大负载控制,也不把公开状态包装成端到端隐私服务。
选择GATT时要分别设计read、write、notify的意义。Read取当前快照;Write Request的成功响应表示服务端接受该次请求;Notify需CCCD订阅,单向通知入队不表示手机应用收到。连接成功只说明建立链路,不自动完成认证或订阅。
参考:[8] 固定NimBLE ATT:特征安全权限执行[9] 固定GATT定义:flags与min_key_size[10] 固定GAP:连接安全状态/订阅/IO事件
维护开关默认关,LED只作低压可观察负载
GPIO5/J1-5经独立10kΩ上拉到3V3,SPST另一端接地。开路HIGH为维护关闭,闭合LOW允许本次配对及敏感写入;程序不依赖内部上拉。GPIO4/J1-4经330Ω±5%≥0.25W接WP7113ID阳极,阴极接GND/J1-22;按厂家外形极性核对,不只看剪短后的脚长。
先全部断电检查支路和短路,再开关开路、桥USB供电。GPIO4是外接单LED,不是板载GPIO38地址灯;LED字段及串口日志是命令,没有光学反馈。不要把此支路换成继电器或风扇后沿用安全结论。
| 端点 | 连接 | 默认 |
|---|---|---|
| GPIO5/J1-5 | 10k到3V3,同节点SPST到GND | HIGH维护关 |
| GPIO4/J1-4 | 330Ω→WP7113ID阳极,阴极GND | LOW/off |
| 桥USB | 下载+UART0 115200 | 私人读取本次PIN |
| 原生USB19/20 | 本例不需要 | 不混作桥口 |
敏感特征只接受已认证的安全连接
锁定IDF5.5.5所带NimBLE提交,最终配置SC_ONLY=1、SM_LVL=4、SC与security启用,Legacy/debug keys与bond持久化关闭。固定Kconfig帮助有级别编号表述差异,port却把数值直接映射;实际NimBLE syscfg和ble_sm实现按0..4定义。这里明确取4,不以帮助文字里的3冒称Level4;SC_ONLY实现再要求SC和16字节密钥。
配置特征附READ/WRITE_ENC与AUTHEN权限、min_key_size16;callback再次核encrypted、authenticated、key_size16。连接安全变化若失败或较弱则终止。公开状态特征没有这些敏感权限,安全配置不等于广告和公开状态自动保密。
IO固定DISPLAY_ONLY:只有现场维护LOW且收到DISPLAY动作才从硬件随机源为本次尝试生成六位PIN,私人桥口显示,手机输入。没有默认PIN、没有把官方示例123456复制进应用,也不接受Just Works作为MITM认证。若客户端只支持Legacy或无PIN输入,停止实验而非降安全;真实手机握手尚未执行。
sm_bonding=0且无NVS bond保存;RESET丢RAM配置回5s/off。手机可能缓存旧bond,必要时只忘记本台再配对。维护开关关闭不撤销已经认证连接的配置read权限;它封住新的配对DISPLAY与所有config write,不能把关门误写成“立刻断开所有已授权连接”。
CONFIG_BT_NIMBLE_SECURITY_ENABLE=y
CONFIG_BT_NIMBLE_SM_SC=y
CONFIG_BT_NIMBLE_SM_SC_ONLY=1
CONFIG_BT_NIMBLE_SM_LVL=4
# CONFIG_BT_NIMBLE_SM_LEGACY is not set
# CONFIG_BT_NIMBLE_SM_SC_DEBUG_KEYS is not set
# CONFIG_BT_NIMBLE_NVS_PERSIST is not set参考:[3] IDF5.5.5 NimBLE Kconfig:SC/安全/角色[4] IDF5.5.5 port:SM_LVL直接映射[5] 固定NimBLE syscfg:实际安全级别定义[6] 固定NimBLE SMP:SC_ONLY、MITM与key长度[7] 固定NimBLE SC实现[8] 固定NimBLE ATT:特征安全权限执行[9] 固定GATT定义:flags与min_key_size[11] IDF5.5.5 esp_random:硬件随机条件
UUID、长度和订阅按字节约定
service为36fb49a7-8c8a-4c92-9d55-6cf2b877d702,status尾d703,config尾d704。config恰好4B:[版本1, interval uint16LE, LED0/1];合法例01 02 00 01表示2秒/LED命令on。未知版本、错误长度、interval0或61、LED2都拒绝,配置整体保持原值;未实现Write Without Response。
status8B为[版本1, seq uint32LE, interval uint16LE, LED命令]。每个本地定时回调递增seq,即使无人订阅也递增;断开清连接与订阅,重连重新订阅,不回补历史。seq回绕/RESET归零,不是全局消息ID。
ATT权限可能在应用callback之前拒绝请求,手机显示的错误顺序/文本未必等于host模型逐行分支;通过安全门后,0x08授权、0x0D长度、0x13字段值等帮助定位。模型检查通过不等于协议栈已在真实手机上走过同样事件。
| 特征 | 操作 | 访问与确认 |
|---|---|---|
| …d703公开status | Read / Notify | Read快照;Notify需CCCD,无应用ACK |
| …d704 config | Read | 加密+认证+16B key |
| …d704 config | Write Request | 上述安全+实体LOW;恰好4B,1..60s/LED0..1 |
参考:[8] 固定NimBLE ATT:特征安全权限执行[9] 固定GATT定义:flags与min_key_size[10] 固定GAP:连接安全状态/订阅/IO事件
完整主程序与可重建环境
以下main.c与包内逐字一致;纯C模型、host测试、CMake、分区、锁和默认配置一起下载。所有可变config、当前handle、订阅标志及通知定时器都在NimBLE主机事件队列运行,避免另一个任务使用已断开的handle;入队失败只记录,不无限缓存历史。
先按包README在IDF5.5.5/Python3.12.13执行idf.py -B build -D SDKCONFIG=sdkconfig.local build,再核最终安全项和单连接角色,再经桥口flash monitor。实际首轮两处misleading-indentation被-Werror拒绝,保留原日志/源快照;仅修括号换行后重编Exit0,未关闭告警,最终appbin463360B。构建通过不证明手机配对。
/* SPDX-License-Identifier: MIT */
#include <string.h>
#include <inttypes.h>
#include "esp_log.h"
#include "esp_random.h"
#include "driver/gpio.h"
#include "nvs_flash.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "nimble/nimble_npl.h"
#include "host/ble_hs.h"
#include "host/ble_sm.h"
#include "host/util/util.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "gatt_model.h"
static const char *TAG="W06";
static const ble_uuid128_t svc_uuid=BLE_UUID128_INIT(0x02,0xd7,0x77,0xb8,0xf2,0x6c,0x55,0x9d,0x92,0x4c,0x8a,0x8c,0xa7,0x49,0xfb,0x36);
static const ble_uuid128_t status_uuid=BLE_UUID128_INIT(0x03,0xd7,0x77,0xb8,0xf2,0x6c,0x55,0x9d,0x92,0x4c,0x8a,0x8c,0xa7,0x49,0xfb,0x36);
static const ble_uuid128_t config_uuid=BLE_UUID128_INIT(0x04,0xd7,0x77,0xb8,0xf2,0x6c,0x55,0x9d,0x92,0x4c,0x8a,0x8c,0xa7,0x49,0xfb,0x36);
static uint16_t status_handle;static uint8_t own_addr_type;static uint32_t sequence;static w06_config_t config={.interval_s=5};static w06_link_t link={.handle=UINT16_MAX};static struct ble_npl_callout timer;
static bool maintenance(void){return gpio_get_level(GPIO_NUM_5)==0;}
static void schedule(void){int rc=ble_npl_callout_reset(&timer,ble_npl_time_ms_to_ticks32(config.interval_s*1000U));if(rc)ESP_LOGE(TAG,"status timer rc=%d",rc);}
static w06_security_t security(uint16_t handle){struct ble_gap_conn_desc d;if(ble_gap_conn_find(handle,&d))return (w06_security_t){0};return (w06_security_t){d.sec_state.encrypted,d.sec_state.authenticated,d.sec_state.key_size};}
static int access(uint16_t conn,uint16_t attr,struct ble_gatt_access_ctxt *ctxt,void *arg){
(void)attr;const bool is_config=arg!=NULL;
if(ctxt->op==BLE_GATT_ACCESS_OP_READ_CHR){
if(!is_config){uint8_t out[8];w06_status(&config,sequence,out);return os_mbuf_append(ctxt->om,out,sizeof(out))?BLE_ATT_ERR_INSUFFICIENT_RES:0;}
int e=w06_secure(security(conn));if(e)return e;uint8_t out[4]={1,(uint8_t)config.interval_s,(uint8_t)(config.interval_s>>8),config.led?1:0};return os_mbuf_append(ctxt->om,out,sizeof(out))?BLE_ATT_ERR_INSUFFICIENT_RES:0;
}
if(!is_config||ctxt->op!=BLE_GATT_ACCESS_OP_WRITE_CHR)return BLE_ATT_ERR_WRITE_NOT_PERMITTED;
const size_t length=OS_MBUF_PKTLEN(ctxt->om);if(length!=4)return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
uint8_t bytes[4];if(os_mbuf_copydata(ctxt->om,0,4,bytes))return BLE_ATT_ERR_UNLIKELY;
int e=w06_write(&config,bytes,sizeof(bytes),security(conn),maintenance());
if(e){ESP_LOGW(TAG,"WRITE rejected ATT=0x%02x; config unchanged",e);return e;}
gpio_set_level(GPIO_NUM_4,config.led);schedule();ESP_LOGI(TAG,"WRITE accepted RAM interval=%u LED_command=%u; not observed light",config.interval_s,config.led);return 0;
}
static const struct ble_gatt_svc_def services[]={
{.type=BLE_GATT_SVC_TYPE_PRIMARY,.uuid=&svc_uuid.u,.characteristics=(struct ble_gatt_chr_def[]){
{.uuid=&status_uuid.u,.access_cb=access,.flags=BLE_GATT_CHR_F_READ|BLE_GATT_CHR_F_NOTIFY,.val_handle=&status_handle},
{.uuid=&config_uuid.u,.access_cb=access,.arg=(void*)1,.flags=BLE_GATT_CHR_F_READ|BLE_GATT_CHR_F_WRITE|BLE_GATT_CHR_F_READ_ENC|BLE_GATT_CHR_F_READ_AUTHEN|BLE_GATT_CHR_F_WRITE_ENC|BLE_GATT_CHR_F_WRITE_AUTHEN,.min_key_size=16},
{0}}},{0}};
static void advertise(void);
static int gap_event(struct ble_gap_event *event,void *arg){
(void)arg;
switch(event->type){
case BLE_GAP_EVENT_CONNECT:
if(event->connect.status){ESP_LOGW(TAG,"connect failed=%d",event->connect.status);advertise();return 0;}
w06_connect(&link,event->connect.conn_handle);ESP_LOGI(TAG,"CONNECTED handle=%u; subscription reset",link.handle);
if(maintenance()){int rc=ble_gap_security_initiate(link.handle);ESP_LOGI(TAG,"security request rc=%d; verify private UART PIN",rc);}return 0;
case BLE_GAP_EVENT_DISCONNECT:w06_disconnect(&link);ESP_LOGI(TAG,"DISCONNECTED; no pending notifications/history");advertise();return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
if(event->subscribe.attr_handle==status_handle){w06_subscription(&link,event->subscribe.conn_handle,event->subscribe.cur_notify);}
return 0;
case BLE_GAP_EVENT_ENC_CHANGE:{
w06_security_t s=security(event->enc_change.conn_handle);ESP_LOGI(TAG,"SECURITY status=%d encrypted=%u authenticated=%u key_bytes=%u",event->enc_change.status,s.encrypted,s.authenticated,s.key_size);
if(event->enc_change.status||w06_secure(s)){ble_gap_terminate(event->enc_change.conn_handle,BLE_ERR_AUTH_FAIL);}
return 0;}
case BLE_GAP_EVENT_PASSKEY_ACTION:{
if(!maintenance()||event->passkey.params.action!=BLE_SM_IOACT_DISP){ESP_LOGW(TAG,"pairing rejected: maintenance closed or unsupported IO action; no JustWorks acceptance");ble_gap_terminate(event->passkey.conn_handle,BLE_ERR_AUTH_FAIL);return 0;}
uint32_t number,limit=UINT32_MAX-(UINT32_MAX%1000000U);do{number=esp_random();}while(number>=limit);
struct ble_sm_io io={.action=BLE_SM_IOACT_DISP,.passkey=number%1000000U};ESP_LOGW(TAG,"PRIVATE PAIRING PIN %06"PRIu32" (this attempt only; do not publish UART log)",io.passkey);
int rc=ble_sm_inject_io(event->passkey.conn_handle,&io);if(rc)ESP_LOGW(TAG,"inject PIN rc=%d",rc);return 0;}
case BLE_GAP_EVENT_REPEAT_PAIRING:ESP_LOGW(TAG,"repeat pairing refused; no automatic bond deletion");return BLE_GAP_REPEAT_PAIRING_IGNORE;
default:return 0;
}
}
static void advertise(void){
struct ble_hs_adv_fields fields={0};fields.flags=BLE_HS_ADV_F_DISC_GEN|BLE_HS_ADV_F_BREDR_UNSUP;fields.uuids128=(ble_uuid128_t*)&svc_uuid;fields.num_uuids128=1;fields.uuids128_is_complete=1;
int rc=ble_gap_adv_set_fields(&fields);if(rc){ESP_LOGE(TAG,"adv_fields rc=%d",rc);return;}
struct ble_hs_adv_fields scan={0};const char *name="HW-W06";scan.name=(const uint8_t*)name;scan.name_len=strlen(name);scan.name_is_complete=1;rc=ble_gap_adv_rsp_set_fields(&scan);if(rc){ESP_LOGE(TAG,"scan_rsp rc=%d",rc);return;}
struct ble_gap_adv_params p={0};p.conn_mode=BLE_GAP_CONN_MODE_UND;p.disc_mode=BLE_GAP_DISC_MODE_GEN;rc=ble_gap_adv_start(own_addr_type,NULL,BLE_HS_FOREVER,&p,gap_event,NULL);ESP_LOGI(TAG,"advertise rc=%d",rc);
}
static void timer_event(struct ble_npl_event *event){
(void)event;++sequence;
if(link.connected&&link.subscribed){uint8_t out[8];w06_status(&config,sequence,out);struct os_mbuf *om=ble_hs_mbuf_from_flat(out,sizeof(out));if(!om)ESP_LOGW(TAG,"notify allocation failed");else{int rc=ble_gatts_notify_custom(link.handle,status_handle,om);ESP_LOGI(TAG,"NOTIFY queued rc=%d seq=%"PRIu32"; no phone receipt ACK",rc,sequence);}}
schedule();
}
static void reset(int reason){w06_disconnect(&link);ESP_LOGW(TAG,"host reset=%d",reason);}
static void sync_ready(void){int rc=ble_hs_util_ensure_addr(0);if(!rc)rc=ble_hs_id_infer_auto(0,&own_addr_type);if(rc){ESP_LOGE(TAG,"identity rc=%d",rc);return;}advertise();schedule();}
static void host_task(void *arg){(void)arg;nimble_port_run();nimble_port_freertos_deinit();}
void app_main(void){
gpio_config_t led={.pin_bit_mask=1ULL<<4,.mode=GPIO_MODE_OUTPUT};ESP_ERROR_CHECK(gpio_config(&led));gpio_set_level(GPIO_NUM_4,0);
gpio_config_t arm={.pin_bit_mask=1ULL<<5,.mode=GPIO_MODE_INPUT};ESP_ERROR_CHECK(gpio_config(&arm));
esp_err_t e=nvs_flash_init();if(e!=ESP_OK){ESP_LOGE(TAG,"NVS=%s; no automatic erase",esp_err_to_name(e));return;}
e=nimble_port_init();if(e!=ESP_OK){ESP_LOGE(TAG,"NimBLE init=%s",esp_err_to_name(e));return;}
ble_hs_cfg.reset_cb=reset;ble_hs_cfg.sync_cb=sync_ready;ble_hs_cfg.sm_io_cap=BLE_HS_IO_DISPLAY_ONLY;ble_hs_cfg.sm_mitm=1;ble_hs_cfg.sm_sc=1;ble_hs_cfg.sm_sc_only=1;ble_hs_cfg.sm_sec_lvl=4;ble_hs_cfg.sm_bonding=0;
ble_svc_gap_init();ble_svc_gatt_init();ble_svc_gap_device_name_set("HW-W06");int rc=ble_gatts_count_cfg(services);if(!rc)rc=ble_gatts_add_svcs(services);if(rc){ESP_LOGE(TAG,"GATT register rc=%d",rc);return;}
ble_npl_callout_init(&timer,nimble_port_get_dflt_eventq(),timer_event,NULL);nimble_port_freertos_init(host_task);
ESP_LOGI(TAG,"W06 SC-only, authenticated16B key for config, physicalLOW for write; RAM only, no default PIN/bond persistence");
}
参考:[3] IDF5.5.5 NimBLE Kconfig:SC/安全/角色[9] 固定GATT定义:flags与min_key_size[10] 固定GAP:连接安全状态/订阅/IO事件[12] 固定NimBLE NPL:事件队列/callout
按顺序做认证、读回、写入和订阅
以官方Android nRF Connect4.29.1为操作参考,记录真实手机/系统/App版本并处理扫描/连接权限;本站没有手机运行结果。配对PIN只在本台私人UART读取,不把运行日志公开上传;000000也可能是本次随机结果,需保留前导零,不能当成预设密码。
解码工具host/decode_gatt.py只解析复制的4B/8B,不连接、不订阅、不写BLE。README的例字节明确是合成格式;真实观察CSV只填手机实际读取结果。
- 维护开关关。扫描HW-W06或service UUID→CONNECT→CLIENT展开;读公开status应有8B。尚未认证读写config应触发安全升级或失败,不能直接改LED。
- 断开,维护开关闭合LOW后重连,按本次私人UART六位PIN输入;无提示/失败不降低到Just Works。执行时核SECURITY status=0、encrypted=1、authenticated=1、key_bytes=16。
- 读config核4B默认01 05 00 00;对status启用Notify/CCCD,确认读和订阅是两种操作。取消再开订阅并记录。
- config用HEX和Write Request写01 02 00 01;读回核2s/LED1,并独立观察LED。试错误长度/边界值后读回不变。
- 维护关后合法写应失败,已认证config read仍可能成功;断开重连后重新认证和订阅,无历史回补。
- RESET回5s/off;如手机缓存旧bond,仅忘记本设备再配对。结束维护关闭,记录每次拒绝/恢复。
参考:[10] 固定GAP:连接安全状态/订阅/IO事件[13] nRF Connect4.29.1客户端读写/日志[14] Android官方蓝牙权限
让拒绝、重连和恢复都能被观察
至少分组执行未认证、正确PIN、错误PIN/取消、维护关闭、长度3/5B、interval0/61、LED2、订阅开关、断开重连与RESET。拒绝时同时记录ATT错误和读回,不能只用“灯没亮”证明安全门工作。已认证且维护关闭仍可读配置,是本设计范围,不是发现了绕过写门。
无Notify先核CCCD、当前handle与timer/notify rc,不把服务发现当订阅成功;重连不应期待历史。写成功但灯没亮先核命令读回和330Ω/极性,断电修线。配对失败核客户端SC能力和手机旧bond,绝不为方便打开Legacy/debug keys。
| 故障 | 定位 | 恢复 |
|---|---|---|
| 保护特征拒绝 | 安全状态/key16/实体LOW | 回现场检查,不降认证 |
| 无Notify | CCCD/连接状态/入队rc | 重订阅;不补历史 |
| PIN/旧bond异常 | 本次PIN、手机缓存、无持久bond | 仅忘记本台重配对 |
| 配置更改未进固件 | 旧sdkconfig优先/版本锁 | 新配置重建,核最终全部安全项 |
参考:[3] IDF5.5.5 NimBLE Kconfig:SC/安全/角色[6] 固定NimBLE SMP:SC_ONLY、MITM与key长度[8] 固定NimBLE ATT:特征安全权限执行[10] 固定GAP:连接安全状态/订阅/IO事件[13] nRF Connect4.29.1客户端读写/日志
主机能验证权限逻辑,不能替手机证明握手
本轮同源C模型在ASan/UBSan下实际通过68组安全输入、长度0..8、所有版本字节、间隔/LED范围、拒绝不改状态、LE32格式以及错误handle/重连订阅。它不运行ATT/SMP加密、RF或手机UI;加密布尔输入不是密钥交换测试证据。
原生恢复构建、最终配置/源/固件/原log SHA在ble-usb-builds.json,同源命令在ble-usb-host.json,固定源码与读取笔记在ble-usb-sources.json。observations.csv仅表头,物理配对、LED、通知收件和兼容性仍pending。需要USB实体输入时进入下一篇独立工程,不在此公开状态上暗加控制命令。
参考:[6] 固定NimBLE SMP:SC_ONLY、MITM与key长度[8] 固定NimBLE ATT:特征安全权限执行[9] 固定GATT定义:flags与min_key_size
资料来源与版本说明
按固定官方实现、厂家引脚和实际构建核查;主机夹具仅证明软件边界,指定板/手机/USB实体运行仍未执行。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。
- 官方固定板文档
- Kingbright WP7113ID V.14A:极性、Vf与电流
- IDF5.5.5 NimBLE Kconfig:SC/安全/角色
- IDF5.5.5 port:SM_LVL直接映射
- 固定NimBLE syscfg:实际安全级别定义
- 固定NimBLE SMP:SC_ONLY、MITM与key长度
- 固定NimBLE SC实现
- 固定NimBLE ATT:特征安全权限执行
- 固定GATT定义:flags与min_key_size
- 固定GAP:连接安全状态/订阅/IO事件
- IDF5.5.5 esp_random:硬件随机条件
- 固定NimBLE NPL:事件队列/callout
- nRF Connect4.29.1客户端读写/日志
- Android官方蓝牙权限