概述
你可以前往我的博客获得更好的阅读体验。
本文主要介绍在以太坊中的签名问题,主要涵盖以下内容:
- ECDSA公钥密码学的数学原理与代码实现解析
- 以太坊客户端对交易信息签名的基本流程与源代码分析
- 智能合约内签名的验证
ECDSA公钥密码学
为了方便读者理解和实战本文中的内容,本文将结合一个可以使用Typescript
编写用于生产环境的noble-secp256k1
库作为实战案例解析。你可以在这里找到源代码。当然,为了节省篇幅,本文不会对此库中的所有代码进行解析。
公钥生成
以下内容部分参考了比特币 P2TR 交易详解。
在椭圆密码学中,许多不同种类的曲线都可以用于生成公钥。以太坊选择了与比特币相同的曲线类型,形式为y² = x³ + 7
,被称为secp256k1
。具体的图像如下图:
在此图像上,我们可以选择一个点作为生成点G
,使用陷门函数
计算获得公钥。陷门函数特点是正向计算简单,我们可以快速从私钥求出公钥,而逆向计算难度巨大。
比特币与以太坊均选择了一种被称为点倍增
的陷门函数。如下图为我们选择的生成点G
:
我们画出过点G
的切线与曲线交与一点,我们选择此点关于x
轴的对称点作为2G
点。下图展示了进行第一次点倍增后的结果2G
:
连结G
与2G
与曲线交与一点,我们选择与此点关于x轴对称的点作为3G
。如下图:
依次类推,我们可以得到4G
的图像如下:
显然上述操作是直觉上是无法逆向的,关于严格的数学证明,读者可以自行查阅相关论文。以上过程可以进行算法上的优化,读者可以自行阅读noble-secp256k1的开发者的写的关于加速secp256k1
计算的博客。
在此给出比特币规定的secp256k1
的G
的数值:
G.x = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
G.y = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8
综上所述,当我们生成一个私钥d
后,我可以通过计算P=dG
获得公钥P
,而以太坊账户就是选择公钥最后20 Bytes
进行keccak-256
计算得到的。
在noble-secp256k1
实现如下:
static fromPrivateKey(privateKey: PrivKey) {
return Point.BASE.multiply(normalizePrivateKey(privateKey));
}
当然,上述代码中的multiply
是经过优化的。Point.BASE
即上文给出的G
点。在代码中使用了bigint
表示,定义如下:
Gx: BigInt('55066263022277343669578718895168534326250603453777594175500187360389116729240')
Gy: BigInt('32670510020758816978083085130507043184471273380659243275938904335757337482424')
当然,secp256k1
也存在定义域,其最大值被记为n
,任何有效的点都应在n
之内。具体定义如下:
n: BigInt('0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141')
我们也可以在go-ethereum
找到以下定义:
var (
secp256k1N, _ = new(big.Int).SetString("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16)
secp256k1halfN = new(big.Int).Div(secp256k1N, big.NewInt(2))
)
签名
为了方便读者阅读,给出变量说明:
变量缩写 | 含义 |
---|---|
d | 私钥 |
G | 生成点 |
n | 曲线最大值 |
标准的ECDSA
签名由两个整数r
和s
构成,签名流程如下:
- 对待签数据进行哈希计算获得哈希值
m
- 生产随机数
k
,并使用k
与G
相乘获得点R
- 计算
r = R.x mod n
。如果r = 0
则需要重新生产随机数k
- 计算
s = (1/k * (m + dr) mod n
。如果s = 0
则需要重新生产随机数k
上述过程中进行mod n
是为了确保计算出的数值在我们所规定的定义域内。
在以太坊中,为了避免签名字段被其他应用使用,对哈希值m
计算进行特别规定,即使用Keccak256("\x19Ethereum Signed Message:\n32" + Keccak256(message))
进行哈希计算,使用go
实现如下:
func signHash(data []byte) []byte {
msg := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(data), data)
return crypto.Keccak256([]byte(msg))
}
对其签名的具体的代码实现方式如下:
function kmdToSig(kBytes: Uint8Array, m: bigint, d: bigint): RecoveredSig | undefined {
const k = bytesToNumber(kBytes);
if (!isWithinCurveOrder(k)) return;
// Important: all mod() calls in the function must be done over `n`
const {
n } = CURVE;
const q = Point.BASE.multiply(k);
// r = x mod n
const r = mod(q.x, n);
if (r === _0n) return;
// s = (1/k * (m + dr) mod n
const s = mod(invert(k, n) * mod(m + d * r, n), n);
if (s === _0n) return;
const sig = new Signature(r, s);
const recovery = (q.x === sig.r ? 0 : 2) | Number(q.y & _1n);
return {
sig, recovery };
}
上述代码给出了签名必要的两个元素r
和s
,通过这两个元素我们就可以得到一个完整的比特币签名。根据BIP66规定,比特币的签名组成如下:
0x30 [total-length] 0x02 [R-length] [R] 0x02 [S-length] [S]
我们可以通过上述代码给出的sig
变量中的信息填充上述签名。
但在以太坊中,以太坊要求的签名格式如下:
[R][S][V]
其中增加了变量v
,此值已在上文的代码中给出,为recovery
值。但与尚未给出的通用recover
不同,以太坊交易签名中的v
为CHAIN_ID * 2 + 35
或CHAIN_ID * 2 + 36
,分别对应v=0
和v=1
。此过程由EIP155规定,目的是为了防止签名层面的重放攻击。
CHAIN_ID
即每一条区块链的专属ID,具体可参考ChianList
验证签名
验证签名需要对签名者的公钥进行恢复,具体的流程如下:
- 计算签名信息的哈希值
m
- 计算点
R = (x, y)
。其中,当v=0
时,x=r
; 当v=1
时,x=r+n
- 计算
u1 = hs^-1 mod n
,其中h
为经过调整的哈希值,调整算法参考truncateHash - 计算
u2 = sr^-1 mod n
- 计算
Q = u1 * G + u2 * R
,Q
即签名者的公钥。
我们在此给出对应的实现代码:
static fromSignature(msgHash: Hex, signature: Sig, recovery: number): Point {
msgHash = ensureBytes(msgHash);
const h = truncateHash(msgHash);
const {
r, s } = normalizeSignature(signature);
if (recovery !== 0 && recovery !== 1) {
throw new Error('Cannot recover signature: invalid recovery bit');
}
if (h === _0n) throw new Error('Cannot recover signature: msgHash cannot be 0');
const prefix = recovery & 1 ? '03' : '02';
const R = Point.fromHex(prefix + numTo32bStr(r));
const {
n } = CURVE;
const rinv = invert(r, n);
// Q = u1⋅G + u2⋅R
const u1 = mod(-h * rinv, n);
const u2 = mod(s * rinv, n);
const Q = Point.BASE.multiplyAndAddUnsafe(R, u1, u2);
if (!Q) throw new Error('Cannot recover signature: point at infinify');
Q.assertValidity();
return Q;
}
对于上述代码,基本逻辑与我们介绍的流程是相同的。但开发者为了优化代码使用了许多函数,这些函数大多包含位移、算法优化和增强安全性的内容,我们不在此深入研究。开发者将所有的代码都放在了index.ts
中,读者可以仅下载index.ts
,然后自行使用vscode
阅读代码,请善用函数定义跳转功能F12
。
通过上述流程,我们可以获得信息签名者的公钥,进一步可以获得签名者的以太坊地址。
以太坊交易签名
在go-ethereum
中,我们可以查到以下关于交易签名的源代码:
// SignTx signs the transaction using the given signer and private key.
func SignTx(tx *