串口加密服务

更新时间:2026-07-17 07:42:17LLM 副本以 Markdown 格式查看下载 PDF

功能简介

门锁蓝牙模组可以提供与 MCU 之间串口通讯加密的服务,以便满足国标 GB21556.2 对锁板链路加密的要求。

加密方案由模组与 MCU 协同完成:MCU 在产品信息应答中声明支持加密后,模组生成随机种子(Seed)并发起协商;MCU 生成并保存索引列表,并依据模组下发的种子派生出会话密钥,此后双方使用该密钥对业务帧进行加解密。

适用模组

  • 模组:BK3633
  • 固件版本:0.0.12 及以上版本

适用场景

适用于使用涂鸦公版门锁技术方案、且 MCU 与模组之间串口链路需要加密的产品。MCU 若暂不支持加密,不上报加密能力即可,模组不会启用加密,现有产品行为不受影响。

功能列表

串口加密服务涉及以下命令字:

命令字 命令说明
0x01 在产品信息应答的 Option Config 区声明 MCU 串口加密能力
0xD8 提供串口加密协商,包含三步:查询索引列表、通知随机种子、加密试探

串口加密相关协议

帧格式说明

串口帧格式沿用涂鸦蓝牙模组串口通讯协议(55 AA 帧头 + 版本号 + 命令字 + 长度 + 数据 + 校验和),本节仅说明与加密相关的差异。帧的第 3 字节(byte3)在两种帧中含义不同:

帧类型 byte3 数据区
明文帧 命令字 CMD 明文数据
加密业务帧 加密方式 EncType(0x01=AES,0x02=Simple) 加密后的 [CMD + 长度(2B) + 数据]

加密业务帧即将 “命令字 + 两字节长度 + 数据” 组成的内层明文整体加密后作为数据区,byte3 改填加密方式(AES 模式内层需补零至 16 字节倍数,Simple 模式无填充)。协商命令 0xD8 始终以明文收发。

byte3 取值重合(须区分):

数值 明文 CMD EncType
0x01 产品信息查询/应答 AES
0x02 请求工作模式 Simple

要点CMD=0x01 && len=055 AA 00 01 00 00)应始终识别为明文的 0x01 查询 MCU 信息指令并清 handshake_done,出现场景:模组重启;其余在已握手且 byte3 == EncType 时解密。

串口加密能力声明(CMD-0x01)

模组发送 CMD 0x01 查询 MCU 产品信息(明文),MCU 在应答的 Option Config 区(第 13 字节开始的 TLD 列表)中追加一个 TLD [08 01 01] 来声明支持串口加密。

MCU 应答 模组行为
无该 TLD 或 D=0x00 不启用加密,全程保持明文
08 01 01 模组将发起 0xD8 加密协商流程

一旦双方已成功启用串口加密(enable=1),除非 恢复出厂 清除加密状态,否则不允许加密降级为明文;MCU 即使去掉本 TLD,模组也不会恢复明文业务。

串口加密协商(CMD-0xD8)

以明文帧 cmd = 0xD8 承载,分三步完成:查询索引列表 → 通知随机种子 → 加密试探。完成后,双方业务帧进入加密通讯。

协商时序如下:

模组MCU0x00 心跳0x00 MCU 第一次接收心跳0x01 查询产品信息0x01 应答 + TLD(08 01 01)0xD8 Op=0x00 Data=[0x01, 0x02]0xD8 Op=0x00 Data=[EncType, IndexList[16]]MCU 保存 IndexList、选定 EncTypealt[模组无 IndexList]0xD8 Op=0x01 Data=[EncType, Seed[4]]MCU 保存 Seed,派生 session_key0xD8 Op=0x01 Data=[EncType]0xD8 Op=0x03 Data=[PlainRandom[16]]MCU 用 session_key 加密 PlainRandom0xD8 Op=0x03 Data=[Cipher[16]]试探成功,enable=1密文 0xE8 / 0x02(CMD 0x03 同步工作状态,随 0x02 处理发出)MCU 收到 0x02 后再发初始化配置(module_ready),比如开关广播/休眠设置等模组MCU

Data 区结构0xD8 帧通用)

Scope OpType 数据
0x00 见下 操作相关数据
  • Scope:固定 0x00(全量加密范围)。
  • OpType
    • 0x00:查询索引列表。
    • 0x01:通知随机种子。
    • 0x03:加密试探。

查询索引列表(OpType 0x00)

仅当模组 Flash 中无有效 IndexList(index_valid=0)时发起,例如首次上电或模组被重置。

模组发送 Data

Scope OpType EncType 列表
0x00 0x00 0x01 0x02
  • EncType 列表(2 字节):模组支持的加密方式,当前为 [0x01, 0x02](AES 与简单加密)。

MCU 回复 Data

Scope OpType EncType IndexList
0x00 0x00 0x01/0x02 xx xx … xx(16 字节)
  • EncType(1 字节):MCU 固件配置的加密类型(须在模组请求的 [0x01, 0x02] 列表中),作为后续全程使用的加密方式。
  • IndexList(16 字节):MCU 生成(或返回已保存)的索引列表,每字节取值 0x00~0x1F,用于会话密钥派生。

MCU 首次生成 IndexList 后应写入自身 Flash,恢复出厂前保持不变。模组重启不会重新查询索引(仅模组被重置、丢失索引时才重新发起 Op 0x00),MCU 与模组保存的同一 IndexList,确保双方密钥一致。

示例(末字节校验和仅作格式示意,以实际计算为准):

  • 模组 → MCU:55 AA 00 D8 00 04 00 00 01 02 DE
  • MCU → 模组:55 AA 00 D8 00 13 00 00 01 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 72

通知随机种子(OpType 0x01)

模组每次协商密钥都会生成新的随机种子并下发给 MCU。

模组发送 Data

Scope OpType EncType Seed
0x00 0x01 0x01/0x02 xx xx xx xx
  • EncType(1 字节):与查询索引时 MCU 返回一致。
  • Seed(4 字节):本次会话的随机种子。

MCU 回复 Data

Scope OpType EncType
0x00 0x01 0x01/0x02
  • MCU 收到新 Seed 后,用当前 IndexList 重新派生会话密钥,并回复 EncType 确认(与收到的保持一致)。

每次加密协商模组会更新 Seed,但 不会 重新查询 IndexList。MCU 收到 Op 0x01 后必须用新的 Seed 重算 session_key

示例

  • 模组 → MCU:55 AA 00 D8 00 07 00 01 01 65 AA 89 D2 49
  • MCU → 模组:55 AA 00 D8 00 03 00 01 01 DC

加密试探(OpType 0x03)

协商最后一步,模组发送 16 字节随机明文,MCU 加密后回送,模组校验一致性。

模组发送 Data

Scope OpType PlainRandom
0x00 0x03 xx xx … xx(16 字节)
  • PlainRandom(16 字节):模组生成的随机明文。

MCU 回复 Data

Scope OpType Cipher
0x00 0x03 xx xx … xx(16 字节)
  • Cipher(16 字节):MCU 用当前 session_key 与协商的 EncType 对 PlainRandom 加密的结果。

模组将本地 PlainRandom 加密后与 MCU 回送的 Cipher 比对,一致则协商成功,模组会下发加密的 0x02 指令,MCU 可据此置加密使能 enable=1 并存 Flash,MCU 此后也可以主动给模组加密发送业务指令。协商失败模组侧重试最多 3 次

MCU 持久化与运行时状态

MCU 侧建议维护如下字段(可映射至 Flash 结构体):

字段 FLASH存储 说明
enable
  • 0:未启用
  • 1:已启用(仅恢复出厂清除)
enc_type
  • 0x01:AES
  • 0x02:Simple
index_list[16] 密钥派生索引表
index_valid 索引是否已生成
seed[4] 当前生效种子
session_key[16] 运行时派生,上电或收到新 Seed 后重算
handshake_done 运行时标志,协商完成后置 1

写入 Flash 的时机

时机 写入内容
Op 0x00 应答后 index_listindex_validenc_type
Op 0x01 收到新 Seed 仅更新内存中的 seed 并重算 session_key
Op 0x03 试探成功后 enable=1(第一次协商成功)

MCU 冷启动(enable=1

  1. 从 Flash 恢复 index_validindex_listenc_typeenable
  2. handshake_done须等模组再次完成 0xD8 协商成功后再置位。
  3. 非白名单业务须等 handshake_done=1 后再加密收发。
  4. 模组重启并重新协商时,按 模组重启识别 流程更新 Seed。

密钥派生算法

会话密钥由模组下发的 Seed 与 MCU 生成并保存的 IndexList 共同决定,流程如下:

Seed[4]
  → MD5(Seed, 4)              得到 16 字节二进制摘要
  → 转为 32 字节小写 hex ASCII(candidate_key)
  → 按 IndexList[16] 逐字节选取 candidate_key 中的字节
  → session_key[16](会话密钥)
  • IndexList 长度固定 16 字节,每个元素为 candidate_key 的下标,取值范围 0x00~0x1F
  • 首次生成后写入 MCU Flash,恢复出厂前保持不变。

MCU 和模组各自按上述流程派生出的 session_key 应保持一致。MD5、AES 等标准算法用芯片平台硬件引擎或成熟软件库实现,完整的平台接口声明与派生伪代码见 附录 A

加密算法

协商 Op 0x00 阶段,模组发送支持的类型列表 [0x01, 0x02]。MCU 返回其固件配置的 EncType(须落在该列表中),后续全程使用。

AES-128-ECB(EncType = 0x01

  • 密钥:派生出的 session_key[16]
  • 模式:ECB。
  • 填充:Zero Padding,明文末尾补 0x00 至长度为 16 的倍数。
  • 每 16 字节独立加解密;解密后按内层 Len 字段截取有效 Payload,忽略 ZeroPad 区。

简单加密(EncType = 0x02

逐字节运算,加解密使用同一操作:

data[i] = ~(data[i] ^ key[i % 16])      // 取值低 8 位
  • 无填充,密文长度等于明文长度。
  • 内层明文结构为 [CMD(1) + Len(2) + Payload(N)]。AES 模式需先 Zero Pad 到 16 字节倍数再按块加密;简单加密直接对内层逐字节运算。
  • 解密为逆过程:AES 模式按块解密还原后,读取内层 Len 字段,仅取前 Len 字节作为有效 Payload忽略尾部 ZeroPad;简单加密用同一运算还原。协商试探包(Op 0x03)的 16 字节明文/密文也按此处的加解密逻辑处理。

业务帧加解密封装的参考伪代码见 附录 B

白名单命令与收发规则

加密启用后,除白名单外的业务命令(如 DP 0x06/0x07)须加密收发。

白名单

命令字 说明
0x00 心跳
0x01 产品信息查询/应答(含加密能力 TLD)
0xD8 加密协商
0xEA~0xEE MCU OTA
0x04/0x05 重置指令(条件放行)

条件放行(非固定白名单):当模组处于 未绑定 状态时,明文重置指令 0x04/0x05 会被模组接受(便于非绑定状态完成模组侧清加密态);已绑定 时重置仍须加密。

其余场景行为:

条件 行为
enable=0(从未成功启用加密) 全明文,兼容未加密设备
enable=1 且协商未完成(非 ACTIVE) 非白名单明文 丢弃;不发非白名单业务
enable=1 且协商完成(ACTIVE) 非白名单按 EncType 密文收发
  • 禁止加密降级enable=1 落盘后,即使本轮 MCU 不报加密能力或协商失败,也 不会 退回全明文通讯;清除 enable 仅允许恢复出厂操作
  • MCU 无论是否支持加密,均建议在收到模组 0x02(请求工作模式) 后再开始发送业务指令;支持加密时用密文,不支持加密时用明文。
  • 支持加密时,模组在协商成功后会立即加密发送指令:0xE80x02(现有 0x02 处理中再发 CMD 0x03 同步工作状态)。
  • 协商失败最多重试 3 次后放弃,不降级明文启动

模组重启识别:模组每次重启都会发出明文查询帧 55 AA 00 01 00 00CMD: 0x01,len: 0)。MCU 收到该帧时应:

  1. handshake_done = 0(暂停向模组发送非白名单密文)。
  2. 保留 enableindex_listenc_type(禁止因重启而降级)。
  3. 明文应答 CMD 0x01(含加密能力 TLD)。
  4. 等待 D8 更新 Seed 并协商成功;模组密文下发 0xE8/0x02 后,再发初始化配置。

启动时序(加密宏开启)

  1. 模组心跳发明文 0x00,随后发明文查询产品信息 0x01
  2. MCU 应答 0x01 带 TLD:
    • 不支持加密 且模组 enable=0:模组发明文 0xE80x02
    • 不支持加密 且模组 enable=1:禁止降级,不补发明文启动指令。
    • 支持加密:模组发 0xD8 加密协商指令。
  3. 协商成功:模组密文发0x02(加密场景为密文解密后的内层 CMD 0x02)。
  4. MCU 收到 0x02 后再按需主动发业务指令。

MCU 集成要点

集成点 说明
CMD 0x01 组包 在 Option Config 区追加 TLD 08 01 01 声明支持加密
UART 接收预处理 白名单放行明文;0x01 && len=0 识别模组重启,清 handshake_done;已握手且 byte3 == EncType 时解密;否则丢弃
UART 发送挂钩 白名单明文;enable=1handshake_done=1 时对其余 CMD 加密
CMD 0xD8 处理 响应 Op 0x00/0x01/0x03
初始化配置 收到 0x02 后再发(与是否加密无关;加密场景为密文 0x02);收到0x02 才能确保模组能正常解密 MCU 发的加密指令
恢复出厂设置 加密握手仍有效时不得先清加密态再发明文重置;蓝牙已未绑定时可用明文 0x04/0x05

MCU 恢复出厂顺序(启用加密时必遵)

当串口加密已启用且协商完成(enable=1 && handshake_done=1)时,MCU 本地重置必须按下列顺序执行,否则模组收不到重置、无法清自身加密 Flash,会出现双边 enable/IndexList 不一致、后续无法再协商:

  1. 先发送加密重置指令(通常为 CMD 0x05,或产品使用的同等恢复出厂通知):在仍持有 session_key / handshake_done 时经加密发送路径发出;禁止 此时因已清 enable 而回退明文。
  2. 确认重置帧已入队/发出(至少完成加密组包并提交 UART 发送)后,再清除本端加密相关内存状态与 Flash(enableseedindex_listenc_typehandshake_done 等)。
  3. 再执行其余业务恢复出厂与重启。
  • 先加密通知模组,再清状态/Flash。 若仍处于可加密会话且蓝牙 已绑定,却先擦除加密信息再发明文 0x05,模组会丢弃该帧并保留加密使能,双方状态撕裂。
  • 例外:模组蓝牙已处于 未绑定 时,明文 0x04 / 0x05 会被模组接受(见白名单条件放行)。仍建议优先走 “先加密重置、再清本端” 主路径。
  • 未启用加密(enable=0)或尚未协商完成时,仍可按明文恢复出厂流程处理。

常见问题

不支持串口加密的 MCU 会不会受影响?

若模组侧 enable=0(从未谈成过加密),则不会。MCU 不上报加密 TLD(无 T=0x08)或上报 D=0x00,模组走明文启动(补发 0xE8/0x02),与未加密产品一致。

若模组侧 enable=1(曾与支持加密的 MCU 协商成功),而 MCU 变成不支持加密的固件:属 不允许的加密降级,模组不响应非白名单明文,需 恢复出厂 后再配。

如何启用串口加密?对接步骤是什么?

MCU 需完成以下工作:

  1. 在 CMD 0x01 应答的 Option Config 区增加 TLD 08 01 01,声明支持加密。
  2. 生成并持久化 16 字节 IndexList(每字节 < 0x20),恢复出厂前保持不变。
  3. 增加 CMD 0xD8 处理分支,响应 Op 0x00(返回 IndexList)、Op 0x01(保存 Seed 并派生密钥)、Op 0x03(回送试探密文)。
  4. 密钥派生算法加密算法 实现业务帧的加解密封装。
  5. 收到模组下发的 0x02 后再发初始化配置(加密启用后为密文)。
  6. 恢复出厂:若加密已启用且协商完成,必须先发加密重置指令(如 0x05),再清除本端加密状态与 Flash;详见 MCU 恢复出厂顺序

模组侧在收到 D=0x01 后会自动发起 0xD8 协商;成功后密文补发 0xE80x02

AES 与简单加密如何选择?

两种均在模组支持的类型列表 [0x01, 0x02] 中。AES-128-ECB 安全性更高,但需 MCU 具备 AES 实现且密文长度为 16 字节倍数;简单加密运算轻量、密文长度等于明文长度,适合资源紧张的 MCU。MCU 在固件中配置一种 EncType,在 Op 0x00 应答时返回(须被模组列表包含),后续全程使用该类型,不可中途切换。

模组重启后是否需要重新协商?

需要。详见 模组重启识别。模组重启后会先发出明文查询帧 55 AA 00 01 00 00,MCU 置 handshake_done=0 并保留 enable 与 IndexList,随后:

  1. 0xD8 Op 0x01 下发新 Seed,MCU 在内存中重算 session_key
  2. 0xD8 Op 0x03 完成加密试探,成功后模组密文补发 0xE8/0x02

模组重启 不会 重新查询 IndexList(除非模组被重置、丢失索引)。MCU 须持久化保存 IndexList,保证密钥派生一致。

MCU 冷启动时如何处理已启用的加密状态?

若 Flash 中 enable=1,MCU 上电后应恢复 index_listenc_type。模组通过 0x00 握手指令识别到 MCU 发生重启后,会重新发起加密协商流程。 MCU 须等再次完成 D8 并将 handshake_done 置位;初始化配置仍须等密文 0x02。勿在协商完成前向已 enable=1 的模组发送非白名单明文。

IndexList 何时生成?何时会被重新查询?

MCU 在首次收到 Op 0x00 时生成 IndexList 并写入 Flash。此后只要模组侧索引有效(index_valid=1),就不会再查询。仅当模组被重置、Flash 索引丢失时,才会重新发起 Op 0x00,此时 MCU 应返回已保存的同一 IndexList(或重新生成并保证双方一致)。

加密启用后,哪些命令仍走明文?

固定白名单:0x000x010xD8、OTA 0xEA~0xEE。另:模组蓝牙 未绑定 时,明文重置 0x04/0x05 条件放行。其余如 0xE80x020x03 等在 enable=1 且协商成功后均为密文。enable=0 时全链路明文。

能否在 enable=1 后让 MCU 降回不支持加密?

不能。 禁止加密降级。enable=1 后模组不因 MCU 去掉能力声明而恢复明文业务。若需回到明文产品形态,须走 恢复出厂 清除加密状态后再配。

为什么加密启用后不能降级为明文?

出于安全考虑,一旦 enable=1,模组与 MCU 均拒绝非白名单明文业务帧。即使链路被干扰或 MCU 固件被替换为不支持加密的版本,也不能通过明文命令绕过加密。仅 恢复出厂 可清除加密状态。

MCU 恢复出厂时能否先清 Flash 再通知模组?

必须先发加密重置指令(如 0x05),再清除本端 enable/IndexList 等状态与 Flash。若先清再发明文,在仍绑定场景下模组会丢弃该帧。
若模组蓝牙已未绑定,明文 0x04/0x05 可被模组接受。详见 MCU 恢复出厂顺序

EncType 0x01/0x02 会不会和明文命令 0x01/0x02 混淆?

数值会重合,须按收发规则区分,见 帧格式说明。要点:

  • MCU:用 55 AA 00 01 00 000x01 且 len=0)识别模组重启。
  • Simple(EncType=0x02)与明文工作模式 0x020x02 不在白名单,协商成功后应为密文,一般不会双白名单冲突。

附录

下述伪代码用于辅助开发者理解密钥派生与业务帧加解密的完整流程。其中 MD5、AES 为标准算法,本文档不提供其实现 —— MCU 直接调用芯片平台的 硬件 AES 引擎,或移植成熟软件库(如 mbedTLS、tinyAES、micro-ecc 等),只要满足下述接口语义即可。

附录 A:密钥派生参考实现

平台接口(开发者自行替换)

/* MD5:对 in 前 in_len 字节做摘要,输出 16 字节二进制 */
extern int  platform_md5(const uint8_t *in, uint16_t in_len, uint8_t out[16]);
/* AES-128-ECB 单块(16 字节)加 / 解密 */
extern int  platform_aes128_ecb_encrypt(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);
extern int  platform_aes128_ecb_decrypt(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);

派生流程(Seed + IndexList → session_key)

#define SEED_LEN            4
#define INDEX_LIST_LEN      16
#define CANDIDATE_KEY_LEN   32          /* MD5 转 hex 后长度 */
#define SESSION_KEY_LEN     16

/* 16 字节二进制 → 32 字节小写 hex ASCII */
static void bin_to_hex_lower(const uint8_t in[16], uint8_t hex[CANDIDATE_KEY_LEN])
{
    static const char tab[] = "0123456789abcdef";
    uint8_t i;
    for (i = 0; i < 16; i++) {
        hex[i * 2]     = (uint8_t)tab[(in[i] >> 4) & 0x0F];
        hex[i * 2 + 1] = (uint8_t)tab[in[i] & 0x0F];
    }
}

/* Seed[4] + IndexList[16] → session_key[16]
 * 流程:Seed → MD5 → 16B → 32B 小写hex(candidate_key)
 *       → 按 IndexList 每字节作下标取值 → session_key */
int derive_session_key(const uint8_t seed[SEED_LEN],
                       const uint8_t index_list[INDEX_LIST_LEN],
                       uint8_t session_key[SESSION_KEY_LEN])
{
    uint8_t md5_out[16];
    uint8_t candidate_key[CANDIDATE_KEY_LEN];
    uint8_t i;

    if (platform_md5(seed, SEED_LEN, md5_out) != 0) {
        return -1;                      /* MD5 失败 */
    }
    bin_to_hex_lower(md5_out, candidate_key);

    for (i = 0; i < SESSION_KEY_LEN; i++) {
        if (index_list[i] >= CANDIDATE_KEY_LEN) {
            return -1;                  /* 索引非法,应为 0x00~0x1F */
        }
        session_key[i] = candidate_key[index_list[i]];
    }
    return 0;
}

附录 B:业务帧加解密参考实现

#define AES_BLOCK_LEN              16
#define ENC_AES                    0x01
#define ENC_SIMPLE                 0x02
#define UART_CRYPTO_MAX_INNER_LEN  64      /* 视最大业务帧而定,需 >= padded */

/* 内层 [CMD|Len|Payload] → 密文 */
static int crypt_inner(uint8_t enc_type, const uint8_t key[16],
                       const uint8_t *inner, uint16_t inner_len,
                       uint8_t *out, uint16_t *out_len, int encrypt)
{
    uint16_t padded = ((inner_len + 15) / 16) * 16;
    uint16_t off;

    if (enc_type == ENC_AES) {
        uint8_t buf[UART_CRYPTO_MAX_INNER_LEN];
        if (padded > sizeof(buf)) {
            return -1;
        }
        memcpy(buf, inner, inner_len);
        memset(buf + inner_len, 0, padded - inner_len);
        for (off = 0; off < padded; off += AES_BLOCK_LEN) {
            if (encrypt) {
                platform_aes128_ecb_encrypt(key, &buf[off], &out[off]);
            } else {
                platform_aes128_ecb_decrypt(key, &buf[off], &out[off]);
            }
        }
        *out_len = padded;
    } else {
        for (off = 0; off < inner_len; off++) {
            out[off] = (uint8_t)(~(inner[off] ^ key[off % 16]));
        }
        *out_len = inner_len;
    }
    return 0;
}

/* 发送:内层加密并组装外层帧 55 AA 00 EncType EncLen Cipher Sum */
int wrap_encrypt_send(uint8_t enc_type, const uint8_t session_key[16],
                      uint8_t cmd, const uint8_t *payload, uint16_t len,
                      uint8_t *frame_out, uint16_t *frame_len)
{
    uint8_t inner[UART_CRYPTO_MAX_INNER_LEN];
    uint8_t cipher[UART_CRYPTO_MAX_INNER_LEN];
    uint16_t inner_len = (uint16_t)(3 + len);
    uint16_t cipher_len = 0;
    uint16_t pos = 0;

    inner[0] = cmd;
    inner[1] = (uint8_t)(len >> 8);
    inner[2] = (uint8_t)(len);
    memcpy(&inner[3], payload, len);

    if (crypt_inner(enc_type, session_key, inner, inner_len,
                    cipher, &cipher_len, 1) != 0) {
        return -1;
    }

    frame_out[pos++] = 0x55;
    frame_out[pos++] = 0xAA;
    frame_out[pos++] = 0x00;
    frame_out[pos++] = enc_type;
    frame_out[pos++] = (uint8_t)(cipher_len >> 8);
    frame_out[pos++] = (uint8_t)(cipher_len);
    memcpy(&frame_out[pos], cipher, cipher_len);
    pos = (uint16_t)(pos + cipher_len);
    frame_out[pos++] = checksum8(frame_out, pos);   /* 按平台校验算法实现 */
    *frame_len = pos;
    return 0;
}

/* 接收:byte3==EncType 时解密,还原明文帧 55 AA 00 CMD Len Payload Sum */
int unwrap_decrypt_recv(uint8_t enc_type, const uint8_t session_key[16],
                        const uint8_t *frame_in, uint16_t frame_len,
                        uint8_t *frame_out, uint16_t *frame_out_len)
{
    uint16_t enc_len;
    uint8_t plain[UART_CRYPTO_MAX_INNER_LEN];
    uint16_t plain_len = 0;
    uint8_t inner_cmd;
    uint16_t payload_len;
    uint16_t pos = 0;

    if (frame_in[3] != enc_type) {
        return -1;   /* 非密文帧,交白名单或丢弃逻辑处理 */
    }
    enc_len = (uint16_t)((frame_in[4] << 8) | frame_in[5]);
    if (crypt_inner(enc_type, session_key, &frame_in[6], enc_len,
                    plain, &plain_len, 0) != 0) {
        return -1;
    }

    inner_cmd = plain[0];
    payload_len = (uint16_t)((plain[1] << 8) | plain[2]);
    if ((uint16_t)(3 + payload_len) > plain_len) {
        return -1;
    }

    frame_out[pos++] = 0x55;
    frame_out[pos++] = 0xAA;
    frame_out[pos++] = 0x00;
    frame_out[pos++] = inner_cmd;
    frame_out[pos++] = plain[1];
    frame_out[pos++] = plain[2];
    memcpy(&frame_out[pos], &plain[3], payload_len);
    pos = (uint16_t)(pos + payload_len);
    frame_out[pos++] = checksum8(frame_out, pos);
    *frame_out_len = pos;
    return 0;
}