通信安全:给联网设备穿上衣服
- 分清传输加密、身份鉴权、设备认证三件事,按优先级落地
- 给 esp-mqtt 配 mqtts:// + CA 证书连上加密 Broker,给 esp_http_client 配 cert_pem / crt_bundle 走 https
- 用双向 TLS(client cert)做设备身份认证,知道证书怎么嵌进固件
- 避开跳过证书校验、时间不对导致校验失败、硬编码密钥这几个新手常踩的坑
把场景摆出来你就懂了。你做了块 ESP32-S3 温湿度板,连上 MQTT 往 home/temp 发数据,手机订阅着看。看起来很美。但如果你拿 Wireshark 在同一个 WiFi 下抓个包,会发现一件让人后背发凉的事:你的数据在网线和空气里跑的时候,是明文的。温度、湿度、你设的 topic、甚至你连 Broker 用的账号密码——只要有人和你在同一个网段,全都看得一清二楚。
更糟的是控制端。如果你的灯订阅了 home/led,任何知道这个 topic 的人——不需要密码,只要能连上同一个 Broker——往那个 topic 发一条 on,你家的灯就亮了。明文裸奔的联网设备,等于把家门钥匙挂在门把手上。
这一节讲的就是怎么给设备穿上衣服。读完你会知道新手最该做的几件安全事、它们的优先级,并且手里会有把 esp-mqtt 改成 mqtts://、把 esp_http_client 改成 https:// 的可跑配置。
阅读前你得先做完 MQTT 入门,因为下面直接在那套 esp-mqtt 骨架上加 TLS;HTTPS 部分接的是 HTTP 请求 那篇留的尾巴。WiFi 连接细节见 WiFi 联网。
安全四件事,按这个顺序做
新手一听"安全"就发怵,觉得是个深不见底的坑。其实对一个个人 IoT 项目,你要做的事就四件,而且有明确的优先级。先做前两件,你的设备就从"裸奔"变成"穿了衣服",挡住绝大多数随手就能干的坏事。
第一件:传输加密(最重要,必须做)。 让数据在路上是密文,别人抓包也看不懂。具体就是:用 HTTPS 而不是 HTTP,用 MQTTS(mqtts://...:8883)而不是裸 MQTT(mqtt://...:1883)。这一件做了,抓包看到的就是一坨乱码,前面那个"温度被偷看"的问题直接消失。
第二件:身份鉴权(必须做)。 证明"我是我",别让陌生人随便连。MQTT Broker 加上用户名密码、HTTP API 加上 token、所有地方都别用默认密码。这一件做了,陌生人连不上你的 Broker,也就发不了那条点灯的消息。
第三件:设备认证(进阶,看场景)。 用证书让服务器也能确认"连上来的确实是我的设备",也就是双向 TLS(mTLS)。个人项目大多用不到,等你要做能卖的产品时再说——本篇末尾会给出 esp-mqtt 的 client cert 配法。
第四件:固件与启动安全(别忽略)。 远程升级的通道如果不加密、不验签,等于给攻击者开了一扇推恶意固件的后门;芯片里 flash 不加密,别人撬开板子就能 dump 出你的固件和密钥。ESP-IDF 提供 Secure Boot v2(只跑签了名的固件)和 Flash Encryption(flash 内容加密),这是产品级才上的更高阶手段,本篇只提一句指路,OTA 通道安全单独见 OTA 空中升级。
优先级很清楚:一和二是地基,任何上公网的设备都必须做;三和四按需上。 这一节重点讲一和二,末尾带一下三。
TLS 到底在干什么
传输加密的标准做法叫 TLS(旧名 SSL,你看到 SSL 多半指的就是它)。HTTPS 就是"HTTP 跑在 TLS 上",MQTTS 就是"MQTT 跑在 TLS 上"。在 ESP-IDF 里,无论你用 esp-mqtt 还是 esp_http_client,底下都是同一个组件 esp-tls 在做这件事,所以两边的概念完全一致。理解它只要抓住两件事:
一是加密(对称密钥)。 你的设备和服务器在正式说话前,先用一套数学方法(这一步用的是非对称加密——公钥加密、私钥解密,安全地交换出一把密钥)协商出一把只有它俩知道的对称密钥,之后所有数据都用这把钥匙锁起来再发。中间的人就算把数据原封不动抄下来,没有钥匙也解不开。这就是为什么抓包看到的是乱码。非对称用来"安全地交钥匙",对称用来"高速地传数据",TLS 握手把两者结合起来。
二是验证服务器身份(CA 证书)。 光加密还不够——万一你以为在和真 Broker 说话,其实有个坏人冒充 Broker 截在中间(这叫中间人攻击)?TLS 解决这个靠"证书"。真服务器持有一张由权威机构(CA)签发的证书,设备这边预存着 CA 的根证书,握手时拿根证书一验,就能确认"对面这张证书是真的,不是冒牌货"。
记住这句:加密保证别人看不懂,验证服务器身份保证你没连错人。 两件事缺一不可。下面写代码时你会看到,关键就是给设备塞一张 CA 证书让它去验——在 ESP-IDF 里这就是一个 certificate / cert_pem 字段。
证书从哪来、怎么嵌进固件
ESP-IDF 不像 Arduino 那样把证书写成一段 R"EOF(...)" 字符串常量塞进 .ino,而是把证书当成一个文件嵌进固件,编译时由构建系统转成一段只读数据。这套做法更干净,也是产品里的标准姿势。
你需要的是连接对象(HTTPS 网站、MQTTS Broker)所用 CA 的根证书,是一段以 -----BEGIN CERTIFICATE----- 开头的文本。怎么拿:
- 浏览器打开目标 HTTPS 网站,点地址栏的锁图标,能导出证书链,取最顶上的根证书。
- 命令行:
openssl s_client -showcerts -connect broker.emqx.io:8883,从输出里复制根证书那段。 - 很多公共 Broker / 平台官网直接提供 CA 证书文件下载。
把这段文本存成一个文件,比如 main/certs/server_root_ca.pem,然后在 main/CMakeLists.txt 里用 EMBED_TXTFILES 让构建系统把它嵌进固件:
idf_component_register(
SRCS "main.c"
INCLUDE_DIRS "."
EMBED_TXTFILES "certs/server_root_ca.pem" # 把 PEM 文件嵌成只读数据
)
EMBED_TXTFILES(对应 component 注册函数里的 target_add_binary_data ... TEXT)会自动生成两个符号,让你在 C 代码里直接拿到这段文本的首尾指针:
// 符号名规则:文件名里的 . / 都换成 _,前后加 _binary_ 和 _start/_end
extern const uint8_t server_root_ca_pem_start[] asm("_binary_server_root_ca_pem_start");
extern const uint8_t server_root_ca_pem_end[] asm("_binary_server_root_ca_pem_end");
EMBED_TXTFILES(带 TXT)会在末尾自动补一个 \0,所以 _start 可以直接当 C 字符串用——这正是 esp-mqtt / esp_http_client 的证书字段要的格式。证书会过期,这点务必标在心里:今天能连,半年后突然握手失败,十有八九是证书到期了,换张新 PEM 重新编译烧录就好。
给 esp-mqtt 加 TLS(带鉴权)
把 MQTT 入门 那篇的明文配置改成加密 + 账号密码,核心只动配置结构体几个字段,事件回调那套骨架一行不用改:
#include "mqtt_client.h"
static const char *TAG = "mqtts";
// 上面 EMBED_TXTFILES 嵌进来的 CA 根证书
extern const uint8_t server_root_ca_pem_start[] asm("_binary_server_root_ca_pem_start");
void start_secure_mqtt(void) {
esp_mqtt_client_config_t cfg = {
// 1) 协议从 mqtt:// 换成 mqtts://,端口 1883 换 8883——这一步开启 TLS
.broker.address.uri = "mqtts://broker.emqx.io:8883",
// 2) 塞 CA 根证书:让设备验证 Broker 身份,挡掉中间人。这是 TLS 的另一半
.broker.verification.certificate = (const char *)server_root_ca_pem_start,
// 3) 身份鉴权:用户名 + 密码。陌生人没有它连不上你的 Broker
.credentials.username = "your_user",
.credentials.authentication.password = "your_pass",
};
esp_mqtt_client_handle_t client = esp_mqtt_client_init(&cfg);
// 回调注册、start、在 MQTT_EVENT_CONNECTED 里订阅——全和 l3-mqtt 那篇一模一样,略
esp_mqtt_client_register_event(client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL);
esp_mqtt_client_start(client);
}
和明文版的差别就三处,对着记:URI 从 mqtt://...:1883 换成 mqtts://...:8883、加 .broker.verification.certificate 塞 CA 证书、加 .credentials 那两个字段做鉴权。esp_mqtt_client_init / register_event / start 那套流程、MQTT_EVENT_CONNECTED 里订阅、断线自动重连——一个字都不用改,这就是 esp-mqtt 把 TLS 收进配置结构体的好处。
如果你连的是有正规 CA 签发证书的公共 Broker / 云平台,可以不自己找 CA,改用 ESP-IDF 内置的证书包:在 cfg.broker.verification 里设 .crt_bundle_attach = esp_crt_bundle_attach(需要在 menuconfig 里打开 mbedTLS → Certificate Bundle),它内置了一批主流根 CA,省去手动找证书。自建 Broker 用自签证书时则必须老老实实塞自己那张 CA。
你应该看到什么
idf.py monitor里照样滚出已连上 Broker,说明 TLS 握手过了、证书验过了、账号密码对了,三道关一起过。- 拿 Wireshark 在同网段抓包:明文 1883 时你能直接读到 topic 和数据;切到 8883 后,看到的是
Application Data一坨密文,读不出内容。这就是 TLS 在干活的铁证。 - 故意把账号密码改错重连,会卡在连不上、回调里反复进
MQTT_EVENT_ERROR/DISCONNECTED——证明鉴权真的在拦人,不是摆设。 - 故意把 CA 证书删掉或贴错,握手阶段就会失败(日志里有
mbedtls的证书校验错误码),连CONNECTED都到不了。
给 esp_http_client 加 TLS
HTTPS 是 HTTP 请求 那篇留的尾巴,原理和上面 MQTTS 完全一样——同一个 esp-tls 在底下做事。改动就两处:url 从 http:// 换 https://,再把 CA 证书塞给 config.cert_pem:
extern const uint8_t server_root_ca_pem_start[] asm("_binary_server_root_ca_pem_start");
esp_http_client_config_t config = {
.url = "https://httpbin.org/post", // http 换 https
.cert_pem = (const char *)server_root_ca_pem_start, // 塞 CA 根证书验服务器
.event_handler = _http_event_handler, // 收 body 的回调,和 l3-http 一样
.timeout_ms = 8000,
};
// 后面 init / perform / get_status_code / cleanup 全和 l3-http 那篇一致,略
如果目标站点用的是正规 CA 签的证书(绝大多数公网 https 都是),同样可以不自己找证书,用内置证书包:
esp_http_client_config_t config = {
.url = "https://httpbin.org/post",
.crt_bundle_attach = esp_crt_bundle_attach, // 用 ESP-IDF 内置根 CA 包,免手动找证书
.event_handler = _http_event_handler,
};
cert_pem 和 crt_bundle_attach 二选一:连固定的某个站/自建服务,塞那站的 cert_pem 最省事;要连各种公网 https、不想为每个站找证书,用 crt_bundle_attach。两个都不配,https:// 一连就握手失败——这就是 l3-http 那篇说的"HTTPS 必须带证书"。
几个让"加密"变成"假加密"的坑
下面这些坑,每一个都能让你以为安全了、其实没有,杀伤力比不加密还大,因为你会放松警惕。
跳过证书校验 = 假加密。 网上很多教程图省事,把证书字段干脆不填、或在 menuconfig 里关掉服务器校验、或用 esp_tls_cfg_t 里的 skip_common_name 之类的口子绕过验证——这等于 Arduino 那个臭名昭著的 setInsecure()。数据确实还是加密的,但你失去了"验证服务器身份"那一半,中间人可以冒充服务器和你建一条加密连接,你照样把数据乖乖加密了送给坏人。正式上线必须配 certificate / cert_pem / crt_bundle_attach 其中之一,别把校验关掉。
时间不对导致证书校验失败(ESP-IDF 头号阴坑)。 TLS 校验证书要看"证书有效期",而有效期是按当前时间判断的。ESP32-S3 上电后系统时间默认停在 1970 年,TLS 一看"证书有效期还没开始"直接拒——代码、证书、URI 全对,就因为没对时一直连不上。连 TLS 之前必须先用 SNTP 对时(esp_sntp_* / esp_netif_sntp_*),这是 mqtts/https 的隐藏前置条件,比 Arduino 时代更要命,因为报错信息往往只是个含糊的握手失败码。
硬编码密钥进固件 = 等于公开。 把账号密码、API token、私钥直接写死在源码里很方便,但固件烧进 flash 后,任何人拿到你的板子都能用 esptool.py read_flash 把整个固件 dump 出来,再用字符串搜索几秒钟翻出你的密码。个人项目里这无法根治,但至少:别把密码上传到公开仓库、别在论坛贴带密码的完整代码、能用一次性 token 的就别用长期密码。真要根治,靠的就是前面提的 Flash Encryption——flash 被加密后 dump 出来是密文。
用默认密码。 路由器、Broker、各种平台的出厂默认账号密码是黑客字典里的头几条。admin/admin、mqtt/public 这种,扫描器几秒钟就试出来。第一件事永远是改掉默认密码。
公网 Broker 裸奔。 把 Mosquitto / EMQX 装在有公网 IP 的服务器上、开着 1883、不设密码、不设防火墙——这相当于在互联网上摆了个无人值守的广播站。Shodan 这类搜索引擎专门扫这种暴露的端口,你的 Broker 可能几小时内就被陌生人发现并占用。自建 Broker 的安全配置见 自建 MQTT Broker。
取舍:不是所有项目都要全套
安全要花的力气和你面临的威胁要匹配,过度也是浪费。
纯局域网、不出家门的项目,可以适当简化。 比如一块只在自家 WiFi 内和 Home Assistant 通信、永不接公网的传感器,威胁面就是"有人进了你家 WiFi"。这种场景,账号密码该加还得加(防家里访客的设备乱连),但 TLS 可以视情况省——前提是你确信这个网段可信。省 TLS 还有个实际理由:TLS 握手吃内存,ESP32-S3 虽然内存比老 ESP32 宽裕,但一条 mbedTLS 连接握手期间也要吃掉几十 KB 堆,连接多了要盯着 esp_get_free_heap_size()。
只要数据会经过公网,加密和鉴权都是底线,没有商量。 用了公共 Broker、用了花生壳/frp 把设备暴露到公网、走 4G——这些情况下数据要经过你控制不了的网络,TLS + 鉴权一个都不能少。判断很简单:问自己"这条连接会不会离开我信任的网络",会,就上全套。
故障排查
加 TLS 之后第一次连不上很常见,对照这张表通常几分钟搞定。
| 现象 | 多半是 | 怎么修 |
|---|---|---|
TLS 握手失败 / 证书校验错误码(mbedtls 报错) |
CA 证书贴错或不完整 | 确认是根证书不是中间证书,BEGIN/END 那两行别漏;确认 EMBED_TXTFILES 把对的文件嵌进来了 |
| 偶尔能连、突然全连不上 | 证书过期,或设备没对时 | 先确认证书有效期;ESP32-S3 上电时间是 1970,连 TLS 前必须先 SNTP 对时 |
| 一直握手失败但 http 明文能通 | 端口连错(连了 1883)或服务器不支持 TLS | 确认用的是 mqtts://...:8883 / https://,确认目标确实开了加密端口 |
反复重启 / mbedtls 内存分配失败 |
TLS 握手吃内存,堆不够 | 减少同时打开的连接、调小 mbedTLS 缓冲、用 esp_get_free_heap_size() 盯堆 |
| 握手过了但 MQTT 连接被拒 | 鉴权被拒(用户名密码错或无权限) | 核对 .credentials.username/password,确认 Broker 端给这个账号开了对应 topic 的权限 |
时间不对导致证书校验失败这条尤其阴——配置、证书、端口全对,就因为没对时一直连不上,而报错往往只是个干巴巴的握手失败码,不会明说"是你没对时"。ESP32-S3 上电默认时间停在 1970,TLS 一看"证书有效期还没开始"就拒了。先 SNTP 对时是 ESP-IDF 上 TLS 的隐藏前置条件,比 Arduino 时代踩得更频。
变体
- 双向 TLS(mTLS)——把"设备认证"落地。 前面是设备验证服务器;双向 TLS 让服务器也验证设备——给每台设备发一张客户端证书和私钥,没有合法证书的设备连不上。在 esp-mqtt 里就是在
verification.certificate(CA)之外,再填.credentials.authentication.certificate(设备证书)和.credentials.authentication.key(设备私钥),同样可以用EMBED_TXTFILES把这两个文件嵌进固件。这是把"设备认证"那件事真正落地的方式,适合要量产、要确保只有你的设备能接入的场景——AWS IoT、阿里云物联网平台等都强制走 mTLS。
esp_mqtt_client_config_t cfg = {
.broker.address.uri = "mqtts://your-iot-endpoint:8883",
.broker.verification.certificate = (const char *)server_root_ca_pem_start, // 验服务器
.credentials.authentication.certificate = (const char *)client_crt_pem_start, // 设备证书:让服务器验你
.credentials.authentication.key = (const char *)client_key_pem_start, // 设备私钥
};
- 敏感数据额外加密(端到端)。 TLS 保护的是"传输途中"。如果你的数据落到 Broker、落到数据库后也不想被运维或第三方平台看到,可以在 publish 前先在设备端用 AES 把 payload 加密(ESP-IDF 有
mbedtls的 AES API,或用芯片硬件加速),订阅端再解密。TLS 管路上、应用层加密管两端,两者叠加是端到端加密的思路。
动手挑战
把你在 MQTT 入门 里做的那个明文连接,改造成带账号密码的 MQTTS:
- 找一个支持加密 + 鉴权的 Broker(
broker.emqx.io的 8883 端口可用于练手,或用你 自建的 Broker)。 - 拿到它的 CA 根证书,存成
main/certs/server_root_ca.pem,在main/CMakeLists.txt里用EMBED_TXTFILES嵌进去。 - 配置结构体里:URI 换成
mqtts://...:8883、加.broker.verification.certificate、加.credentials账号密码。事件回调骨架不动。 - 别忘了先 SNTP 对时——这是最容易漏、又最难排查的一步。
- 验收:日志打出连接成功;用 Wireshark 抓包,确认看到的是密文而不是明文 topic。
做完这一步,你的设备就从"裸奔"正式毕业了。给自己提个醒:所有教程里图省事"不验证证书"的写法,从今往后都换成正经塞 CA 证书。养成习惯前,可以参考我们关于如何核验技术信息的做法,别盲信任何一段贴上来的安全代码。
小结与下一步
通信安全没有玄学,新手按优先级做四件事即可:传输加密(TLS / HTTPS / MQTTS)、身份鉴权(账号密码 / token)是必做地基,设备认证(双向 TLS)和 OTA 安全/Secure Boot 按需上。 在 ESP-IDF 上落地,比 Arduino 干净得多:esp-mqtt 把 URI 从 mqtt:// 换成 mqtts://、加 verification.certificate;esp_http_client 把 url 换 https://、加 cert_pem 或 crt_bundle_attach——证书用 EMBED_TXTFILES 嵌进固件。关键是别把证书校验关掉、连 TLS 前先 SNTP 对时、上公网必须全套。
本篇配置为参考实现,需结合你所用的最新 ESP-IDF 文档自校,尤其是
esp_mqtt_client_config_t/esp_http_client_config_t的证书与鉴权字段(5.x 的.broker.verification/.credentials.authentication结构)随版本可能微调,以官方 ESP-TLS 文档为准。
设备单点安全做好了,下一步是把多个设备和服务组织起来——网关怎么把局域网设备安全地汇聚、转发到云端,去 边缘网关 这一节看。想看本级全貌,回 学习路线 或 指南目录。
本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。