0%

StudyRecord-Solidity_personal_sign

数字签名算法:

以太坊使用的数字签名算法叫双椭圆曲线数字签名算法(ECDSA),基于双椭圆曲线“私钥-公钥”对的数字签名算法。它主要起到了三个作用:

  • 身份认证:证明签名方是私钥的持有人
  • 不可否认:发送方不能否认发送过这个消息
  • 完整性:消息在传输过程中无法被修改

流程:

  • 签名即正向算法(消息 + 私钥 + 随机数)= 签名,其中消息是公开的,私钥是隐私的,经过ECDSA正向算法可得到签名,即r、s、v

  • 验证即反向算法(消息 + 签名)= 公钥,其中消息是公开的,签名是公开的,经过ECDSA反向算法可得到公钥,然后对比已公开的公钥

签名交易:

20221116112417

签名的类型:

eth_sign 是用来签署任意数据。这使得它是最强大的,最简单的(只是签署数据),但也是最危险的。
这里的问题是,你可以让用户签署一个数据,而这个实际上是交易数据。想象一下,你让用户登录到你的服务,但你让他们签署的数据实际上是一个交易,如 “发送5个ETH给攻击者”。
交易毕竟只是由字节组成,人们很可能不会检查他们所签署的这串字符的实际含义。看似无害的签名,却成了窃取资金的攻击。所以一般不鼓励直接使用eth_sign

personal_sign 后来加入来解决这个问题。该方法在任何签名数据前加上"\x19Ethereum Signed Message:\n",这意味着如果有人要签署交易数据,添加的前缀字符串会使其成为无效交易。

对于更复杂的用例,特别是在智能合约中使用时,EIP-712标准被创建。EIP-712标准随着时间的推移而有所改变,但目前MetaMask支持的最后一个版本是signTypedData_v4。或者你可以使用一个特定的库,如eip-712。

EIP-712解决的主要问题是确保用户清楚地知道他们在签署什么,为哪个合约地址和网络签署,而且每个签名最多只能使用一次。简而言之,这是通过签署所有需要的配置数据(地址、chain id、版本、数据类型)的哈希值+实际数据本身来实现的。ERC20-Permit 是一个关于如何使用signTypedData_v4的好例子。

实例:

新地址刚登录opensea时,就会弹出签名。下图是小狐狸(metamask)钱包进行签名时弹出的窗口,它可以证明你拥有私钥的同时不需要对外公布私钥。

20221122181424

创建签名:

打包信息:

在以太坊的ECDSA标准中,被签名的消息是一组数据的keccak256哈希,为bytes32类型

我们可以把任何想要签名的内容利用abi.encodePacked()函数打包,然后用keccak256()计算哈希,作为消息。
我们例子中的消息由string类型变量得出:

1
2
3
4
// solidity
function getSingleMessageHash(string memory _message) public pure returns (bytes32){
return keccak256(abi.encodePacked(_message));
}
1
2
3
4
5
6
7
8
9
10
// 测试脚本
describe("Verify sign", function () {
it.only("generate sign", async function () {
const {Verify, users, tokenOwner} = await setup();
const message = "hello";
// 调用智能合约打包信息
let result = await Verify.getSingleMessageHash(message);
console.log("messageHash: " + result);
});
});
1
2
# 测试脚本输出结果
messageHash: 0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8

打包信息 = abi.encodePacked()打包 + keccak256()计算哈希

计算以太坊签名消息:

消息可以是能被执行的交易,也可以是其他任何形式。
为了避免用户误签了恶意交易,ethereum PR2940提倡在消息前加上"\x19Ethereum Signed Message:\n32"字符,并再做一次keccak256哈希,作为以太坊签名消息。

1
2
3
4
5
// solidity
function toEthSignedMessageHash(bytes32 hash) public pure returns (bytes32) {
// 哈希的长度为32
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
1
2
# 测试脚本输出结果
sig: 0x456e9aea5e197a1f1af7a3e85a3212fa4049a3ba34c2289b4c860fc0b0c64ef3

对消息签名 = 签名hash+固定字符串后abi.encodePacked()打包 + keccak256()计算哈希

钱包进行签名:

日常操作中,大部分用户都是通过这种方式进行签名。

在获取到需要签名的消息之后,我们需要使用metamask钱包进行签名。
metamask的personal_sign方法会自动把消息转换为以太坊签名消息,然后发起签名。

所以我们只需要输入消息和签名者钱包account即可。
需要注意的是输入的签名者钱包account需要和metamask当前连接的account一致才能使用。

1
2
3
4
5
6
# Account #18: 0xdD2FD4581271e230360230F9337D5c0430Bf44C0 (10000 ETH)
# Private Key: 0xde9be858da4a475276426320d5e9262ecfc3ba460bfac56360bfa6c4c28b4ee0
ethereum.enable()
account = "0xdD2FD4581271e230360230F9337D5c0430Bf44C0"
hash = "0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8"
ethereum.request({method: "personal_sign", params: [account, hash]})

在控制台进行输入:
20221123143209
20221123143228
在返回的结果中(Promise的PromiseResult)可以看到创建好的签名。不同账户有不同的私钥,创建的签名值也不同。利用上面私钥创建的签名如下所示:

1
0x606a2bb1fa3459b622a37b54a88b051ad8d3e0c266373ca3d9a9da3c1b05e0dd4c5c647d72762cb65628ee377a3b9fb1ac09f157d5ae2150c07258117e8a24ae1c

利用ethersjs签名:

测试代码地址: https://github.com/jerrychan807/my-awesome-solidity/blob/main/easy_sign/contracts/Verify.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// hardhat测试脚本
describe("Verify sign", function () {
// 生成签名
it.only("generate sign", async function () {
const {Verify, users, tokenOwner} = await setup();
const provider = new ethers.providers.JsonRpcProvider("http://localhost:8545");
let privateKey = '0xde9be858da4a475276426320d5e9262ecfc3ba460bfac56360bfa6c4c28b4ee0';
let wallet = new ethers.Wallet(privateKey);

const message = "hello";
console.log("message: " + message);
// 打包签名
const messageHash = ethers.utils.solidityKeccak256(["string"], [message]);
const messageHashByte = ethers.utils.arrayify(messageHash);
// 消息进行签名
let signature = await wallet.signMessage(messageHashByte);
console.log("signature: " + signature);
});
});

测试结果如下,与Metamask的消息生成的签名一致。

1
2
message: hello
signature: 0x606a2bb1fa3459b622a37b54a88b051ad8d3e0c266373ca3d9a9da3c1b05e0dd4c5c647d72762cb65628ee377a3b9fb1ac09f157d5ae2150c07258117e8a24ae1c

验证签名:

为了验证签名,验证者需要拥有:

  • 消息

  • 签名

  • 签名使用的公钥。

我们能验证签名的原因是只有私钥的持有者才能够针对交易生成这样的签名,而别人不能。

1
2
3
4
5
6
// solidity
function verify(address _signer, string memory _message, uint8 _v, bytes32 _r, bytes32 _s) public pure returns (bool) {
bytes32 msgHash = keccak256(abi.encodePacked(_message));
bytes32 msgDigest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", msgHash));
return ecrecover(msgDigest, _v, _r, _s) == _signer;
}

签名中包含r, s, v三个值的信息,通过签名、消息恢复出 签名者的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// hardhat测试脚本
describe("Verify sign", function () {
// 生成签名
it.only("generate sign", async function () {
const {Verify, users, tokenOwner} = await setup();
const provider = new ethers.providers.JsonRpcProvider("http://localhost:8545");
let privateKey = '0xde9be858da4a475276426320d5e9262ecfc3ba460bfac56360bfa6c4c28b4ee0';
let wallet = new ethers.Wallet(privateKey);

const message = "hello";
console.log("message: " + message);
// 打包签名
const messageHash = ethers.utils.solidityKeccak256(["string"], [message]);
const messageHashByte = ethers.utils.arrayify(messageHash);
// 消息进行签名
let signature = await wallet.signMessage(messageHashByte);
console.log("signature: " + signature);
let sig = ethers.utils.splitSignature(signature);
// 调用智能合约验证签名
let result = await Verify.verify(wallet.address, message, sig.v, sig.r, sig.s);
console.log(result);
});
});

测试结果:

1
2
3
4
message: hello
signature: 0x606a2bb1fa3459b622a37b54a88b051ad8d3e0c266373ca3d9a9da3c1b05e0dd4c5c647d72762cb65628ee377a3b9fb1ac09f157d5ae2150c07258117e8a24ae1c
# 验证通过
true

Refs: