跳到主要内容
ecdsa.com
← 全部文章

ECDSA 公钥恢复:恢复 ID、v 与 ecrecover

ECDSA 签名只需再附加 2 位信息,就足以算出签名者的公钥,因此以太坊交易根本不携带公钥。我们用 Python 手动恢复公钥,并将结果与 cast、ecrecover 预编译合约、Bitcoin Core 31.1、viem 和 noble 逐一对照。

简短回答:根据 ECDSA 签名 (r, s) 和消息哈希 e,签名者的公钥为 Q = r⁻¹(s·R − e·G),其中 R 是 x 坐标等于 r 的曲线点。这样的点最多有 4 个,因此签名者会附上一个恢复 ID:其中一位表示 R 的 y 坐标的奇偶性,理论上还有一位表示 R.x 是否经过了模 n 约简。以太坊把它编码为 v(消息中为 27/28,传统交易中为 chainId·2 + 35 + yParity,类型化交易中为 yParity),比特币则把它编码为 65 字节紧凑签名的头部字节(27~42)。对于格式正确的输入,恢复总会返回某个密钥:只有把结果与预期的地址或密钥进行比较,它才算是验证。以太坊的 ecrecover 预编译合约在输入格式错误时不返回任何内容,Solidity 会把这种情况转换为 address(0);而且 s 和 n − s 它都接受。

数学原理

ECDSA 签名时选取 nonce k,计算 R = kG,令 r = R.x mod n、s = k⁻¹(e + r·d) mod n。利用 Q = dG 对第二个等式变形,可得

s·k = e + r·d → s·R = e·G + r·Q → Q = r⁻¹(s·R − e·G)。

右边除 R 本身以外都是已知的,而签名中只包含 R 的 x 坐标,并且是模 n 约简后的值。要重建 R,验证者还需要知道:

  • R.y 的奇偶性:给定 x,有 (x, y) 和 (x, p − y) 两个点,其中一个点的 y 是偶数。
  • R.x 是否 ≥ n:在 secp256k1 上,域的素数 p 略大于群的阶 n,因此落在 [n, p) 区间内的 x 坐标会被约简。这种情况的概率为 (p − n)/p,约为 2⁻¹²⁷;实际中从不会发生,以太坊的 v 甚至无法表示它。

这就是恢复 ID:recid = 2·[R.x ≥ n] + (R.y mod 2),定义见 SEC 1 v2.0 §4.1.6(SEC 1)。下面用 Python 完整写出,不省略任何步骤:

recover.py(节选):公钥恢复,Python 3.14.8(曲线运算与 secp256k1-explained 中相同)
def recover(e: int, r: int, s: int, recid: int):
    """Return the public key Q with s = k^-1 (e + r*d), or None if recid does not name a curve point."""
    if not (0 < r < n and 0 < s < n):
        raise ValueError("r and s must be in [1, n-1]")
    x = r + (recid >> 1) * n          # recid 2 and 3: R.x was >= n and got reduced
    if x >= p:
        return None
    alpha = (x * x * x + 7) % p
    y = pow(alpha, (p + 1) // 4, p)   # p % 4 == 3, so this is a square root if one exists
    if y * y % p != alpha:
        return None                   # r is not the x-coordinate of any point
    if y & 1 != recid & 1:
        y = p - y                     # recid bit 0 is the parity of R.y
    R = (x, y)
    r_inv = pow(r, -1, n)
    return add(mul(s * r_inv % n, R), mul(-e * r_inv % n, G))   # Q = r^-1 (sR - eG)


def eth_address(Q):
    k = keccak.new(digest_bits=256, data=Q[0].to_bytes(32, "big") + Q[1].to_bytes(32, "big"))
    return "0x" + k.hexdigest()[-40:]


def eth_personal_digest(message: bytes) -> int:
    data = b"\x19Ethereum Signed Message:\n" + str(len(message)).encode() + message
    return int.from_bytes(keccak.new(digest_bits=256, data=data).digest(), "big")

除了常量和求平方根的捷径,这里没有任何 secp256k1 特有的内容;在 P-256 上同样可以恢复公钥。公钥恢复是比特币和以太坊生态的惯例,而不是曲线的性质;JOSE、TLS 和 WebAuthn 则直接传输密钥,或用证书来证明密钥。

以太坊:手动恢复 cast 生成的签名

实验中,我们用 Foundry 1.8.5 生成了一个临时密钥,并按 personal_sign 的语义(EIP-191)对一条消息签名:

eth.sh(节选):签名,用 cast 验证,再用 Python 恢复
KEY=$(cast wallet new --json | python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['private_key'])")
ADDR=$(cast wallet address --private-key "$KEY")
SIG=$(cast wallet sign --private-key "$KEY" "hello from ecdsa.com")
cast wallet verify --address "$ADDR" "hello from ecdsa.com" "$SIG"
python3 eth_recover.py "hello from ecdsa.com" "$SIG" "$ADDR"

eth_recover.py 把 65 个字节拆分为 r、s 和 v,逐一尝试全部 4 个恢复 ID,然后针对一条略有不同的消息恢复同一个签名:

输出(cast 1.8.5、pycryptodome 3.23.0)
address: 0x22baE48F42D493eDcF82ddB7e47480B7233917b3
signature: 0x150e0d6f3cc85f603300fe1801bc4397021a51495c53186f15ecb05f9e57dcbe2a17a5521f6c42792fc22a98754e5c01dd26ac42009e8d24d3cb713a8fe96fed1c
Validation succeeded. Address 0x22baE48F42D493eDcF82ddB7e47480B7233917b3 signed this message.
r=0x150e0d6f3cc85f603300fe1801bc4397021a51495c53186f15ecb05f9e57dcbe
s=0x2a17a5521f6c42792fc22a98754e5c01dd26ac42009e8d24d3cb713a8fe96fed
v=28  (s is low)
recid 0: 0xb1775d58a680572a45fe5d6ba094c56b0b748c9d
recid 1: 0x22bae48f42d493edcf82ddb7e47480b7233917b3  <- v - 27
recid 2: no point (x = r + n is not below p)
recid 3: no point (x = r + n is not below p)
matches the signer: True
same signature, other message -> 0x994ba1aaf9e6b96710727d919140aff23f56407c

从中可以得出两点。错误的恢复 ID 得到的是另一个有效的密钥,而不是错误,因此靠猜测 v 的验证者会若无其事地“恢复”出别人的密钥。此外,同一个签名配上另一条消息(ecdsa.org 而不是 ecdsa.com),又会恢复出另一个格式正确的地址。数据错误时,恢复本身并不会失败;只有与预期的签名者进行比较,它才成为一项检查。

ecrecover 返回什么

合约调用地址 0x01 处的预编译合约时传入 128 字节:先是哈希,然后是 v、r 和 s,各占一个 32 字节的字。实验中,我们在本地 anvil 节点上用 cast call 向它发送了五组输入:

cast call 0x0000000000000000000000000000000000000001 <哈希 ‖ v ‖ r ‖ s>(anvil 1.8.5)
as signed (v, r, s)                0x00000000000000000000000022bae48f42d493edcf82ddb7e47480b7233917b3
twin (55-v, r, n-s)                0x00000000000000000000000022bae48f42d493edcf82ddb7e47480b7233917b3
v = 29                             0x
s + 1                              0x000000000000000000000000412fbd6e833de18577db8f41022c54e852192b34
s = 0                              0x
  • 孪生签名(v 在 27 和 28 之间翻转,s 换成 n − s)恢复出的是同一个签名者。EIP-2 规定 high-S 的交易签名无效,但没有改动预编译合约:它“将继续接受高 s 值”。任何把签名字节当作标识符的合约,例如用签名把已签名的兑换券标记为已使用,都可能被人用孪生签名重放。要标记为已使用的是消息(nonce 或摘要),绝不能是签名。
  • 格式错误的输入(v 不是 27/28、s = 0)会产生空输出。Solidity 的 ecrecover 把这种情况转换为 address(0),其文档写明出错时“返回零”。如果预期的签名者有可能是 address(0)(未设置的所有者、mapping 中不存在的条目),所有无效签名都会通过。
  • 修改过的 s 会恢复出一个看似随机的地址,与 Python 测试中的情况相同。

针对这三种情况,通常的修复方法是使用 OpenZeppelin 的 ECDSA.recover(Contracts 5.6.1):它对大于 n/2 的 s 抛出 ECDSAInvalidSignatureS,对零结果抛出 ECDSAInvalidSignature,并在接受 bytes 的重载中拒绝一切长度不是 65 字节的输入。智能合约钱包根本没有可供恢复的密钥;它们通过 ERC-1271 验证,详见 Sign-In with Ethereum。

v 的多种形态

恢复位存放在哪里,取决于签名的对象:

场景编码示例
personal_sign、EIP-712、大多数钱包65 字节 r ‖ s ‖ v,v = 27 + yParityv = 27 或 28
传统交易,EIP-155v = chainId·2 + 35 + yParity链 1:37 或 38
类型化交易(EIP-2930、EIP-1559、EIP-4844、EIP-7702)单独的 yParity 字段,取 0 或 10 或 1
EIP-2098 紧凑签名64 字节 r ‖ yParityAndS,奇偶位存放在 s 的最高位最高位为 1 表示 yParity 为 1
比特币消息签名(BIP-137)65 字节 header ‖ r ‖ s,header = 27 + recid,再按地址类型加上 0(未压缩 P2PKH)、4(压缩 P2PKH)、8(P2SH-P2WPKH)或 12(P2WPKH)压缩 P2PKH 密钥为 31 或 32
底层库(libsecp256k1、noble)recid(0~3)单独传入0 或 1

实验中,我们用 viem 2.57.7 签名,并检查了它的恢复函数如何处理这些变体:

rec.mjs(节选):viem 2.57.7、@noble/curves 2.4.0
for (const [label, sig] of [
  ["65-byte", signature],
  ["64-byte compact", compact],
  ["63 bytes", signature.slice(0, -4)],
  ["v = 29", signature.slice(0, -2) + "1d"],
]) {
  try {
    const who = await recoverMessageAddress({ message, signature: sig });
    console.log(`recover ${label.padEnd(16)} ${who === account.address ? "signer" : who}`);
  } catch (e) {
    console.log(`recover ${label.padEnd(16)} ${e.shortMessage ?? e.message.split("\n")[0]}`);
  }
}

// The same recovery with noble: the recovery bit travels separately from the 64-byte (r, s).
const digest = hashMessage(message, "bytes");
const rs = Buffer.from(signature.slice(2, 130), "hex");
const pub = secp256k1.Signature.fromBytes(rs, "compact").addRecoveryBit(parsed.yParity).recoverPublicKey(digest);
console.log(`noble recovers    ${pub.toHex(true)} (compressed public key)`);
输出(node:24.22.0-bookworm-slim)
signer            0xa9650f6049917850af00B8e7Ffc386d9A3e18C08
signature         65 bytes, v=27, yParity=0
EIP-2098 compact  64 bytes, top bit of byte 32 = yParity: true
recover 65-byte          signer
recover 64-byte compact  invalid signature length
recover 63 bytes         invalid signature length
recover v = 29           Invalid yParityOrV value
noble recovers    036a079737b9587224344944600570a311f21b3b06f5c920b24bcd95e993afd7e0 (compressed public key)
legacy tx, chain 1: v=38 (yParity 1)
EIP-1559 tx, chain 1: v=28, yParity=1

viem 的 recoverMessageAddress 不接受 64 字节的 EIP-2098 字符串;要先用 compactSignatureToSignature 转换,否则会报错 invalid signature length。v 的取值不对时,会报错 Invalid yParityOrV value。传统交易的 v = 38 即 1·2 + 35 + 1。对于 EIP-1559 交易,viem 为方便起见报告 v = 28,但签名后的交易本身只携带 yParity。Python 的 web3.py 对长度同样严格:Expected 65, but got 64 bytes。

编写这个实验时,我们就遇到过一个真实的 bug:用 v ^ 1 翻转奇偶性,会把 27 变成 26、把 28 变成 29,因为 27 是奇数。应当在内部使用 yParity = v − 27(EIP-155 则用 (v − 35) mod 2),只在边界处再转换回来。

比特币:紧凑消息签名

Bitcoin Core 的 signmessage 生成的是同一类签名,只是前面多了一个头部字节。在 regtest 中使用临时密钥:

btc.sh(节选):Bitcoin Core 31.1
SIG=$($CLI signmessagewithprivkey "$WIF" "$MSG")
$CLI verifymessage "$ADDR" "$SIG" "$MSG"
python3 btc_recover.py "$MSG" "$SIG"      # recover by hand, then build three variants
输出(bitcoin/bitcoin:31.1)
address: mvzrMJdGFmYPJw79zHz5i2WqMHdwchtbr1
signature: H6uRgJJnTugkk5XeUQl+eE1oY5/L6yZZORJHwTn1ohJdFOWEPPUvL6oHzDx2lAeoI+l1d3ipt5sw3TmF/tkpz60=
verifymessage: true
65 bytes: header 31 -> recid 0, compressed key: True, s is low
recovered public key: 03b7b74bd1e7c935c284dbb71b48649f1ff6a5cb939d615061204271a61f4232e1
its regtest P2PKH address: mvzrMJdGFmYPJw79zHz5i2WqMHdwchtbr1
other recid (header 32)              verifymessage: false
uncompressed header (27)             verifymessage: false
high-S twin (header 32, n - s)       verifymessage: true

摘要是对 "\x18Bitcoin Signed Message:\n"、消息长度和消息本身计算的双重 SHA-256。头部字节 31 表示 recid 为 0 且使用压缩公钥;把它改成 27 后,恢复出的点不变,但哈希的是未压缩编码,得到的是另一个地址,因此验证失败。与 ecrecover 一样,Bitcoin Core 的消息验证接受了 high-S 孪生签名:low-S 是交易中继规则,而不是消息规则。对于 SegWit 和 Taproot 地址,这种格式力不从心;比特币消息签名与 BIP-322 介绍了各钱包意见不一的头部字节变体以及较新的格式。

为 HSM 和 KMS 签名补上恢复 ID

硬件安全模块和云 KMS 对摘要签名后返回 DER 编码的 (r, s):没有恢复 ID,也不保证 low-S。要把这样的密钥用于以太坊,先根据公钥推导一次地址,然后对每个签名规范化 s,并找出能恢复出你自己密钥的奇偶性。实验中用一个只对预哈希摘要签名的 pyca/cryptography 密钥来模拟 HSM:

der_to_eth.py(cryptography 49.0.0)
"""Turn a DER ECDSA signature without a recovery id (what HSMs and cloud KMS return) into an Ethereum signature."""
import sys

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed, decode_dss_signature

from recover import eth_address, eth_personal_digest, n, recover

# Stand-in for the HSM: a secp256k1 key that signs a 32-byte digest and returns DER, nothing else.
hsm_key = ec.generate_private_key(ec.SECP256K1())
public = hsm_key.public_key().public_numbers()
expected = eth_address((public.x, public.y))     # what you would derive once from GetPublicKey


def hsm_sign_digest(digest: bytes) -> bytes:
    return hsm_key.sign(digest, ec.ECDSA(Prehashed(hashes.SHA256())))   # any 32-byte digest


message = sys.argv[1].encode()
e = eth_personal_digest(message)
der = hsm_sign_digest(e.to_bytes(32, "big"))
r, s = decode_dss_signature(der)
print(f"DER signature: {len(der)} bytes, s is {'high' if s > n // 2 else 'low'}")
if s > n // 2:
    s = n - s                                     # EIP-2: transactions need low-S; normalize everything
for recid in (0, 1):                              # find the parity that recovers our own key
    Q = recover(e, r, s, recid)
    if Q and eth_address(Q) == expected:
        break
else:
    raise SystemExit("neither recovery id matches the key")
sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") + bytes([27 + recid])
print(f"address {expected}\nrecid {recid}\nsignature 0x{sig.hex()}")
python3 der_to_eth.py 'invoice 1042 approved',然后运行 cast wallet verify(三次运行中的第一次)
DER signature: 71 bytes, s is high
address 0x3bf48296556c8b52217d22066c7239c50f2102e6
recid 1
signature 0x052fbd197e707e99a4c38648b4a65fb674b15c93025878c55b3950f876bb5cf634aa76d1ffe274696a3d94ba225ae72ab73af4292a0681dc7262dc8ef9495d071c
Validation succeeded. Address 0x3bf48296556c8B52217D22066C7239C50F2102E6 signed this message.

Prehashed(hashes.SHA256()) 这层包装只是告诉库:输入已经是 32 字节的摘要;摘要本身按以太坊的要求是 Keccak-256。这里尝试两种奇偶性是安全的,因为结果要与一个已知地址比较。注意,这次运行的 DER 长度是 71 字节,另一次则是 72 字节:DER 会去掉前导零字节,并在最高位为 1 时补上一个零字节,所以固定大小的缓冲区会出问题(见 ECDSA 签名格式)。同样的流程也适用于 AWS KMS 的 ECC_SECG_P256K1 密钥;Google Cloud KMS 对 secp256k1 已经返回 low-S,但同样不提供恢复 ID。

什么时候适合使用公钥恢复

公钥恢复让每个签名节省 33~65 字节,这正是以太坊在每笔交易中都使用它的原因。它也改变了签名所能证明的内容。如果密钥随签名一起传输,验证者检查的是“这条消息由这个密钥签名”。使用恢复时,验证者算出的是“这条消息由某个密钥签名”,然后还必须判断这个密钥是否可以接受,通常的做法是推导出地址,再与允许列表、已存储的所有者或用户声称的 from 比较。跳过这一比较的代码什么也没有验证。

在区块链之外,很少有理由使用公钥恢复。能够携带密钥 ID 或证书的协议(JWT kid、TLS、X.509)就应当携带,这样验证者只需查找唯一的一个密钥,而不是信任数学运算算出的任意结果。

常见错误

  • 恢复后不做比较:ecrecover 一成功就在日志中写下“签名有效”。每个格式正确的签名都能恢复出某个结果。
  • 把 address(0) 当作签名者接受,无论是直接接受,还是经由未初始化的变量。
  • 用签名字节做重放保护:high-S 孪生签名是同一条消息的第二个同样有效的签名。
  • 猜测 v:尝试两种奇偶性,并接受任何能恢复出“某个”密钥的结果,会把验证变成查找任意一个密钥;只有随后要与一个预期地址比较时,才可以尝试两种奇偶性。
  • 哈希了错误的内容:personal_sign 会加上 \x19Ethereum Signed Message:\n 前缀和十进制长度;EIP-712 使用 \x19\x01 和两个结构体哈希。用错误的摘要恢复会得到错误的地址,而且不会报错。具体差异见 EIP-191 与 EIP-712。
  • 65 字节签名、64 字节 EIP-2098 签名和 DER 签名之间的格式不匹配。应在边界处显式转换;对于 HSM 和 KMS 的输出,按上文所示补上恢复 ID。

参考资料