简介:这是一份面向8位微控制器场景的轻量级ECDH与ECDSA安全库C语言实现,基于kmackay的micro-ecc项目整理而来,主要服务于在资源受限单片机上实现密钥协商与数字签名的嵌入式开发者。代码具备抵御常见侧信道攻击的能力,无需动态内存分配,ECDH模块最小约6KB,与ECDSA联合使用不超过7KB,在16MHz的ATmega328P上192位ECDH密钥生成约4034毫秒,并支持secp128r1、secp192r1、secp256r1与secp384r1四种标准曲线。资源包共22个文件,约1.04MB,以c与h源码、Makefile构建脚本、测试程序及README说明文档为主,另含Arduino示例与测试向量,便于快速移植与验证。目前已有37人学习。读者可据此掌握曲线选型、密钥生成与签名验证流程,并借助测试用例排查移植问题。
1. 微型 8 位微控制器上跑 ECDH 与 ECDSA:为什么这件事比想象中难
很多人第一次听到「在 8 位单片机上做椭圆曲线密码」的反应是:这不是找罪受吗?一颗主频 8MHz、RAM 只有几百字节、Flash 也就几十 KB 的芯片,连个像样的整数乘法都要靠软件模拟,居然要跑 ECDH 密钥协商和 ECDSA 签名验签。但现实需求就摆在那里——低功耗传感器节点、无线遥控设备、工业现场的身份认证模块,它们没有条件上 32 位 MCU,更不可能挂一颗专用安全芯片来抬高 BOM 成本。于是「微型 8 位微控制器专用小型 ECDH 与 ECDSA 安全库」就成了一个真实存在的工程命题:用 C 语言把椭圆曲线密码学压缩到 8 位平台能承受的范围内,同时还要支持多曲线、抗侧信道攻击,并且代码要精简高效。
这件事的难点不在于「能不能算出来」,而在于「算得够快、够省、够安全」。8 位 MCU 没有硬件大数乘法器,所有有限域运算都得靠字节级操作拼出来;侧信道攻击又要求你在实现标量乘法时不能有数据相关的分支和内存访问模式;再加上多曲线支持意味着不能把代码写死成某一条曲线的参数。这些约束叠在一起,才是这个方向真正值得投入的原因。下面从选型、实现到避坑,把这条路走一遍。
2. 曲线选型与有限域算术:8 位平台上的第一道取舍
2.1 为什么 Curve25519 和 secp256r1 是两条必须支持的曲线
在 8 位 MCU 上做 ECC,曲线选型直接决定了你能不能把代码塞进 Flash、把中间变量塞进 RAM。常见做法是至少支持两条曲线:一条是 NIST P-256(secp256r1),因为它是 TLS、X.509 生态里最通用的曲线,设备要和服务器对接基本绕不开;另一条是 Curve25519(或它的 Edwards 形式 Ed25519),因为它在软件实现上对侧信道更友好, Montgomery ladder 天然恒定时间,且 255 位字长在 8 位平台上做进位处理比 256 位更顺手。
但这里有个现实问题:8 位 MCU 的 RAM 通常只有 256 字节到 2KB。P-256 的一个仿射点坐标就要 64 字节,Jacobian 坐标下三个分量就是 96 字节,再加上标量、临时变量、栈空间,很容易就爆了。所以选型时不能只看安全性,还要看「这条曲线在你的芯片上能不能跑完一次完整标量乘法而不溢出」。我一般会先做一个 RAM 预算表:
| 曲线 | 坐标表示 | 单点存储 | 标量乘法峰值 RAM | 典型 8 位耗时(8MHz) |
|---|---|---|---|---|
| secp256r1 | Jacobian | 96 B | ~220 B | 1.8–3.2 s |
| Curve25519 | 扩展坐标 | 160 B | ~280 B | 1.2–2.0 s |
| secp160r1 | Jacobian | 60 B | ~140 B | 0.6–1.1 s |
如果 RAM 实在紧张,secp160r1 是一个可接受的降级选项,但要注意 160 位曲线在现代安全强度下已经偏弱,只适合对寿命要求不长的场景。选型时还要确认曲线参数是否以常量表形式存放在 Flash 里,而不是运行时计算——8 位 MCU 算一次模逆的开销足够你多跑几百条指令。
2.2 有限域算术:用字节数组模拟大数运算的 C 语言写法
8 位 MCU 上没有 64 位整数,也没有硬件除法,所有有限域运算都要用字节数组手工实现。核心操作包括:模加、模减、模乘、模平方、模逆。其中模乘是最耗时的,通常用「移位-相加」或「Comba 乘法」实现。下面是一个在 8 位平台上比较实用的模乘骨架,假设模数p以字节数组形式存储,长度为NLEN:
// 有限域模乘:r = a * b mod p // a, b, r 均为 NLEN 字节的大端表示 // p 为模数,同样 NLEN 字节 void fp_mul(uint8_t *r, const uint8_t *a, const uint8_t *b, const uint8_t *p) { uint8_t t[2 * NLEN]; // 临时乘积,长度翻倍 uint8_t carry; uint16_t sum; // 步骤1:清零临时缓冲区 for (int i = 0; i < 2 * NLEN; i++) t[i] = 0; // 步骤2:逐字节做小学乘法,累加到 t for (int i = NLEN - 1; i >= 0; i--) { carry = 0; for (int j = NLEN - 1; j >= 0; j--) { sum = t[i + j + 1] + (uint16_t)a[i] * b[j] + carry; t[i + j + 1] = (uint8_t)(sum & 0xFF); carry = (uint8_t)(sum >> 8); } t[i] = carry; // 最高字节进位 } // 步骤3:对 p 做模约减(这里用逐字节减法,实际常用 Barrett 或 Montgomery) while (cmp(t, p, 2 * NLEN) >= 0) { sub(t, t, p, 2 * NLEN); } // 步骤4:拷贝低 NLEN 字节到结果 for (int i = 0; i < NLEN; i++) r[i] = t[i + NLEN]; }这段代码的逻辑说明:fp_mul先把两个 NLEN 字节的数当成无符号大整数相乘,结果存到 2*NLEN 的临时区;然后通过反复减法做模约减。参数说明:NLEN由曲线决定,P-256 是 32,Curve25519 是 32,secp160r1 是 20。cmp和sub是大数比较和减法辅助函数,需要自己实现。注意这里的模约减用减法循环只是为了说明原理,实际在 8 位平台上会慢到不可接受,生产代码一般用 Montgomery 乘法或 Barrett 约减,把约减次数从「可能几百次」降到「固定几次」。
提示:在 8 位平台上,Montgomery 乘法需要把操作数转到 Montgomery 域,这会增加一次转换开销,但标量乘法里要跑几百次模乘,摊下来仍然划算。如果只做一次 ECDH,转换开销可能占主导,这时可以考虑用 Barrett 约减。
2.3 多曲线支持的代码组织方式
多曲线支持不等于把几套代码复制粘贴。常见做法是把曲线参数抽成结构体,把有限域运算的「字长」和「模数」作为运行时参数传入,但这样会牺牲速度。更高效的方式是用编译期宏选择曲线,同一份源码编译出不同目标。例如:
#if defined(CURVE_P256) #define NLEN 32 #define FP_MUL fp_mul_p256 #elif defined(CURVE_25519) #define NLEN 32 #define FP_MUL fp_mul_25519 #elif defined(CURVE_SECP160R1) #define NLEN 20 #define FP_MUL fp_mul_160 #endif这样每条曲线可以有自己优化的模乘实现,同时上层 ECDH/ECDSA 逻辑共用。代价是固件体积会随曲线数量线性增长,所以如果 Flash 紧张,通常只编一条曲线进去,或者把不常用的曲线做成可选模块。
3. ECDH 与 ECDSA 的 8 位实现:从标量乘法到签名验签
3.1 标量乘法:Montgomery ladder 与恒定时间实现
ECDH 的核心是计算Q = k * P,其中k是私钥标量,P是基点或对方公钥点。在 8 位 MCU 上,最直接的实现是 double-and-add,但它有一个致命问题:分支依赖于标量比特,攻击者可以通过功耗或电磁辐射区分「加」和「不加」,从而恢复私钥。抗侧信道攻击的要求在这里体现得最直接——必须用恒定时间算法。
Montgomery ladder 是常见选择,它对每个标量比特执行相同的操作序列,只是交换输入输出:
// Montgomery ladder 标量乘法(伪代码,实际需适配具体曲线坐标) // 输入:k 为标量字节数组,P 为基点 // 输出:R = k * P void scalar_mul(point_t *R, const uint8_t *k, const point_t *P) { point_t R0, R1; point_copy(&R0, P); // R0 = P point_set_infinity(&R1); // R1 = 无穷远点 for (int i = bitlen(k) - 1; i >= 0; i--) { if (bit_get(k, i)) { point_add(&R0, &R0, &R1); point_double(&R1, &R1); } else { point_add(&R1, &R0, &R1); point_double(&R0, &R0); } } point_copy(R, &R0); }逻辑说明:每个比特都执行一次点加和一次倍点,分支只影响操作对象,不影响操作类型,因此功耗曲线在宏观上更均匀。参数说明:bitlen(k)是标量位长,P-256 是 256,Curve25519 是 255。point_add和point_double需要针对具体曲线坐标实现,Jacobian 坐标下点加和倍点的公式不同,要小心处理无穷远点和相同点的特殊情况。
注意:Montgomery ladder 本身不是万能的。如果
point_add内部有条件分支(比如判断是否无穷远点),仍然会泄漏信息。真正恒定时间的实现需要把条件分支改成条件赋值,用位掩码选择结果,而不是用if。
3.2 ECDSA 签名与验签:8 位平台上的模逆与随机数
ECDSA 签名需要计算s = k^{-1}(z + r*d) mod n,其中k是随机数,d是私钥,r是k*G的 x 坐标模 n。这里有两个 8 位平台上的大坑:模逆和随机数。
模逆在 8 位 MCU 上通常用扩展欧几里得算法或费马小定理(a^{p-2} mod p)。扩展欧几里得的分支更多,侧信道风险更大;费马小定理需要做模幂,耗时更长但更容易做成恒定时间。我一般会选费马小定理,因为 8 位平台上模幂可以用固定窗口法,窗口大小选 4 或 5,平衡速度和 RAM。
随机数k绝对不能复用,也不能用弱随机源。8 位 MCU 通常没有硬件 TRNG,常见做法是用 ADC 采样热噪声或 RC 振荡器抖动作为熵源,再经过哈希函数(如 SHA-256)做条件化。如果k被预测或复用,私钥直接泄漏,这是 ECDSA 最经典的翻车方式。
验签需要计算u1 = z*s^{-1} mod n和u2 = r*s^{-1} mod n,然后验证u1*G + u2*Q的 x 坐标是否等于r。验签过程中s^{-1}也要做模逆,但验签用的是公钥,侧信道风险相对低,可以用更快的扩展欧几里得。
3.3 代码精简:把 Flash 占用压到 8KB 以内的几个手段
8 位 MCU 的 Flash 通常只有 32KB 到 128KB,留给安全库的空间可能只有 8KB 到 16KB。要压到这个量级,需要做几件事:
第一,去掉所有不必要的抽象层。不要用函数指针做曲线切换,不要用动态内存分配,所有缓冲区静态分配。
第二,把常量表放在 Flash 里,用const修饰,避免复制到 RAM。曲线参数、预计算表、SHA-256 常量都属于这一类。
第三,合并相似函数。比如模加和模减可以共用一个核心,只是进位方向不同;点加和点倍可以共用一部分有限域运算。
第四,用宏代替小函数。8 位平台上函数调用开销不小,对于只有几条指令的操作,宏展开比函数调用更省。
下面是一个 Flash 占用的参考表:
| 模块 | 典型占用 | 说明 |
|---|---|---|
| 有限域运算 | 2–3 KB | 模乘、模逆、模加减 |
| 曲线点运算 | 1.5–2.5 KB | 点加、点倍、标量乘法 |
| ECDH | 0.5 KB | 密钥协商封装 |
| ECDSA | 1–1.5 KB | 签名、验签 |
| SHA-256 | 1–1.5 KB | 哈希与随机数条件化 |
| 曲线参数 | 0.5–1 KB | 每条曲线 |
合计大约 7–10 KB,具体取决于编译优化等级和曲线数量。用-Os优化,关闭异常和 RTTI(如果是 C++ 混编),可以再省一点。
4. 抗侧信道攻击:8 位平台上最容易翻车的几个地方
4.1 时序攻击:为什么你的模逆会泄漏私钥
时序攻击是侧信道攻击里最容易在 8 位平台上复现的一种。攻击者不需要物理接触设备,只需要精确测量每次操作的耗时。如果你的模逆用扩展欧几里得,而扩展欧几里得的循环次数依赖于输入,那么不同私钥比特会导致不同的执行时间,攻击者通过大量采样就能恢复私钥。
现象:设备在做 ECDSA 签名时,签名耗时随私钥不同而波动,波动幅度超过 5%。
原因:模逆或标量乘法中存在数据相关的分支或循环次数。
解决:把模逆改成费马小定理加固定窗口模幂,把标量乘法改成 Montgomery ladder,所有条件分支改成位掩码条件赋值。验证方法是跑一万次签名,统计耗时分布,标准差应该接近零。
4.2 简单功耗分析:点加和点倍功耗差异
简单功耗分析(SPA)通过观察一次标量乘法的功耗曲线,直接区分点加和点倍操作。如果 double-and-add 的分支依赖于标量比特,功耗曲线上会明显看到「加」和「不加」的差异。
现象:用示波器观察 VCC 上的电流波形,能看到与标量比特相关的规律性波动。
原因:标量乘法使用了分支依赖标量比特的算法。
解决:改用 Montgomery ladder,并确保点加和点倍的功耗特征尽量接近。在 8 位平台上,还可以在关键操作前后插入随机延时或伪操作,但这不是根本办法,根本办法是算法层面恒定时间。
4.3 故障注入:电压毛刺与时钟毛刺的防御
故障注入攻击通过瞬间拉低电压或注入时钟毛刺,让 MCU 执行出错,然后利用错误结果推导私钥。8 位 MCU 通常没有电压监测和时钟监测,防御手段有限。
现象:设备在特定电压下签名结果错误,但错误结果仍然被输出。
原因:没有对签名结果做验证,也没有检测异常复位。
解决:在签名输出前做一次验签自检,如果验签失败就不输出签名。另外,可以在关键计算前后读一个已知常量,如果常量被篡改就复位。这些手段不能完全防御故障注入,但能提高攻击成本。
4.4 随机数质量:ADC 噪声够不够
8 位 MCU 的随机数质量直接决定 ECDSA 的安全性。用 ADC 采样热噪声是常见做法,但 ADC 本身有量化噪声和周期性干扰,直接采样可能不够随机。
现象:生成的 ECDSA 签名中,r值出现重复或规律性分布。
原因:随机数熵不足,或者随机数生成器被复位后重复初始化。
解决:用多个 ADC 通道采样,混合 RC 振荡器抖动,再过 SHA-256 条件化。每次签名前重新播种,避免复位后重复。验证方法是收集一万个随机数,跑 NIST SP 800-22 测试套件,至少通过频率测试和游程测试。
5. 避坑与排查:8 位 ECC 实现中最常见的 5 个问题
5.1 现象:标量乘法结果偶尔错误,但大部分时候正确
原因:Jacobian 坐标下点加或点倍没有正确处理无穷远点,或者模约减在边界条件下溢出。
解决:检查point_add和point_double对无穷远点的处理,确保Z=0时返回正确结果。模约减后要再做一次条件减法,确保结果严格小于模数。用已知测试向量(如 NIST P-256 的测试向量)跑一千次,统计错误率。
5.2 现象:ECDH 协商出的共享密钥两端不一致
原因:一方用了压缩点格式,另一方没有正确解压;或者字节序处理不一致。
解决:统一点格式,要么都用非压缩格式(0x04 || X || Y),要么都支持压缩格式并正确解压。字节序上,8 位平台通常用大端存储大数,但通信协议可能是小端,转换时要小心。用固定密钥对做交叉验证,两端各跑一次,对比共享密钥的字节序列。
5.3 现象:签名验证在 PC 上通过,在 8 位设备上失败
原因:8 位设备上的模逆或模乘实现有细微错误,导致u1或u2计算偏差。
解决:把 8 位设备上的中间结果打印出来(通过串口),和 PC 上的 OpenSSL 结果逐字节对比。重点检查模逆结果和模乘结果的最高字节。如果串口资源紧张,可以把中间结果存到 Flash 的保留区,复位后读出来。
5.4 现象:Flash 占用超出预算,链接时报错
原因:曲线参数表太大,或者编译器没有做函数合并。
解决:检查是否把不必要的曲线编进去了,用编译期宏裁剪。开启-Os和-ffunction-sections -Wl,--gc-sections,让链接器丢弃未使用的函数。如果 SHA-256 占用太大,可以考虑用更小的哈希,但不要低于 SHA-256,否则安全性不够。
5.5 现象:设备在低温或高温下签名失败
原因:8 位 MCU 的 RAM 在极端温度下可能出现位翻转,或者 ADC 噪声源在低温下熵不足。
解决:在关键计算前后做 RAM 校验,用 CRC 或简单校验和。随机数生成器在低温下要增加采样次数,或者切换到 RC 振荡器抖动作为主熵源。如果设备工作温度范围宽,建议做高低温测试,覆盖 -40°C 到 85°C。
6. 进阶技巧:用预计算表和窗口法把 ECDH 再压快 30%
如果你已经跑通了基础版本,下一步可以优化标量乘法的速度。在 8 位平台上,最有效的优化是预计算表加固定窗口法。以 P-256 为例,如果固定基点的标量乘法用 4 位窗口,可以预计算 16 个点,把点加次数从 256 次降到 64 次左右,速度提升 30% 到 40%。代价是 Flash 里多存 16 个点,每个点 64 字节,一共 1KB。
预计算表的生成可以在 PC 上做,然后把结果作为常量数组编进固件。下面是一个生成预计算表的 Python 脚本片段:
# 在 PC 上生成 P-256 基点的 4 位窗口预计算表 # 输出为 C 数组,供 8 位固件使用 from ecdsa import SigningKey, NIST256p G = NIST256p.generator table = [] for i in range(16): P = i * G table.append((P.x(), P.y())) print("const uint8_t precomp_table[16][64] = {") for x, y in table: xb = x.to_bytes(32, 'big') yb = y.to_bytes(32, 'big') print(" {" + ",".join(f"0x{b:02X}" for b in xb + yb) + "},") print("};")逻辑说明:这段脚本用ecdsa库生成 0 到 15 倍基点的坐标,每个点 64 字节(X 和 Y 各 32 字节),输出成 C 数组。参数说明:NIST256p是 P-256 曲线,to_bytes(32, 'big')保证大端字节序。生成后的表直接编进固件,运行时用窗口法查表做点加。
注意:预计算表会占用 Flash,如果 Flash 紧张,可以只预计算奇数倍点,运行时用倍点补偶数倍点,这样表的大小减半,但点加次数增加。具体取舍要看你的 Flash 和速度需求。
另一个技巧是把 SHA-256 和 ECDSA 的模运算流水线化。8 位 MCU 没有流水线,但可以在 SHA-256 计算哈希的同时,预取 ECDSA 需要的曲线参数,减少等待时间。这个优化需要仔细安排代码顺序,收益通常在 5% 到 10%。
最后说一个我自己的习惯:每次改完 ECC 代码,不管改多小,都要跑一遍已知测试向量,再跑一遍随机测试向量,最后用示波器看一眼功耗曲线。8 位平台上的 ECC 实现,玄学问题不少,但大部分都能通过「对比 PC 结果 + 看功耗波形」定位。血泪经验是,不要相信「这次只改了一行,肯定没问题」——在 8 位平台上,一行改错可能让签名结果在 99% 的情况下正确,剩下 1% 泄漏私钥。希望帮到你。
本文还有配套的精品资源,点击获取