Skip to content

LV060-Ed25519签名校验原理 ​

前置阅读:LV055-CRC32校验原理 说清了 CRC32 "管意外、不管故意"的边界。本篇讲的是补上"故意"这一环的机制——Ed25519 签名校验:它是什么、为什么需要、设备端怎么用十几 KB 的代码把它跑起来。

一、 为什么 CRC 不够 ​

升级镜像从局域网飞进设备,路径上有路由器、有 AP、有 PC 上的 TFTP 服务。CRC32 保证的是:随机误码几乎必然被发现。但它挡不住下面这条路径:

为什么 CRC 挡不住蓄意篡改

攻击者截获一帧合法镜像,把载荷里任何字节换成自己的代码,然后用协议里公开写明的 zlib 参数重算一遍 CRC32 填回帧头——在接收端眼里,这帧数据和原版完全合法。CRC 的数学性质只对"不知道会改哪里"的随机错误成立;对"知道算法、能重算校验值"的对手,它只是一个帮人确认"改完了没"的工具。

问题本质:CRC 回答的是 "字节变没变",而升级场景真正要问的是 "字节是谁给的"。把这两件事绑在一起,需要一个只有"合法签发方"才算得出、任何人都能检验的值——这就是数字签名。

二、 签名校验是什么 ​

1. 从"检测错误"到"绑定来源" ​

数字签名借用非对称密码把"数据完整性"升级为"数据来源可信":

(1)签发方持有一对密钥:私钥自己保管,公钥公开散发——公钥放出去不影响私钥安全;

(2)签发方用私钥对数据算出一个 64 字节的签名,附在数据后面;

(3)任何人拿着公钥都能验证"这个签名只能由那把私钥生成";

(4)攻击者没有私钥,改了数据的任何一个字节,签名就对不上——他既算不出新签名,也无法把旧签名改成对得上的。

本工程选用的算法是 Ed25519:一种基于 Edwards 曲线(Curve25519)的现代 EdDSA 签名算法。选它有三个工程理由:签名与公钥都很小(签名 64B、公钥 32B)、验签可在无浮点的 Cortex-M3 上纯 C 实现、实现有公有领域的参考代码,可裁剪可审计。

2. 签名与验签的数学骨架 ​

不用深入椭圆曲线细节,把握三步即可:

(1)签发:私钥 + 消息 → 签名 R‖S(64 字节,R 是一个随机化点坐标,S 是一个标量);

(2)重构挑战:验证方对 R ‖ 公钥 A ‖ 消息 算 SHA512,把结果对群阶 L 取模得到挑战值 k——注意消息在这里参与了挑战的构成,消息变则 k 变;

(3)点乘比对:检查 [S]·B(S 倍基点)与 R + [k]·A(R 加上 k 倍公钥)是否为同一个曲线上的点。k 同时绑定了签名、公钥与消息,没有私钥的人无法找到能同时满足等式的 S。

验签方不需要私钥——这正是"公开可验证"的来源;而伪造等价于解椭圆曲线离散对数问题,计算上不可行。

3. 签名覆盖范围:头部也算在消息里 ​

本工程把帧格式升级到第三版,签名插在载荷与尾标之间,覆盖范围是"头部 20 字节 + 载荷 N 字节":

第三版帧格式与签名覆盖范围

把头部纳入签名是刻意的:头部里躺着载荷长度、载荷 CRC32、目标加载地址三个字段。若只签载荷,攻击者可以保留签名原封不动、只改头部的目标地址,把一段为 SRAM 签名的镜像骗进 Flash 区;长度与 CRC 同理,都是"换个信封复用旧签名"的攻击面。现在这三者与载荷一起被锁进同一个签名,任何一个字段被改都会验签失败。

三、 设备侧验签实现 ​

1. 验签总流程 ​

设备侧的验签入口是 ed25519_verify_parts(),整条流程如下:

设备侧验签流程

两个设计点值得展开:

(1)CRC 先行、验签殿后。载荷 CRC32 只要几十微秒就能拒绝一条误码帧,而 Ed25519 的点乘要跑约 50~60ms。把廉价的检查放在前面,昂贵的密码学运算只花在结构完好的帧上,误码帧的拒绝路径也不会被拖慢;

(2)两段式消息接口。收帧时头部 20 字节在内部 RAM,载荷最长 128KB 在外部 SRAM,两者不连续。若为验签把 128KB 拷到一起,既费时间又占内存。因此接口设计为:

c
// atk-f103/Drivers/BSP/Crypto/ed25519.h
int ed25519_verify_parts(const uint8_t sig[64], const uint8_t pk[32],
                         const uint8_t *p1, uint32_t n1,
                         const uint8_t *p2, uint32_t n2);

【函数作用】

对"两段拼接的消息"做 Ed25519 验签,等价于先 SHA512(sig[0..32] ‖ pk ‖ p1 ‖ p2) 得 k,再用双标量点乘比较 [S]·B == R + [k]·A。bootloader 与 flash-app 各持一份逐字节相同的实现。

【参数含义】

  • sig:帧里的 64 字节签名 R‖S;
  • pk:32 字节公钥,来自 iap_key_public();
  • p1/n1:消息第一段(帧头 20 字节);
  • p2/n2:消息第二段(载荷 N 字节),与第一段不要求内存连续。

【返回值】

0 表示验签通过;-1 表示失败(参数非法、公钥不在曲线上、签名不符都归入失败,不向调用方细分原因)。

2. 公钥从哪来:记录优先、genesis 兜底 ​

验签要用公钥,而公钥本身也可能坏、也可能需要换。本工程的策略是"外部 Flash 记录优先,ROM 固化兜底":

公钥来源与 genesis 回退

公钥记录存放在 W25Q128 扇区 132/133,复用参数模块的 64 字节记录信封(魔数 "IAPK"、版本号、写入序号、载荷 CRC32),双槽乒乓、序号大者生效。两个槽都无效(出厂、被擦、记录损坏)时,回退到编译进 ROM 的 genesis 公钥——两侧固件各有一份逐字节相同的副本,它是整个信任体系的出厂锚点。这个设计的实际意义:公钥记录无论怎么坏,设备都能退回一个确定的、随固件一起被烧死的状态继续工作。

初始化实现很短,该函数在 atk-f103/Drivers/BSP/Key/iap_key.h 文件中声明:

c
// atk-f103/Drivers/BSP/Key/iap_key.c
void iap_key_init(void)
{
    uint8_t  pk_a[IAP_KEY_PLEN];
    uint8_t  pk_b[IAP_KEY_PLEN];
    uint32_t seq_a = 0U;
    uint32_t seq_b = 0U;
    uint8_t  ok_a;
    uint8_t  ok_b;

    /* 复用参数模块的记录信封:魔数/版本/长度/CRC32 全部校验通过才算有效 */
    ok_a = param_rec_read(IAP_KEY_ADDR_A, IAP_KEY_MAGIC, IAP_KEY_REC_VERSION,
                          IAP_KEY_PLEN, pk_a, &seq_a);
    ok_b = param_rec_read(IAP_KEY_ADDR_B, IAP_KEY_MAGIC, IAP_KEY_REC_VERSION,
                          IAP_KEY_PLEN, pk_b, &seq_b);

    if ((ok_a != 0U) && (ok_b != 0U))
    {
        s_from_flash = 1U;
        if (seq_a >= seq_b)
        {
            (void)memcpy(&s_active_pk[0], &pk_a[0], IAP_KEY_PLEN);
        }
        else
        {
            (void)memcpy(&s_active_pk[0], &pk_b[0], IAP_KEY_PLEN);
        }
    }
    else if (ok_a != 0U) { /* 单槽有效:直接采用,置 s_from_flash = 1 */ }
    else if (ok_b != 0U) { /* 同上 */ }
    else
    {
        /* 两槽都无效(出厂/被擦/记录损坏):回退 ROM 内 genesis */
        s_from_flash = 0U;
        (void)memcpy(&s_active_pk[0], &s_genesis_pk[0], IAP_KEY_PLEN);
    }
}

【函数作用】

上电时确定"当前用哪把公钥":读两个记录槽,全部校验通过取序号大者,全部无效用 genesis 公钥,结果缓存供所有验签点共用。

【参数含义】

无参数。内部依赖 spiflash_init() 已完成(记录存放在外部 Flash 上)。

【返回值】

无。通过 iap_key_public() 取缓存、iap_key_from_flash() 查来源,启动时 iap_key_print() 打印十六进制与来源,实测输出形如 IAPKEY: public key 1282...(genesis)。

3. 三处验签点:同一个签名验三次 ​

签名只在 PC 上生成一次,但在设备侧被验三处——分别对应"传输后""存储后""即将执行前":

全链路三处验签点

(1)收帧校验(bootloader 的 iap_rx_poll()):串口、TCP、TFTP 三条通道共用同一个收帧状态机,帧格式升级后在状态机里新增了 SIGN 阶段,整帧收齐先比 CRC32 再验签。这条通道的拒绝发生在"字节刚进来"的时刻;

(2)下载落槽前(app 的 atk_mw8266d_net_img_verify()):WiFi 下载完的镜像在写入外部 Flash 槽之前整体校验,验签失败返回 NET_ERR_SIGN,不写槽、不置 pending、不改升级记录;

(3)搬运前(bootloader 的 svc_install_pending()):go flash 从槽里读出整帧后,在擦写内部 Flash 之前做完整七项校验(魔数、长度、目标地址、尾标、载荷 CRC、整帧 CRC、验签)。这是最后一关——它防的是"校验通过之后、执行之前"槽内容被换掉的窗口。

三处验的是同一个签名、同一把公钥、同一个覆盖范围,任何一处失败,后续动作都不会发生。

4. 收帧校验分支:CRC 与验签的接力 ​

bootloader 收帧校验的核心分支如下,定义在 atk-f103/Drivers/BSP/Iap/iap.c 中:

c
// atk-f103/Drivers/BSP/Iap/iap.c(iap_rx_poll 的校验分支)
if (s_rx.verify_req != 0U)
{
    s_rx.verify_req = 0U;
    s_rx.evt_header = 0U;

    /* 校验顺序固定:先 CRC32(廉价,先挡传输误码),再 Ed25519 验签(昂贵)。
     * 两者都通过才算有效帧;验签覆盖头部 20 字节 + 载荷 N 字节 */
    if (iap_crc32(&s_payload[0], s_rx.len) == s_rx.expect_crc)
    {
        if (ed25519_verify_parts(&s_rx.sign[0], iap_key_public(),
                                 &s_header[0], IAP_HEADER_LEN,
                                 &s_payload[0], s_rx.len) == 0)
        {
            s_rx.frames++;                  /* 计数只在双重校验都通过后递增 */
            iap_back_to_scan();
            return IAP_EVT_FRAME_OK;
        }

        iap_fail(IAP_ERR_SIGN);             /* 新增的独立失败原因:签名无效 */
        return IAP_EVT_FRAME_ERR;
    }

    iap_fail(IAP_ERR_CRC);
    return IAP_EVT_FRAME_ERR;
}

配合收帧状态机看更完整:帧尾校验通过后置 verify_req,验签工作从串口中断里挪到了主循环——中断上下文每字节只做 O(1) 的状态迁移,128KB 载荷的 CRC 与点乘都留给主循环完成,不会丢后续字节。

IAP_ERR_SIGN 是本次新增的独立失败原因,与"版本不符""CRC 不符"并列且可区分:串口上看到 signature invalid 就知道帧结构没问题、是内容对不上签名,排查方向完全不同。

四、 PC 侧配套工具 ​

1. 密钥生成:iap_keygen.py ​

python tools/iap_keygen.py gen 生成 Ed25519 密钥对:私钥存为 PKCS8 PEM(keys/iap_priv.pem),公钥存标准 PEM 与 32 字节原始格式(keys/iap_pub.bin,即烧进 ROM 的 genesis 值)。私钥目录整体列入 .gitignore,任何提交都不含私钥;生成完立即对固定文本做一次签名往返自检。

pubrec 子命令负责"换公钥"场景:把任意公钥组装成 64 字节的 IAPK 记录,输出可直接粘贴的 spiflash erase/write 命令(按 32B 分两条,避开串口命令行长度限制)。固件侧没有任何改写公钥的文本命令——公钥的写入口径与镜像一致,都走"外部生成 + 既有通道"。

2. 打包签名:pack_iap.py ​

打包命令从第三版起必须携带私钥:

python tools/pack_iap.py -p flash-app.bin -k keys/iap_priv.pem

build_packet() 的核心动作只有一步:对 帧头 20B + 载荷 N B 这段拼接消息调用 cryptography 库的 Ed25519 签名,把 64 字节签名插进载荷与尾标之间。打包完成后立即自检:回读文件、解帧、用公钥独立验签、核对向量表——任何一步不符都不产出文件。缺 -k 直接报错退出,不存在"无签名帧"这个产物形态。

3. 独立复核:--pub ​

分发前还可以用另一条完全独立的路径复核:

python tools/pack_iap.py -x app.img --pub keys/iap_pub.bin

解帧模式会重算长度、CRC32,并用指定公钥验签,输出"签名校验通过 / 失败"。它与 MCU 侧 ed25519_verify_parts() 对同一份文件的结论必须一致——两侧结论不一致本身就是缺陷。实测中改载荷 1 字节先报 CRC 不符,重算 CRC 后再报签名失败,与设备侧行为逐条对应。

五、 防护边界 ​

最后明确这套机制的边界,它解决的是"远端投递"这一类威胁:

(1)防得住:局域网内伪造/篡改 TFTP 响应、串口或 TCP 通道上投递恶意镜像、把为 A 目标签的镜像骗到 B 目标装载、旧版本帧(无签名)的降级重放——全部被签名拦下;

(2)不防:能物理接触设备的人。未开 Flash 读保护(RDP),调试器可以直接改写 0x08020000;公钥记录扇区本身也可以被 spiflash 命令改写——这是设计时确认过的取舍:签名防的是"网络路径上的对手",不是"拿到板子的人";

(3)信任根:整套体系最终收敛到 ROM 里的 genesis 公钥与"私钥不出仓库"这条纪律。genesis 公钥随固件固化;私钥丢了或泄露了,唯一出路是生成新密钥对并 J-Link 重烧两侧固件。

CRC32 与 Ed25519 至此各司其职:前者用几十微秒守住"意外",后者用几十毫秒守住"故意"——升级链路的每一字节,都要先过这两关,才有资格被写进 0x08020000。


本文档由 markdowncli 技能辅助生成