为什么是 ECDSA-P256 而不是 RSA
STM32F1 是 Cortex-M3 内核,没有硬件密码加速器(PKA / HASH)。所有密码学运算都得靠软件跑,这时的瓶颈就是 RAM 占用和 Flash 占用。
RSA-2048 的私钥操作涉及大数模幂,软件实现下签名验证一次要几百毫秒到秒级;而且 RSA 公钥本身就几百字节,在 20KB SRAM 的 F103 上不小。ECDSA-P256(secp256r1)用 256 位素数域,公钥只要 64 字节(X、Y 两个坐标各 32 字节),签名 64 字节(r、s 各 32 字节),一次验证在 72MHz F1 上约 0.3~0.5 秒,完全可接受。
项目里把算法标识定为单一常量,不打算在设备端做多算法热切换:
#define UPGRADE_SIGNATURE_ALG_NONE 0x0000u
#define UPGRADE_SIGNATURE_ALG_ECDSA_P256_SHA256 0x0001u
后续要扩 Ed25519 或 RSA,再加一个枚举即可,但当前设备端只认这一种。
micro-ecc 库集成
micro-ecc 是一个纯 C 实现的 ECC 库,作者 Ken MacKay,许可证 BSD-2-Clause,仓库在 github.com/kmackay/micro-ecc。它只暴露四个核心函数:uECC_verify、uECC_sign、uECC_make_key、uECC_shared_secret,以及曲线选择函数 uECC_secp256r1()。
集成时需要注意的几点:
- 只编译 verify 路径。设备端不需要 sign,编译时通过
uECC_OPTIMIZATION_LEVEL和去掉uECC_SIGN相关源码可省掉一半以上的代码体积。 - 禁用汇编。micro-ecc 在 ARM 上默认会启用
asm_inline的快速乘法路径,但不同工具链版本和-O2配合时偶有链接问题。在 STM32 + arm-none-eabi-gcc 下直接走纯 C 路径,验证速度够用。 - 字序约定。micro-ecc 内部所有大数都是大端字节序,X||Y 拼接,r||s 拼接。这点和 imgtool 输出的格式天然一致,省了一次转换。
库本身只需要 uECC.h 和 uECC.c 两个文件,没有依赖。放进 BSP 目录的 ThirdParty/micro-ecc/ 即可,CMake/Makefile 里直接加入编译列表。
签名清单(manifest)的 50 字节结构设计
签名验证不是直接对固件二进制做 ECDSA 验证,而是先打包一份”签名清单”再签名。这样可以把”这次升级针对哪个槽、什么版本、多大规模、CRC 多少、SHA256 多少”一次性绑定到签名里,防止攻击者把同一份签名挪用到别的槽或别的版本号上。
清单布局严格控制在 50 字节,字段全部小端序:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | version | manifest 版本,当前 0x0001 |
| 2 | 2 | alg | 算法标识,0x0001 = ECDSA-P256-SHA256 |
| 4 | 2 | target_slot | 目标槽 A/B |
| 6 | 4 | target_fw_version | 目标固件版本号 |
| 10 | 4 | image_size | 镜像大小 |
| 14 | 4 | image_crc32 | 镜像 CRC32 |
| 18 | 32 | image_sha256 | 镜像 SHA-256 |
合计 2+2+2+4+4+4+32 = 50 字节,对应宏:
#define UPGRADE_SIGNATURE_MANIFEST_VERSION 0x0001u
#define UPGRADE_SIGNATURE_MANIFEST_SIZE 50u
构建逻辑在 Upgrade.c 里,单纯的小端写入:
void Upgrade_BuildSignatureManifest(uint8_t manifest[UPGRADE_SIGNATURE_MANIFEST_SIZE],
uint16_t target_slot,
uint32_t target_fw_version,
uint32_t image_size,
uint32_t image_crc32,
const uint8_t image_sha256[UPGRADE_IMAGE_SHA256_SIZE])
{
Upgrade_WriteLe16(&manifest[0], UPGRADE_SIGNATURE_MANIFEST_VERSION);
Upgrade_WriteLe16(&manifest[2], UPGRADE_SIGNATURE_ALG_ECDSA_P256_SHA256);
Upgrade_WriteLe16(&manifest[4], target_slot);
Upgrade_WriteLe32(&manifest[6], target_fw_version);
Upgrade_WriteLe32(&manifest[10], image_size);
Upgrade_WriteLe32(&manifest[14], image_crc32);
memcpy(&manifest[18], image_sha256, UPGRADE_IMAGE_SHA256_SIZE);
}
设计要点:
- version 放最前。未来清单结构扩展(比如加签名时间戳或 anti-rollback counter)时,设备端可以先读 version 决定如何解析后续字段。
- alg 直接进清单。这样同一套签名框架可以支持多算法并存,签名侧和验证侧通过清单声明用了哪种算法。
- slot 和 fw_version 进清单。把”把 slot A 的签名挪用到 slot B”这种攻击直接挡掉。即使两槽镜像完全相同,签名也不可互换。
- 不把整包固件塞进签名输入。签名的是 manifest 哈希,而不是固件本身。固件完整性交给 SHA256 字段保证,SHA256 又被签名保护,链条完整且性能可控。
设备端只嵌入公钥
私钥永远不进设备。设备里固化的只有 64 字节公钥(X||Y 大端拼接,micro-ecc 原生格式):
/* 只提交公钥,私钥保存在本机 Deploy/Keys/ota_ecdsa_p256_private.pem。
* 公钥格式为 micro-ecc 需要的 64 字节 X||Y 大端坐标。 */
static const uint8_t k_upgrade_signing_public_key[64] =
{
0x1E, 0x02, 0xC6, 0x36, 0x7B, 0x4A, 0xDF, 0xD1,
/* ... 32 字节 X ... */
0x32, 0x18, 0x86, 0x42, 0x7E, 0x2C, 0xDF, 0x6A,
/* ... 32 字节 Y ... */
0x9A, 0xB1, 0x5E, 0x26, 0x96, 0xAC, 0x71, 0x28
};
私钥文件 ota_ecdsa_p256_private.pem 放在构建机 Deploy/Keys/ 下,永远不进 git,也不进固件镜像。私钥泄漏等于整个签名体系作废,这是整个方案的安全前提。
签名验证流程
验证流程在 UpgradeSignature.c 里,一气呵成:
uint8_t UpgradeSignature_Verify(uint16_t target_slot,
uint32_t target_fw_version,
uint32_t image_size,
uint32_t image_crc32,
const uint8_t image_sha256[UPGRADE_IMAGE_SHA256_SIZE],
uint16_t signature_alg,
const uint8_t image_signature[UPGRADE_IMAGE_SIGNATURE_SIZE])
{
uint8_t manifest[UPGRADE_SIGNATURE_MANIFEST_SIZE] = {0};
uint8_t manifest_hash[UPGRADE_IMAGE_SHA256_SIZE] = {0};
if (signature_alg != UPGRADE_SIGNATURE_ALG_ECDSA_P256_SHA256 ||
image_sha256 == NULL ||
image_signature == NULL ||
Upgrade_IsSlotIdValid(target_slot) == 0u)
{
return 0u;
}
Upgrade_BuildSignatureManifest(manifest,
target_slot,
target_fw_version,
image_size,
image_crc32,
image_sha256);
Upgrade_SHA256_Calculate(manifest, sizeof(manifest), manifest_hash);
return (uECC_verify(k_upgrade_signing_public_key,
manifest_hash,
sizeof(manifest_hash),
image_signature,
uECC_secp256r1()) == 1) ? 1u : 0u;
}
三步:
- 构建 manifest。把传入的 slot、版本、大小、CRC32、SHA256 按 50 字节布局拼起来。
- 对 manifest 做 SHA-256。注意这里哈希的是 50 字节清单,不是固件本体。固件完整性靠清单里的 SHA256 字段间接保护。
- uECC_verify。传入公钥、manifest 哈希、签名(64 字节 r||s),用 secp256r1 曲线验证。
签名长度 64 字节对应宏 UPGRADE_IMAGE_SIGNATURE_SIZE。这是 ECDSA-P256 原生签名长度(r、s 各 32 字节大端),不包含 ASN.1 DER 包装,micro-ecc 直接吃裸签名。
SHA-256 是项目自己手写的纯 C 实现(UpgradeSha256Context 那一坨),没有引入 mbedTLS 之类的重型库,原因是 F103 Flash 紧张,引入 mbedTLS 会多占十几 KB。手写 SHA-256 行为确定、可测、体积小,对这种单算法场景够用。
.NET 侧签名工具:OTA.SigningTool
签名不在设备端做,而是在构建机用 .NET 工具完成。OTA.SigningTool 是一个薄壳,把 imgtool 命令包成三子命令:
| 子命令 | 对应 imgtool | 作用 |
|---|---|---|
keygen | imgtool keygen | 生成 ecdsa-p256 私钥 |
getpub | imgtool getpub | 从私钥导出 C 数组形式公钥 |
sign | imgtool sign | 对镜像签名,生成带 header+TLV+trailer 的 MCUboot 镜像 |
典型工作流:
# 1. 生成私钥(只做一次)
dotnet run --project OTA.SigningTool -- keygen --private Deploy\Keys\ota_ecdsa_p256_private.pem
# 2. 提取公钥,把输出的 C 数组粘进 UpgradeSignature.c 的 k_upgrade_signing_public_key
dotnet run --project OTA.SigningTool -- getpub --private Deploy\Keys\ota_ecdsa_p256_private.pem
# 3. 对固件镜像签名
dotnet run --project OTA.SigningTool -- sign `
--image app.bin `
--key Deploy\Keys\ota_ecdsa_p256_private.pem `
--version 1.2.3 `
--out app_signed.bin
keygen 有一个防误覆盖保护:私钥已存在时直接拒绝,避免把线上私钥覆盖掉:
if (File.Exists(privateKeyPath))
{
return Fail($"私钥已存在,避免误覆盖:{privateKeyPath}");
}
sign 子命令默认值对齐了 Zephyr DTS 配置:
--header-size 0x200:MCUboot 镜像头--slot-size 0x37000:Zephyr slot0/slot1 各 220KB--align 1:imgtool 默认对齐
这些参数在 MCUboot 配置变化时需要同步调整,否则签名出来的镜像在 Bootloader 里解析会错位。
与 imgtool / MCUboot 工具链的配合
OTA.SigningTool 不是要重新发明 imgtool,而是把 imgtool 的输出接入项目自己的验证链。imgtool 是 Zephyr MCUboot 官方签名工具,支持 ecdsa-p256、rsa-2048、ed25519 多种算法,私钥格式和公钥导出格式都是事实标准。
为什么不直接在设备端跑 imgtool 的 TLV 解析?原因是 MCUboot 的 TLV 格式是为 MCUboot 引导流程设计的,它把签名放在镜像尾部的 protected TLV 区。但项目的 Bootloader 有自己的状态页和槽管理逻辑,不是标准 MCUboot 流程。所以方案是:
- imgtool 负责”签名”这件事:私钥管理、签名算法、哈希计算,这些交给成熟工具。
- 设备端负责”验证”这件事:用 micro-ecc + 手写 SHA256,按自己的 manifest 结构做最小验证。
两侧通过”公钥 + 签名格式”这两个约定对接:
- imgtool
getpub输出的 C 数组直接粘到k_upgrade_signing_public_key。 - imgtool
sign输出的签名(64 字节 r||s)直接作为image_signature传给UpgradeSignature_Verify。
这样私钥永远只在 imgtool 侧,设备端只持有公钥,密钥管理边界清晰。
Bootloader 侧:薄封装
Bootloader 不直接调用 micro-ecc,而是通过 boot_signature.c 这一层薄封装:
uint8_t BootSignature_Verify(uint16_t target_slot,
uint32_t target_fw_version,
uint32_t image_size,
uint32_t image_crc32,
const uint8_t image_sha256[UPGRADE_IMAGE_SHA256_SIZE],
uint16_t signature_alg,
const uint8_t image_signature[UPGRADE_IMAGE_SIGNATURE_SIZE])
{
return UpgradeSignature_Verify(target_slot,
target_fw_version,
image_size,
image_crc32,
image_sha256,
signature_alg,
image_signature);
}
这层封装的作用是隔离编译单元。Bootloader 工程和 App 工程是两套独立的链接目标,各自有自己的 include 路径和编译选项。boot_signature.c 让 Bootloader 不直接依赖 BSP 的 UpgradeSignature.h,而是通过自己的 boot_signature.h 暴露同一接口。这样 Bootloader 可以单独链接 micro-ecc,不把整个 BSP 模块拖进来。
Bootloader 的验证决策入口在 boot_verify.c,它会先检查 signature_alg:
if (signature_alg == UPGRADE_SIGNATURE_ALG_NONE)
{
/* 无签名,走 legacy CRC 路径 */
}
if (signature_alg != UPGRADE_SIGNATURE_ALG_ECDSA_P256_SHA256)
{
/* 未知算法,拒绝 */
}
这样保证了向后兼容:旧的无签名镜像仍能启动(走 CRC32 + 可选 SHA256),新镜像必须带 ECDSA 签名。
一些踩过的坑
字序坑。imgtool 输出的公钥是 X||Y 大端,micro-ecc 也是大端,但项目里 manifest 用小端。早期版本曾把 manifest 字段也写成大端,结果 .NET 侧和 C 侧对不齐,验证一直失败。后来统一约定:manifest 字段小端(和 STM32 本机序一致),密码学原语大端(和 micro-ecc / imgtool 一致)。
signature_alg 必须进 manifest。一开始 alg 没进清单,只存在状态页里。后来发现这样有漏洞:攻击者可以把状态页的 alg 改成 NONE,让设备走 CRC 路径绕过签名。把 alg 写进 manifest 并参与签名后,改 alg 等于改签名输入,签名直接失效。
SHA256 实现要测 Known Answer。手写 SHA256 没有第三方库的信任背书,必须用 NIST 官方测试向量(空串、“abc”、长串)验证一遍,否则单字节写错一位整个签名链就崩了,而且报错信息完全看不出是 SHA 错。
小结
整套方案的核心思路:
- 算法选 ECDSA-P256:STM32F1 无硬件加速,RSA 太重,P256 在 72MHz 上 0.5 秒可接受。
- 库选 micro-ecc:纯 C、BSD 许可、两文件集成、只 verify 不 sign。
- manifest 50 字节:version+alg+slot+fw_ver+size+crc32+sha256,字段全进签名,防挪用。
- 设备端只存公钥:私钥在构建机
Deploy/Keys/,永远不进固件。 - 验证三步:构建 manifest → SHA256 → uECC_verify。
- .NET 工具三子命令:keygen / getpub / sign,薄壳包 imgtool。
- Bootloader 薄封装:
boot_signature.c隔离编译单元,不直接依赖 BSP。
这套方案在 STM32F103 上跑下来,Flash 占用约 8KB(micro-ecc verify-only + 手写 SHA256),RAM 占用约 2KB,单次验证 0.4 秒左右。对于 OTA 场景这个延迟完全可接受,毕竟擦写一个槽要好几秒。