如果你已经把 secp256k1 的曲线方程、生成元和验签流程跑通,那你大概率会遇到一个挺尴尬的中间状态:原理全都能看懂,一打开真实工程库的源码,或者自己动手写点签名相关的小工具,立刻被一堆"补充说明"卡住。为什么有的库返回的 s 会被翻转?为什么公钥恢复要多传一个 v 或者 recid?为什么私钥不能是 0?为什么曲线参数里那个 k 和签名流程里的 k 完全是两回事?这些问题并不在曲线方程本身,恰恰是大多数入门教程不会展开讲的位置。这篇作为 secpk 算法详解系列的第四篇,不做基础推导,专门把那些"关键点补充说明"一次性补齐,适合已经写过验签代码、正要深入工程实现或开始审计签名库的读者。
1. 参数表里的密码:p、n、G、k 各有各的坑
1.1 记错一个参数,所有验证结果都会让你怀疑人生
我刚接触这个算法的时候,最大的错觉是"参数表随便抄一个就行"。直到我有一天把一个十六进制串里的字母抄错了一次,跑出来的验签结果让我整整排查了一个下午。后面我才意识到,secp256k1 的每个参数都是被严格定义过的一环,少一位、多一位、搞混一个字母,整个有限域就完全变了。
先贴一遍标准参数,这组值建议直接收藏,最好能记到条件反射的程度:
| 参数 | 值(十六进制) | 含义 |
|---|---|---|
| p | FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE FFFFFC2F | 有限域 Fp 的素数模数,p = 2^256 - 2^32 - 977 |
| a | 0 | 曲线方程 y^2 = x^3 + ax + b 中的 a |
| b | 7 | 曲线方程中的 b |
| Gx | 79BE667E F9DCBBAC 55A06295 CE870B07 029BFCDB 2DCE28D9 59F2815B 16F81798 | 生成元 G 的 x 坐标 |
| Gy | 483ADA77 26A3C465 5DA4FBFC 0E1108A8 FD17B448 A6855419 9C47D08F FB10D4B8 | 生成元 G 的 y 坐标 |
| n | FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE BAAEDCE6 AF48A03B BFD25E8C D0364141 | G 所在子群的阶,素数 |
| h | 1 | 余因子(cofactor) |
p 和 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 型曲线",特征是 a=0、b=7 这样极简的参数选取方式。相比之下,secp256r1(也就是 NIST P-256)的参数是带种子生成的,有一套可验证的随机过程;secp256k1 走的是"nothing up my sleeve"路线,选最朴素的常数,没法塞后门,这也是它在不少社区里格外受信任的原因。
这里有一个值得补充的知识点:因为 a=0,点加法公式里的斜率计算会简化不少,但随之而来的坑是,当你要做通用标量乘法时,很多教科书公式并没有覆盖"两点互为逆元"和"斜率分母为 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 这种素数阶曲线上,实际不会出现 y=0 的点,但通用实现还是应该写上这个分支。
- 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 加法的人,就是在这栽的跟头:判断 P=Q 时用原始坐标硬比,结果明明是同一点却走错分支,算出来的点完全不对。
这块不是"优化问题",而是"正确性问题"。我的建议是,除非你是在做教学或者学习验证,否则直接用已经验证过的优化实现,比如 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 n,u2 = 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, (p+1)//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 EVP | EC_KEY 传统接口下一般不主动做低 s | 有 ECDSA_SIG 相关函数 | 不同版本 API 差异很大 |
| 多数 Web3 库 | 签名生成时常用 low-s,解析 v 时按协议来 | 直接封装了 recover 函数 | 必须看文档确认 recid 编码 |
这些差异不是"哪个对哪个错",而是协议层约定和库设计取向不同。我习惯的做法是:所有签名出去之前统一转成低 s,所有恢复公钥的入口统一接收 recid,再在协议层去适配 v。这样即使底层换库,上层逻辑也不会乱。
5. 实测中的实现建议,以及我固定会检查的几个位置
5.1 公钥压缩和非压缩,别在序列化上省事
公钥的常见编码方式有三种:
- 非压缩格式:前缀
04+ x(32字节)+ y(32字节),总共 65 字节。 - 压缩格式:前缀
02+ x 或03+ x,总共 33 字节。前缀的奇偶性表示 y 的奇偶性。 - 混合格式(hybrid):前缀
06或07+ x + y,这种用得越来越少。
压缩格式能省一半空间,但接收方拿到之后需要自己通过曲线方程恢复 y。如果你只判定了前缀奇偶性,却没验证 x^3+7 的平方根存在性,可能把非法点放进后续计算。更稳的流程是:收到公钥后先解析成仿射坐标,然后立刻做点合法性校验,确认点在曲线上、并且落在 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 实现,基本都能一眼看出它哪里做得严谨、哪里在打擦边球。