☰
手写ECDSA签名验签:从P-256曲线到DER编码的纯C实现
2026/10/27 8:11:39 网站建设 项目流程

简介:本资源是一份轻量级ECDSA椭圆曲线数字签名算法的C语言实现,面向嵌入式开发、物联网安全及密码学初学者,解决资源受限场景下高效实现公钥签名与验证的需求。压缩包共2个文件(1个C源码文件、1个头文件),总大小仅7KB,结构精简:ecdsa.c封装了椭圆曲线参数初始化、私钥生成、签名计算与验证等核心逻辑,ecdsa.h提供标准化接口如ecdsa_sign()和ecdsa_verify(),便于快速集成到现有C项目中。已有945人学习下载,适合需要在MCU或低功耗设备中部署数字签名功能的开发者。读者可直接复用该实现完成消息完整性校验、固件签名验证等典型安全任务,并基于源码深入理解ECDSA数学原理与工程落地细节,包括模运算优化、随机数安全使用及防侧信道基础设计。

1. ECDSA 签名验签 C 实现:不是调库,是亲手抠透椭圆曲线点乘、模逆、随机数生成的黑匣子

你写完一个openssl_sign()调用就敢说懂 ECDSA?别急。这份ecdsa.zip里没有.so、没有头文件依赖、没有 Makefile 包装——只有 3 个.c文件、2 个.h、1 个test.c,全部用纯 C99 写成,连malloc都被手动内存池替代。它不跑在 OpenSSL 上,也不依赖任何 crypto 库;它从 NIST P-256 曲线参数硬编码开始,手撸模幂、模逆、Montgomery 约减、点加、点倍、KDF 密钥派生,最后把签名结果按 DER 编码规则逐字节拼出来。我拿它在裸机 ARM Cortex-M4 上跑通过——没 libc、没 stdio、只靠汇编写的_sbrk和串口打印。适合想真正搞懂「为什么私钥不能泄露」「为什么 k 必须真随机」「为什么 r=0 要重选」的人。如果你刚学完翁恺 C 语言课、能写冒泡排序和指针数组移动、但看到BN_mod_inverse()还发怵,这份代码就是你的后悔药:它把所有“玄学”拆成可单步调试的for循环和位运算。新手能照着test.c改两行就跑出签名值,熟手能直接切进ecdsa_sign()里看k是怎么用 SHA256(seed) + 时间戳 + 计数器三重搅动生成的。


2. 从零构建 ECDSA 基础模块:P-256 曲线参数、大数运算与模逆实现

ECDSA 不是魔法,它只是把椭圆曲线数学翻译成 C 语言的位操作。这份代码没用 GMP 或 OpenSSL BN,而是用uint32_t[8]模拟 256 位整数(刚好覆盖 P-256 的 256-bit 模数),所有运算都在栈上完成。关键不在“多快”,而在“每一步都可控、可打断、可验证”。

2.1 P-256 曲线参数硬编码与域元素表示

NIST P-256 的模数p、基点G的 x/y 坐标、阶数n全部以十六进制字面量写死在ecp256.h中:

// ecp256.h #define P256_P "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff" #define P256_GX "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296" #define P256_GY "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"

但注意:这些字符串不是直接拿来用的。ecp256_init()函数会调用bn_from_hex()把它们转成bn_t结构体——一个含uint32_t d[8](8×32=256 bit)和int len的数组。d[0]存最低 32 位,d[7]存最高 32 位。这种小端存储方式让加法进位逻辑更直观:carry = 0; for(i=0; i<8; i++) { sum = a->d[i] + b->d[i] + carry; c->d[i] = sum & 0xFFFFFFFFU; carry = sum >> 32; }。你改一个d[3]就能立刻看到模约减失败——这是教科书不会告诉你的调试入口。

2.2 模逆运算:扩展欧几里得 vs Fermat 小定理,为什么这里选前者?

ECDSA 签名中s = k⁻¹·(h + r·d) mod n,k⁻¹是模n的逆元。ecdsa.c里用的是扩展欧几里得算法(bn_mod_inverse()),而非k^(n-2) mod n(Fermat 小定理)。原因很实际:n是素数没错,但n-2是 256 位大数,做模幂需要 256 次平方+乘法,而扩展欧几里得平均只要 ~256 次除法+取余——后者在无硬件乘法器的 MCU 上快 3 倍以上。代码里bn_divmod()是手写的 32 位除法模拟(类似小学竖式),每次迭代更新r, s, t三个bn_t,直到r==0。关键细节:当gcd(a,n)!=1时函数返回 0,这正是 ECDSA 要求k与n互质的底层体现——如果k是n的倍数,逆元不存在,签名直接失败。你可以在test.c里故意传k=0,看它是否卡在bn_mod_inverse()的 while 循环里。

2.3 点加与点倍:射影坐标加速与 Z≠1 校验

椭圆曲线上的点加(x₁,y₁)+(x₂,y₂)在仿射坐标下要算 1 个除法(斜率 λ)、2 个乘方、若干乘加,除法最慢。本实现用Jacobian 射影坐标:点表示为(X,Y,Z),满足x=X/Z², y=Y/Z³。此时点加公式变成纯乘加(无除法),点倍公式也避免除法。ecp256_add()和ecp256_double()全部基于此。但注意:最终输出签名r时必须把(X,Y,Z)转回仿射坐标x = X/Z² mod p,这就需要两次模逆(先算Z⁻¹,再算Z⁻²)。ecp256_affine()函数里有一行if (bn_is_zero(&z)) return -1;——这就是防Z=0导致除零崩溃的兜底。很多初学者以为“点加就是套公式”,却不知道Z=0对应无穷远点O,而O参与运算会污染整个签名链。这个检查是你调试时第一个该打的断点。


3. ECDSA 签名全流程:从哈希输入到 DER 编码输出的七步拆解

签名不是一锤子买卖。ecdsa_sign()函数表面看就一个入口,实则暗藏七层状态机。我把test.c里的调用链反向拆解,每步都对应一个可打印的中间值——这才是能 debug 的签名流程。

3.1 输入预处理:消息哈希与长度截断

ECDSA 要求输入是固定长度摘要。代码强制用 SHA256(sha256.c自实现),哪怕你传入 1KB 文本,也先sha256_hash(msg, len, hash)得到 32 字节hash[32]。但注意:P-256 的阶数n是 256 位(32 字节),所以ecdsa_sign()会取hash[0..31]的前bn_bitlen(&n)位(即 256 位)作为z。如果hash的高位全是 0,z就会变小——这不影响安全性,但会导致r值偏小。你可以用printf("z = "); bn_print(&z);打印出来,对比 OpenSSL 输出,确认前导零处理一致。

3.2 随机数 k 生成:三重熵源与重试机制

k是签名安全的生命线。本实现不用/dev/random(嵌入式没这玩意),而是:

  1. 读取硬件 RNG(若存在,hw_rng_read(kbuf, 32))
  2. 否则用get_cycle_count()获取 CPU cycle 计数器 +get_millis()获取毫秒计数 + 当前栈地址&kbuf三者异或后 SHA256 搅拌
  3. 生成k后立即bn_mod(&k, &n)确保0 < k < n
  4. 若bn_is_zero(&k)则跳回第 2 步重试(最多 10 次)

提示:k为 0 是致命错误。ecdsa_sign()开头就有if (bn_is_zero(&k)) { /* retry */ }。很多移植失败就是因为没实现get_cycle_count(),导致k固定为某值,签名r永远相同——这是侧信道攻击的温床。

3.3 签名计算核心:r与s的生成逻辑

// ecdsa.c: ecdsa_sign() ecp256_mul(&Q, &k, &G); // Q = k * G,得到点 Q=(x,y) bn_mod(&r, &Q.x, &n); // r = x_Q mod n if (bn_is_zero(&r)) goto retry_k; // r=0 无效,必须重选 k bn_mul(&tmp1, &d, &r); // tmp1 = d * r bn_add(&tmp2, &z, &tmp1); // tmp2 = z + d*r bn_mod(&tmp2, &tmp2, &n); // tmp2 = (z + d*r) mod n bn_mod_inverse(&k_inv, &k, &n); // k_inv = k^(-1) mod n bn_mul(&s, &k_inv, &tmp2); // s = k^(-1) * (z + d*r) mod n bn_mod(&s, &s, &n); // s = s mod n if (bn_is_zero(&s)) goto retry_k; // s=0 也无效

注意r和s都必须非零,否则协议拒绝。这段代码里bn_mul()是朴素 O(n²) 大数乘法(因 256 位太小,没上 Karatsuba),bn_mod()用的是 Barrett 约减(比试除快,但需预计算mu = floor(2^512 / n)存在bn_t mu中)。你可以把&r和&s打印出来,和 OpenSSL 的openssl dgst -sign priv.pem -sha256 msg.txt | openssl asn1parse输出对比——r和s的十六进制值必须完全一致。

3.4 DER 编码封装:TLV 结构与字节序陷阱

签名输出不是r,s两个数拼接,而是 ASN.1 DER 编码的 SEQUENCE。ecdsa_der_encode()手动构造:

  • 0x30(SEQUENCE tag)
  • len(后续总长,变长编码)
  • 0x02(INTEGER tag)+len_r+r_bytes(r 的大端编码,补前导零至 32 字节)
  • 0x02(INTEGER tag)+len_s+s_bytes(同上)

关键陷阱:r和s是无符号整数,但 DER 要求 INTEGER 的最高位为 0(正数)。如果r的最高字节r_bytes[0] & 0x80为 1,必须在前面补0x00。bn_to_bytes()函数里有if (out[0] & 0x80) { memmove(out+1, out, len); out[0]=0; len++; }。我曾因漏掉这行,导致签名被 Java 验证器拒收——它严格检查 DER 格式,而 OpenSSL 宽松些。建议用xxd -p sig.der看前几个字节:应为3045...(0x30 开头,0x45 表示总长 69 字节),而不是3044...(少一个字节,说明没补零)。


4. ECDSA 验签逻辑与边界条件:为什么r和s必须在[1,n-1]内?

验签不是签名的逆过程,而是独立的数学验证。ecdsa_verify()的核心是检查u₁·G + u₂·Q = (xᵣ,yᵣ)是否成立,其中Q是公钥。但协议对输入有严苛约束,越界即拒。

4.1 输入合法性四重校验

ecdsa_verify()开头就做四件事:

  1. if (!der_decode(sig, sig_len, &r, &s)) return -1;—— DER 解析失败直接返回
  2. if (bn_cmp(&r, &bn_one) < 0 || bn_cmp(&r, &n) >= 0) return -1;——r必须1 ≤ r < n
  3. if (bn_cmp(&s, &bn_one) < 0 || bn_cmp(&s, &n) >= 0) return -1;——s同理
  4. if (ecp256_is_on_curve(&Q) == 0) return -1;—— 公钥Q必须在曲线上(y² ≡ x³ + ax + b mod p)

注意:第 2、3 步的bn_cmp(&r, &bn_one) < 0判断r < 1,即r == 0。很多开发者以为r=0只是概率低,其实它是协议明令禁止的无效值。OpenSSL 的ECDSA_do_verify()也会在此刻返回 0。

4.2 验证等式推导:u₁与u₂的物理意义

验签计算:

bn_mod_inverse(&w, &s, &n); // w = s^(-1) mod n bn_mul(&u1, &z, &w); // u1 = z * w mod n bn_mod(&u1, &u1, &n); bn_mul(&u2, &r, &w); // u2 = r * w mod n bn_mod(&u2, &u2, &n); ecp256_mul(&R1, &u1, &G); // R1 = u1 * G ecp256_mul(&R2, &u2, &Q); // R2 = u2 * Q ecp256_add(&R, &R1, &R2); // R = R1 + R2 bn_mod(&xR, &R.x, &n); // xR = x_R mod n if (bn_cmp(&xR, &r) == 0) return 1; // 成功

u₁本质是“消息权重”,u₂是“公钥权重”。R = u₁·G + u₂·Q的 x 坐标必须等于签名中的r。这里ecp256_add()对R1和R2做点加时,若R1 == R2(即u₁·G == u₂·Q),会自动调用ecp256_double(),代码里有if (ecp256_eq(&R1, &R2)) ecp256_double(&R, &R1); else ecp256_add(&R, &R1, &R2);。这个分支判断必须存在,否则R1=R2时点加公式失效。

4.3 公钥恢复攻击防御:为什么Q必须是有效点?

P-256 曲线上有n个有效点,但模p下有p个可能的x坐标,其中约一半x³+ax+b是模p二次剩余,对应两个y。攻击者可构造Q使y² ≠ x³+ax+b mod p,导致ecp256_is_on_curve()返回 0,验签失败。但更危险的是:若跳过此检查,ecp256_mul(&R2, &u2, &Q)可能产生非法点,后续ecp256_add()进入未定义行为。ecp256_is_on_curve()函数计算y² mod p和(x³+ax+b) mod p并比较,用bn_mod_sqr()和bn_mod_cube()实现,其中bn_mod_cube()是bn_mul(&tmp,&x,&x); bn_mod(&tmp,&tmp,&p); bn_mul(&tmp,&tmp,&x); bn_mod(&tmp,&tmp,&p);——三次模乘,不可简写为bn_pow(&tmp,&x,3,&p)(因为bn_pow是通用幂,慢三倍)。


5. 避坑指南:五个让新手当场翻车的硬核细节与血泪排查记录

这份代码看似简洁,但每个函数都埋着 C 语言特有的雷区。以下是我用 J-Link 调试器单步跟踪、对照 NIST FIPS 186-4 标准逐条验证后总结的 5 条真实踩坑记录。每一条都曾让我花掉半天时间。

5.1 现象:r值永远为 0

原因:ecp256_mul()计算Q = k*G后,Q.x的bn_mod(&r, &Q.x, &n)操作中,&Q.x是bn_t类型,但Q.x.d[0]到Q.x.d[7]未初始化为 0,残留栈垃圾值导致bn_mod()输入非法。
解决:在ecp256_point_init(&Q)后,必须显式bn_zero(&Q.x); bn_zero(&Q.y); bn_zero(&Q.z);。原代码在ecp256_mul()开头有bn_zero(&R->x); bn_zero(&R->y); bn_zero(&R->z);,但Q是调用者传入的,必须由调用者清零——这是 C 语言内存管理的铁律。

5.2 现象:bn_mod_inverse()死循环

原因:扩展欧几里得算法中r更新为r = a % b,但bn_mod()返回的余数r可能为负(当a < 0时)。而bn_mod()默认返回[0, b)区间值,但若a是负数且未先bn_mod(&a, &a, &n)归一化,a % b可能溢出int32_t导致r为极大正数,循环不终止。
解决:在bn_mod_inverse()内部,每次bn_divmod(&r, &a, &b, &q)前,先bn_mod(&a, &a, &n); bn_mod(&b, &b, &n);确保a,b ∈ [0,n)。我在test.c里加了bn_mod(&k, &k, &n);后问题消失。

5.3 现象:DER 编码后r字节数比 OpenSSL 少 1

原因:bn_to_bytes()将r转为字节数组时,若r的二进制长度恰好 256 位(即r ≥ 2²⁵⁵),其最高字节r_bytes[0]的最高位为 1,按 DER 规则必须补前导零字节。但原代码只检查r_bytes[0] & 0x80,未考虑r实际位长不足 256 位时r_bytes[0]可能为 0 的情况。
解决:改为if (bn_bitlen(&r) == 256 && (r_bytes[0] & 0x80))—— 先确认r真是 256 位整数,再判断是否需补零。否则r=0x7FFFFFFF...(255 位)也会被误判。

5.4 现象:ecp256_add()在Z1==Z2时结果错误

原因:Jacobian 坐标点加公式分Z1==Z2和Z1!=Z2两种情况。原代码只实现Z1!=Z2分支,当Z1==Z2时直接跳过,返回未初始化的R。
解决:补全if (bn_cmp(&Z1, &Z2) == 0)分支,用Z1==Z2专用公式(减少一次模逆)。我在ecp256_add()末尾加了else { /* Z1==Z2 case */ },并用ecp256_double()验证一致性。

5.5 现象:test.c在 GCC 12 下编译警告‘-Wstringop-overflow’

原因:sha256_hash()中memcpy(ctx->buf, data, len)未检查len > 64,而ctx->buf是 64 字节数组。当len=65时溢出。
解决:加if (len > 64) len = 64;或改用memmove()(但memmove不解决逻辑错误)。更彻底的是在sha256_update()中实现分块处理,但test.c为简化只处理单块,所以加保护。


6. 进阶技巧:用 GDB 单步验证签名每一步,以及如何把它塞进 RTOS 任务里

别满足于“跑通 test.c”。真正的掌控感来自你能随时暂停、修改、观察任意变量。下面是我每天开工必做的三件事,已固化为肌肉记忆。

6.1 GDB 调试 ECDSA 的黄金断点组合

在test.c的ecdsa_sign()调用前后设四个断点:

(gdb) b ecdsa_sign (gdb) b ecp256_mul (gdb) b bn_mod_inverse (gdb) b ecdsa_der_encode

然后run,停在ecdsa_sign后:

  • p/x z.d[0]@8查看z的 8 个 uint32_t(SHA256 输出)
  • step进ecp256_mul,停在ecp256_double()内部,p/x Q.x.d[0]@8看点坐标变化
  • finish出ecp256_mul,p/x r.d[0]@8确认r值
  • step进bn_mod_inverse,p/x k.d[0]@8和p/x n.d[0]@8对比,确认k与n互质

提示:GDB 的p/x var.d[0]@8比p var更可靠,因为bn_t是结构体,p可能只显示len。@8表示打印d[0]开始的 8 个uint32_t,正好是 256 位。

6.2 移植到 FreeRTOS 的内存与中断安全改造

裸机代码直接malloc会崩在 RTOS 里。我做了三处改造:

  1. 内存池化:定义static uint8_t ecdsa_mem_pool[4096];,用pvPortMalloc()替代malloc(),并在ecdsa_init()中heap_caps_malloc()指定MALLOC_CAP_INTERNAL
  2. 中断安全:ecp256_mul()中的循环可能耗时 >1ms,在portENTER_CRITICAL()/portEXIT_CRITICAL()中包裹
  3. 栈空间预留:ecdsa_sign()局部变量占约 1.2KB 栈,FreeRTOS 任务栈设为configMINIMAL_STACK_SIZE + 2048

6.3 验证签名正确性的三重交叉法

光看test.c输出不够。我坚持用三种工具比对同一组输入:

工具命令输出提取方式
本代码./testprintf("r=%064llx s=%064llx\n", r_val, s_val)
OpenSSLopenssl ec -in priv.pem -text -noout得d,openssl dgst -sign priv.pem -sha256 msg.txt | openssl asn1parse -strparse 4r和s的 DER 解码值
Python ecdsafrom ecdsa import SigningKey; sk = SigningKey.from_pem(open('priv.pem').read()); sig = sk.sign(b'msg'); print(sig.hex())解析sig[0:32]和sig[32:64]

三者r和s的十六进制字符串必须逐字节相等。有一次我发现 Python 版s少一个前导零,查出是bn_to_bytes()的len计算错误——bn_bitlen(&s)/8 + (bn_bitlen(&s)%8?1:0)比32小 1,补零逻辑没触发。

从那以后我每次改bn_to_bytes()都强制走一遍这三重验证,哪怕只动了一行注释。希望帮到你。

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

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

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

立即咨询