先固定任务、器材与本轮证据范围
原计划R04不以insecure绕过报错。沿N03的HTTP客户端和N05的时间状态继续:先有链路,再取得同步结果和UTC,再验证信任链、DNS身份与有效期,最后区分TLS错误和HTTP应用状态。
本例生成自己的实验CA与SAN叶证书,私钥只在电脑私有目录;固件只嵌公开根。URL用自己的LAN IP路由,TLS_NAME保持esp32-lab.test并由common_name传给SNI/校名;它不自动修改HTTP Host,本例单站/health不依赖虚拟Host。
默认和网络链接两个最终app实际为190512B/888272B,均构建Exit0。真实电脑loopback TLS三场景和OpenSSL verify五场景通过;不是ESP32 Wi-Fi、SNTP、mbedTLS、板上峰值内存或目标时延测量。所有实板字段保持空白。
参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
构建与运行基线
安装官方IDF5.5.5并在新终端激活 export.sh / EIM 对应环境。作者复用已安装SDK,没有修改SDK源码。POSIX终端进入本例解压目录;PowerShell用等效变量/换行,不能直接照搬export语法。构建目录使用绝对路径,避免把对象写入网站仓库。
同一build保留app.bin、未剥离ELF、map、sdkconfig、dependencies.lock、源文件SHA和完整串口日志,不能仅保存一张panic截图或只下载来历不明bin。idf.py monitor退出Ctrl+]。若误刷或实验卡住,先停止输入、按RESET;必要时在BOOT配合RESET进入ROM下载模式并刷回已备份且分区匹配的正常固件。不要为排查修改eFuse或盲目erase_flash,NVS和其他分区可能有数据。
# POSIX shell · 配合本节前提逐项执行
export LAB_PROJECT="$PWD"
export LAB_BUILD="$HOME/esp32-labs/r04-safe"
python "$IDF_PATH/tools/idf.py" -C "$LAB_PROJECT" -B "$LAB_BUILD" -D SDKCONFIG="$LAB_BUILD/sdkconfig" build
# 确认板型、串口和备份后才自行执行;本文没有执行烧录。
python "$IDF_PATH/tools/idf.py" -C "$LAB_PROJECT" -B "$LAB_BUILD" -p YOUR_SERIAL_PORT flash monitor参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
默认关闭与实验范围
默认SSID、URL、TLS_NAME为空,启动打印CONFIG_DISABLED,既不连接Wi-Fi也不请求HTTPS。config/network-link.defaults仅用文档网段192.0.2.1和假SSID做实际链接构建,不是联网通过记录。私有配置参考sdkconfig.private.example,不打印密码,不把它加入包/仓库。
本例自定义partitions.csv的factory槽为3MiB(offset0x10000),无OTA;Flash8MiB不代表app可无限增长。刷分区前先按E09备份,旧分区数据不能靠同名假设兼容。
本例只请求自己控制的私有LAN HTTPS端点;电脑本机夹具默认127.0.0.1,若ESP要访问,需显式绑定自己LAN接口并检查防火墙/网络隔离,不暴露到公网。URL中的IP用于路由,TLS_NAME固定为证书SAN中的DNS身份esp32-lab.test;common_name传入mbedTLS hostname,既用于SNI也用于名称验证。HTTP默认Host来自URL的IP,本例单站/health不依赖虚拟主机Host;需要虚拟站点时按服务端要求另行配置并验证,不把TLS名称配置误当HTTP Host已自动改写。
证书时间失败应先修UTC和同步链路;改时区只改变显示,不能修证书有效期。SNTP并非认证时间,本例2025≤UTC<2100只是粗门限,最终仍由证书notBefore/notAfter/签名/用途/名称验证作判定。不能把处于这个范围的任意年份称为正确时间。
参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
生成自己的受控证书,私钥留在包外
Python3.12、cryptography50.0.1;依赖写在tools/requirements.txt。为主机工具单独venv,不能覆盖IDF解释器。示例验证使用OpenSSL3.6.3,/usr/bin/openssl若显示LibreSSL不是本文同版CLI。证书按执行时UTC生成,根365天、叶30天、DNS SAN+serverAuth,使用RSA2048/SHA256;该配方只用于隔离实验,不是公共CA或生产证书管理系统。
输出目录必须全新,已存在拒绝;身份必须为小写ASCII DNS名,IP/非法标签在创建目录前拒绝。私有目录0700、三个私钥0600且不打印;Windows需另核ACL,POSIX mode不能代替Windows权限验收。下载中certs/lab_ca.pem只是作者本轮公开测试根,不含私钥。读者必须重生成自己的根/叶、复制公开根再构建;不能指望作者根为自己的服务签名。叶过期重签并部署,根轮换时客户端根和服务链也要协调;不要把过期样例或某年系统CA列表永久硬编码。
# POSIX shell · 配合本节前提逐项执行
python3.12 -m venv "$HOME/esp32-labs/r04-python"
export LAB_PY="$HOME/esp32-labs/r04-python/bin/python"
"$LAB_PY" -m pip install -r tools/requirements.txt
export LAB_PRIVATE="$HOME/esp32-labs/r04-private-$(date +%s)"
"$LAB_PY" tools/generate_lab.py --out "$LAB_PRIVATE" --name esp32-lab.test
cp "$LAB_PRIVATE/lab_ca.pem" certs/lab_ca.pem
# 只复制公开根证书,然后重新build;不复制server.key/lab_ca.key/wrong_ca.key。
"$LAB_PY" tools/fixture_tls.py --lab "$LAB_PRIVATE" --openssl /ABS/PATH/to/openssl3 --out /tmp/r04-fixture-NEW参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
实际本机TLS夹具
夹具启动真实loopback HTTPS服务、限定超时和响应大小,依次执行正确CA+正确DNS(200)、错误CA拒绝、错误DNS拒绝;再用OpenSSL verify的-attime测试正确/错误CA/错误名/早于有效期/晚于有效期五项。它只向验证命令传epoch,不改系统时间。原始命令、返回码、公开证书SHA和服务log保留。PythonSSL/OpenSSL的错误码不等于ESP-IDF mbedTLS错误码,不能逐数值照抄到板上。
作者本轮8项实际通过,说明电脑上的受控TLS与X509条件能区分;没有验证ESP32射频、SNTP、mbedTLS堆、板上TLS或真实LAN。主机5项同源粗时钟门限和4项非法DNS CLI拒绝另计,不冒充握手。
参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
在自己的LAN启动端点并配置设备
URL必须https://;Wi-Fi仅一次10s应用连接窗口,断开标志与IP位一起判定。获得链路后创建SNTP信号量并显式sync_wait10s;该API等待与“init自动阻塞”不同。超时/粗UTC不合格停止HTTPS,修复后RESET,不降级HTTP或关闭验证。NVS初始化失败不擦除;Wi-Fi配置存RAM,事件组/驱动在程序结束后保留至RESET,避免回调引用已释放对象,也因此after_client_cleanup并不代表所有Wi-Fi内存已归还。
# POSIX shell · 配合本节前提逐项执行
"$LAB_PY" tools/tls_server.py --cert "$LAB_PRIVATE/server_cert.pem" --key "$LAB_PRIVATE/server.key" --bind YOUR_OWN_LAN_IP --port 8443
# 另一个已激活IDF的终端,进入本工程;文件中填自己的SSID/密码/LAN IP/DNS身份/NTP。
cp sdkconfig.private.example sdkconfig.private
export LAB_TLS_BUILD="$HOME/esp32-labs/r04-private-build"
python "$IDF_PATH/tools/idf.py" -C "$LAB_PROJECT" -B "$LAB_TLS_BUILD" -D SDKCONFIG="$LAB_TLS_BUILD/sdkconfig" -D 'SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.private' build
# 确认公开根来自同一私有lab后,自行flash/monitor该目录。参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
单次HTTPS日志怎么读
先取internal_free/internal_largest/general_free,域有重叠不能相加。然后init/perform/取TLS错误与verify_flags/cleanup/再取快照。body最多打印前256B,总字节计数饱和防溢出;不记录私密业务内容,本地服务只返回固定夹具文本。日志tls_state表示get_and_clear_last_tls_error的读取返回状态,不是握手状态;HTTPS result才是perform返回码,tls_error/verify_flags是保存的原始TLS线索。HTTP缓冲各1024不等于TLS峰值内存仅2048B。5s是网络操作timeout,不是整个perform端到端5s保证,慢分块仍可更久;本例未实现全程总deadline。
disable_auto_redirect=true,避免自动跳到未审目标;本例单次请求后END,不是长期重连下载器。每次失败修一项并RESET重试,填写observations.csv的实测项。时间/证书状态与内存峰值要来自同次板上日志,不能用本机OpenSSL结果代填。面向普通互联网域名时优先用DNS URL和受维护的SDK证书bundle/更新流程,核对应版本与服务链;本实验私有根不覆盖互联网证书,不提供insecure fallback。
| 阶段 | 需要保留的线索 | 修复方向 |
|---|---|---|
| LINK_NOT_READY | 无线/路由/IP窗口 | 先网络,不把它叫证书错 |
| TIME_NOT_READY | sync返回码、UTC秒 | 修NTP/UTC,不调时区糊过去 |
| TLS名称/信任/有效期失败 | 保存tls_error、verify_flags、实际SAN/链与当前UTC | 逐项修DNS身份、完整链、根和日期;保留verify |
| init/握手内存不足 | 同caps的free/largest、配置、请求前后及峰值采集方案 | 回R03核连续内部RAM;不盲删验证/换全局malloc |
| perform返回OK但HTTP404 | TLS成功与应用状态分开 | 检查path/Host/服务,不能当TLS失败 |
参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
文件、许可与未验证项
应用/工具MIT;Pythoncryptography、OpenSSL、IDF按上游许可安装,不分发私钥/SDK/第三方二进制。空observations.csv保留实际UTC/SAN/错误/内存与恢复栏;没有板、网络或测流结果。固定依据:IDF5.5.5 esp_http_client.h/c、esp_tls_mbedtls.c、esp_tls_errors.h、esp_netif_sntp.h/c;cryptography50.0.1 X509 API与OpenSSL3.6 verify官方手册。不同版本错误位含义先查本版源码,禁止用“连接成功”省略身份与信任验证。
参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
完整客户端:同步前置、验证、错误日志与清理
下列是下载包中的完整同名文件;其余CMake、配置、锁、工具和空表在独立工程内,按README的相同版本构建。
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <inttypes.h>
#include "sdkconfig.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "esp_wifi.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "esp_netif_sntp.h"
#include "esp_http_client.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "nvs_flash.h"
#include "tls_policy.h"
extern const char ca_start[] asm("_binary_lab_ca_pem_start");
static EventGroupHandle_t flags;
static const int IP_OK=1,LINK_FAIL=2;
static void network_event(void *unused,esp_event_base_t base,int32_t id,void *data){
(void)unused;(void)data;
if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_START){esp_err_t e=esp_wifi_connect();if(e!=ESP_OK)xEventGroupSetBits(flags,LINK_FAIL);}
else if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED)xEventGroupSetBits(flags,LINK_FAIL);
else if(base==IP_EVENT&&id==IP_EVENT_STA_GOT_IP)xEventGroupSetBits(flags,IP_OK);
}
typedef struct {size_t bytes,shown;} Body;
static esp_err_t http_event(esp_http_client_event_t *e){
Body *b=e->user_data;
if(e->event_id==HTTP_EVENT_ON_DATA&&e->data_len>0){
size_t n=(size_t)e->data_len;if(n<=SIZE_MAX-b->bytes)b->bytes+=n;else b->bytes=SIZE_MAX;
size_t show=256-b->shown;if(show>n)show=n;
if(show){printf("BODY_PREFIX %.*s\n",(int)show,(const char*)e->data);b->shown+=show;}
}
return ESP_OK;
}
static void memory(const char *where){printf("MEM %s internal_free=%zu internal_largest=%zu general_free=%zu (overlapping caps, not a sum)\n",where,heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),heap_caps_get_free_size(MALLOC_CAP_8BIT));}
void app_main(void){
if(!CONFIG_HX_SSID[0]||!CONFIG_HX_HTTPS_URL[0]||!CONFIG_HX_TLS_NAME[0]){puts("CONFIG_DISABLED: no WiFi/TLS request; generate own lab CA and configure private LAN first");return;}
if(strlen(CONFIG_HX_SSID)>31||strlen(CONFIG_HX_PASSWORD)>63||strncmp(CONFIG_HX_HTTPS_URL,"https://",8)||strstr(CONFIG_HX_SSID,"REPLACE")||strstr(CONFIG_HX_HTTPS_URL,"REPLACE")||strstr(CONFIG_HX_TLS_NAME,"REPLACE")){puts("CONFIG_INVALID: no request, credentials not printed");return;}
esp_err_t e=nvs_flash_init();if(e!=ESP_OK){printf("NVS_INIT=%s; no erase\n",esp_err_to_name(e));return;}
flags=xEventGroupCreate();if(!flags){puts("NO_MEMORY_EVENT_GROUP");return;}
ESP_ERROR_CHECK(esp_netif_init());ESP_ERROR_CHECK(esp_event_loop_create_default());esp_netif_create_default_wifi_sta();
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,network_event,NULL));ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT,IP_EVENT_STA_GOT_IP,network_event,NULL));
wifi_init_config_t w=WIFI_INIT_CONFIG_DEFAULT();ESP_ERROR_CHECK(esp_wifi_init(&w));ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));wifi_config_t c={0};strlcpy((char*)c.sta.ssid,CONFIG_HX_SSID,sizeof c.sta.ssid);strlcpy((char*)c.sta.password,CONFIG_HX_PASSWORD,sizeof c.sta.password);ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA,&c));ESP_ERROR_CHECK(esp_wifi_start());
EventBits_t bits=xEventGroupWaitBits(flags,IP_OK|LINK_FAIL,pdFALSE,pdFALSE,pdMS_TO_TICKS(10000));
if(!(bits&IP_OK)||(bits&LINK_FAIL)){puts("LINK_NOT_READY: no TLS request; driver remains initialized, RESET after repair");return;}
esp_sntp_config_t ntp=ESP_NETIF_SNTP_DEFAULT_CONFIG(CONFIG_HX_NTP_SERVER);ntp.wait_for_sync=true;
ESP_ERROR_CHECK(esp_netif_sntp_init(&ntp));e=esp_netif_sntp_sync_wait(pdMS_TO_TICKS(10000));time_t utc=time(NULL);bool time_ok=tls_clock_ok(e==ESP_OK,(int64_t)utc);printf("CLOCK sync=%s utc_s=%"PRId64" plausible_and_synced=%u (not authenticated time)\n",esp_err_to_name(e),(int64_t)utc,time_ok);esp_netif_sntp_deinit();
if(!time_ok){puts("TIME_NOT_READY: keep certificate verification, no HTTPS attempt; RESET after time/network repair");return;}
Body body={0};esp_http_client_config_t h={.url=CONFIG_HX_HTTPS_URL,.common_name=CONFIG_HX_TLS_NAME,.cert_pem=ca_start,.skip_cert_common_name_check=false,.disable_auto_redirect=true,.timeout_ms=5000,.buffer_size=1024,.buffer_size_tx=1024,.event_handler=http_event,.user_data=&body};
memory("before_client");esp_http_client_handle_t client=esp_http_client_init(&h);if(!client){puts("HTTP_CLIENT_INIT_FAILED");return;}
int64_t start=esp_timer_get_time();e=esp_http_client_perform(client);int tls_error=0,verify_flags=0;esp_err_t saved=esp_http_client_get_and_clear_last_tls_error(client,&tls_error,&verify_flags);
printf("HTTPS result=%s status=%d body_bytes=%zu elapsed_us=%"PRId64" tls_state=%s tls_error=%d verify_flags=0x%08x\n",esp_err_to_name(e),esp_http_client_get_status_code(client),body.bytes,esp_timer_get_time()-start,esp_err_to_name(saved),tls_error,(unsigned)verify_flags);
ESP_ERROR_CHECK(esp_http_client_cleanup(client));memory("after_client_cleanup");puts("END single request; WiFi/event group remain initialized for inspection until RESET. No periodic retry or insecure fallback.");
}
参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
同源粗UTC前置:不能代替证书有效期
下列是下载包中的完整同名文件;其余CMake、配置、锁、工具和空表在独立工程内,按README的相同版本构建。
// ---- main/tls_policy.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include <stdint.h>
#include <stdbool.h>
static inline bool tls_clock_ok(bool synced,int64_t utc){return synced&&utc>=1735689600LL&&utc<4102444800LL;}
参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
完整证书配方:只把公开根交给固件
下列是下载包中的完整同名文件;其余CMake、配置、锁、工具和空表在独立工程内,按README的相同版本构建。
# ---- tools/generate_lab.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Generate a new private lab, never overwrite. Only lab_ca.pem belongs in firmware."""
import argparse,datetime,ipaddress,json,os,pathlib,hashlib,re
from cryptography import x509
from cryptography.x509.oid import NameOID,ExtendedKeyUsageOID
from cryptography.hazmat.primitives import hashes,serialization
from cryptography.hazmat.primitives.asymmetric import rsa
p=argparse.ArgumentParser();p.add_argument('--out',required=True);p.add_argument('--name',default='esp32-lab.test');a=p.parse_args();out=pathlib.Path(a.out)
label=r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
if len(a.name)>253 or not re.fullmatch(label+r'(?:\.'+label+r')*',a.name):p.error('valid lowercase ASCII DNS identity required')
try:ipaddress.ip_address(a.name)
except ValueError:pass
else:p.error('DNS identity required, not a numeric IP; IP routing URL is configured separately')
out.mkdir(mode=0o700,parents=True,exist_ok=False)
now=datetime.datetime.now(datetime.timezone.utc);start=now-datetime.timedelta(minutes=5)
def keyfile(name,key):
f=out/name;f.write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()));os.chmod(f,0o600)
def ca(label):
key=rsa.generate_private_key(public_exponent=65537,key_size=2048);subject=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,label)])
cert=x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(start).not_valid_after(now+datetime.timedelta(days=365)).add_extension(x509.BasicConstraints(ca=True,path_length=0),True).add_extension(x509.KeyUsage(True,False,False,False,False,True,True,False,False),True).add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()),False).sign(key,hashes.SHA256());return key,cert
root,cert=ca('Hixinwu generated local lab CA');wrong,wrong_cert=ca('Different local lab CA');server=rsa.generate_private_key(public_exponent=65537,key_size=2048);subject=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,a.name)])
leaf=x509.CertificateBuilder().subject_name(subject).issuer_name(cert.subject).public_key(server.public_key()).serial_number(x509.random_serial_number()).not_valid_before(start).not_valid_after(now+datetime.timedelta(days=30)).add_extension(x509.BasicConstraints(ca=False,path_length=None),True).add_extension(x509.SubjectAlternativeName([x509.DNSName(a.name)]),False).add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),False).add_extension(x509.KeyUsage(True,False,True,False,False,False,False,False,False),True).add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(root.public_key()),False).sign(root,hashes.SHA256())
for name,obj in [('lab_ca.pem',cert),('wrong_ca.pem',wrong_cert),('server_cert.pem',leaf)]: (out/name).write_bytes(obj.public_bytes(serialization.Encoding.PEM))
keyfile('lab_ca.key',root);keyfile('server.key',server);keyfile('wrong_ca.key',wrong)
manifest={'generatedAt':now.isoformat(),'identity':a.name,'leafNotBefore':leaf.not_valid_before_utc.isoformat(),'leafNotAfter':leaf.not_valid_after_utc.isoformat(),'currentEpoch':int(now.timestamp()),'pastEpoch':int((start-datetime.timedelta(days=1)).timestamp()),'futureEpoch':int((now+datetime.timedelta(days=400)).timestamp()),'publicCertificates':{name:hashlib.sha256((out/name).read_bytes()).hexdigest() for name in ['lab_ca.pem','wrong_ca.pem','server_cert.pem']},'scope':'Generated test CA, not a system trust store. Private keys stay only in this private directory; do not put them in firmware, repo or ZIP.'}
(out/'public-manifest.json').write_text(json.dumps(manifest,indent=2)+'\n');print('Generated private lab at',out,'identity',a.name,'; copy ONLY lab_ca.pem into certs then rebuild. No private key printed.')
参考:[1] 固定esp_http_client.h:common_name/timeout/TLS错误API[2] 固定HTTP客户端实现:网络操作timeout与错误传播[3] 固定mbedTLS适配:hostname/SNI与名称验证
资料来源与版本说明
保持原计划ID/slug/阶段和用途。固定上游与实际构建、适用主机/QEMU验证留证;没有把本机结果升级为实体运行结论。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。