以太坊上的几种签名: eth_sign, personal_sign, eth_signTypedData
September 21st, 2022

以太坊的签名算法是ECDSA-secp256k1,以下介绍的每一种签名都是基于该算法,只是用来签名的数据不同。

1 交易签名 eth_sign

以太坊上,签名之前的交易结构如下。

let transaction = {
		to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
		value: ethers.utils.parseEther('1'),
		data: '0xE0A293E08F72454CEd99E1769c3ebd21fD2C20a1',
		gasLimit: '22000',
		maxFeePerGas: ethers.utils.parseUnits('20', 'gwei'),
		maxPriorityFeePerGas: ethers.utils.parseUnits('5', 'gwei'),
		nonce: 1,
		type: 2,
		chainId: chainId, // 31337
}

各项目的含义不再介绍,有兴趣可以查阅https://ethereum.org/en/developers/docs/transactions/

我们可以看到这里有to地址,但是没有from地址。from地址并不必要写出来,因为在私钥签名之后,根据签名和用来签名的数据就可以计算出私钥对应的地址(from地址)。

在ECDSA签名之前,我们需要先把上面的交易数据序列化。以太坊采用的序列化方式是RLP

rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data])

ether.js有专门的函数完成这个功能

let serializedTransaction = await ethers.utils.serializeTransaction(transaction)

对于上面的例子,我们序列化之后得到:

0x02f847827a690185012a05f2008504a817c8008255f09470997970c51812dc3a010c7d01b50e0d17dc79c8881bc16d674ec8000094e0a293e08f72454ced99e1769c3ebd21fd2c20a1c0

然后需要对serializedTransaction进行keccak256哈希,然后就可以用私钥做签名了。

本例中用来签名的账户为0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

privateKey=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

let signingKey = new ethers.utils.SigningKey(privateKey)
let hash = ethers.utils.keccak256(serializedTransaction)
let signature = await signingKey.signDigest(hash)
let { v, r, s } = signature

我们可以得到签名的signature,signature有3个部分: v(1字节),r(32字节),s(32字节)

上面的例子中,我们可以得到

hash=0xdd54f25d0bbeae343a1dec3acce8dc5b3a0886bdb9c5e667f65d5bbbe11bc093

r: '0x0d254c2662f6b1db272520fdb6b64d2402849058e89deab6c64d3b6cf2ebea4c',

s: '0x73460b644f90d5ccc59b3737f6e514b4ff91053756846bfbff16fa38d017a1b5',

v: 28

其实这个签名的过程我们也可以使用小狐狸钱包完成。我们需要把privateKey导入,为了得到一样的结果,我们需要切换网络到本地区块链。在网页中,F12打开console。输入如下命令:

hash="0xdd54f25d0bbeae343a1dec3acce8dc5b3a0886bdb9c5e667f65d5bbbe11bc093"
ethereum.request({method:"eth_sign", params: [ethereum.selectedAddress, hash]}).then(console.log)

我们看到小狐狸出现如下弹窗:

我们可以注意到红字的提醒。eth_sign是一个非常危险的操作,因为它是对交易的签名。如果大家在交互网站的时候发现弹出这样的签名,如果不懂千万不要随意签名。因为不怀好意的攻击者可能会拿着任何一个交易让你签名,这个交易可以做任何事情,例如可以把你钱包里的所有资金转走。

小狐狸签名的结果如下:

和我们使用ether.js算出的signature是一样的。

我们把r,s,v和之前的交易信息放在一起,就得到了raw_transaction.

rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, v, r, s])

不过这里有一个trick,需要把v的值从28改成1(如果v是27则改成0)。v值的定义,在以太坊上修改过2次,第一次是The Dao事件之后为了防范重放攻击做出的修改,第二次是EIP1559,具体参见

于是我们得到

0x02f88a827a690185012a05f2008504a817c8008255f09470997970c51812dc3a010c7d01b50e0d17dc79c8881bc16d674ec8000094e0a293e08f72454ced99e1769c3ebd21fd2c20a1c001a00d254c2662f6b1db272520fdb6b64d2402849058e89deab6c64d3b6cf2ebea4ca073460b644f90d5ccc59b3737f6e514b4ff91053756846bfbff16fa38d017a1b5

上面这串数字就是我们在交易的时候,最终需要广播给全网矿工的东西。

如果你用ether.js的话,还有一个更简单的把交易进行签名和序列化的函数,应该会得到一样的结果。

let wallet = new ethers.Wallet(privateKey)
let signedTransaction = await wallet.signTransaction(transaction)

2 消息签名 personal_sign

在以太坊中,签名不仅仅只会发生在发送交易的时候。

有时候,某个网站可能需要确认我们是否是某个账号的主人,那么它会拿一条消息出来,让我们签名,以便它确认我们的身份。

也有的智能合约是支持元交易(meta transaction),这样的设计可以帮助我们节省gas费。假设某个ERC20代币合约支持这样的交易,那么可能会有这样的操作。我打算给Bob付一笔钱,但是我不想支付gas费,那么我可以线下做个签名,让Bob拿着我的线下签名自己去发起交易领取。在智能合约的逻辑中,会通过ecrecover判断我签名的真实性,如果是真实有效的,Bob就能从我的账户里拿走钱财。

这样的设计虽然给我们带来了方便,但是也给我们带来了风险,如果在攻击者的诱导下,用我们的账号给支持这种功能的合约做了签名,那么我们的财产就不保了。opensea上发生的一些NFT零元购事件就是这样的原因https://mp.weixin.qq.com/s/XF1Zo_wWmrjRYsHykvR2Eg

下面我们具体看一下personal_sign,这个签名的标准在EIP-191里有描述

"\x19Ethereum Signed Message:\n" + len(message) + message

对上面的字符串进行keccak256,然后再用私钥签名即可。

我们可以看到有一个前缀“\x19Ethereum Signed Message:\n”,这就保证了这样消息不可能是第一节说的那种危险的交易签名,一定程度上提高了安全性。

2.1 原始消息签名

我们依然用第1节里的账号进行实验,这次我们签名的消息是“I will pay Bob 1 ETH.”

我们可以得到签名的结果为:

r: '0xd82e945511655405916227caa6f22e4c886676ee94a24d6919809abc4006e1e5',

s: '0x27dc36778b38306c47c2102aab5e294bc0bc9f73be4d0c97a4762f5b37338136',

v: 28

同样这个功能小狐狸钱包实现:

message="I will pay Bob 1 ETH."
ethereum.request({method:"personal_sign", params: [ethereum.selectedAddress, message]}).then(console.log)

实际上ether.js有一个更方便的函数:

let message = "I will pay Bob 1 ETH.";
let messageBytes = ethers.utils.toUtf8Bytes(message);
let sig = await signer.signMessage(messageBytes);

2.2 消息hash签名

上面说的是对整个消息签名,不过有时候我们的消息会很长。我们在链上验证的时候,是需要把整个消息都传上去的,这就会有点不方便且会消耗更多gas。所有有些项目是对消息做了一次keccak256哈希,然后把hash作为消息再处理。因为hash过后固定为32字节,所以需要上传的消息也就固定为32字节。

我们仍然拿上面的message="I will pay Bob 1 ETH."作为例子,我们对这个消息进行keccak256哈希,得到0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52

消息前缀的添加方式和之前一样,只是消息长度固定为32字节了。

"\x19Ethereum Signed Message:\n32" + message

let messageHash = "0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52";
let privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
let signingKey = new ethers.utils.SigningKey(privateKey);

let prefix = "\x19Ethereum Signed Message:\n32";
let hash = ethers.utils.keccak256(ethers.utils.solidityPack(
        ["string", "bytes32"],
        [prefix, messageHash]));
let sig = await signingKey.signDigest(hash);

我们可以得到签名的结果为:

r: '0xedd6c4704b1ac676c8f13f96e54ae2dcb76d942f7bfaea178aaeaeff4033c2c0',

s: '0x2d9b6511fc1d9948fa8f5261ee290f507d25d4b2c0378cd316c1ccbc25d24bf7',

v: 27

同样我们也可以用小狐狸来完成签名:

hash="0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52"
ethereum.request({method:"personal_sign", params: [ethereum.selectedAddress, hash]}).then(console.log)

ether.js签名函数也可以实现这个功能:

let messageHash = "0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52";
let sig = await signer.signMessage(ethers.utils.arrayify(messageHash));

这种签名方式其实是更加危险的,因为hash后的数据已经不可读了,当钱包弹出这样一个签名的时候,我们无从判断到底在签名什么。

3 结构化数据签名 eth_signTypedData(EIP-712)

第2节中介绍的message并没有固定的格式,每个项目可以自行签名的标准。但是这只适合简单的情形,例如验证账户拥有者,此时签名的消息内容如何是无关紧要的。但是如果是涉及代币、NFT资产的转移,就需要比较小心了,如果设计不好很容易被攻击者利用。

于是发展出了标准的结构化签名标准EIP-712

这种签名的消息格式为:

"\x19\x01" + domainSeparator + structHash

其中domainSeprator是域信息,有这样一些可选项目:域名称,版本号,链ID,合约地址,盐值。

structHash是消息的具体信息,是一个自定义的结构类型名称 + 一系列的自定义参数。

我们来看一下如何用代码实现,这个代码模拟了一个代币Permit。

const [signer] = await ethers.getSigners(); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const chainId = await signer.getChainId(); // 31337
const nonce = 0;
const name = "Gold";
const version = "1";
const token = "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1";
const spender = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const value = 1000;
const deadline = 100;

const abi = ethers.utils.defaultAbiCoder;

const _PERMIT_TYPEHASH = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(
    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"));

const structHash = ethers.utils.keccak256(abi.encode(
    ["bytes32", "address", "address", "uint256", "uint256", "uint256"],
    [_PERMIT_TYPEHASH, signer.address, spender, value, nonce, deadline]));

const typeHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(
    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
const nameHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(name));
const versionHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(version));
const domainSeparator = ethers.utils.keccak256(abi.encode(
    ["bytes32", "bytes32", "bytes32", "uint256", "address"],
    [typeHash, nameHash, versionHash, chainId, token]
));

const typedDataHash = ethers.utils.keccak256(ethers.utils.solidityPack(
    ["string", "bytes32", "bytes32"],
    ["\x19\x01", domainSeparator, structHash]));

const privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const signingKey = new ethers.utils.SigningKey(privateKey); // without prefix "\x19Ethereum Signed Message:\n32"

const signature = await signingKey.signDigest(typedDataHash);

最终结果:

r: '0x967cf9274943d2048f132ca12e491bd730d00a5ddae5fad3979d8fa535a17d05',

s: '0x3f931eb53f53d7fe38f25a08157c14585903304850d334fb79854cc88943f435',

v: 27

我们用小狐狸来做一下交叉验证,

let domain = [
    {name: "name", type: "string" },
    {name: "version", type: "string"},
    {name: "chainId", type: "uint256"},
    {name: "verifyingContract", type: "address"}
]
 
let domainData = {
    name: "Gold",
    version: "1",
    chainId: ethereum.chainId,
    verifyingContract: "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1"
}

let permit = [
    {"name": 'owner', "type": 'address'},
    {"name": 'spender', "type": 'address'},
    {"name": 'value', "type": 'uint256'},
    {"name": 'nonce', "type": 'uint256'},
    {"name": 'deadline', "type": 'uint256'},
]
 
let message = {
    owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
    spender: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
    value: 1000,
    nonce: 0,
    deadline: 100
}
 
let eip712TypedData = {
    types: {
        EIP712Domain: domain,
        Permit: permit
    },
    domain: domainData,
    primaryType: "Permit",
    message: message
}

let data = JSON.stringify(eip712TypedData)

ethereum.request({method:"eth_signTypedData_v4", params: [ethereum.selectedAddress, data]}).then(console.log)

对于这种EIP-712格式的签名,ether.js当然本身就有相应的函数。上面写了那么多代码只是想要让大家直观看一下EIP-712的消息是如何组织的。

const [signer] = await ethers.getSigners(); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const chainId = await signer.getChainId(); // 31337
const nonce = 0;
const name = "Gold";
const version = "1";
const token = "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1";
const spender = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const value = 1000;
const deadline = 100;

let sig = ethers.utils.splitSignature(
      await signer._signTypedData(
        {
          name,
          version,
          chainId,
          verifyingContract: token
        },
        {
          Permit: [
            {
              name: "owner",
              type: "address",
            },
            {
              name: "spender",
              type: "address",
            },
            {
              name: "value",
              type: "uint256",
            },
            {
              name: "nonce",
              type: "uint256",
            },
            {
              name: "deadline",
              type: "uint256",
            },
          ],
        },
        {
          owner: signer.address,
          spender,
          value,
          nonce,
          deadline,
        }
      )
    )

目前大部分项目的签名都是这种EIP-712格式的签名,它的优点就是最大程度地做到了“所见即所签”,如果对代币和NFT比较熟悉的朋友一眼就能看出这个签名是在做什么样的授权。

不过这个签名本身并不能保证安全性,还是需要用户对这些消息内容有一定程度的了解。例如攻击者故意让你做一个NFT合约的签名,依然可能会让很多小白用户在不知不觉中丢失资产。

Subscribe to rbtree
Receive the latest updates directly to your inbox.
Nft graphic
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from rbtree

Skeleton

Skeleton

Skeleton