目录背景MQTT 传输安全模型TLS 通道加密 vs Payload 应用层加密两层加密的关系什么场景必须做 Payload 加密安规对 MQTT Payload 加密的具体要求等保 2.0GB/T 22239-2019ETSI EN 303 645欧洲消费 IoT 安全标准安规对照总结加密方案设计算法选型密钥管理方案一机一密Payload 报文格式设计AADAdditional Authenticated Data的使用落地实现设备端加密C / mbedTLS云端解密GoNonce 去重防重放攻击完整 MQTT Publish 流程设备端伪代码国密方案SM4-GCM测试与验证功能验证安规审查验证清单抓包验证示例最佳实践与避坑Nonce 管理最易出错的环节性能优化密钥存储安全常见审计不通过原因总结参考内容背景在物联网IoT系统中设备与云端之间的数据传输通常基于 MQTT 协议。很多团队上了 TLS 之后就认为传输加密搞定了但实际过安规评审时会发现TLS 只保护了设备到 Broker 这一段链路Broker 本身能看到明文 payload。这意味着MQTT Broker 被攻破时所有经过它的业务数据裸奔多级 Broker 桥接场景下中间节点可读取和篡改数据云端消息队列、日志系统中的 payload 以明文存储安规对此有明确要求。等保 2.0GB/T 22239-2019三级要求采用密码技术保证通信过程中数据的保密性和完整性欧洲 ETSI EN 303 645 条款 5.5 要求设备通信应使用加密协议且敏感数据在传输中必须加密。本文聚焦一个具体问题在 MQTT 传输场景下如何对 payload 做应用层加密以满足安规要求。覆盖方案设计、报文格式、代码落地、测试验证和避坑指南。MQTT 传输安全模型TLS 通道加密 vs Payload 应用层加密两层加密的关系维度TLS 通道加密Payload 应用层加密保护范围设备 ↔ Broker 单段链路设备 ↔ 最终消费者端到端Broker 是否可见明文✅ Broker 可见❌ Broker 只看到密文防护对象网络窃听、中间人攻击Broker 被攻破、内部人员、日志泄露安规满足满足传输通道加密要求满足数据保密性深度要求性能开销TLS 握手 记录层加密每条消息额外加解密开销什么场景必须做 Payload 加密Broker 不可信使用第三方托管 MQTT 服务如公有云 MQTT Broker不希望 Broker 看到业务数据多级桥接设备 → 边缘 Broker → 云端 Broker中间链路安全无法完全保证安规深度要求等保三级密评要求敏感数据全链路加密仅 TLS 不满足要求合规审计需要证明即使 Broker 日志泄露攻击者也无法获取明文 如果你的场景是设备直连自建 Broker且 Broker 与云端在同一内网TLS 通常已满足等保二级要求。但等保三级、密评、以及面向欧洲市场的产品建议加上 Payload 层加密。安规对 MQTT Payload 加密的具体要求等保 2.0GB/T 22239-2019等保三级安全通信网络章节对通信传输的要求测评项要求内容与 Payload 加密的关系通信传输完整性采用校验或密码技术保证通信过程中数据的完整性需要 HMAC 或 AEAD如 GCM 的 Auth Tag通信传输保密性采用密码技术保证通信过程中数据的保密性需要对称加密AES/SM4⚠️ 等保三级及以上的密评要求如果系统涉及政企场景还需满足 GB/T 39786-2021《信息系统密码应用基本要求》要求使用国密算法SM2/SM3/SM4保护数据传输的完整性和保密性。ETSI EN 303 645欧洲消费 IoT 安全标准EN 303 645 条款 5.5安全通信明确规定条款要求说明5.5-1设备通信应使用 TLS 1.2 或等效加密协议传输通道加密底线5.5-2敏感安全参数在传输中必须加密包括传感器数据、用户数据、控制指令5.5-3禁用不安全协议SSL、TLS 1.0/1.1最低 TLS 1.25.5-4必须验证通信对端证书防中间人攻击 EN 303 645 没有强制要求 payload 层加密但条款 5.5-2 的敏感数据在传输中必须加密在 Broker 中转场景下TLS 无法完全满足——因为 Broker 端 TLS 终结后数据是明文。审计时可能被判定不合规。安规对照总结安规通道加密TLSPayload 加密国密要求等保二级必须建议否等保三级必须必须密评要求是SM4/SM3EN 303 645必须5.5-1敏感数据场景必须5.5-2否NIST CSF必须建议Protect 层否加密方案设计算法选型算法用途适用场景安规满足AES-256-GCMPayload 加密 完整性认证全球通用等保/CE/FCC 均认可✅SM4-GCMPayload 加密 完整性认证国内政企密评合规✅ 国密HMAC-SHA256仅完整性认证不加密数据不敏感但防篡改部分满足 推荐AES-256-GCM一次操作同时完成加密和认证AEAD输出密文 16 字节认证标签Auth Tag接收方验标签失败直接丢包防止任何篡改。密钥管理方案一机一密每台设备出厂时注入一把唯一的 AES-256 密钥32 字节云端存储deviceId → key的映射关系。解密时通过 MQTT Topic 中的deviceId直接查找对应密钥。产线注入流程 1. 产线 HSM 为每台设备生成 32 字节随机密钥 2. 密钥写入设备安全存储SE / eFuse / 加密 Flash 3. 密钥 deviceId 映射关系同步到云端密钥库 4. 设备出厂后密钥不再变更⚠️ 禁止多台设备共享同一密钥、密钥硬编码在固件源码中、密钥以明文存储在普通 Flash。Payload 报文格式设计加密前明文 payload{ id: msg-001, version: 1.0, params: { temperature: { value: 25.6, time: 1715760000000 }, humidity: { value: 62.3, time: 1715760000000 } } }加密后密文 payload{ id: msg-001, version: 1.0, params: { enc: { v: 1, nonce: dGhpcyBpcyBub25jZQ, ct: base64编码的密文..., tag: base64编码的16字节认证标签... } } } 设计思路保留外层id/version不加密便于云端路由和消息去重params内嵌enc对象承载加密数据云端收到后先检查params.enc是否存在来判断是否需要解密。云端通过 Topic 中的deviceId直接查找对应设备的唯一密钥。字段长度说明id可变消息 ID用于请求-响应配对和去重version可变协议版本如 1.0params.enc.v1 字节加密协议版本号方便后续升级params.enc.nonce12 字节96-bitAES-GCM 要求每次加密必须唯一params.enc.ct与明文等长AES-GCM 输出的密文原始paramsJSON 的加密结果params.enc.tag16 字节128-bit认证标签验证失败则丢弃整个消息AADAdditional Authenticated Data的使用AAD 是 GCM 的不加密但参与认证部分可以防止消息被移花接木把 A 设备的消息转发给 B 设备的 topicAAD MQTT Topic完整路径 示例cwlink/device001/thing/property/report这样即使攻击者把密文从cwlink/device001/thing/property/report搬到cwlink/device002/thing/property/report解密时 AAD 不匹配验证会失败。落地实现设备端加密C / mbedTLS适用于有完整 OS 的嵌入式设备如基于 Linux 的网关、ESP32 等#include mbedtls/gcm.h #include mbedtls/entropy.h #include mbedtls/ctr_drbg.h #include string.h /* 加密 MQTT Payload 的 params 部分 * key: 32字节 AES-256 密钥 * plaintext: 原始 params JSON如 {temperature:{value:25.6,time:1715760000000}} * pt_len: 明文长度 * topic: 完整 MQTT topic 路径作为 AAD * out_nonce: 输出 12 字节 nonce * out_ciphertext: 输出密文长度 pt_len * out_tag: 输出 16 字节认证标签 */ int mqtt_payload_encrypt(const uint8_t *key, const uint8_t *plaintext, size_t pt_len, const char *topic, uint8_t *out_nonce, uint8_t *out_ciphertext, uint8_t *out_tag) { mbedtls_gcm_context gcm; mbedtls_entropy_context entropy; mbedtls_ctr_drbg_context ctr_drbg; int ret; /* 1. 初始化随机数生成器 */ mbedtls_entropy_init(entropy); mbedtls_ctr_drbg_init(ctr_drbg); ret mbedtls_ctr_drbg_seed(ctr_drbg, mbedtls_entropy_func, entropy, NULL, 0); if (ret ! 0) goto cleanup; /* 2. 生成 12 字节随机 nonce每条消息必须唯一 */ ret mbedtls_ctr_drbg_random(ctr_drbg, out_nonce, 12); if (ret ! 0) goto cleanup; /* 3. AAD 完整 MQTT Topic 路径 */ size_t aad_len strlen(topic); /* 4. AES-256-GCM 加密 */ mbedtls_gcm_init(gcm); ret mbedtls_gcm_setkey(gcm, MBEDTLS_CIPHER_ID_AES, key, 256); if (ret ! 0) goto cleanup; ret mbedtls_gcm_crypt_and_tag(gcm, MBEDTLS_GCM_ENCRYPT, pt_len, out_nonce, 12, (uint8_t *)topic, aad_len, plaintext, out_ciphertext, 16, out_tag); cleanup: mbedtls_gcm_free(gcm); mbedtls_ctr_drbg_free(ctr_drbg); mbedtls_entropy_free(entropy); return ret; }云端解密Gopackage mqtt import ( crypto/aes crypto/cipher encoding/base64 encoding/json errors fmt ) // MQTTPayload 标准 MQTT 消息结构与 Topic 设计规范一致 type MQTTPayload struct { ID string json:id Version string json:version Params json.RawMessage json:params } // EncryptedParams 加密后的 params 结构 type EncryptedParams struct { Enc EncBlock json:enc } // EncBlock 加密数据块 type EncBlock struct { V int json:v Nonce stringjson:nonce Ct stringjson:ct Tag stringjson:tag } // DecryptMQTTPayload 解密 MQTT 加密 payload // rawPayload: 收到的完整 MQTT payload JSON // key: 32 字节 AES-256 密钥一机一密通过 topic 中的 deviceId 查找 // topic: 消息的完整 MQTT Topic 路径用于 AAD 验证 // 返回解密后的原始 params JSON func DecryptMQTTPayload(rawPayload []byte, key []byte, topic string) ([]byte, error) { // 1. 解析外层结构 var payload MQTTPayload if err : json.Unmarshal(rawPayload, payload); err ! nil { returnnil, fmt.Errorf(解析 payload 失败: %w, err) } // 2. 解析加密 params var encParams EncryptedParams if err : json.Unmarshal(payload.Params, encParams); err ! nil { returnnil, fmt.Errorf(解析 enc params 失败: %w, err) } // 3. Base64 解码各字段 nonce, err : base64.StdEncoding.DecodeString(encParams.Enc.Nonce) if err ! nil { returnnil, fmt.Errorf(解码 nonce 失败: %w, err) } ciphertext, err : base64.StdEncoding.DecodeString(encParams.Enc.Ct) if err ! nil { returnnil, fmt.Errorf(解码 ciphertext 失败: %w, err) } tag, err : base64.StdEncoding.DecodeString(encParams.Enc.Tag) if err ! nil { returnnil, fmt.Errorf(解码 tag 失败: %w, err) } // 4. 校验 nonce 长度 iflen(nonce) ! 12 { returnnil, errors.New(nonce 长度必须为 12 字节) } // 5. AES-256-GCM 解密 block, err : aes.NewCipher(key) if err ! nil { returnnil, fmt.Errorf(创建 AES cipher 失败: %w, err) } gcm, err : cipher.NewGCM(block) if err ! nil { returnnil, fmt.Errorf(创建 GCM 失败: %w, err) } // GCM Open 要求 ciphertext 和 tag 拼接 sealed : append(ciphertext, tag...) // AAD 完整 MQTT Topic 路径 aad : []byte(topic) plaintext, err : gcm.Open(nil, nonce, sealed, aad) if err ! nil { returnnil, fmt.Errorf(解密失败认证标签验证不通过: %w, err) } return plaintext, nil }使用示例在 IoT Rule 触发的 Lambda / 消息处理服务中func handleMQTTMessage(topic string, payload []byte) { // 一机一密从 topic 中提取 deviceId查找该设备的唯一密钥 deviceId : extractDeviceId(topic) // 如 cwlink/device001/... → device001 key : keyStore.GetKey(deviceId) // 32 字节 plainParams, err : DecryptMQTTPayload(payload, key, topic) if err ! nil { log.Printf(解密失败, topic%s, err%v, topic, err) return } // plainParams 现在是原始的 params JSON // {temperature:{value:25.6,time:1715760000000},humidity:{value:62.3,time:1715760000000}} log.Printf(解密成功: %s, string(plainParams)) }Nonce 去重防重放攻击AES-GCM 的 nonce 每条消息唯一天然适合做防重放的去重 key。攻击者录下一条合法加密消息并原封不动重发时云端通过 nonce 去重即可识别并丢弃。// NonceDedup 基于 Redis 的 nonce 去重服务 type NonceDedup struct { rdb *redis.Client ttl time.Duration // nonce 缓存过期时间 } func NewNonceDedup(rdb *redis.Client) *NonceDedup { return NonceDedup{ rdb: rdb, ttl: 24 * time.Hour, // 24 小时后自动过期释放内存 } } // CheckAndMark 检查 nonce 是否已使用未使用则标记 // 返回 true 表示是新 nonce合法false 表示重复重放攻击 func (d *NonceDedup) CheckAndMark(deviceId string, nonce []byte) (bool, error) { // key nonce:{deviceId}:{nonce_hex}按设备隔离 key : fmt.Sprintf(nonce:%s:%x, deviceId, nonce) // SETNX不存在则设置返回 true已存在返回 false ok, err : d.rdb.SetNX(context.Background(), key, 1, d.ttl).Result() if err ! nil { returnfalse, fmt.Errorf(nonce 去重查询失败: %w, err) } return ok, nil }完整的解密 去重流程func handleMQTTMessageWithDedup(topic string, payload []byte) { deviceId : extractDeviceId(topic) key : keyStore.GetKey(deviceId) // 1. 先提取 nonce 做去重检查避免无效解密消耗算力 nonce, err : extractNonce(payload) if err ! nil { log.Printf(解析 nonce 失败: %v, err) return } // 2. Nonce 去重重复则丢弃 isNew, err : nonceDedup.CheckAndMark(deviceId, nonce) if err ! nil { log.Printf(去重服务异常: %v, err) return } if !isNew { log.Printf(重放攻击检测: topic%s, nonce 已使用丢弃, topic) return } // 3. 解密 plainParams, err : DecryptMQTTPayload(payload, key, topic) if err ! nil { log.Printf(解密失败: %v, err) return } // 4. 业务处理 processParams(deviceId, plainParams) } 为什么不用 timestamp 防重放IoT 设备时钟常不准确无 RTC 或 NTP 不稳定基于时间窗口的判断容易误判。nonce 去重不依赖设备时钟更可靠。TTL 设 24 小时足够覆盖网络延迟和 QoS 重传场景。完整 MQTT Publish 流程设备端伪代码void publish_sensor_data(mqtt_client_t *client, const char *topic) { /* 1. 构造原始 params JSON */ char params[256]; snprintf(params, sizeof(params), {\temperature\:{\value\:%.1f,\time\:%lu}, \humidity\:{\value\:%.1f,\time\:%lu}}, read_temperature(), get_timestamp(), read_humidity(), get_timestamp()); /* 2. 加密 params */ uint8_t nonce[12], ciphertext[256], tag[16]; int ret mqtt_payload_encrypt( device_key, (uint8_t *)params, strlen(params), topic, nonce, ciphertext, tag); if (ret ! 0) { log_error(Payload encryption failed: %d, ret); return; } /* 3. 组装标准格式的加密报文id version params.enc */ char encrypted_msg[1024]; snprintf(encrypted_msg, sizeof(encrypted_msg), {\id\:\%s\,\version\:\1.0\, \params\:{\enc\:{ \v\:1, \nonce\:\%s\, \ct\:\%s\, \tag\:\%s\}}}, generate_msg_id(), base64_encode(nonce, 12), base64_encode(ciphertext, strlen(params)), base64_encode(tag, 16)); /* 4. 通过 TLS 通道 publish 加密报文 */ mqtt_publish(client, topic, encrypted_msg, strlen(encrypted_msg), QOS_1); }国密方案SM4-GCM如果需要满足等保三级密评的国密要求将 AES-256-GCM 替换为 SM4-GCM#include gmssl/sm4.h #include gmssl/rand.h /* SM4-GCM 加密GMSSL 3.x API */ int mqtt_payload_encrypt_sm4(const uint8_t *key, /* 16 字节 SM4 密钥 */ const uint8_t *plaintext, size_t pt_len, const uint8_t *aad, size_t aad_len, uint8_t *out_nonce, /* 12 字节输出 */ uint8_t *out_ciphertext, uint8_t *out_tag) { /* 16 字节输出 */ SM4_KEY sm4_key; /* 生成随机 nonce */ rand_bytes(out_nonce, 12); /* SM4-GCM 加密 */ sm4_set_encrypt_key(sm4_key, key); sm4_gcm_encrypt(sm4_key, out_nonce, 12, aad, aad_len, plaintext, pt_len, out_ciphertext, 16, out_tag); return0; }测试与验证功能验证测试项验证方法预期结果正常加解密设备加密 → 云端解密 → 比对明文明文一致Nonce 唯一性连续发送 1000 条检查所有 nonce 不重复0 重复Tag 验证篡改密文 1 字节后解密解密失败抛出认证错误AAD 验证用不同 topic 解密解密失败密钥版本切换轮换密钥后新旧消息各自用对应密钥解密均解密成功安规审查验证清单过安规评审时审计人员通常会检查以下点检查项要求验证方式算法合规AES-256 或 SM4禁止 DES/3DES/RC4查看代码和配置密钥长度AES 密钥 ≥ 128 bit推荐 256代码审查工作模式GCM/CCMAEAD禁止 ECB不推荐 CBC代码审查Nonce 管理每次加密使用随机唯一 nonce长度 96-bit抓包验证不重复认证标签Tag 长度 128-bit不截断代码审查密钥存储不明文存储在 Flash/代码中固件逆向检查一机一密每台设备密钥唯一不共享产线流程审查完整性保护有 Auth Tag 或 HMAC抓包验证抓包验证示例用 Wireshark 或 MQTT 客户端工具验证# 订阅设备 topic查看收到的是密文而非明文 mosquitto_sub -h broker.example.com -p 8883 \ --cafile ca.crt --cert client.crt --key client.key \ -t cwlink/device001/thing/property/report -v # 预期输出标准格式params 内为加密数据看不到原始传感器数据 # cwlink/device001/thing/property/report {id:msg-001,version:1.0,params:{enc:{v:1,nonce:...,ct:...,tag:...}}}最佳实践与避坑Nonce 管理最易出错的环节做法风险建议❌ Nonce 硬编码同密钥同 nonce 密钥泄露每条消息随机生成❌ 用时间戳做 nonce设备重启后时间回退导致重复用硬件 RNG❌ 用自增计数器但不持久化掉电后计数器归零计数器存 NVS或用随机 nonce✅ 12 字节硬件随机数重复概率极低2^96 空间推荐方案⚠️ AES-GCM 的核心安全假设同一密钥下nonce 绝对不能重复。一旦重复攻击者可通过 XOR 两条密文直接恢复明文。这是最常见的实现错误。性能优化优化手段说明硬件 AES 加速ESP32、STM32 等 MCU 内置 AES 硬件引擎速度比软件实现快 5-10 倍减少 Base64 开销如果 MQTT Broker 支持二进制 payload直接传二进制省 33% 带宽批量加密多条消息合并后一次加密减少 GCM 初始化次数预计算密钥表AES 密钥展开只做一次复用 context密钥存储安全做法风险建议❌ 密钥硬编码在源码固件逆向后密钥泄露产线动态注入❌ 密钥明文存 Flash读取 Flash 即获取密钥存入 SE/eFuse/加密分区❌ 多台设备共用一把密钥一台被攻破全部沦陷一机一密✅ SE安全芯片存储物理防篡改推荐方案常见审计不通过原因问题安规条款修复方案使用 AES-CBC 无认证等保完整性条款改为 AES-GCMAEAD密钥硬编码在固件EN 303 645 条款 5.4出厂注入 SE/安全 FlashTLS 1.0 仍在使用所有安规均禁止升级到 TLS 1.2禁用旧版本Payload 明文经过 Broker等保三级数据保密性加上应用层 Payload 加密总结过安规的 MQTT Payload 加密核心就三件事选对算法AES-256-GCM全球通用或 SM4-GCM国密合规一步到位解决加密 认证管好密钥一机一密出厂安全注入存储在 SE/eFuse 中不明文存储做好 Nonce每条消息 12 字节硬件随机数绝不重复TLS 是底线Payload 加密是纵深。对于等保三级、密评、以及面向欧洲市场的 IoT 产品建议 TLS Payload 双层加密方案从架构上满足端到端数据保密性要求。参考内容https://www.tc260.org.cn/https://www.etsi.org/deliver/etsi_en/303600_303699/303645/03.01.03_60/en_303645v030103p.pdfhttps://datatracker.ietf.org/doc/html/rfc5116引入地址