通过前几篇专栏文章,我们已经了解了私钥、公钥相关概念,以及私钥到公钥的生成过程。私钥和公钥的核心作用是私钥持有人使用私钥对某个数据,例如交易数据进行授权,其他人可用该私钥对应的公钥,对授权有效性进行验证,整个过程无私钥暴露。
举个实际例子,以太坊中一个典型的转账过程是这样的:用户 Alice 有一个钱包,假设钱包地址为 0x001d3F1ef827552Ae1114027BD3ECF1f086bA0F9,对应私钥为 \(d\)。若 Alice 想将该钱包中的以太转到其它账户,她需要构造一个交易数据,数据包含转出地址、转入地址、转账金额、由私钥 \(d\) 派生的授权信息和中间变量等。其中授权信息和中间变量仅可由 \(d\) 派生,不知道 \(d\) 的情况下无法伪造。交易数据广播给矿工,矿工负责执行交易,他们会使用中间变量和授权信息验证该笔交易未被篡改(例如交易数据在传播过程中被修改了金额或者转入地址),从而保证交易严格按照 Alice 的意图执行。整个交易过程,私钥 \(d\) 是不暴露的,其他人不可从授权信息和中间变量推出 \(d\),但可用这些数据验证该笔交易确由私钥 \(d\) 持有者授权且未被篡改。
以上授权和验证过程,即 ECDSA(Elliptic Curve Digital Signature Algorithm,椭圆曲线数字签名算法)。以前我对该算法原理也是一知半解,遇到相关内容只能死记硬背,过目即忘。希望通过本篇内容,帮我们完整了解 ECDSA 的数学原理。这是我了解原理和不了解原理的前后状态差异:
了解前:❓这里为什么要传入 r、s、v 参数,这是什么东西?死记硬背这里要传 r、s、v 参数,实际使用时:又忘了传 r、s、v 参数!!!
了解后:💡这里肯定要传入 r、s、v 参数;这里传入 r 和 s 就够了,不需要 v。
实际上,算法的原理并不复杂,主要涉及两个数学工具——同余运算和费马小定理,因此我们先会介绍算法所涉及同余运算的性质和费马小定理,然后介绍 ECDSA 算法过程,为了加深印象,会利用前面介绍的性质和定理证明算法的正确性。
同余运算
定义
给定正整数 \(m\)(称为模),对于两个整数 \(a\)、\(b\),若 \(m\) 能整除 \(a - b\)(即存在整数 \(k\) 使得 \(a - b = k \cdot m\)),则称 \(a\) 与 \(b\) 在模 \(m\) 下同余,记作:
等价地,\(a \equiv b \pmod{m}\) 当且仅当 \(a\) 和 \(b\) 除以 \(m\) 得到的余数相同。例如 \(10 \equiv 3 \pmod{7}\)(\(10 - 3 = 7\),能被 7 整除),\(-4 \equiv 3 \pmod{7}\)(\(-4 - 3 = -7\),能被 7 整除)。
核心性质
以下列出 ECDSA 中用到的同余核心性质,所有性质均可利用同余的定义进行证明,为了防止篇幅过长,这里就不包含证明过程了,感兴趣的读者可作为练习自证。
性质 1:等价关系
同余是严格的等价关系,满足三个核心特性:
- 自反性:对任意整数 \(a\),有 \(a \equiv a \pmod{m}\)
- 对称性:若 \(a \equiv b \pmod{m}\),则 \(b \equiv a \pmod{m}\)
- 传递性:若 \(a \equiv b \pmod{m}\) 且 \(b \equiv c \pmod{m}\),则 \(a \equiv c \pmod{m}\)
性质 2:加减保序性
若 \(a \equiv b \pmod{m}\),\(c\) 为任意整数,则:
性质 3:乘法保序性
若 \(a \equiv b \pmod{m}\),\(c\) 为任意整数,则:
性质 4:乘法传递性
若 \(a \equiv b \pmod{m}\) 且 \(c \equiv d \pmod{m}\),则:
性质 5:消去律
若 \(a \cdot c \equiv b \cdot c \pmod{m}\),且 \(c\) 与 \(m\) 互质(即 \(\gcd(c, m) = 1\)),则:
ECDSA 中模 \(m\) 是素数(即 0x02:Secp256k1 中介绍的椭圆曲线 secp256k1 的阶 \(n\),这个是一个非常大的素数),因此只要 \(c \in [1, n-1]\),必然满足 \(\gcd(c, n) = 1\),消去律始终成立。
性质 6:幂次保序性
若 \(a \equiv b \pmod{m}\),\(k\) 为正整数,则:
费马小定理与乘法逆元
上一节我们建立了同余运算的基础,本节介绍费马小定理——它是 ECDSA 中计算乘法逆元的理论依据。
乘法逆元
对于整数 \(a\) 和模 \(m\),若存在整数 \(b\),使得:
则称 \(b\) 是 \(a\) 在模 \(m\) 下的乘法逆元,记作 \(a^{-1}\)。逆元的直觉理解是模运算中的"倒数":乘以逆元等价于做除法。
逆元存在的充要条件是 \(\gcd(a, m) = 1\)(即 \(a\) 与 \(m\) 互质)。当 \(m\) 是素数时,所有 \(a \in [1, m-1]\) 都与 \(m\) 互质,因此逆元必然存在。
费马小定理
定理:若 \(p\) 是素数,\(a\) 是任意整数,且 \(\gcd(a, p) = 1\),则:
推论(逆元公式):将定理两边同时乘以 \(a^{-1}\),得到:
这个推论就是 ECDSA 中计算乘法逆元的标准公式:对于模素数 \(n\) 下的任意非零元素 \(k\),其逆元为 \(k^{n-2} \mod n\)。证明过程放在附录。
ECDSA
本节我们首先回顾一下前几篇专栏文章介绍的 secp256k1 曲线、密钥对生成,然后详细讲解签名生成、签名验证和公钥恢复的完整过程,并给出签名验证和公钥恢复的正确性证明。
安全性假设
ECDSA 的安全性建立在椭圆曲线离散对数难题(ECDLP)之上:已知公钥 \(Q = d \cdot G\),在计算上无法反向求解私钥 \(d\)。本文将 ECDLP 作为安全性假设,不展开讨论其困难性。
secp256k1 参数
此前已经介绍过,以太坊使用的 secp256k1 曲线方程定义在素数有限域 \(\mathbb{F}_p\) 上:
\(\mathbb{F}_p\) 的含义是:曲线上所有点的坐标运算结果都对素数 \(p\) 取模,确保坐标值落在 \([0, p-1]\) 范围内。secp256k1 的核心参数如下:
- 素数 \(p\):256 位大质数,定义了有限域的大小。曲线上所有点的坐标值都小于 \(p\)。
- 基点 \(G\):椭圆曲线群的一个固定生成元。所有密钥运算都基于这个点进行。
- 阶 \(n\):基点 \(G\) 的阶,即满足 \(n \cdot G = \mathcal{O}\)(\(\mathcal{O}\) 为无穷远点,即群的单位元)的最小正整数。\(n\) 是素数,这保证了模 \(n\) 下任意非零元素都有唯一的乘法逆元——私钥、签名参数均在 \([1, n-1]\) 范围内,因此逆元运算始终合法。
密钥对生成
以太坊的账户体系直接基于 ECDSA 密钥对:
- 私钥 \(d\):密码学安全的随机整数,满足 \(1 \leq d \leq n-1\)。私钥是账户的核心机密,持有私钥即拥有账户的控制权。
- 公钥 \(Q\):由私钥通过标量乘法生成,是椭圆曲线上的点:
\(Q\) 的坐标为 \((x_Q, y_Q)\),以太坊地址由公钥的 keccak256 哈希值的后 20 字节生成。从前文的介绍我们已经知道,私钥到地址的推导是单向的:由私钥可以推出公钥和地址,但无法从地址反推公钥或私钥。
签名生成
ECDSA 签名的输出为三个参数 \((r, s, v)\)。其中 \(r, s\) 是标准 ECDSA 签名参数,\(v\) 是以太坊扩展的恢复标识符。下面逐步讲解签名生成过程。
签名输入:
- 签名者私钥 \(d\)
- 待签名消息的哈希值 \(z\):以太坊中先对消息(如交易的 RLP 编码内容)执行 keccak256 哈希,得到 256 位整数 \(z\)。若哈希值超过 \(n\),则需对 \(n\) 取模,确保 \(0 \leq z < n\)。
步骤 1:生成随机数 \(k\)
生成密码学安全的随机整数 \(k\),满足 \(1 \leq k \leq n-1\)。\(k\) 必须使用密码学安全的伪随机数生成器(CSPRNG)生成。
步骤 2:计算参数 \(r\)
通过标量乘法计算椭圆曲线点 \(R\),并提取 \(r\):
取 \(R\) 的 \(x\) 坐标 \(x_R\),对 \(n\) 取模得到 \(r\):
若 \(r = 0\),需重新生成随机数 \(k\)。\(r = 0\) 时签名无效,无法完成后续验证和公钥恢复。
步骤 3:计算参数 \(s\)
先计算 \(k\) 在模 \(n\) 下的乘法逆元 \(k^{-1}\)(因 \(n\) 是素数,由费马小定理,\(k^{-1} = k^{n-2} \mod n\)),再计算 \(s\):
若 \(s = 0\),需重新生成随机数 \(k\)。
步骤 4:计算恢复标识符 \(v\)
\(v\)(Recovery ID)是以太坊对标准 ECDSA 的核心扩展。标准 ECDSA 签名仅包含 \((r, s)\),没有 \(v\) 参数。
椭圆曲线方程 \(y^2 = x^3 + 7\) 中,一个 \(x\) 坐标对应两个 \(y\) 值:\(y_R\) 和 \(p - y_R\),两者的奇偶性相反。也就是说,给定 \(r\)(即 \(x_R \mod n\)),签名时使用的椭圆曲线点 \(R\) 有两个候选:\(R = (x_R, y_R)\) 和 \(-R = (x_R, p - y_R)\)。\(v\) 用于标识实际使用的 \(R\) 点的 \(y\) 坐标特征(奇偶性),从而实现仅通过 \((r, s, v)\) 即可恢复出公钥 \(Q\),无需额外传输公钥。这大幅节省了链上存储和 gas 成本。
\(v\) 的核心是 \(y_R\) 的奇偶性,即 \(y_R \mod 2\),取值为 0 或 1。以太坊在不同阶段对 \(v\) 的编码方式有所不同:
EIP-155 之前:
沿用比特币的编码规范,将恢复标识嵌入 \(v\) 值:
即 \(v\) 取值为 27 或 28。
EIP-155 之后(Legacy 交易现行规范):
为防止跨链重放攻击,\(v\) 值嵌入了链 ID(chain_id):
例如以太坊主网 \(chain\_id = 1\),则 \(v\) 取值为 37 或 38。
EIP-1559 Type 2+ 交易:
链 ID 单独作为交易字段,\(v\) 不再需要编码链 ID,简化为 \(y\_parity\),直接取值 0 或 1,对应 \(y_R\) 的奇偶性。
绝对禁止重复使用同一个 \(k\) 对不同消息签名
若用同一个 \(k\) 对两条不同消息 \(z_1\)、\(z_2\) 签名,分别产生 \(s_1 = k^{-1}(z_1 + r \cdot d) \mod n\) 和 \(s_2 = k^{-1}(z_2 + r \cdot d) \mod n\),攻击者可计算 \(s_1 - s_2 = k^{-1}(z_1 - z_2) \mod n\),进而求出 \(k = (z_1 - z_2)(s_1 - s_2)^{-1} \mod n\),再代入任意一个签名公式即可解出私钥 \(d\)。一些加密钱包应用曾因 \(k\) 值重用导致私钥泄露,用户资产被盗。
签名延展性
在讲解签名验证之前,需要先理解一个问题:签名延展性(Malleability)。
给定一个有效签名 \((r, s)\),\((r, n - s)\) 也是一个有效签名。原因在于验证公式 \(P = u_1 \cdot G + u_2 \cdot Q\) 中,\(u_2 = r \cdot s^{-1} \mod n\)。当把 \(s\) 替换为 \(n - s\) 时,新的 \(u_2' = r \cdot (n - s)^{-1} \mod n\)。由于 \((n - s)^{-1} \equiv -s^{-1} \pmod{n}\)(因为 \((n-s) \equiv -s \pmod{n}\)),所以 \(u_2' \equiv -u_2 \pmod{n}\)。此时计算出的点 \(P' = u_1 \cdot G + u_2' \cdot Q = u_1 \cdot G - u_2 \cdot Q\),虽然 \(P'\) 与原来的 \(P\) 不同,但 \(P'\) 的 \(x\) 坐标恰好与 \(P\) 的 \(x\) 坐标相同(\(P\) 和 \(-P\) 是关于 \(x\) 轴对称的两个点,\(x\) 坐标相同而 \(y\) 坐标相反),因此 \(x_{P'} \mod n = r\) 仍然成立,签名依然通过验证。
这意味着攻击者可以在不知道私钥的情况下,将一个有效签名 \((r, s)\) 变造为另一个有效签名 \((r, n-s)\)。虽然攻击者无法伪造任意消息的签名,但同一个消息存在两个有效签名会导致交易唯一性问题——节点可能认为这是两笔不同的交易。
EIP-2 通过在 Homestead 阶段引入一条规则解决了这个问题:强制要求 \(s \leq n/2\)。节点会直接拒绝 \(s > n/2\) 的签名。签名生成时,若计算出的 \(s > n/2\),替换为 \(n - s\) 即可(因为 \((r, n-s)\) 也是有效签名,且 \(n - s < n/2\))。
签名验证
签名验证的输入为:公钥 \(Q\)、消息哈希 \(z\)、签名 \((r, s)\)。验证目标是确认签名由对应私钥 \(d\) 合法生成。
验证步骤:
- 合法性校验:检查 \(r\) 和 \(s\) 是否均满足 \(1 \leq r \leq n-1\)、\(1 \leq s \leq n-1\)。若不满足,签名直接无效。
- 计算逆元与中间参数:计算 \(s\) 在模 \(n\) 下的乘法逆元 \(s^{-1}\),再计算两个中间标量:
- 计算椭圆曲线点 \(P\):通过标量乘法与点加法计算 \(P\):
- 最终校验:若 \(P\) 为无穷远点 \(\mathcal{O}\),签名无效;否则取 \(P\) 的 \(x\) 坐标 \(x_P\),验证:
若等式成立,签名有效;否则无效。
正确性证明:
证明目标:若签名 \((r, s)\) 由私钥 \(d\)(对应公钥 \(Q = d \cdot G\))按照上述合法流程生成,则验证过程必然满足 \(x_P \mod n = r\)。
已知条件:
- \(R = k \cdot G\),\(r = x_R \mod n\)
- \(s = k^{-1} \cdot (z + r \cdot d) \mod n\)
- \(Q = d \cdot G\)
- \(n\) 是 \(G\) 的阶,即对任意整数 \(a\),有 \(a \cdot G = (a \mod n) \cdot G\)
步骤 1:对 \(s\) 的定义式变形。对 \(s = k^{-1} \cdot (z + r \cdot d) \mod n\),两边同时乘以 \(k\):
两边同时乘以 \(s^{-1}\):
步骤 2:代入中间参数 \(u_1\)、\(u_2\)。根据验证步骤的定义,\(u_1 = z \cdot s^{-1} \mod n\),\(u_2 = r \cdot s^{-1} \mod n\),代入上式:
步骤 3:两边同时乘以基点 \(G\)。根据椭圆曲线标量乘法的性质,等式两边同时乘以 \(G\),等式依然成立(注意乘 \(G\) 后进入椭圆曲线点群,同余号变成了等号):
根据标量乘法的分配律,右边展开为:
代入公钥定义 \(Q = d \cdot G\):
步骤 4:完成等式闭环。左边 \(k \cdot G = R\),右边 \(u_1 \cdot G + u_2 \cdot Q = P\),因此:
步骤 5:验证最终等式。\(P = R\) 意味着二者的 \(x\) 坐标完全相等,即 \(x_P = x_R\)。根据 \(r\) 的定义,\(r = x_R \mod n\),因此:
与验证步骤的最终校验条件完全一致。证明完毕。
公钥恢复
通过消息哈希 \(z\)、签名 \((r, s, v)\),可求出签名者公钥,在以太坊的智能合约经常可以看到公钥恢复函数 ecrecover。
恢复算法:
步骤 1:解析恢复标识 \(v\),还原椭圆曲线点 \(R\)
从 \(v\) 中提取 \(y_R\) 的奇偶性信息(具体解析规则取决于交易类型,参见签名生成一节中 \(v\) 的三种以太坊规范)。然后:
- 取 \(x = r\)(因 \(r = x_R \mod n\),且 \(n < p\),默认 \(x_R = r\))。
- 根据 \(x\) 计算椭圆曲线方程 \(y^2 = x^3 + 7 \mod p\) 的两个 \(y\) 候选值,两者奇偶性相反。
- 根据恢复标识选择奇偶性匹配的 \(y_R\),得到完整的 \(R = (x, y_R)\)。
步骤 2:计算核心标量参数
计算 \(r\) 在模 \(n\) 下的乘法逆元 \(r^{-1}\),再计算两个中间标量:
步骤 3:反向推导公钥 \(Q\)
该公式等价于核心恢复式 \(Q = r^{-1} \cdot (s \cdot R - z \cdot G)\)。等价性推导如下:
计算结果 \(Q\) 是椭圆曲线上的点 \((x_Q, y_Q)\),即恢复出的签名者公钥。
正确性证明:
证明目标:若签名 \((r, s, v)\) 由私钥 \(d\)(对应公钥 \(Q = d \cdot G\))按 ECDSA 规范合法生成,则通过上述流程恢复出的公钥 \(Q'\) 满足 \(Q' = Q\)。
已知条件:
- 签名随机数 \(k \in [1, n-1]\),\(R = k \cdot G\),\(r = x_R \mod n\)
- \(s = k^{-1} \cdot (z + r \cdot d) \mod n\)
- \(Q = d \cdot G\)
- \(n\) 是 \(G\) 的素数阶,即 \(a \cdot G = (a \mod n) \cdot G\)
- 恢复流程中通过 \(r\) 和 \(v\) 还原的点与签名生成时的 \(R\) 完全一致
步骤 1:从签名公式出发。\(s = k^{-1} \cdot (z + r \cdot d) \pmod{n}\),两边乘以 \(k\):
步骤 2:移项,把含 \(d\) 的项单独留在右侧:
两边同时乘以 \(r^{-1}\)(\(r \in [1, n-1]\),逆元必然存在):
步骤 3:转换为椭圆曲线点运算。根据椭圆曲线标量乘法的性质——若 \(a \equiv b \pmod{n}\),则 \(a \cdot G = b \cdot G\)——对式 (2) 两边同时乘以基点 \(G\):
步骤 4:展开并代入 \(R = k \cdot G\):
步骤 5:完成等式闭环。式 (3) 左边 \(d \cdot G = Q\)(原始公钥),式 (4) 右边即为恢复公式计算的 \(Q'\):
证明完毕。对于合法生成的 ECDSA 签名,通过恢复流程得到的公钥必然与签名者的原始公钥完全一致。
go-ethereum 中的实现
go-ethereum 的 crypto 包封装了 ECDSA 的核心操作,本节分析其签名生成、签名验证和公钥恢复的实现。
整体架构
crypto 包的上层接口定义在 crypto.go 中,底层的椭圆曲线运算有两套实现:
- cgo 版本(
signature_cgo.go):调用 libsecp256k1 C 库,性能最优,是默认实现 - nocgo 版本(
signature_nocgo.go):调用 decred 的 Go 语言 secp256k1 库,用于无法使用 cgo 的环境
两套实现通过 Go 的构建标签(build tag)自动选择,对外暴露完全相同的 API。本节展示的代码主要为 cgo 版本。
secp256k1 曲线参数
S256() 函数返回 secp256k1 曲线实例,曲线参数定义在 crypto/secp256k1/curve.go 中:
// crypto/secp256k1/curve.go
type BitCurve struct {
P *big.Int // 有限域的阶(素数 p)
N *big.Int // 基点 G 的阶(素数 n)
B *big.Int // 曲线方程常数项(值为 7)
Gx, Gy *big.Int // 基点 G 的坐标
BitSize int // 有限域的位长(256)
}
func init() {
theCurve.P, _ = new(big.Int).SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 16)
theCurve.N, _ = new(big.Int).SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16)
theCurve.B, _ = new(big.Int).SetString("0000000000000000000000000000000000000000000000000000000000000007", 16)
theCurve.Gx, _ = new(big.Int).SetString("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", 16)
theCurve.Gy, _ = new(big.Int).SetString("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8", 16)
theCurve.BitSize = 256
}
BitCurve 结构体的字段与前文介绍的 secp256k1 参数一一对应:\(P\) 是有限域的素数,\(N\) 是基点的阶,\(B\) 是曲线方程 \(y^2 = x^3 + 7\) 中的常数项 7,\(G_x\)、\(G_y\) 是基点 \(G\) 的坐标。
签名格式约定
go-ethereum 中的 ECDSA 签名格式定义为 65 字节的 [R || S || V]:
// crypto/crypto.go
const SignatureLength = 64 + 1 // 64 字节 ECDSA 签名 + 1 字节恢复标识
const RecoveryIDOffset = 64 // V 在签名中的偏移量
其中 \(R\) 和 \(S\) 各 32 字节(256 位,对应 \(n\) 的位长),\(V\) 为 1 字节(取值 0 或 1,表示 \(y_R\) 的奇偶性)。
签名生成
Sign 函数是签名生成的入口:
// crypto/signature_cgo.go
func Sign(digestHash []byte, prv *ecdsa.PrivateKey) (sig []byte, err error) {
if len(digestHash) != DigestLength {
return nil, fmt.Errorf("hash is required to be exactly %d bytes (%d)", DigestLength, len(digestHash))
}
seckey := math.PaddedBigBytes(prv.D, prv.Params().BitSize/8)
defer zeroBytes(seckey)
return secp256k1.Sign(digestHash, seckey)
}
Sign 接收 32 字节的消息哈希和私钥,将私钥转为 32 字节大端序整数后,调用底层的 secp256k1.Sign。defer zeroBytes(seckey) 确保私钥的内存表示在函数返回后被清零,防止泄露。
底层实现在 crypto/secp256k1/secp256.go 中:
// crypto/secp256k1/secp256.go
func Sign(msg []byte, seckey []byte) ([]byte, error) {
if len(msg) != 32 { return nil, ErrInvalidMsgLen }
if len(seckey) != 32 { return nil, ErrInvalidKey }
seckeydata := (*C.uchar)(unsafe.Pointer(&seckey[0]))
if C.secp256k1_ec_seckey_verify(context, seckeydata) != 1 {
return nil, ErrInvalidKey
}
var (
msgdata = (*C.uchar)(unsafe.Pointer(&msg[0]))
noncefunc = C.secp256k1_nonce_function_rfc6979
sigstruct C.secp256k1_ecdsa_recoverable_signature
)
if C.secp256k1_ecdsa_sign_recoverable(context, &sigstruct, msgdata, seckeydata, noncefunc, nil) == 0 {
return nil, ErrSignFailed
}
var (
sig = make([]byte, 65)
sigdata = (*C.uchar)(unsafe.Pointer(&sig[0]))
recid C.int
)
C.secp256k1_ecdsa_recoverable_signature_serialize_compact(context, sigdata, &recid, &sigstruct)
sig[64] = byte(recid)
return sig, nil
}
关键实现细节:
- 先通过
secp256k1_ec_seckey_verify校验私钥合法性 - 调用
secp256k1_ecdsa_sign_recoverable生成可恢复签名(recoverable signature),这比标准 ECDSA 签名多出一个恢复标识 - 使用 RFC 6979 确定性 nonce(
secp256k1_nonce_function_rfc6979)替代随机数 \(k\)。RFC 6979 基于私钥和消息哈希通过 HMAC-DRBG 确定性地生成 \(k\),消除了因随机数生成器质量问题导致 \(k\) 值泄露的风险,同时保证了相同输入产生相同签名(对测试和确定性场景友好) - 通过
secp256k1_ecdsa_recoverable_signature_serialize_compact将签名序列化为 64 字节的[R || S]加上恢复标识recid,最终组装为 65 字节[R || S || V]
签名验证
VerifySignature 函数接收公钥、消息哈希和 64 字节签名 [R || S]:
// crypto/signature_cgo.go
func VerifySignature(pubkey, digestHash, signature []byte) bool {
return secp256k1.VerifySignature(pubkey, digestHash, signature)
}
底层调用 libsecp256k1 的验证函数:
// crypto/secp256k1/secp256.go
func VerifySignature(pubkey, msg, signature []byte) bool {
if len(msg) != 32 || len(signature) != 64 || len(pubkey) == 0 {
return false
}
sigdata := (*C.uchar)(unsafe.Pointer(&signature[0]))
msgdata := (*C.uchar)(unsafe.Pointer(&msg[0]))
keydata := (*C.uchar)(unsafe.Pointer(&pubkey[0]))
return C.secp256k1_ext_ecdsa_verify(context, sigdata, msgdata, keydata, C.size_t(len(pubkey))) != 0
}
注意签名验证的输入是 64 字节的 [R || S],不包含 \(V\)——因为验证时已经拥有公钥,不需要恢复标识。
公钥恢复
Ecrecover 函数接收消息哈希和 65 字节签名,返回未压缩公钥:
// crypto/signature_cgo.go
func Ecrecover(hash, sig []byte) ([]byte, error) {
return secp256k1.RecoverPubkey(hash, sig)
}
底层调用 libsecp256k1 的恢复函数:
// crypto/secp256k1/secp256.go
func RecoverPubkey(msg []byte, sig []byte) ([]byte, error) {
if len(msg) != 32 { return nil, ErrInvalidMsgLen }
if err := checkSignature(sig); err != nil { return nil, err }
var (
pubkey = make([]byte, 65)
sigdata = (*C.uchar)(unsafe.Pointer(&sig[0]))
msgdata = (*C.uchar)(unsafe.Pointer(&msg[0]))
)
if C.secp256k1_ext_ecdsa_recover(context, (*C.uchar)(unsafe.Pointer(&pubkey[0])), sigdata, msgdata) == 0 {
return nil, ErrRecoverFailed
}
return pubkey, nil
}
checkSignature 校验签名长度为 65 字节且恢复标识 sig[64] < 4。恢复成功后返回 65 字节的未压缩公钥(0x04 前缀 + \(x\) 坐标 32 字节 + \(y\) 坐标 32 字节)。
SigToPub 封装了 Ecrecover,将返回的字节解析为 ecdsa.PublicKey 结构体:
// crypto/signature_cgo.go
func SigToPub(hash, sig []byte) (*ecdsa.PublicKey, error) {
s, err := Ecrecover(hash, sig)
if err != nil { return nil, err }
return UnmarshalPubkey(s)
}
签名合法性校验
ValidateSignatureValues 函数在公钥恢复之前校验签名参数的合法性:
// crypto/crypto.go
var (
secp256k1N = S256().Params().N
secp256k1halfN = new(big.Int).Div(secp256k1N, big.NewInt(2))
)
func ValidateSignatureValues(v byte, r, s *big.Int, homestead bool) bool {
if r.Cmp(common.Big1) < 0 || s.Cmp(common.Big1) < 0 {
return false
}
// EIP-2: reject upper range of s values (ECDSA malleability)
if homestead && s.Cmp(secp256k1halfN) > 0 {
return false
}
return r.Cmp(secp256k1N) < 0 && s.Cmp(secp256k1N) < 0 && (v == 0 || v == 1)
}
校验逻辑对应前文介绍的三条规则:
- \(v\) 只能为 0 或 1(恢复标识的有效取值)
- \(r, s \in [1, n-1]\)(签名参数的有效范围)
- Homestead 之后,\(s \leq n/2\)(EIP-2 签名延展性修复)
总结
ECDSA 在以太坊中承担三项核心功能:
签名生成:发送者用私钥对交易哈希签名,产生 \((r, s, v)\) 三个参数,作为交易的身份凭证。签名过程引入随机数 \(k\),确保同一私钥对不同交易的签名各不相同。
签名验证:验证者用发送者公钥、交易哈希和签名 \((r, s)\) 校验签名是否合法。验证通过则证明交易确实由对应私钥持有者发起,且消息未被篡改。
公钥恢复:仅通过消息哈希和签名 \((r, s, v)\) 即可反向推导出签名者公钥,进而计算出以太坊地址。这使得交易无需携带公钥,大幅节省了链上存储和 gas 成本。以太坊内置的 ecrecover 预编译合约(地址 0x01)即实现了这一功能,支撑了元交易、ERC-20 Permit 等链上签名验证场景。
签名参数含义:
- \(r\):签名随机椭圆曲线点 \(R = k \cdot G\) 的 \(x\) 坐标对 \(n\) 取模的值。\(r\) 将签名与本次签名使用的随机数 \(k\) 绑定。
- \(s\):由消息哈希 \(z\)、私钥 \(d\) 和随机数 \(k\) 共同计算得到,公式为 \(s = k^{-1}(z + r \cdot d) \mod n\)。\(s\) 将签名与具体的消息内容和签名者私钥绑定。EIP-2 要求 \(s \leq n/2\) 以消除签名延展性问题。
- \(v\):恢复标识符,标识签名时椭圆曲线点 \(R\) 的 \(y\) 坐标奇偶性。\(v\) 使得公钥恢复成为可能——没有它,一个 \(r\) 值对应两个候选公钥,无法确定唯一签名者。
附录:费马小定理证明
定理:若 \(p\) 是素数,\(a\) 是任意整数,且 \(\gcd(a, p) = 1\),则 \(a^{p-1} \equiv 1 \pmod{p}\)。
证明:
因 \(p\) 是素数,\(1, 2, \ldots, p-1\) 都与 \(p\) 互质,构成模 \(p\) 的简化剩余系——即所有与 \(p\) 互质的整数模 \(p\) 后的集合,任意与 \(p\) 互质的整数模 \(p\) 的结果都落在这个集合中。
将简化剩余系的每个元素乘以 \(a\),得到数列:
该数列模 \(p\) 后具有两个性质:
- 非零性:若存在 \(k \in [1, p-1]\) 使得 \(a \cdot k \equiv 0 \pmod{p}\),则 \(p \mid a \cdot k\)。因 \(\gcd(a, p) = 1\) 且 \(p\) 是素数,\(p\) 必须整除 \(k\),但 \(k < p\),矛盾。
- 互异性:若 \(a \cdot i \equiv a \cdot j \pmod{p}\)(\(i < j\)),由消去律(\(\gcd(a, p) = 1\))得 \(i \equiv j \pmod{p}\),但 \(i, j \in [1, p-1]\),矛盾。
因此该数列模 \(p\) 后是 \(1, 2, \ldots, p-1\) 的一个全排列。将数列所有元素相乘:
左边提取 \(p-1\) 个 \(a\):
因 \((p-1)!\) 的所有因子都与 \(p\) 互质,\(\gcd((p-1)!, p) = 1\),由消去律两边消去 \((p-1)!\):
证明完毕。
-- EOF --
暂无评论,来抢沙发吧!