绝不能重复的那个数
ECDSA 签名是一对整数 (r, s)。生成签名需要三个输入:私钥 d、消息哈希 z,以及一个全新的秘密整数 k——即 nonce,是“number used once”(只使用一次的数)的缩写。签名者在 [1, n−1] 范围内选取 k,其中 n 是曲线基点 G 的阶,然后计算:
R = k * G (a point on the curve)
r = x-coordinate of R mod n
s = k^-1 * (z + r * d) mod n
signature = (r, s)
看看每个输出分别来自哪里。s 把所有输入混在了一起——nonce、哈希和密钥。但 r 只由 k 推导而来,与其他任何值都无关:它是 kG 的 x 坐标,只取决于 nonce 和曲线。消息不参与其中。私钥也不参与其中。
这一句话就道出了整个问题。用同一个 k 生成的两个签名会带有相同的 r,而每个签名都会公开 r。重复的 nonce 并不是需要费力发掘的隐蔽故障——它明摆在所有人眼前,就在每个验证者都会读取、每个区块浏览器都会永久保存的那个字段里。
两个签名,两个未知数
假设签名者用同一个 k 和同一个密钥 d 对两条不同的消息签名,两条消息的哈希分别为 z1 和 z2。两个签名的 r 相同,于是有:
s1 = k^-1 * (z1 + r * d) mod n
s2 = k^-1 * (z2 + r * d) mod n
除了 k 和 d,这里的一切都是公开的。两个方程,两个未知数。将每个方程两边同乘 k,消去逆元:
k * s1 = z1 + r * d mod n
k * s2 = z2 + r * d mod n
r * d 这一项在两行中完全相同,因为两条消息由同一个密钥签名,而同一个 nonce 产生了同一个 r。用第一个方程减去第二个方程,这一项就会抵消,私钥也随之彻底退出了等式:
k * s1 - k * s2 = z1 - z2 mod n
k * (s1 - s2) = z1 - z2 mod n
k = (z1 - z2) * (s1 - s2)^-1 mod n
这样就恢复出了 nonce。把它代回第 2 步中的任意一个方程,就能得到密钥,因为此时与 d 相乘的只剩下 r:
k * s1 = z1 + r * d mod n
r * d = k * s1 - z1 mod n
d = (s1 * k - z1) * r^-1 mod n
四步,无需搜索,也无需猜测。计算代价只是两次模逆运算和几次乘法——在任何机器上都只需几微秒,所用的数据也早已公开。
实际操作中还有一个小问题:编码并不总能保留签名者算出的原始 s。比特币和一些库会将签名规范化为“low-S”形式:当 s 落在取值范围的上半部分时,用 n − s 替换它。这是 BIP-62 提出、BIP-146 作为中继规则确立的延展性修复措施,与 nonce 无关,却与这里的运算有交集。这样一来,恢复出的值可能对应 −k 而不是 k,需要先确定符号,各个数值才能对上。这丝毫不改变难度;编码问题另见 ECDSA 签名格式。
这不是思想实验
这是现实中泄露私钥最多的一类 ECDSA 故障,而其中没有一起是数学本身造成的。每个案例都是某个实现提供了它本不该提供的 nonce。
- PlayStation 3,2010年:索尼的固件签名实现在本该放
k的位置使用了一个常数。它生成的每个签名都带有相同的r,任取其中两个签名就能算出这台游戏机的主签名密钥。这一故障在 27C3 大会的演讲 Console Hacking 2010 中被公开演示。这是最纯粹的例子:算法的实现完全正确,只是有一个“变量”从来没变过。 - Android 钱包,2013年:2013年8月14日,Android Developers Blog 在调查了一起事件之后发表了 Some SecureRandom Thoughts,文中将这起事件描述为“一笔比特币交易遭到破解,导致多个比特币应用在8月11日更新”。根本原因在于,使用 Java Cryptography Architecture 的应用“由于底层 PRNG 初始化不当,在 Android 设备上可能得不到密码学强度的随机值”。在受影响设备上签名的钱包,从一个可能反复以相同状态启动的生成器中取得 nonce。Google 为 API 级别 16~18 发布了
PRNGFixes临时解决方案,并修补了平台本身。注意出问题的是什么:不是钱包的密码学代码,也不是曲线,而是两者底层的操作系统原语。 - 更广泛的一类问题——有偏差的 nonce:完全重复是动静大的那种。悄无声息的那种是 nonce 仅仅有些偏斜:由于生成器返回的值范围略有偏差、做了截断或者泄露了时序信息,其中有几位并不是均匀分布的。这种情况没有简洁的代数捷径,但问题可以转化为在格中寻找短向量,而格基规约能够解决它。Breitner 和 Heninger 的论文 Biased Nonce Sense(Financial Cryptography 2019)把这种方法用于公开区块链和全网扫描数据,恢复出数百个比特币密钥,以及数十个以太坊、Ripple、SSH 和 HTTPS 密钥——而这些 nonce 从未重复,只是不够完美。教训很残酷:“基本上随机”并不是一道防线。
这些案例都不需要 ECDSA 本身有缺陷,换用更大的曲线也一个都解决不了。该算法要求每个签名都有一个完美均匀分布的秘密值,而得不到这样的值时,它也不会发出任何警告。
如今链上能看到什么
重复的 r 值至今仍留在比特币区块链上,而且会永远留在那里,因为区块链不会遗忘。我们的 nonce 重用检测工具从 mempool.space API 拉取一个地址最近的交易,从花费该地址资金的输入中提取 DER 签名,并按 (公钥, r) 分组。下面是它在2026年8月20日对 1BFhrfTTZP3Nw4BNy4eX4KFLsn9ZeijcMm 返回的结果:
address 1BFhrfTTZP3Nw4BNy4eX4KFLsn9ZeijcMm (P2PKH)
transactions returned 50 <- API cap, not the full history
spending from it 30
inputs from address 110
signatures parsed 110 (unattributed 0, taproot 0, unparsed 0)
distinct public keys 1
distinct r values 73 <- 110 signatures, only 73 nonces
repeated-r groups 6
identical duplicates 0
一个密钥,110 个签名,73 个不同的 nonce。这个差额说明了一切:110 个签名中有 43 个与至少一个其他签名共用同一个 r,而且六个组中每一组的 s 值都各不相同,这就排除了“同一个签名被重复发布”这种无害的解释。最大的一组:
r = d47ce4c025c35ec440bc81d99834a624875161a26bf56ef7fdc0f5d52f843ad1
pubkey = 04dbd0c6...49c5ff (uncompressed)
ad698b53bbba9e7d807559ec7d3a466fccb669dd3659a1604a206eeb9965c527 vin 0 2012-12-21
b4922bc8cc3624487cdcf4b0ed5a98a21a4552a48dfe495490a7443dd546821e vin 0 2012-12-21
8ab813e5a4039eb79484c315e9c0e201c9d80385c3f3650a4c4b974db9172148 vin 0 2012-12-21
0b5315f4d472a22a6937a20a8d44b8e77099f2a951b65c53c9ebf912352504fa vin 0 2012-12-19
952d8301780ca74fca5f6541db44ba49e47e8514dfdef36bc7e3d534085dbbf6 vin 0 2012-12-19
... 5 more, the oldest on 2012-08-16
有两个细节值得留意。第一,第二大的组同样是十个签名,共用另一个 r;而且在这两个组中,每个签名都位于所在交易的同一个输入索引上(一组是索引 0,另一组是索引 1)。偶然的随机数碰撞不会是这个样子。生成器在每次签名操作开始时被重新初始化为同一状态、然后逐步向前推进,才会呈现这样的规律:输入 0 总是拿到第一个值,输入 1 总是拿到第二个值。第二,这些重用发生在2012年8月到12月,比 Android SecureRandom 问题的披露早了将近一年。这类缺陷在获得公开的名称之前就已经在现实中存在。这提醒我们:此类公告标记的是问题被注意到的时间,而不是问题出现的时间。
解决办法:别再掷骰子
长久之计不是“换一个更好的随机数生成器”,而是把随机数生成器从签名路径中彻底移除。
RFC 6979,确定性 ECDSA:不再随机抽取 k,而是推导出它。具体做法是以私钥和消息哈希作为种子,初始化一个基于 HMAC 的确定性随机比特生成器(HMAC-DRBG),从它的输出中取 k;如果取值落在 [1, n−1] 之外,就重新抽取。这样得到的值看起来是均匀分布的,没有密钥就无法预测,而且——最关键的是——它是消息的函数。不同的消息必然产生不同的 k,因此意外重用从结构上就不可能发生,而不只是可能性很小。对同一条消息签名两次会得到相同的字节,这没有危害,还恰好是一个方便测试的特性。签名时不会用到任何熵源,所以即使熵源坏了也伤不到你。FIPS 186-5 允许在随机化形式之外使用确定性 ECDSA。
RFC 8032,EdDSA:Ed25519 和 Ed448 把同样的思路直接做进了签名方案,使其不再是可选项:每个签名的秘密值由从密钥派生的前缀与消息一起哈希得到,API 也没有提供任何途径让实现者换成随机值。如果你是在选择方案,而不是沿用现成的方案,它就从设计上消除了这种失效模式。比特币的 Taproot 签名遵循同样的理念——BIP-340 强制要求确定性地派生 nonce,并可选择加入辅助随机数。
对冲签名:纯确定性方案有一个值得了解的取舍。签名过程中被注入的故障——例如智能卡上的时钟或电压毛刺——可能让确定性签名者用同一个派生出的 k 对同一条消息生成两个不同的签名,这又回到了 nonce 被重复使用的情形。RFC 6979 在 §3.6 中预见到了这一点。对冲(hedged)的做法是把新鲜的随机性混入确定性派生过程,而不是用它取而代之,这样得到的 nonce 只要派生过程和随机数生成器中有一个可靠,就是安全的。Go 的 crypto/ecdsa 就是这样签名的;@noble/curves 通过 extraEntropy 选项提供这种方式;BIP-340 的辅助随机数也是同样的思路。
常见库的默认做法如下:
| 库 | 默认使用确定性 nonce? | 说明 |
|---|---|---|
| libsecp256k1 (Bitcoin Core) | 是——RFC 6979 | 如有需要,可以传入其他 nonce 函数。 |
@noble/curves | 是——RFC 6979 | 可通过 extraEntropy 启用对冲模式。 |
| Rust p256 / k256 (RustCrypto) | 是——RFC 6979 | 相同的密钥和消息总是产生相同的字节;随机化变体需显式启用。 |
| Go crypto/ecdsa | 对冲,而非 RFC 6979 | 由密钥、消息和新鲜随机数共同派生 nonce,因此每次运行得到的字节都不同,无法在测试中固定。 |
| Python ecdsa(纯 Python) | 需显式启用 | 确定性签名是一个单独的方法;普通的 sign() 调用使用随机数。 |
| Python cryptography (pyca) | 需显式启用 | 较新的版本接受一个确定性签名标志,而走这条路径要求底层是较新的 OpenSSL。 |
| OpenSSL CLI | 需显式启用(3.2 及以上) | 使用签名参数 nonce-type:1,并显式指定摘要算法:pkeyutl -sign -pkeyopt nonce-type:1 -pkeyopt digest:sha256,或 dgst -sha256 -sign key.pem -sigopt nonce-type:1。 |
| Node.js node:crypto | 否——随机 nonce | 相同的密钥和消息每次调用都会产生不同的签名字节。 |
| WebCrypto (crypto.subtle) | 否——随机 nonce | 该 API 也没有提供任何方式让你传入自己的 nonce。 |
| Java JCA (SunEC) | 否——随机 nonce | 添加 Bouncy Castle 等提供者后即可使用确定性 ECDSA。 |
| PHP openssl_* / Ruby OpenSSL | 否——随机 nonce | 两者都没有提供确定性模式。 |
表中的“否”并不是缺陷报告。从健康的操作系统 CSPRNG 中取得的随机 nonce 是完全合规的签名方式,也是 FIPS 186 历次版本都规定的方式;FIPS 186-5(2023年)在保留这种方式的同时增加了确定性变体。这一列衡量的是依赖,而不是正确性:“否”这几行把你的私钥押在签名那一刻的熵源上,“是”这几行则不会。这是否要紧,取决于你的部署环境——加固过的服务器与刚启动的嵌入式设备、克隆出来的虚拟机镜像或者一部2013年的手机,是完全不同的赌注。库互操作速查表把这一点和其他默认行为上的差异并排列了出来。
实际应该怎么做
- 切勿自行提供
k:如果某个签名 API 接受 nonce 参数,应把它当作容易出错的陷阱,而不是一项功能——也切勿只因为测试需要可复现的字节就去用它。确定性签名已经能安全地给你这些字节。 - 切勿自己编写生成器:“我们用时间戳和请求 ID 作为种子”恰恰就是这类事故的开端。使用平台提供的 CSPRNG,或者使用根本不需要它的库。
- 确认你实际使用的是哪种 nonce 策略:一个两行的测试就能告诉你大部分答案——但要仔细解读结果,因为它无法区分随机 nonce 和对冲 nonce:
import { generateKeyPairSync, sign } from "node:crypto";
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const msg = Buffer.from("same message, twice");
const a = sign("sha256", msg, privateKey);
const b = sign("sha256", msg, privateKey);
console.log(a.equals(b));
// false in node:crypto -> random or hedged nonce
// true -> deterministic derivation (RFC 6979 or EdDSA-style)
如果字节不同,你仍然无法知道 nonce 是纯随机的还是对冲的——只有库的文档能回答这个问题。如果字节相同,还要确认它用的是 RFC 6979 而不是某种自创的方案:把你的输出与 RFC 6979 附录 A.2 中的已知答案测试向量进行对比。我们的 ECDSA 测试向量生成器可以根据种子生成 P-256、P-384、P-521 和 secp256k1 的 RFC 6979 签名,因此日后可以逐字节地重新生成相同的向量。
- 警惕低熵时刻:嵌入式硬件的首次启动、从同一个快照恢复的虚拟机和容器,以及熵池获得种子之前的早期初始化阶段,都是随机 nonce 最容易出问题的时候。确定性签名让这个问题不复存在。
- 一旦发现重用,这个密钥就已失守:不是被削弱,而是彻底失守,而且是追溯性的——任何见过这两个签名的人都能得到它。转移资金,在所有用到它的地方轮换密钥,并把它生成过的每一个签名都视为可能出自他人之手。
一手资料
- RFC 6979(Deterministic Usage of DSA and ECDSA)——HMAC-DRBG 派生方法、设计依据,以及附录 A 中的已知答案测试向量;关于故障攻击的注意事项见 §3.6。
- RFC 8032——EdDSA(Ed25519、Ed448),确定性的 nonce 派生在其中是签名方案的一部分,而不是可选项。
- FIPS 186-5——现行的数字签名标准,规定了 ECDSA,并允许确定性地生成 nonce。
- Some SecureRandom Thoughts——Android Developers Blog,2013年8月14日:平台 PRNG 的初始化缺陷、它与比特币钱包被攻破事件的关联,以及
PRNGFixes临时解决方案。 - Biased Nonce Sense——Breitner 和 Heninger,IACR ePrint 2019/023 / Financial Cryptography 2019:针对仅仅存在偏差的 nonce 的格攻击,并用真实的区块链数据和全网数据做了测量。
- Console Hacking 2010——27C3 大会上对 PlayStation 3 常数 nonce 故障的公开演示。
- BIP-62 和 BIP-146——low-S 规范化(这是针对延展性而非 nonce 的修复,但也正是恢复出的值可能需要修正符号的原因);另有 BIP-340,规定了 Taproot 强制使用的确定性 nonce。
- libsecp256k1 和 @noble/curves——两个经过广泛审计、默认采用 RFC 6979 的实现,它们的源代码就是说明 nonce 派生应当如何实现的最清晰的文档。
为什么 ECDSA 的这一 nonce 要求成了 RSA 至今仍保有的少数真正运维优势之一,以及两种算法在其他方面的比较,参见 ECDSA 与 RSA 对比。