为什么是 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_verifyuECC_signuECC_make_keyuECC_shared_secret,以及曲线选择函数 uECC_secp256r1()

集成时需要注意的几点:

  1. 只编译 verify 路径。设备端不需要 sign,编译时通过 uECC_OPTIMIZATION_LEVEL 和去掉 uECC_SIGN 相关源码可省掉一半以上的代码体积。
  2. 禁用汇编。micro-ecc 在 ARM 上默认会启用 asm_inline 的快速乘法路径,但不同工具链版本和 -O2 配合时偶有链接问题。在 STM32 + arm-none-eabi-gcc 下直接走纯 C 路径,验证速度够用。
  3. 字序约定。micro-ecc 内部所有大数都是大端字节序,X||Y 拼接,r||s 拼接。这点和 imgtool 输出的格式天然一致,省了一次转换。

库本身只需要 uECC.huECC.c 两个文件,没有依赖。放进 BSP 目录的 ThirdParty/micro-ecc/ 即可,CMake/Makefile 里直接加入编译列表。

签名清单(manifest)的 50 字节结构设计

签名验证不是直接对固件二进制做 ECDSA 验证,而是先打包一份”签名清单”再签名。这样可以把”这次升级针对哪个槽、什么版本、多大规模、CRC 多少、SHA256 多少”一次性绑定到签名里,防止攻击者把同一份签名挪用到别的槽或别的版本号上。

清单布局严格控制在 50 字节,字段全部小端序:

偏移长度字段说明
02versionmanifest 版本,当前 0x0001
22alg算法标识,0x0001 = ECDSA-P256-SHA256
42target_slot目标槽 A/B
64target_fw_version目标固件版本号
104image_size镜像大小
144image_crc32镜像 CRC32
1832image_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;
}

三步:

  1. 构建 manifest。把传入的 slot、版本、大小、CRC32、SHA256 按 50 字节布局拼起来。
  2. 对 manifest 做 SHA-256。注意这里哈希的是 50 字节清单,不是固件本体。固件完整性靠清单里的 SHA256 字段间接保护。
  3. 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作用
keygenimgtool keygen生成 ecdsa-p256 私钥
getpubimgtool getpub从私钥导出 C 数组形式公钥
signimgtool 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 错。

小结

整套方案的核心思路:

  1. 算法选 ECDSA-P256:STM32F1 无硬件加速,RSA 太重,P256 在 72MHz 上 0.5 秒可接受。
  2. 库选 micro-ecc:纯 C、BSD 许可、两文件集成、只 verify 不 sign。
  3. manifest 50 字节:version+alg+slot+fw_ver+size+crc32+sha256,字段全进签名,防挪用。
  4. 设备端只存公钥:私钥在构建机 Deploy/Keys/,永远不进固件。
  5. 验证三步:构建 manifest → SHA256 → uECC_verify。
  6. .NET 工具三子命令:keygen / getpub / sign,薄壳包 imgtool。
  7. Bootloader 薄封装boot_signature.c 隔离编译单元,不直接依赖 BSP。

这套方案在 STM32F103 上跑下来,Flash 占用约 8KB(micro-ecc verify-only + 手写 SHA256),RAM 占用约 2KB,单次验证 0.4 秒左右。对于 OTA 场景这个延迟完全可接受,毕竟擦写一个槽要好几秒。