联网与数据交换 / 安全配网发布 内容更新

给自己的ESP32项目加SoftAP安全配网入口:保存Wi-Fi和清配置恢复

采用ESP-IDF5.5.5自带SoftAP与Security1官方客户端,交互填写凭据,分开验证错误密码、持久配置、重启和长按清除;明确不是普通浏览器填表页面。

读完能做什么
得到带每设备PoP、300秒入口窗口和实体清除按钮的安全配网工程及官方客户端包装器。
开始之前
下载包为完整独立工程。先读列出的前置文章,或具备等价ESP-IDF、GPIO、局域网经验。
所需器材
指定S3板、USB数据线、常开按钮、面包板、导线、支持2.4GHz WPA2的路由器、可加入SoftAP的电脑、Python3.12和官方SDK客户端依赖
版本组合
SDK:ESP-IDF5.5.5 / b774170ff46c参考板:DevKitC-1 v1.1 / WROOM-1-N8R8;8MB DIO80MHz;PSRAM不启用依赖:SDK内置wifi_provisioning + protocomm Security1;无新增固件组件
适用范围

官方ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8;桥接UART115200、USB单路供电、8MB DIO80MHz、不启用PSRAM;GPIO5按钮到GND,内部上拉;SDK自带wifi_provisioning,SoftAP+Security1,官方Python客户端,无浏览器明文表单。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/network-systems.md#n08;固定源码/器材与具体引用。验证日期:
构建 / 配置已通过
docs/esp32-evidence/network-systems-builds.json;实际构建Exit0和源/日志/产物SHA。对象:ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8版本:ESP-IDF5.5.5验证日期:
仿真不适用
未执行ESP32或外设仿真。同源host与本机服务检查不冒充Wi-Fi、调度、NVS或电气结果。
本站实板待验证
无本站烧录/实体网络/传感器/灯运行记录;observations.csv仅表头,需按README逐项实测。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 安全配网

把“配网页”落实为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

02 / 安全配网

先生成每设备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实现

03 / 安全配网

用一个明确的外接按钮提供恢复后路

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

参考:[8] 官方 ESP32-S3-DevKitC-1 v1.1 用户指南与 N8R8 模组配置

04 / 安全配网

连接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实现

05 / 安全配网

完整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语义

06 / 安全配网

客户端包装器只隐藏输入,不发明新加密协议

下方完整包装器调用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实现

07 / 安全配网

按完整生命周期填写实板记录

首次成功后重启两次,确认不打开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语义

08 / 安全配网

哪些链路已经在本机真实执行?

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实现

09 / 安全配网

从失败阶段选择恢复动作

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] 固定官方配网客户端会话与配置函数

BACK TO THE SOURCE

资料来源与版本说明

按固定官方源码核对;实际构建、同源主机检查、本机服务和实体硬件分别记录。未把预期实板步骤写成已运行结果。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. 固定Wi-Fi provisioning manager接口:安全会话、is_provisioned和reset
  2. 固定manager实现:Flash配置、重试失败、恢复函数
  3. 固定SoftAP scheme:HTTP protocomm传输
  4. 固定protocomm Security1:X25519/PoP/AES-CTR
  5. 固定官方配网客户端会话与配置函数
  6. 固定官方Security1 Python实现
  7. 固定Wi-Fi持久存储与restore语义
  8. 官方 ESP32-S3-DevKitC-1 v1.1 用户指南与 N8R8 模组配置