☰
8位微控制器上实现ECDH与ECDSA:曲线选型、标量乘抗侧信道与RAM优化
2026/10/28 7:25:19 网站建设 项目流程

简介:这是一份面向8位微控制器场景的轻量级ECDH与ECDSA安全库C语言实现,基于kmackay的micro-ecc项目整理,适合在资源受限的单片机上落地椭圆曲线密钥协商与数字签名功能,并具备抵御常见侧信道攻击的能力。资源包共22个文件,约1.04MB,以c与h源码为核心,辅以Makefile构建脚本、Arduino示例ino、测试用例及README说明文档,另含若干备份文件,便于直接编译验证与二次移植。代码体积紧凑,ECDH模块最小约6KB,与ECDSA联合使用不超过7KB,无需动态内存分配,支持secp128r1、secp192r1、secp256r1与secp384r1四条标准曲线,在16MHz的ATmega328P上192位ECDH密钥生成约4034毫秒。目前已有37人学习关注。读者可据此获得完整的曲线运算、密钥生成、签名验签与速度测试参考实现,并借助测试向量与示例工程快速排错、评估性能,适合嵌入式安全方向的初学者与工程师研读。

1. 微型 8 位微控制器上跑 ECDH 与 ECDSA:为什么这件事比想象中难

很多人第一次听到「在 8 位单片机上做椭圆曲线密钥交换和签名」,反应都是:这不是找罪受吗。STM32、ESP32 这类 32 位芯片跑 mbedTLS 已经够呛,8 位机那点 RAM 和主频,连一次大数乘法都要掰着指头算。但现实需求就摆在那里——低成本的智能门锁、工业传感器节点、一次性耗材的防伪芯片,主控往往就是一颗 8 位 MCU,Flash 几十 KB,RAM 一两 KB,却要求跟网关做双向认证、协商会话密钥。这时候你不可能塞一个通用密码库进去,只能自己写一个专用的小型 ECDH 与 ECDSA 安全库,用 C 语言实现,支持多曲线,还要抗侧信道攻击,代码精简高效。

这篇文章讲的就是这条路怎么走通。我会把曲线参数怎么选、有限域运算怎么裁、标量乘怎么防侧信道、签名验签怎么省 RAM 这几个环节拆开,给出可复现的代码骨架和参数配置。适合两类人:一类是被产品逼着要在 8 位机上做非对称密码的嵌入式工程师,另一类是手里有现成密码库但发现根本塞不进去、想自己裁剪的开发者。读完你应该能判断自己的芯片能不能扛住,以及第一步该从哪里下手。

2. 曲线选型与有限域裁剪:8 位机能扛住哪几条曲线

2.1 为什么 secp256r1 在 8 位机上基本是奢望

先算一笔账。secp256r1 的基域是 256 位素数域,一个域元素要占 32 字节。做一次点加需要若干次模乘,每次模乘又拆成多轮 8 位乘法累加。8 位 MCU 没有硬件乘法器的话,一次 256 位模乘轻松上千个指令周期。一次标量乘大约需要 1500 到 2000 次点运算,算下来单次 ECDH 在 8 MHz 的 AVR 上要好几秒。更致命的是 RAM:雅可比坐标下同时保存多个中间点,光坐标就要几百字节,加上栈开销,2 KB RAM 的芯片直接爆掉。

所以第一条结论:8 位机上不要碰 256 位曲线,除非你有硬件加速或者只做验签这种低频操作。真正能落地的是 160 到 192 位这个区间,安全强度对应 80 到 96 位,对于传感器认证、耗材防伪这类场景足够。

2.2 推荐曲线与参数表

我一般会在这几条里选,具体看芯片资源和协议要求:

曲线基域位宽私钥字节公钥压缩字节安全强度适用场景
secp160r1160202180 位RAM 极紧、只做 ECDH
secp160k1160202180 位兼容老协议
secp192r1192242596 位需要 ECDSA 签名
Curve255192553232128 位有 32 位协处理器时

选曲线时优先看协议对端支持什么,其次看你的 RAM。160 位曲线一个域元素 20 字节,雅可比点三个坐标 60 字节,标量乘过程中同时活跃的点不超过四个,240 字节左右,加上临时变量,512 字节 RAM 能跑下来。192 位曲线大概要 700 到 800 字节。

2.3 有限域运算的裁剪策略

8 位机上不要写通用的任意精度大数库,那是给自己挖坑。正确做法是针对选定曲线的素数 p 写死约减逻辑。以 secp160r1 为例,p = 2^160 - 2^31 - 1,这个特殊形式让约减可以只用移位和加法完成,不需要除法。

下面是一个模乘的核心骨架,用 8 位数组表示大数,小端序:

/* secp160r1 模乘:a * b mod p,结果写入 r * p = 2^160 - 2^31 - 1 * 输入输出均为 20 字节小端序数组 */ void fp_mul(uint8_t *r, const uint8_t *a, const uint8_t *b) { uint8_t t[40]; /* 320 位中间积 */ uint8_t carry; int i, j; /* 第一步:普通 160x160 乘法,结果 320 位 */ for (i = 0; i < 40; i++) t[i] = 0; for (i = 0; i < 20; i++) { carry = 0; for (j = 0; j < 20; j++) { uint16_t sum = t[i + j] + (uint16_t)a[i] * b[j] + carry; t[i + j] = (uint8_t)(sum & 0xFF); carry = (uint8_t)(sum >> 8); } t[i + 20] = carry; } /* 第二步:利用 p 的特殊形式做快速约减 * 2^160 ≡ 2^31 + 1 (mod p) * 把高 160 位折叠回低 160 位 */ for (i = 39; i >= 20; i--) { uint16_t hi = t[i]; t[i] = 0; /* 高字节乘以 2^31 等价于左移 31 位,再乘 1 */ /* 这里展开成对 t[i-20] 和 t[i-20+4] 的加法 */ uint16_t sum = t[i - 20] + hi; t[i - 20] = (uint8_t)(sum & 0xFF); carry = (uint8_t)(sum >> 8); /* 处理进位传播,略 */ } /* 第三步:条件减 p,保证结果小于 p */ fp_cond_sub_p(t); for (i = 0; i < 20; i++) r[i] = t[i]; }

这段代码的关键在第二步。通用大数库会老老实实做 320 位除以 160 位的除法,那在 8 位机上慢得没法看。利用 p 的特殊形式,把高 160 位乘以 (2^31 + 1) 折叠回低位,只需要移位和加法。参数上要注意:折叠可能产生新的高位,需要迭代一到两次直到高位清零,实际实现里通常展开成固定两轮,避免循环次数泄露信息。

提示:如果你的曲线素数没有这种特殊形式,比如随机选的素数,那 8 位机上基本没戏,换曲线比优化代码划算。

2.4 点运算层的实现顺序

域运算搞定后,点加和倍点用雅可比坐标避免求逆。求逆在 8 位机上是一次扩展欧几里得,几百个周期,能省则省。标量乘结束后再统一转回仿射坐标做一次求逆。

/* 雅可比坐标点加:R = P + Q,P != Q */ void point_add(jacobian_t *R, const jacobian_t *P, const jacobian_t *Q) { fp_t Z1Z1, Z2Z2, U1, U2, S1, S2, H, I, J, rr, V; fp_sqr(Z1Z1, P->z); /* Z1^2 */ fp_sqr(Z2Z2, Q->z); /* Z2^2 */ fp_mul(U1, P->x, Z2Z2); /* U1 = X1*Z2^2 */ fp_mul(U2, Q->x, Z1Z1); /* U2 = X2*Z1^2 */ fp_mul(S1, P->y, Q->z); /* S1 = Y1*Z2 */ fp_mul(S1, S1, Z2Z2); /* S1 = Y1*Z2^3 */ fp_mul(S2, Q->y, P->z); fp_mul(S2, S2, Z1Z1); /* S2 = Y2*Z1^3 */ fp_sub(H, U2, U1); /* H = U2 - U1 */ fp_sub(rr, S2, S1); /* r = S2 - S1 */ /* 后续按标准公式计算 X3, Y3, Z3 */ }

参数说明:所有 fp_t 是 20 字节数组,函数内部不做内存分配,全部用栈上临时变量。这样做的代价是栈深度增加,8 位机上要留意栈不要溢出到堆区。我一般会把临时变量数量压到最少,宁可多算一次也不多存一个。

3. 标量乘与抗侧信道:简单功耗分析怎么防

3.1 朴素双倍点加为什么会被打

最朴素的标量乘是从高位到低位扫描私钥比特,遇到 1 做一次倍点加一次点加,遇到 0 只做倍点。问题很明显:点加比倍点耗电多,功耗曲线上能直接看出私钥的 0 和 1 模式。这就是简单功耗分析(SPA)。8 位机没有随机数发生器的话,连掩码都做不了,只能靠算法结构本身抵抗。

3.2 Montgomery 阶梯:结构对称的标量乘

Montgomery 阶梯每次迭代固定做一次点加和一次倍点,跟当前比特是 0 还是 1 无关,只是交换两个中间变量的角色。这样功耗曲线在宏观上是对称的,SPA 看不出比特模式。

/* Montgomery 阶梯标量乘:R = k * P * k 为 20 字节小端序私钥,P 为仿射点 * 固定执行 160 次迭代,每次一个点加一个倍点 */ void scalar_mul_montgomery(affine_t *R, const uint8_t *k, const affine_t *P) { jacobian_t R0, R1; int i, bit; /* 初始化:R0 = O(无穷远点),R1 = P */ point_set_infinity(&R0); affine_to_jacobian(&R1, P); /* 从最高位到最低位扫描 */ for (i = 159; i >= 0; i--) { bit = (k[i >> 3] >> (i & 7)) & 1; if (bit == 0) { point_add(&R1, &R0, &R1); /* R1 = R0 + R1 */ point_double(&R0, &R0); /* R0 = 2*R0 */ } else { point_add(&R0, &R0, &R1); /* R0 = R0 + R1 */ point_double(&R1, &R1); /* R1 = 2*R1 */ } } jacobian_to_affine(R, &R0); }

这段代码里 if 分支的两个路径做的事情在运算量上完全一样,只是操作对象不同。编译器优化时要注意别让它把分支重新排列成条件跳转,可以用 volatile 或者手动展开。参数上,k 必须是固定长度 20 字节,高位补零也要走完 160 次迭代,不能提前退出,否则迭代次数泄露私钥位长。

3.3 固定窗口与预计算表的取舍

Montgomery 阶梯的缺点是慢,每次迭代两个点运算,160 次就是 320 次。如果芯片主频够但 RAM 紧,可以用固定窗口法,窗口宽度 w=2 或 3,预计算表存在 Flash 里而不是 RAM 里。预计算表大小是 2^w 个点,w=2 时四个点,每个点 60 字节,240 字节 Flash,可以接受。但窗口法要做表查找,查找索引跟私钥有关,需要配合常时间查找或者掩码,否则又引入缓存侧信道。8 位机没有缓存,这个风险小一些,但功耗上仍有差异。

我的建议:如果只做 ECDH 且对时间不敏感,用 Montgomery 阶梯,代码简单、抗 SPA 效果好。如果要做 ECDSA 签名且对时间有要求,用固定窗口加随机化投影坐标,每次签名前用随机数把雅可比坐标的 Z 分量随机化,这样即使窗口索引泄露,功耗也和私钥无关。

3.4 标量盲化:给私钥加一层随机掩码

即使算法结构对称,实际功耗仍可能因为数据相关性泄露信息。标量盲化的做法是生成随机数 r,计算 k' = k + r * n,其中 n 是曲线的阶。因为 n * P = O,所以 k' * P = k * P,结果不变,但中间标量变了。8 位机上生成随机数可以用 ADC 采样热噪声,或者用未初始化的 RAM 做种子跑一个轻量 PRNG。

/* 标量盲化:k' = k + r * n,结果仍为 k*P */ void scalar_blind(uint8_t *k_blind, const uint8_t *k, const uint8_t *r) { uint8_t rn[21]; /* r * n 可能比 k 多一个字节 */ int i; /* 计算 r * n,n 为曲线阶,20 字节 */ fp_mul_mod_n(rn, r, curve_n); /* k' = k + rn,模 n */ uint8_t carry = 0; for (i = 0; i < 20; i++) { uint16_t sum = k[i] + rn[i] + carry; k_blind[i] = (uint8_t)(sum & 0xFF); carry = (uint8_t)(sum >> 8); } /* 如果超出 n 则减 n,略 */ }

参数说明:r 每次签名或密钥交换都要重新生成,不能复用。如果芯片没有真随机源,至少用会话 ID 和计数器混合做伪随机,别用固定值。

4. ECDSA 签名与验签:RAM 不够时怎么省

4.1 签名流程与临时密钥的致命细节

ECDSA 签名需要生成临时密钥 k,计算 R = k * G,取 R 的 x 坐标模 n 得到 r,再算 s = k^-1 * (hash + d * r) mod n。这里 k 绝对不能泄露,也不能重复使用。索尼 PS3 的私钥泄露就是因为 k 复用了。8 位机上生成 k 要用前面说的盲化思路,并且每次签名后立刻清零。

/* ECDSA 签名:输出 r 和 s,各 20 字节 */ int ecdsa_sign(uint8_t *r, uint8_t *s, const uint8_t *hash, const uint8_t *priv, const uint8_t *k) { affine_t R; uint8_t k_inv[20], tmp[20]; /* 第一步:R = k * G */ scalar_mul_montgomery(&R, k, &curve_G); /* 第二步:r = R.x mod n */ fp_mod_n(r, R.x); if (is_zero(r)) return -1; /* 极小概率,重试 */ /* 第三步:k_inv = k^-1 mod n */ fp_inv_mod_n(k_inv, k); /* 第四步:s = k_inv * (hash + priv * r) mod n */ fp_mul_mod_n(tmp, priv, r); fp_add_mod_n(tmp, tmp, hash); fp_mul_mod_n(s, k_inv, tmp); if (is_zero(s)) return -1; /* 清零敏感中间变量 */ memset(k_inv, 0, sizeof(k_inv)); memset(tmp, 0, sizeof(tmp)); return 0; }

RAM 占用分析:R 是仿射点,40 字节;k_inv、tmp 各 20 字节;加上标量乘内部的临时变量约 300 字节。总共不到 400 字节,1 KB RAM 的芯片能跑。注意 hash 要先截断或扩展成 20 字节,跟曲线阶位宽对齐。

4.2 验签的优化:只做一次标量乘

验签要算 u1 = hash * s^-1 mod n,u2 = r * s^-1 mod n,然后验证 u1 * G + u2 * Q 的 x 坐标模 n 等于 r。朴素做法是两次标量乘再点加,但可以用 Shamir 技巧把两次标量乘合并成一次,同时扫描 u1 和 u2 的比特,根据两个比特的组合决定做哪种点运算。这样点运算次数从 2 * 160 降到约 160 次,速度翻倍。

/* 合并标量乘:R = u1 * G + u2 * Q */ void shamir_double_mul(affine_t *R, const uint8_t *u1, const uint8_t *u2, const affine_t *Q) { jacobian_t acc; point_set_infinity(&acc); for (int i = 159; i >= 0; i--) { point_double(&acc, &acc); int b1 = (u1[i >> 3] >> (i & 7)) & 1; int b2 = (u2[i >> 3] >> (i & 7)) & 1; if (b1 && b2) { point_add_mixed(&acc, &acc, &curve_G); point_add_mixed(&acc, &acc, Q); } else if (b1) { point_add_mixed(&acc, &acc, &curve_G); } else if (b2) { point_add_mixed(&acc, &acc, Q); } } jacobian_to_affine(R, &acc); }

参数说明:point_add_mixed 是雅可比坐标加仿射坐标的混合加法,比两个雅可比点相加少几次域乘。curve_G 是基点,可以预计算成仿射坐标存在 Flash 里。这个函数的分支仍然跟 u1、u2 的比特有关,验签用的是公钥,不涉及私钥,所以侧信道风险低,不需要盲化。

4.3 公钥压缩与解压

8 位机通信带宽有限,公钥用压缩格式,只传 x 坐标和一个符号位,21 字节而不是 41 字节。解压时需要计算 y = sqrt(x^3 + ax + b) mod p。素数域开方用 Tonelli-Shanks 或者利用 p ≡ 3 mod 4 时的简单公式 y = (x^3 + ax + b)^((p+1)/4)。secp160r1 的 p 满足 p ≡ 3 mod 4,可以用快速公式。

/* 解压公钥:给定 x 和 y 的奇偶性,恢复 y */ int decompress_point(affine_t *P, const uint8_t *x, int y_parity) { fp_t alpha, beta; /* alpha = x^3 + a*x + b */ fp_sqr(beta, x); fp_mul(beta, beta, x); /* 加 a*x 和 b,secp160r1 的 a = -3 */ fp_mul(alpha, x, curve_a); fp_add(beta, beta, alpha); fp_add(beta, beta, curve_b); /* beta = beta^((p+1)/4) */ fp_exp(beta, beta, curve_p_plus1_div4); /* 检查奇偶性,不对则取负 */ if ((beta[0] & 1) != y_parity) { fp_neg(beta, beta); } memcpy(P->x, x, 20); memcpy(P->y, beta, 20); return 0; }

fp_exp 是模幂,用平方乘实现,指数 (p+1)/4 是固定值,可以预计算成比特串存在 Flash 里。这一步在密钥交换开始时做一次,耗时可以接受。

5. 避坑与排查:8 位机上最容易翻车的五个地方

5.1 栈溢出导致随机崩溃

现象:签名偶尔失败,或者跑一段时间后死机,复位后又能跑。原因:8 位机 RAM 小,标量乘函数递归或者临时变量太多,栈指针撞到全局变量区。解决:用编译器选项生成栈使用报告,把大数组改成 static 或者放 Flash,标量乘函数确保没有递归,临时变量数量控制在 20 个以内。

5.2 编译器优化破坏常时间特性

现象:Montgomery 阶梯的功耗曲线仍然能看出分支。原因:编译器把 if-else 优化成了条件跳转,两个分支执行时间不同。解决:把关键变量声明为 volatile,或者用内联汇编写交换逻辑,确保两个路径的指令数一致。GCC 的 -O2 有时会做这种优化,-Os 反而安全一些,要实测。

5.3 随机数质量差导致私钥可预测

现象:不同设备的公钥有相关性,或者签名 r 值重复。原因:用固定种子或者上电时间做随机源,熵不够。解决:ADC 采样悬空引脚的热噪声,多次采样后做哈希,或者用两个不同时钟源的抖动做随机。至少 160 位熵,每次签名重新生成。

5.4 模逆运算泄露私钥

现象:功耗分析能恢复私钥。原因:扩展欧几里得算法的迭代次数跟输入有关,泄露 k 的信息。解决:用费马小定理做模逆,k^-1 = k^(n-2) mod n,固定指数平方乘,时间恒定。代价是慢一些,但安全。

5.5 Flash 里的预计算表被读走

现象:攻击者读出 Flash 内容,恢复基点或预计算表。原因:8 位机通常没有读保护,或者保护等级低。解决:开启芯片的 Flash 读保护,预计算表用的时候再算,或者存到 RAM 里每次上电重新生成。如果芯片支持,把密钥存在受保护的 EEPROM 区域。

6. 验证与进阶:怎么确认你的实现真的抗侧信道

6.1 用示波器做一次简易功耗采集

没有专业侧信道设备也能做初步验证。在芯片的 VCC 引脚串一个 10 欧姆电阻,用示波器差分探头测电阻两端电压,触发信号用 GPIO 在标量乘开始时拉高。采集多次签名或密钥交换的波形,叠加对比。如果 Montgomery 阶梯实现正确,160 次迭代的波形应该呈现均匀的周期性,看不出明显的 0/1 模式。如果某些迭代明显更长或更短,说明分支没消除干净。

6.2 用已知答案测试向量验证正确性

在优化之前先用标准测试向量跑通。NIST 的 CAVP 测试向量包含 ECDH 和 ECDSA 的输入输出对,选 secp160r1 或 secp192r1 的向量,把私钥、公钥、hash、签名值填进去,对比输出。这一步能抓出大部分实现错误,比如约减边界、符号处理、字节序。

/* 用测试向量验证 ECDH */ void test_ecdh_vector(void) { uint8_t priv_a[20] = { /* 测试向量私钥 A */ }; uint8_t pub_b[21] = { /* 测试向量公钥 B 压缩格式 */ }; uint8_t expected[20] = { /* 期望的共享密钥 */ }; affine_t Q; uint8_t shared[20]; decompress_point(&Q, pub_b, pub_b[20] & 1); scalar_mul_montgomery(&Q, priv_a, &Q); memcpy(shared, Q.x, 20); if (memcmp(shared, expected, 20) != 0) { /* 失败,检查约减和字节序 */ } }

6.3 时间恒定性的量化检查

在 GPIO 上翻转电平,用逻辑分析仪测每次标量乘的周期数。跑 1000 次,统计周期数的均值和标准差。如果标准差超过均值的 1%,说明有数据相关的分支。Montgomery 阶梯加固定长度标量,标准差应该接近零。如果做不到,检查编译器是否把循环展开成了变长路径,或者模逆是否用了变时间算法。

6.4 我踩过的一个坑

早期版本里我用扩展欧几里得做模逆,测试向量全过,但功耗采集发现迭代次数跟私钥有关。换成费马小定理后速度慢了 30%,但波形均匀了。后来在另一颗芯片上又翻车,因为编译器把费马小定理的平方乘循环优化成了提前退出,指数虽然固定但中间结果为零时跳过了后续乘法。最后用 volatile 修饰中间变量才解决。这件事让我养成一个习惯:任何涉及私钥的运算,写完先看反汇编,确认没有条件跳转,再上板子测功耗。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询