如果你已经把 secp256k1 的曲线方程、生成元和验签流程跑通那你大概率会遇到一个挺尴尬的中间状态原理全都能看懂一打开真实工程库的源码或者自己动手写点签名相关的小工具立刻被一堆补充说明卡住。为什么有的库返回的 s 会被翻转为什么公钥恢复要多传一个 v 或者 recid为什么私钥不能是 0为什么曲线参数里那个 k 和签名流程里的 k 完全是两回事这些问题并不在曲线方程本身恰恰是大多数入门教程不会展开讲的位置。这篇作为 secpk 算法详解系列的第四篇不做基础推导专门把那些关键点补充说明一次性补齐适合已经写过验签代码、正要深入工程实现或开始审计签名库的读者。1. 参数表里的密码p、n、G、k 各有各的坑1.1 记错一个参数所有验证结果都会让你怀疑人生我刚接触这个算法的时候最大的错觉是参数表随便抄一个就行。直到我有一天把一个十六进制串里的字母抄错了一次跑出来的验签结果让我整整排查了一个下午。后面我才意识到secp256k1 的每个参数都是被严格定义过的一环少一位、多一位、搞混一个字母整个有限域就完全变了。先贴一遍标准参数这组值建议直接收藏最好能记到条件反射的程度参数值十六进制含义pFFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE FFFFFC2F有限域 Fp 的素数模数p 2^256 - 2^32 - 977a0曲线方程 y^2 x^3 ax b 中的 ab7曲线方程中的 bGx79BE667E F9DCBBAC 55A06295 CE870B07 029BFCDB 2DCE28D9 59F2815B 16F81798生成元 G 的 x 坐标Gy483ADA77 26A3C465 5DA4FBFC 0E1108A8 FD17B448 A6855419 9C47D08F FB10D4B8生成元 G 的 y 坐标nFFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE BAAEDCE6 AF48A03B BFD25E8C D0364141G 所在子群的阶素数h1余因子cofactorp 和 n 都是 256 位的素数两者非常接近但差别有一个非常实际的后果r 的定义是 R 点 x 坐标对 n 取模而不是直接取 x。这个细节我在第 3 节里会重点说因为很多实现稍微偷懒一点就会在极端情况下出问题。1.2 k 到底代表什么切曲线又是怎么回事先说第一个最容易记串的概念secp256k1 里的 k指的不是 ECDSA 签名里的随机数 k而是 Koblitz 曲线的意思。Koblitz 曲线最大的特点是它带有一个高效的 Frobenius 自同态在素数域上表现为可计算的三阶端射借助 GLV 方法可以把一个 256 位的标量乘法拆成两个 128 位左右的部分并行计算。这也是为什么 libsecp256k1 在做签名和公钥运算时能比不少通用库快一截的底层原因之一。很多人在网上看到 secp256k1 是 Koblitz 曲线就以为它和那些定义在二进制扩域上的 Koblitz 曲线一样其实严格说它属于素数域上的 Koblitz 型曲线特征是 a0、b7 这样极简的参数选取方式。相比之下secp256r1也就是 NIST P-256的参数是带种子生成的有一套可验证的随机过程secp256k1 走的是nothing up my sleeve路线选最朴素的常数没法塞后门这也是它在不少社区里格外受信任的原因。这里有一个值得补充的知识点因为 a0点加法公式里的斜率计算会简化不少但随之而来的坑是当你要做通用标量乘法时很多教科书公式并没有覆盖两点互为逆元和斜率分母为 0的情况。这部分我放到第 2 节展开它比大多数教程讲的都要关键。2. 有限域上的点运算三个最容易写错代码的分支2.1 无穷远点 O 不是什么都没有而是单位元椭圆曲线上的点在加法意义下构成群单位元就是无穷远点 O。它的作用类似整数加法里的 0任何点加上 O 等于它本身任何点 P 加上它的逆元 -P 等于 O。这个定义看着简单写代码的时候就非常折磨人。很多人第一次实现点加法会默认 P 和 Q 的坐标都是普通整数对结果一遇到两个点关于 x 轴对称这种输入就直接崩了。更隐蔽的是有些库会用特殊编码表示 O比如 libsecp256k1 底层用 Jacobian 坐标O 会被编码成特定的标记而不是简单的 None。你在做二次封装的时候如果把它当普通点处理轻则验签失败重则数据错乱。所以我的第一个建议是不论用什么语言点加法的接口里一定要显式处理输入为 O和计算结果为 O这两种情况不要在调用方去猜。2.2 斜率公式里藏着两个极限分支在仿射坐标下两个点的加法公式是当 P ≠ Qλ (y2 - y1) / (x2 - x1)当 P Q倍点λ (3x1^2) / (2y1)在实数域上这些公式看起来没有任何问题。但在有限域上除法变成乘以模逆元所以分母是不是 0、分母能不能求逆就成了必须显式判断的事情。具体来说你需要处理这几种情况x1 x2 且 y1 p - y2说明 Q 是 P 的逆元结果是 O。P Q 且 y1 0切线是竖直线结果也是 O。虽然在 secp256k1 这种素数阶曲线上实际不会出现 y0 的点但通用实现还是应该写上这个分支。x1 ≠ x2正常计算斜率但别忘了先对 (x2 - x1) 取模 p再求逆。我写过一个教学用的纯 Python 点加法如下生产环境别直接用但用来理解分支逻辑非常合适def inv(x, p): return pow(x, p - 2, p) def point_add(P, Q, p): if P is None: return Q if Q is None: return P x1, y1 P x2, y2 Q # 互为逆元 if x1 x2 and (y1 y2) % p 0: return None if x1 x2 and y1 y2: if y1 0: return None lam (3 * x1 * x1) * inv(2 * y1, p) % p else: lam (y2 - y1) * inv((x2 - x1) % p, p) % p x3 (lam * lam - x1 - x2) % p y3 (lam * (x1 - x3) - y1) % p return (x3, y3)注意这里用pow(x, p-2, p)求模逆是因为 p 是素数费马小定理保证这个做法成立。写通用库的时候更稳妥的做法是用扩展欧几里得算法但对 secp256k1 来说只要 p 不变这两种结果一致。2.3 Jacobian 坐标下两点相等的判断没那么直观工程级实现几乎不会用仿射坐标做标量乘法因为每次点加法都要算一次模逆开销太大。主流做法是切换成 Jacobian 坐标点 (X, Y, Z) 对应仿射坐标 (X / Z^2, Y / Z^3)。这么做的优点是加法公式里没有模逆全部用乘法和加法搞定缺点也很明显同一个仿射点在不同 Z 值下有无数种表示。举个例子(x, y) 可以表示为 (x, y, 1)也可以表示为 (x·Z^2, y·Z^3, Z)。你用(X1, Y1, Z1)判断两个点是相等的绝对不能只比对 X、Y必须先做归一化或者判断它们对应的仿射坐标是否一致。很多自己实现 Jacobian 加法的人就是在这栽的跟头判断 PQ 时用原始坐标硬比结果明明是同一点却走错分支算出来的点完全不对。这块不是优化问题而是正确性问题。我的建议是除非你是在做教学或者学习验证否则直接用已经验证过的优化实现比如 libsecp256k1。自己写 Jacobian 公式很容易在边界条件上翻车而且调试成本极高。3. ECDSA 签名里那些差一行代码就出事的地方3.1 私钥范围为什么是 [1, n-1]而不是 [0, n-1]很多人把私钥理解成一个小于 n 的随机数这没错但严格说还不够。椭圆曲线群里的单位元是无穷远点如果私钥 d 0那么 d·G O这个公钥根本不存在。同样d n 也会得到 O因为 n 是 G 的阶。所以签名规范要求 d ∈ [1, n-1]。这事听起来像是一句废话但在实际工程里很关键。比如某些设备在生成私钥时只检查了位数够不够没检查是否落在有效区间一旦生成 0后面签名、验签、公钥推导全部连环报错而且报错信息往往非常隐晦。现在主流库基本都会在from_bytes时做校验但你自己拼协议的时候还是要显式加一道if d 1 or d n: raise ValueError(invalid private key)另外顺便提一句公钥只要是正确计算出来的一般不会出现无穷远点但如果你拿到一个外部传入的公钥正规做法还是要验证它在曲线上且满足 n·Q O这两个条件。3.2 r 和 s 的计算顺序以及 r x mod n 的隐藏含义ECDSA 签名的核心步骤是用哈希函数算出消息摘要 z截断到和 n 相同的比特长度。生成随机数 k满足 1 ≤ k ≤ n-1。计算 R k·G取 r R.x mod n。如果 r 0重新选 k。计算 s k^(-1) · (z r·d) mod n。如果 s 0重新选 k。这里有一个很容易被忽略的坑r 的定义是 R.x 对 n 取模不是直接取 R.x。p 和 n 都非常接近 2^256而且 p 比 n 略大所以确实存在极小的概率R.x 落在 [n, p) 这个区间里。一旦发生r 就比 R.x 小了一个 n。如果你在实现恢复公钥或者构造签名格式时假设r 就是 R.x那恢复出来的公钥就会是错的。正规的验签实现里收到签名后第一件事就是检查 r 和 s 是否都在 [1, n-1]把非法值直接拒绝。还有一点不要自己搞唯一的 k。ECDSA 对随机数 k 的要求是私钥级的必须不可预测、不能重复、不能泄露。历史上因为 k 泄露被反推出私钥的案例已经很多了。现在更推荐用 RFC 6979 的确定性签名方案从私钥和消息派生 k这样既不需要依赖系统随机源也能保证同样消息不会因为随机数不同而签出不同结果。用确定性 k 之后对端验签时并不会感知到区别因为验签只需要 r、s、z、公钥。3.3 哈希截断这条规则在 secp256k1 上很容易被跳过FIPS 186-4 里对 z 的建议是取哈希结果的最左边 log2(n) bit。对 secp256k1 来说 n 是 256 位SHA-256 的输出也是 256 位所以截断与否通常没有实际区别。这导致很多教程直接写z sha256(msg)省略了截断步骤。但如果你要在代码里做标准合规或者以后要把同一套逻辑迁移到其他曲线上最好还是规范地走一遍截断流程。这不是 secp256k1 本身的问题而是良好的库设计习惯。尤其当你做语言绑定时不同语言的 big integer 对截断位的处理还不太一样容易在跨语言验签的时候踩到同样的消息签出的验签结果不一致这种诡异问题。4. 公钥恢复、低 s 值和签名可延展性4.1 为什么 (r, s) 和 (r, n-s) 都是合法签名这是 ECDSA 一个非常经典的性质如果 (r, s) 是合法签名那么 (r, n-s) 也一定是合法签名。原因是验签时计算 R u1·G u2·Q其中 u1 z/s mod nu2 r/s mod n。把 s 替换成 n-s 后u1 和 u2 都变号R 就变成原来的相反点 -R。而相反点的 x 坐标不变所以最终验签等式依然成立。这个性质被称为签名可延展性。它的实际危害在于同一个消息、同一个公钥、同一个签名可以被第三方悄悄改成一个不同字节表示的签名而且验签依然通过。在链上场景里这可能导致事务哈希被改写。所以很多协议都强制使用低 s 值要求 s ≤ n/2否则就把 s 改成 n-s。代码写起来就一行if s n // 2: s n - s但要不要加这一行取决于协议要求不能想当然。我见过好几个项目两边都验签通过但一个签名是高位形式一个是低位形式最后在去重、缓存或者做原像校验时莫名其妙不一致。结论是如果你在写签名生成主动输出低 s 值如果你在写验签按协议决定是否强制 low-s但无论如何不要假设输入一定是低 s。4.2 公钥恢复需要 recid但 recid 不是 v恢复公钥不是一个数学上做不到的操作而是需要额外信息。给定 z 和 (r, s)签名里其实隐含了公钥的信息但 r 只能告诉我们 R 的 x 坐标不能直接告诉我们 R 的 y 坐标。R 有两种可能的 y一正一负也就是 y 的奇偶性不同。此外r 是 R.x 对 n 取模后的值取模前的 x 可能是 r也可能是 r n。所以枚举下来R 最多有四种可能。recid 的作用就是把这些信息编码进去recid 最低位bit 0表示 y 的奇偶性recid 次低位bit 1表示是否需要把 r 加 n 再当作 x。有了 recid恢复公钥的通用流程变成根据 recid 计算候选点 R 的 x 坐标x r ((recid 1) * n)。判断 x 是否小于 p如果大于等于 p则对应恢复失败。用曲线方程 y^2 x^3 7 和 x 求 y。因为 p ≡ 3 mod 4可以直接用y pow((x^3 7) % p, (p1)//4, p)算出平方根。根据 recid 的 bit 0决定 y 还是 p-y。最后用公式 Q r^(-1) · (s·R - z·G) 计算公钥。这时的 Q 就是签名者的公钥。很多协议会把这个 recid 包装成 v 再放进签名数据里比如 v 27 recid或者 v 35 recid具体加减多少看协议定义。你完全不用死记这些偏移量真正需要理解的是 recid 里那两位的语义不然换个协议很容易被各种常量绕晕。附一个教学用的恢复函数伪码def recover_pubkey(r, s, z, recid, p, n, G): x r (recid 1) * n if x p: return None y pow((x ** 3 7) % p, (p 1) // 4, p) if y % 2 ! recid % 2: y p - y R (x, y) r_inv pow(r, n - 2, n) u1 (-z * r_inv) % n u2 (s * r_inv) % n return point_add(point_mul(G, u1), point_mul(R, u2))4.3 不同库的默认行为差别很大同样一套算法不同库的默认行为能让你的上层逻辑差出十万八千里。我简单整理过几个常见实现的行为差异库/实现默认低 s 规范化公钥恢复接口注意点libsecp256k1提供函数但调用方得自己决定提供了 ecdsa_recover 系列接口底层是 C需要正确初始化上下文Python ecdsa 库默认不强制低 s需要自己按 recid 实现恢复纯 Python 性能弱测试可以用OpenSSL EVPEC_KEY 传统接口下一般不主动做低 s有 ECDSA_SIG 相关函数不同版本 API 差异很大多数 Web3 库签名生成时常用 low-s解析 v 时按协议来直接封装了 recover 函数必须看文档确认 recid 编码这些差异不是哪个对哪个错而是协议层约定和库设计取向不同。我习惯的做法是所有签名出去之前统一转成低 s所有恢复公钥的入口统一接收 recid再在协议层去适配 v。这样即使底层换库上层逻辑也不会乱。5. 实测中的实现建议以及我固定会检查的几个位置5.1 公钥压缩和非压缩别在序列化上省事公钥的常见编码方式有三种非压缩格式前缀04 x32字节 y32字节总共 65 字节。压缩格式前缀02 x 或03 x总共 33 字节。前缀的奇偶性表示 y 的奇偶性。混合格式hybrid前缀06或07 x y这种用得越来越少。压缩格式能省一半空间但接收方拿到之后需要自己通过曲线方程恢复 y。如果你只判定了前缀奇偶性却没验证 x^37 的平方根存在性可能把非法点放进后续计算。更稳的流程是收到公钥后先解析成仿射坐标然后立刻做点合法性校验确认点在曲线上、并且落在 n 阶子群内。5.2 私钥运算和签名过程尽量别自己造轮子我在第 2 节给的是教学代码刻意强调过不能直接上生产。真正的问题是标量乘法如果实现得不够小心会通过时间或功耗泄露私钥信息。你可能会觉得我的服务在服务器上跑不至于被人侧信道但签名库是会被多个业务方复用的你不知道最终运行环境是什么样的。所以我给自己的项目定了一个原则协议打包、序列化、参数校验这些可以自己写但私钥参与的标量乘法和签名计算一律用审计过的库。学习理解是一回事上线部署是另一回事。5.3 我每次集成签名模块都会过一遍的检查清单最后一次次踩坑之后我整理了一个固定的检查清单每次接入新库或者新协议都会逐条过一遍检查 r、s 是否在 [1, n-1] 范围内不在直接拒绝。明确协议是否强制低 s生成和验证要保持一致。公钥恢复时显式传入 recid不要自行推断更不要用两条 y 都试一遍这种低效方式代替编码信息。私钥导入时校验 [1, n-1]公钥导入时校验点合法性和 n·Q O。使用 RFC 6979 确定性随机数至少保证 k 不重复、不泄露。跨语言、跨库联调时先用标准测试向量对比签名结果再谈业务接入。每一条看起来都很基础但我在真实项目中全都遇到过对应的事故。尤其是第 1 条很多库在收到畸形签名时并不会报错只会返回验签失败如果上层把验签失败统一当成用户问题处理你排查问题的成本会成倍上升。我个人在实际操作中的体会是secp256k1 的数学部分反而是最简单的真正让工程师掉头发的永远是边界条件和协议约定。这篇的关键点补充说明就是把我在前几篇里没展开、但实际写代码时绕不开的细节全部晾在台面上。如果你能把这些点全部想明白再看任何一家的 ECDSA 实现基本都能一眼看出它哪里做得严谨、哪里在打擦边球。
阅读完成 · 觉得有帮助?