智能合约的身份保证 - 数字签名
数字签名是什么
数字签名,简单讲,就是一种 证明「这份数据是我发的」 的方法。本质上,就是用私钥去对一段消息去签名,对方用公钥去验证这份签名,证明这份私钥是由我发送的并且消息没有遭到篡改。 在以太坊上,使用到的数字签名(加密)算法是 ECDSA。
数字签名的特性
-
身份验证 数字签名能证明:消息确实是由某个持有私钥的人发出的。因为只有私钥持有者才能生成正确的签名,别人伪造不了。
-
消息完整 签名是基于消息的哈希生成的。如果消息内容哪怕只改动一丁点(哪怕一个标点符号),
hash
就变了,签名也验证不了。 -
不可抵赖 一旦签了名,就无法否认自己签过。因为只有你拥有私钥,签名是你自己产生的,别人通过签名和消息可以恢复出来你的身份,别人无法伪造。
几个概念
数据包
指得是原始的数据消息。例如一段字符串数据
Hello World
消息
指的是原始数据经过
hash
算法生成的32
byte
的数据。在以太坊中,这个消息会经过两次keccak()
的算法进行生成。- 第一次:直接
hash
// 1. 直接把字符串hash bytes32 messageHash = keccak256(bytes(message));
- 第二次:加上以太坊的标志二次
hash
加上这个字符串标志的作用是防止重放攻击。细节我们在下面转化消息的步骤详解。return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
私钥
ECDSA
私钥本身,是一个32
字节的大整数。生成方式如下(go-ethereum
库)privateKey, err := crypto.GenerateKey()
生成结果为:
0x9db7a2287bf11462793f2b5c726d12ce79f2e25fbc9d08316b55386061e35a4c
公钥
公钥是由私钥进行生成的。有两种公钥,压缩公钥和未压缩公钥。以太坊中使用的公钥为未压缩公钥。
- 第一次:直接
-
未压缩公钥: 结构为:
0x04
+X坐标
+Y 坐标
。 大小为:uint8
+bytes32
+bytes32
=65
字节 如:
0x049a9c81e4f44f721e610a9c3cb3c2f6a8ca57b142309ba5c51d6b135988594d8fe3e5c0c5649f413f62de7c3c3e3b4974600bc517a637da51967b15e79b3d9252
- 压缩公钥:压缩公钥是未压缩公钥的编码后的公钥 结构为:
0x02
或0x03
+X 坐标
如:
0x021e2dda68120487e0807300c0f6e8e7d9e974a40f59ae3060d2ae6077fa66c092
地址
在以太坊中,地址格式为未压缩公钥去掉 0x04后
,经过 keccak()
哈希取后 20
字节(40
个 16
进制字符)而产生的。 如
0x49755928d2471581649f356f2a414e36d334055d
签名
签名本质上也是一个 65
字节的字节序列,分别是有三个值(r、s、v)
,大小分别为 32
字节、32
字节、1
字节。r、s、v
简单拼接起来就是一个完整的签名消息。 如:
0x
2c6404e1145f38a8eb7b6a5d7648e6a711f3dfd32e7d7fa8a6c4b17e6b6c6b6d // r (32字节)
56c4f0a1b0494c6578723e1c5e8f302ff7f6ba674fbec6d1571318c43259b2e4 // s (32字节)
1b // v (1字节)
在以太坊中,可以通过 r
、s
、v
值以及以太坊签名消息来进行恢复出来公钥(可推导出地址)来进行验证。 如:
// 恢复 signer
address recovered = ECDSA.recover(ethSignedHash, v, r, s);
签名算法
以太坊中使用到的签名算法是 ECDSA
(一种基于椭圆曲线的签名算法),使用到的椭圆曲线是 secp256k1
。 签名流程为:
- 先对消息做
Hash
用keccak256
处理原始消息,得到messageHash
。 (如果是普通签名,还会加一段\x19Ethereum Signed Message:\n32
前缀) - 用私钥对
messageHash
签名 使用secp256k1
曲线和ECDSA
签名算法,生成(r, s, v)
。 - 得到最终签名 把
r || s || v
按顺序拼接成65
字节数据。以太坊签名
签名流程
- 数据打包(一次
hash
)// 1. 直接把字符串hash bytes32 messageHash = keccak256(bytes(message));
第一次 hash 是直接对原始交易的数据进行使用 keccak() 函数进行 hash 化。
- 转化消息(二次
hash
)return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
在这一步中,对第一步获得的
hash
值添加以太坊的字符串标识再进行一次hash
。通过添加以太坊的标识,明确告诉链上验证者「这是人为签名的消息,不是链上的交易或指令」。如果不加前缀,攻击者可以拿着你签名过的数据,在链上伪造一些危险的行为!这就是签名可重放攻击,例如:from: 0xaaa...aaa to: 0xbbb...bbb value: 1 ETH nonce: 5 gasLimit: 21000 gasPrice: 20 gwei chainId: 1
假设我们有这一份链上交易数据。正常来说,直接使用
keccak256(hash)
后进行签名是可以发上以太坊网络进行交易发起的。
那么,假如此时有攻击者让你签了这个数据,拿到这个交易后直接发送到区块链网络,即发起了一次签名重放攻击。你的资金就发生了损失。(因为 signature
可以证明原交易数据的完整性)
但是,如果进行二次哈希,在第二次计算 hash
的时候,添加上以太坊的字符串。那么,这个签名则会被认为是离线签名,无法进行执行交易等危险行为。(因为最终 signature
只能保证 \x19Ethereum Signed Message:\n32 + hash
的完整性, 而不是可执行交易数据的完整性)
总得来说,二次签名并加上以太坊字符串标识是为了区分 链上交易 和 链下签名数据 两种类型。防签名重放攻击则是为了避免签名被使用在链上交易上面,避免资金丢失。
- 签名 签名这一步我们一般都是在链下进行,因为智能合约不应保存私钥信息。但这里使用到
foundry
的vm
来进行模拟签名。 通过使用私钥对消息hash
进行签名,可以获取签名信息,也就是r、s、v
三个值。// 3. 签名,这一步和之前的步骤都发生在链下 (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, ethSignedHash);
验签流程
- 恢复公钥(地址) 在链上恢复公钥(可推导出地址)相对比较简单。这里使用到
openzepplin
的ECDSA
库,参数为:消息哈希、r、s、v
。
// 4. 恢复 signer
address recovered = ECDSA.recover(ethSignedHash, v, r, s);
- 对比公钥(地址) 经过上一步恢复出来
recovered
(一个地址),我们就可以进行签名人匹配,如果相等,则说明消息完整且发起者为signer
。assertEq(recovered, signer, "Recovered signer does not match");
代码实操
contract TestSignVerifyOZ is Test {
using ECDSA for bytes32;
address signer;
uint256 privateKey;
function setUp() public {
privateKey = 123456789;
signer = vm.addr(privateKey);
}
function testSignAndRecoverOZ() public {
string memory message = "hello world";
// 1. 直接把字符串hash
bytes32 messageHash = keccak256(bytes(message));
// 2. 用 OZ 帮我们加前缀(标准 Ethereum Signed Message 格式)
bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash(messageHash);
console.log("message is:");
console.logBytes32( ethSignedHash );
// 3. 签名,这一步和之前的步骤都发生在链下
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, ethSignedHash);
console.log("signature is:");
console.logBytes( abi.encodePacked(r,s,v) );
// 4. 恢复 signer
address recovered = ECDSA.recover(ethSignedHash, v, r, s);
console.log("recovered is:");
console.logAddress( recovered );
console.log("signer is:");
console.log(signer);
assertEq(recovered, signer, "Recovered signer does not match");
}
}
版权声明
本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。