资讯详情

8位微控制器上实现ECDH与ECDSA:曲线选型、标量乘抗侧信道与RAM优化

📅 2026/10/28 7:25:20 | 华诺云谱 👁 阅读
8位微控制器上实现ECDH与ECDSA:曲线选型、标量乘抗侧信道与RAM优化
简介这是一份面向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 位 MCUFlash 几十 KBRAM 一两 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 极紧、只做 ECDHsecp160k1160202180 位兼容老协议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-204] 的加法 */ 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 QP ! 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 紧可以用固定窗口法窗口宽度 w2 或 3预计算表存在 Flash 里而不是 RAM 里。预计算表大小是 2^w 个点w2 时四个点每个点 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 * nn 为曲线阶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 nu2 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)^((p1)/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 和 bsecp160r1 的 a -3 */ fp_mul(alpha, x, curve_a); fp_add(beta, beta, alpha); fp_add(beta, beta, curve_b); /* beta beta^((p1)/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 是模幂用平方乘实现指数 (p1)/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 修饰中间变量才解决。这件事让我养成一个习惯任何涉及私钥的运算写完先看反汇编确认没有条件跳转再上板子测功耗。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑