把“配网页”落实为SDK安全入口与客户端
原计划N08标题含“配网页”,验收环境明确允许SoftAP或BLE。本成品保留N08与原slug,选择固定IDF5.5.5自带SoftAP + Security1 +官方客户端;最终标题/outcome据此校正,不把不存在的浏览器填表或captive portal算已交付。
SoftAP提供局域HTTP传输,真正的Wi-Fi配置请求先建立protocomm Security1会话:X25519密钥交换、PoP参与密钥派生、AES-CTR加密。它不是随手向HTTP表单POST密码,也不宣称具备HTTPS浏览器站点。
默认公开占位不会打开AP;已配网设备从Wi-Fi Flash存储读取配置。错误凭据、存在但不可用的旧配置、成功持久化和重启后可用性是不同验收。本站已构建固件并实际跑官方客户端与本机合成服务,ESP32无线/Flash持久实测仍待完成。
参考:[1] 固定Wi-Fi provisioning manager接口:安全会话、is_provisioned和reset[2] 固定manager实现:Flash配置、重试失败、恢复函数[3] 固定SoftAP scheme:HTTP protocomm传输[4] 固定protocomm Security1:X25519/PoP/AES-CTR
先生成每设备PoP与SoftAP口令
在私人终端执行README的secrets.token_urlsafe生成两份随机字符串,一份PoP、一份SoftAP WPA2口令。本例PoP长度16..63字节、AP口令12..63字节,含REPLACE_WITH的值一律无效;为每台设备生成不同值,不把公开例子当生产密钥。
进入私有sdkconfig路径的menuconfig,在Demo per-device provisioning填写两项。记录在自己的密码管理器中;不提交sdkconfig.private,不把生成输出、PoP、AP口令、SSID或Wi-Fi密码贴进公开日志。固件内配置可被有物理/调试权限者读取,不能把编进Flash当作安全秘密仓库。
SDK固定5.5.5自带wifi_provisioning适用于本例,不额外混用新network_provisioning API。以后迁移组件时要重新锁版本和安全客户端;本例不启用Security0,也不把示例共享PoP作为默认开放路径。
# 私人终端;只把生成值保存在自己的私有配置和密码管理器
python3 -c 'import secrets; print(secrets.token_urlsafe(24)); print(secrets.token_urlsafe(24))'
# 在示例目录中
idf.py -B build -D SDKCONFIG="$PWD/sdkconfig.private" set-target esp32s3
idf.py -B build -D SDKCONFIG="$PWD/sdkconfig.private" menuconfig
idf.py -B build -D SDKCONFIG="$PWD/sdkconfig.private" build
参考:[1] 固定Wi-Fi provisioning manager接口:安全会话、is_provisioned和reset[4] 固定protocomm Security1:X25519/PoP/AES-CTR[6] 固定官方Security1 Python实现
用一个明确的外接按钮提供恢复后路
GPIO5接常开按钮一端,另一端接GND,程序启用内部上拉;按钮释放为高、按下为低。面包板四脚按钮应先用通断档确认同侧短接,避免一直按下或没有闭合。
使用官方DevKitC-1 v1.1 / N8R8。不要拿板载BOOT当GPIO5,BOOT是另一个信号;USB D+/D−、Flash/PSRAM保留脚也不参与。只用一条USB给板供电,断电接线。
按下需先稳定50毫秒,再连续保持5秒;只触发一次,释放后重装。使用64位单调毫秒,不依赖32位millis整圈后重新变“刚按下”。
| 信号 | 接法 | 结果 |
|---|---|---|
| GPIO5 | 常开按钮→GND | 内部上拉,按下低 |
| USB桥接UART | 电脑数据线 | 115200日志 |
| 3V3/5V | 不接按钮 | 按钮只短接GPIO5到GND |
连接SoftAP,用官方客户端完成会话
烧录私有配置后,无已存SSID时串口只输出HIX-N08-加MAC后三字节的AP名,不输出PoP/AP口令/SSID/Wi-Fi密码。用电脑Wi-Fi设置加入该AP,输入自己的AP口令;无互联网提示可保留连接,临时停用会把请求绕走的VPN/自动切网。
在独立Python3.12环境安装client-requirements.txt,再用IDF_PATH指向同一个5.5.5 SDK。SDK纯构建Python不保证包含protobuf/cryptography;本文实际复用的Python3.12.13环境已安装protobuf7.36.2和cryptography50.0.1,官方客户端可成功加载,不需要另装SDK。
运行provision_client.py,交互隐藏输入PoP、2.4GHz WPA2 SSID及8..63字节密码;秘密不放命令行参数或shell历史。默认地址192.168.4.1:80,包装器直接调用固定官方建立会话/SetConfig/ApplyConfig/GetStatus函数,verbose=false。
只有客户端看到connected且设备出现ONLINE IP才记录当轮成功;这仍不能替代实际重启后从Flash凭据再次联网。电脑切回家庭网络后,根据串口IP核对后续应用通信。
python3.12 -m venv .client-venv
# Windows 使用 .client-venv\Scripts\python;macOS/Linux如下
.client-venv/bin/python -m pip install -r client-requirements.txt
# IDF_PATH应来自同一个5.5.5环境
.client-venv/bin/python provision_client.py
- 做到这里,你应该看到
- 确认PoP错误时在建立会话阶段拒绝,不应进入提交Wi-Fi凭据;确认正确会话后的connected与实际ONLINE IP。
参考:[1] 固定Wi-Fi provisioning manager接口:安全会话、is_provisioned和reset[3] 固定SoftAP scheme:HTTP protocomm传输[5] 固定官方配网客户端会话与配置函数[6] 固定官方Security1 Python实现
完整main:持久Wi-Fi与有限入口窗口
首次启动先初始化NVS、网络接口、Wi-Fi和manager。is_provisioned只判断配置是否存在,不判断路由器当前可用;已存在则停止manager并启动STA,掉线仅重试STA,不因普通网络故障自动重开AP。
无配置且PoP/AP参数有效才开300秒SoftAP窗口;最多3次连接尝试后上报失败,主循环在窗口内清失败状态供客户端再填。这个连接次数是SDK管理器策略,不等同精确射频次数或整个操作耗时。
成功后manager自动停止;本应用在主循环处理END并deinit,callback只上报原子标记/日志。窗口到期请求停止,离线结束则停止Wi-Fi且不自动重新开AP;需要实体长按清配置重启。默认CONFIG_MISSING也保留按钮路径。
esp_wifi_set_storage(WIFI_STORAGE_FLASH)与manager写配置建立持久路径,未实现NVS加密或安全启动。NVS初始化失败不自动擦全区,而是打印错误退出;先备份自己的其他命名空间再做显式恢复。
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <string.h>
#include <stdatomic.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_event.h"
#include "esp_wifi.h"
#include "esp_netif.h"
#include "esp_timer.h"
#include "esp_mac.h"
#include "esp_system.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "wifi_provisioning/manager.h"
#include "wifi_provisioning/scheme_softap.h"
#include "sdkconfig.h"
#include "provision_policy.h"
static const char *TAG="N08";
static atomic_bool in_provisioning, failed, ended, got_ip, lost;
static void event(void *arg,esp_event_base_t base,int32_t id,void *data){
(void)arg;
if(base==WIFI_PROV_EVENT){
if(id==WIFI_PROV_START)ESP_LOGI(TAG,"PROVISIONING: Security1, credentials/PoP deliberately omitted");
else if(id==WIFI_PROV_CRED_RECV)ESP_LOGI(TAG,"CREDENTIALS_RECEIVED: not yet successful; no SSID/password logged");
else if(id==WIFI_PROV_CRED_FAIL){wifi_prov_sta_fail_reason_t *r=data;ESP_LOGW(TAG,"CREDENTIALS_FAILED reason=%d; retry within open window",*r);atomic_store(&failed,true);}
else if(id==WIFI_PROV_CRED_SUCCESS)ESP_LOGI(TAG,"CREDENTIALS_SUCCESS: manager accepted connection; verify restart separately");
else if(id==WIFI_PROV_END)atomic_store(&ended,true);
}else if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_START){if(!atomic_load(&in_provisioning))esp_wifi_connect();}
else if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED){atomic_store(&got_ip,false);atomic_store(&lost,true);}
else if(base==IP_EVENT&&id==IP_EVENT_STA_GOT_IP){
ip_event_got_ip_t *e=data;atomic_store(&got_ip,true);
ESP_LOGI(TAG,"ONLINE IP=" IPSTR " (this is not a reboot persistence test)",IP2STR(&e->ip_info.ip));
}
}
void app_main(void){
gpio_config_t button={.pin_bit_mask=1ULL<<5,.mode=GPIO_MODE_INPUT,.pull_up_en=GPIO_PULLUP_ENABLE};ESP_ERROR_CHECK(gpio_config(&button));
esp_err_t err=nvs_flash_init();
if(err!=ESP_OK){ESP_LOGE(TAG,"NVS_INIT=%s; no automatic erase. Back up other app data, then deliberate erase-flash recovery.",esp_err_to_name(err));return;}
ESP_ERROR_CHECK(esp_netif_init());ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();esp_netif_create_default_wifi_ap();
wifi_init_config_t wi=WIFI_INIT_CONFIG_DEFAULT();ESP_ERROR_CHECK(esp_wifi_init(&wi));
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_FLASH));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_PROV_EVENT,ESP_EVENT_ANY_ID,event,NULL));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,event,NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT,IP_EVENT_STA_GOT_IP,event,NULL));
wifi_prov_mgr_config_t cfg={.scheme=wifi_prov_scheme_softap,.scheme_event_handler=WIFI_PROV_EVENT_HANDLER_NONE,.wifi_prov_conn_cfg={.wifi_conn_attempts=3}};
ESP_ERROR_CHECK(wifi_prov_mgr_init(cfg));bool provisioned=false;ESP_ERROR_CHECK(wifi_prov_mgr_is_provisioned(&provisioned));
int64_t began=esp_timer_get_time()/1000,next_retry=began;bool manager=true,window=false,station_running=provisioned;
if(provisioned){
ESP_LOGI(TAG,"STORED_CONFIG_PRESENT: existence only, not proof credentials still work");
wifi_prov_mgr_deinit();manager=false;ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));ESP_ERROR_CHECK(esp_wifi_start());
}else if(!provisioning_secrets_valid(CONFIG_DEMO_POP,CONFIG_DEMO_AP_PASSWORD)){
ESP_LOGW(TAG,"CONFIG_MISSING: set per-device PoP and AP passphrase. No AP opened; GPIO5 5-second reset still available.");
}else{
uint8_t mac[6];ESP_ERROR_CHECK(esp_read_mac(mac,ESP_MAC_WIFI_STA));char name[32];snprintf(name,sizeof(name),"HIX-N08-%02X%02X%02X",mac[3],mac[4],mac[5]);
atomic_store(&in_provisioning,true);window=true;
ESP_ERROR_CHECK(wifi_prov_mgr_start_provisioning(WIFI_PROV_SECURITY_1,CONFIG_DEMO_POP,name,CONFIG_DEMO_AP_PASSWORD));
ESP_LOGI(TAG,"SoftAP name=%s, window=300s. Use official SoftAP client with Security1/PoP; no captive portal or plaintext form.",name);
}
ResetButton reset={0};
for(;;){
int64_t now=esp_timer_get_time()/1000;
if(reset_step(&reset,gpio_get_level(GPIO_NUM_5)==0,now)){
ESP_LOGW(TAG,"PHYSICAL_RESET: clearing persistent Wi-Fi stack config only; this is NOT all namespaces or a secure flash erase");
err=wifi_prov_mgr_reset_provisioning();
if(err==ESP_OK){ESP_LOGW(TAG,"Wi-Fi config reset returned OK; restarting, expect provisioning path");vTaskDelay(pdMS_TO_TICKS(200));esp_restart();}
else ESP_LOGE(TAG,"RESET_FAILED=%s; no success/restart claimed",esp_err_to_name(err));
}
if(manager&&atomic_exchange(&failed,false)){
err=wifi_prov_mgr_reset_sm_state_on_failure();ESP_LOGW(TAG,"RESET_FAILED_SESSION=%s; window deadline unchanged",esp_err_to_name(err));
}
if(manager&&window&&provisioning_expired(began,now)){
window=false;wifi_prov_mgr_stop_provisioning();ESP_LOGW(TAG,"WINDOW_EXPIRED: stop requested; hold GPIO5 5s to clear/restart and reopen");
}
if(manager&&atomic_exchange(&ended,false)){
wifi_prov_mgr_deinit();manager=false;window=false;station_running=atomic_load(&got_ip);atomic_store(&in_provisioning,false);
if(!atomic_load(&got_ip)){esp_wifi_stop();ESP_LOGW(TAG,"PROVISIONING_ENDED_OFFLINE: no automatic AP reopen");}
}
if(station_running&&!manager&&!atomic_load(&in_provisioning)&&atomic_exchange(&lost,false))next_retry=now+2000;
if(station_running&&!manager&&!atomic_load(&got_ip)&&now>=next_retry){
next_retry=now+5000;err=esp_wifi_connect();if(err!=ESP_OK)ESP_LOGW(TAG,"CONNECT_REQUEST=%s; physical reset available",esp_err_to_name(err));
}
vTaskDelay(pdMS_TO_TICKS(20));
}
}
参考:[1] 固定Wi-Fi provisioning manager接口:安全会话、is_provisioned和reset[2] 固定manager实现:Flash配置、重试失败、恢复函数[3] 固定SoftAP scheme:HTTP protocomm传输[7] 固定Wi-Fi持久存储与restore语义
客户端包装器只隐藏输入,不发明新加密协议
下方完整包装器调用SDK已有协议。它在会话失败时不调用send_wifi_config,配置/apply失败或20次状态轮询没有确认则失败退出,不把“请求已发”当成功。
HTTP传输的单次连接/读取超时由固定官方Transport_HTTP设置为60秒,20次轮询不代表整流程最多20秒;网络操作、人工输入与重试可增加总耗时。本例没有客户端端到端总deadline。
真实Wi-Fi口令只交互进入进程内存与加密配置请求,不写入本包装器文件;SDK、系统、调试工具或恶意本机程序仍能改变安全边界。不要开官方客户端verbose或调试级Wi-Fi日志后公开输出。
# ---- provision_client.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Official fixed-IDF client functions; no credentials in argv or debug output."""
import argparse, asyncio, getpass, os, sys
from pathlib import Path
def sdk_client():
root=Path(os.environ['IDF_PATH'])
sys.path.insert(0,str(root/'tools/esp_prov'))
import esp_prov
return esp_prov
async def provision(address,pop,ssid,password):
client=sdk_client()
tp=await client.get_transport('softap',address)
if not tp: raise RuntimeError('transport unavailable')
try:
sec=client.get_security(1,0,None,None,pop,False)
if not await client.establish_session(tp,sec): raise RuntimeError('Security1 session rejected; no credentials sent')
if not await client.send_wifi_config(tp,sec,ssid,password): raise RuntimeError('SetConfig rejected')
if not await client.apply_wifi_config(tp,sec): raise RuntimeError('ApplyConfig rejected')
for _ in range(20):
state=await client.get_wifi_config(tp,sec)
if state=='connected': return True
if state in ('failed','disconnected'): return False
await asyncio.sleep(1)
raise RuntimeError('status window elapsed; inspect device without assuming success')
finally:
tp.conn.close()
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--address',default='192.168.4.1:80');a=parser.parse_args()
pop=getpass.getpass('Per-device PoP (hidden): ')
ssid=getpass.getpass('2.4GHz WPA2 SSID (hidden): ')
password=getpass.getpass('Wi-Fi password (hidden): ')
if len(pop)<16 or not 1<=len(ssid.encode())<=32 or not 8<=len(password.encode())<=63:
raise SystemExit('Invalid lengths; this demo uses WPA2-PSK only')
try: ok=asyncio.run(provision(a.address,pop,ssid,password))
except Exception as e: raise SystemExit(f'Provisioning not confirmed: {type(e).__name__}: {e}')
print('Client observed connected; now test physical reboot and persistence separately' if ok else 'Connection failed; correct credentials or hold GPIO5 for reset')
raise SystemExit(0 if ok else 1)
参考:[4] 固定protocomm Security1:X25519/PoP/AES-CTR[5] 固定官方配网客户端会话与配置函数[6] 固定官方Security1 Python实现
按完整生命周期填写实板记录
首次成功后重启两次,确认不打开SoftAP、从存储配置启动STA并重新取得IP;记录固件SHA、实际AP名、时间和日志,密码字段留空。仅看到STORED_CONFIG_PRESENT不能填“重启联网通过”。
用错误密码提交,确认客户端failed和设备CREDENTIALS_FAILED,再在同一300秒窗口改正确值。窗口过去或已存配置不可用时,稳定按GPIO5超过5秒,等Wi-Fi restore返回OK并重启,再使用自己的PoP/AP口令配网。
wifi_prov_mgr_reset_provisioning固定实现调用esp_wifi_restore,只恢复Wi-Fi栈持久设置;不是擦除全部NVS命名空间、整Flash工厂重置或安全销毁。若reset失败会打印RESET_FAILED,不显示成功也不重启;按电源重启再查NVS/Wi-Fi错误。
需要彻底重置整个Flash时,先备份其他数据,再用正确板串口的idf.py erase-flash并重烧。本操作范围更大且不可用来代替普通重新配网;硬件PoP/AP配置仍来自重烧固件。
参考:[1] 固定Wi-Fi provisioning manager接口:安全会话、is_provisioned和reset[2] 固定manager实现:Flash配置、重试失败、恢复函数[7] 固定Wi-Fi持久存储与restore语义
哪些链路已经在本机真实执行?
host_check.c直接包含provision_policy.h,8组输入核占位/长度、去抖、长按一次/释放重装、跨32位边界以及300秒窗口到期/整圈不复活。
security1_loopback.py在127.0.0.1启动自写合成HTTP服务,使用真实X25519、SHA256-PoP、AES-CTR与官方protobuf;下载的provision_client.py直接调用官方客户端实际完成加密SetConfig/ApplyConfig/GetStatus。正确会话、合成错误凭据状态、错误PoP不能到SetConfig这3组均已运行。
这个fixture的服务端是主机实现,不是ESP32 protocomm固件;“connected”是合成状态,不表示真实AP连接。它没有Wi-Fi射频、Flash写入、SDK服务端/NVS或按钮电气结果;这些都在空CSV等实板补验。
参考:[1] 固定Wi-Fi provisioning manager接口:安全会话、is_provisioned和reset[2] 固定manager实现:Flash配置、重试失败、恢复函数[4] 固定protocomm Security1:X25519/PoP/AES-CTR[5] 固定官方配网客户端会话与配置函数[6] 固定官方Security1 Python实现
从失败阶段选择恢复动作
CONFIG_MISSING先检查私有menuconfig路径和REPLACE_WITH占位;找不到SoftAP先看是否已有存储配置或300秒过期。官方客户端连接失败先确认电脑仍加入HIX-N08网络且路由到192.168.4.1,普通浏览器GET空页面不证明配网服务失效。
会话失败先核PoP与Security1,不能降级Security0凑通;配网失败再核2.4GHz/WPA2、SSID字节长度与路由器隔离。忘记PoP/AP口令需通过受控重新烧录私有固件恢复,物理清Wi-Fi按钮不会替你生成新PoP。
完成安全入口后,可以接入自己的传感器或LED应用。下一篇回到已配置的可信局域网,用WebSocket观察SHT40曲线,配网生命周期与业务界面仍分别验收。
参考:[1] 固定Wi-Fi provisioning manager接口:安全会话、is_provisioned和reset[3] 固定SoftAP scheme:HTTP protocomm传输[4] 固定protocomm Security1:X25519/PoP/AES-CTR[5] 固定官方配网客户端会话与配置函数
资料来源与版本说明
按固定官方源码核对;实际构建、同源主机检查、本机服务和实体硬件分别记录。未把预期实板步骤写成已运行结果。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。