跳到主要内容

元交易和签名重放

有些时候,你希望你的 DApp 用户有一个无气的体验,或者也许在没有真正把东西放在链上的情况下进行交易。这些类型的交易被称为元交易,在这个层面上,我们将深入探讨如何设计元交易,以及如果不仔细设计,它们如何被利用。

对于那些使用过 OpenSea 的人来说,有没有注意到 OpenSea 如何让你的 NFT 免费上市?无论你想以什么价格出售你的 NFT,不知何故,它从来没有在最初的 NFT 批准交易之外收取气体?答案是,Meta 交易。

元交易也常用于无气体的交易体验,例如要求用户签署一个信息来认领一个 NFT,而不是支付气体来发送一个交易来认领一个 NFT。

还有其他用例,例如让用户用任何代币,甚至是法币来支付燃气费,而不需要转换为加密货币。Multisig 钱包也只要求最后一个签名者为正在从 multisig 钱包进行的交易支付汽油费,其他用户只是签署一些信息。

这难道不是非常酷吗?🎉

它是如何工作的?

元交易只是一个花哨的词,即由第三方(中继者)代表用户支付交易的气体。用户只需要签署包含他们想要执行的交易信息的消息(而不是发送交易),并将其交给中继者。然后,中继者负责使用这些数据创建有效的交易,并自己支付燃气费用。

中继者可以是,例如,你的 DApp 中的代码,让你的用户体验无气的交易,也可以是你用法币支付的第三方公司,在以太坊上执行交易,等等。

在这个层面上,我们将做两件事--学习元交易以及如何构建一个支持元交易的简单智能合约,同时学习我们构建的第一个合约中的一个安全漏洞以及如何修复它。一石二鸟 😎。

数字签名

在前面的 IPFS 层面,我们已经解释了什么是散列。在继续之前,我们需要了解密码学的第二个基本概念--数字签名。

数字签名是一种验证信息真实性的数学方法。给定一些数据,可以用私钥对该数据进行签名,并产生一个签名信息。

然后,其他相信自己拥有原始输入数据的人可以将其与已签署的信息进行比较,以检索签署该信息的公共密钥。由于发送者的公钥必须事先知道,他们可以保证数据没有被篡改,其完整性得到维护。

所以在上面的例子中,Alice 用她的私钥签署了信息 Hello Bob!并将其发送给了 Bob。Bob 已经知道内容是 Hello Bob!,现在他想验证该信息确实是由 Alice 发送的,所以他试图从签名的信息中检索出公钥。如果公钥是 Alice 的公钥,那么该信息就没有被篡改,是安全传输的。

数字签名的使用案例

数字签名通常用于希望安全通信和确保信息内容在通信过程中没有被篡改。你可以发送原始的明文信息,以及来自已知发送者的签名信息,接收者可以通过比较明文信息和签名信息得出签名者的公钥来验证信息的完整性。如果签名者的公钥与预期的发送者的公钥相吻合,则该信息是安全传输的!

以太坊上的数字签名

在以太坊的情况下,用户钱包可以签署消息,然后原始输入数据可以与签署的消息进行验证,以检索签署该消息的用户的地址。签名验证既可以在链外进行,也可以在 Solidity 内部进行。在此情况下,用户的地址就是公钥。

理解这些概念

假设我们想建立一个 DApp,用户可以将代币转移到其他地址,而不需要支付汽油,只有一次批准。由于用户自己不会发送交易,我们将为他们支付汽油 🤑。

这种设计非常类似于 OpenSea 的工作方式,一旦你支付汽油以提供对 ERC-721 或 ERC-1155 集合的批准,OpenSea 上的列表和销售对卖家来说是免费的。OpenSea 只要求卖家在挂牌时签署交易,而当买家出现时,卖家的签名会与买家的交易一起提交,从而将 NFT 转移给买家,将 ETH 转移给卖家--而买家为这一切支付了所有的气体。

在我们的案例中,我们将只是有一个中继器,让你在初步批准后将 ERC-20 代币转移到其他地址 🎉。

让我们想一想,为了实现这一目标,需要什么所有的信息。智能合约需要知道关于代币转移的所有信息,所以我们至少需要将 4 项内容纳入签名信息中。

  • 发件人地址
  • 收件人地址
  • 要转移的代币数量
  • ERC-20 智能合约的 tokenContract 地址

流程看起来会是这样的。

  • 用户首先批准 TokenSender 合约进行无限的代币转移(使用 ERC20 批准功能)
  • 用户签署一个包含上述信息的消息
  • 中继器调用智能合约,传递已签署的信息,并支付气体费用
  • 智能合约验证签名并解码消息数据,并将代币从发送方转移到接收方

值得庆幸的是,ethers.js 自带了一个名为 signMessage 的函数,让我们可以轻松地签署消息。然而,为了避免产生任意长度的消息,我们将首先对所需的数据进行散列,然后签名。在智能合约中,我们也将首先对给定的参数进行散列,然后尝试根据散列来验证签名。

我们将把这个编码作为一个 Hardhat 测试,所以首先,我们需要编写智能合约。

构建

让我们建立一个例子,在那里你可以体验到元交易的作用,稍后我们将扩展该测试以显示漏洞。

创建项目

要建立一个 Hardhat 项目,打开终端并执行这些命令

npm init --yes npm install --save-dev hardhat 如果你是在 Windows 上,请做这个额外的步骤,同时安装这些库:)

npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers 在你安装 Hardhat 的同一目录下运行。

npx hardhat 选择创建一个基本样本项目 对已经指定的 Hardhat 项目根按回车键 在是否要添加.gitignore 的问题上按回车键 在 "是否要用 npm 安装这个样本项目的依赖项(@nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers)? 现在你已经有了一个准备好的 hardhat 项目!

编写合约

让我们也在同一个终端上安装 OpenZeppelin 合约。

npm install @openzeppelin/contracts

我们将创建两个智能合约。第一个是一个超级简单的 ERC-20 实现,第二个是我们的 TokenSender 合约。为了简单起见,我们将在同一个.sol 文件中创建它们。

让我们在合约/目录下创建一个名为 MetaTokenSender.sol 的 Solidity 文件,代码如下

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract RandomToken is ERC20 {
constructor() ERC20("", "") {}

function freeMint(uint amount) public {
_mint(msg.sender, amount);
}
}

contract TokenSender {

using ECDSA for bytes32;

function transfer(address sender, uint amount, address recipient, address tokenContract, bytes memory signature) public {
bytes32 messageHash = getHash(sender, amount, recipient, tokenContract);
bytes32 signedMessageHash = messageHash.toEthSignedMessageHash();

address signer = signedMessageHash.recover(signature);

require(signer == sender, "Signature does not come from sender");

bool sent = ERC20(tokenContract).transferFrom(sender, recipient, amount);
require(sent, "Transfer failed");
}

function getHash(address sender, uint amount, address recipient, address tokenContract) public pure returns (bytes32) {
return keccak256(abi.encodePacked(sender, amount, recipient, tokenContract));
}
}

让我们来看看发生了什么。首先,让我们看一下进口。

导入的内容

ERC20.sol 导入是为了继承 OpenZeppelin 的基本 ERC-20 合约实现,我们在以前的轨道中已经了解了很多。

ECDSA 代表椭圆曲线数字签名算法--它是 Ethereum 使用的签名算法,ECDSA.sol 的 OpenZeppelin 库包含一些用于 Solidity 中数字签名的辅助函数。

功能

ERC-20 合约是不言自明的,因为它所做的就是让你铸造一个任意数量的免费代币。

对于 TokenSender,这里有两个函数。让我们先看看辅助函数--getHash--它接收发送者地址、代币数量、接收者地址和 tokenContract 地址,并返回它们打包在一起的 keccak256 哈希值。 abi.encodePacked 将所有指定的值转换为字节,中间不留任何填充,并将其传递给 keccak256,这是 Ethereum 使用的散列函数。这是一个纯粹的函数,所以我们也将通过 Javascript 在客户端使用它,以避免在 Javascript 中处理 keccak 散列和打包编码,这可能有点令人讨厌。

转移函数是一个有趣的函数,它接收上述四个参数和一个签名。它使用 getHash 帮助器计算哈希值。之后,根据 EIP-191,消息哈希值被转换为 Ethereum 签名的消息哈希值。调用这个函数可以将 messageHash 转换成这样的格式 "\x19Ethereum Signed Message:\n" + len(message) + message)。遵守互操作性的标准是很重要的。

这样做之后,你调用 recover 方法,在该方法中,你传递的签名只不过是你用发件人的私钥签名的 Ethereum Signed Message,你将其与你生成的 Ethereum Signed Message - signedMessageHash 进行比较,以恢复公钥,这应该是发件人的地址。

如果签名者地址与传入的发送者地址相同,那么我们就把 ERC-20 代币从发送者转移到接收者

硬帽子测试

让我们创建一个 Hardhat 测试来演示这将如何工作。在测试目录下创建一个名为 metatxn-test.js 的新文件,代码如下

const { expect } = require("chai");
const { BigNumber } = require("ethers");
const { arrayify, parseEther } = require("ethers/lib/utils");
const { ethers } = require("hardhat");

describe("MetaTokenTransfer", function () {
it("Should let user transfer tokens through a relayer", async function () {
// Deploy the contracts
const RandomTokenFactory = await ethers.getContractFactory("RandomToken");
const randomTokenContract = await RandomTokenFactory.deploy();
await randomTokenContract.deployed();

const MetaTokenSenderFactory = await ethers.getContractFactory(
"TokenSender"
);
const tokenSenderContract = await MetaTokenSenderFactory.deploy();
await tokenSenderContract.deployed();

// Get three addresses, treat one as the user address
// one as the relayer address, and one as a recipient address
const [_, userAddress, relayerAddress, recipientAddress] =
await ethers.getSigners();

// Mint 10,000 tokens to user address (for testing)
const tenThousandTokensWithDecimals = parseEther("10000");
const userTokenContractInstance = randomTokenContract.connect(userAddress);
const mintTxn = await userTokenContractInstance.freeMint(
tenThousandTokensWithDecimals
);
await mintTxn.wait();

// Have user infinite approve the token sender contract for transferring 'RandomToken'
const approveTxn = await userTokenContractInstance.approve(
tokenSenderContract.address,
BigNumber.from(
// This is uint256's max value (2^256 - 1) in hex
// Fun Fact: There are 64 f's in here.
// In hexadecimal, each digit can represent 4 bits
// f is the largest digit in hexadecimal (1111 in binary)
// 4 + 4 = 8 i.e. two hex digits = 1 byte
// 64 digits = 32 bytes
// 32 bytes = 256 bits = uint256
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
)
);
await approveTxn.wait();

// Have user sign message to transfer 10 tokens to recipient
const transferAmountOfTokens = parseEther("10");
const messageHash = await tokenSenderContract.getHash(
userAddress.address,
transferAmountOfTokens,
recipientAddress.address,
randomTokenContract.address
);
const signature = await userAddress.signMessage(arrayify(messageHash));

// Have the relayer execute the transaction on behalf of the user
const relayerSenderContractInstance =
tokenSenderContract.connect(relayerAddress);
const metaTxn = await relayerSenderContractInstance.transfer(
userAddress.address,
transferAmountOfTokens,
recipientAddress.address,
randomTokenContract.address,
signature
);
await metaTxn.wait();

// Check the user's balance decreased, and recipient got 10 tokens
const userBalance = await randomTokenContract.balanceOf(
userAddress.address
);
const recipientBalance = await randomTokenContract.balanceOf(
recipientAddress.address
);

expect(userBalance.lt(tenThousandTokensWithDecimals)).to.be.true;
expect(recipientBalance.gt(BigNumber.from(0))).to.be.true;
});
});

代码在评论里有解释,但让我们试着运行测试。你可以在终端输入 npx hardhat test 来运行测试,如果你做的一切都正确的话,应该会通过

这意味着,在初步批准后,发送者能够将 10 个代币转移给接收者,而不需要自己支付汽油。你可以很容易地进一步扩展这个测试,看看这甚至会对多次转账起作用,只要你不超过用户的 10000 个代币的余额。

安全漏洞

不过你能猜到我们刚才写的代码有什么问题吗?🤔

由于签名包含了必要的信息,中继者可以不断地将签名发送给合同,从而不断地将代币从发送者的账户转移到接收者的账户。

虽然在这个具体的例子中,这似乎不是什么大问题,但如果这个合约是负责处理主网的资金问题呢?如果同样的签名可以被反复使用,用户就会失去他们所有的代币!

相反,只有当用户明确提供第二个签名时,交易才会被执行(当然是在智能合约的规则范围内)。

这种攻击被称为签名重放 - 因为,你猜对了,你重放了一个签名。

解决签名重放的问题

对于更简单的合同,你可以通过在合同中设置一些(嵌套)映射来解决这个问题。但是在这里,每笔转账有 4 个变量需要跟踪--发送方金额接收方 tokenContract。在 Solidity 中,创建一个这么深的嵌套映射可能相当昂贵。

而且,每个智能合约的 "种类 "都不同--因为你并不总是在处理相同的用例。一个更通用的解决方案是创建一个单一的映射,从参数的哈希值到一个布尔值,其中表示这个元交易已经被执行,表示它还没有。

类似于 mapping(bytes32 => bool)

不过这也有一个问题。在当前的参数设置下,如果 Alice 向 Bob 发送了 10 个令牌,它将会在第一次执行时通过,并且映射会被更新以反映这一点。然而,如果 Alice 真的想在第二次向 Bob 再发送 10 个令牌呢?

由于数字签名是确定的,也就是说,对于同一组密钥,相同的输入将得到相同的输出,这意味着 Alice 将永远无法再次向 Bob 发送 10 个令牌!为了避免这种情况,我们引入了第五种方法。

为了避免这种情况,我们引入了第五个参数,即 nonce

nonce 只是一个随机数,可以由用户选择,也可以由合同选择,还可以是随机生成的,这并不重要--只要用户的签名包括这个 nonce。由于完全相同的交易,但使用不同的 nonce 会产生不同的签名,所以上述问题就解决了!

让我们来实现这个 🚀

智能合约的改变

我们需要在三个地方更新智能合约的代码。

首先,我们将添加一个mapping(bytes32 => bool)来跟踪哪些签名已经被执行了。

第二,我们将更新我们的辅助函数 getHash,以接受一个 nonce 参数并将其纳入哈希值。

第三,我们将更新我们的传输函数,以便在验证签名时也能接收 nonce 并将其传递给 getHash。它也会在签名验证后更新映射。

以下是更新后的代码。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract RandomToken is ERC20 {
constructor() ERC20("", "") {}

function freeMint(uint amount) public {
_mint(msg.sender, amount);
}
}

contract TokenSender {

using ECDSA for bytes32;

// New mapping
mapping(bytes32 => bool) executed;

// Add the nonce parameter here
function transfer(address sender, uint amount, address recipient, address tokenContract, uint nonce, bytes memory signature) public {
// Pass ahead the nonce
bytes32 messageHash = getHash(sender, amount, recipient, tokenContract, nonce);
bytes32 signedMessageHash = messageHash.toEthSignedMessageHash();

// Require that this signature hasn't already been executed
require(!executed[signedMessageHash], "Already executed!");

address signer = signedMessageHash.recover(signature);

require(signer == sender, "Signature does not come from sender");

// Mark this signature as having been executed now
executed[signedMessageHash] = true;
bool sent = ERC20(tokenContract).transferFrom(sender, recipient, amount);
require(sent, "Transfer failed");
}

// Add the nonce parameter here
function getHash(address sender, uint amount, address recipient, address tokenContract, uint nonce) public pure returns (bytes32) {
return keccak256(abi.encodePacked(sender, amount, recipient, tokenContract, nonce));
}
}

让我们也来更新我们的测试以反映这一点。我们将有两个测试--一个是证明签名不能再被重放,另一个是证明两个不同的签名与不同的 nonces 仍然可以工作,即使其他东西都是一样的。

以下是更新后的测试

const { expect } = require("chai");
const { BigNumber } = require("ethers");
const { arrayify, parseEther } = require("ethers/lib/utils");
const { ethers } = require("hardhat");

describe("MetaTokenTransfer", function () {
it("Should let user transfer tokens through a relayer with different nonces", async function () {
// Deploy the contracts
const RandomTokenFactory = await ethers.getContractFactory("RandomToken");
const randomTokenContract = await RandomTokenFactory.deploy();
await randomTokenContract.deployed();

const MetaTokenSenderFactory = await ethers.getContractFactory(
"TokenSender"
);
const tokenSenderContract = await MetaTokenSenderFactory.deploy();
await tokenSenderContract.deployed();

// Get three addresses, treat one as the user address
// one as the relayer address, and one as a recipient address
const [_, userAddress, relayerAddress, recipientAddress] =
await ethers.getSigners();

// Mint 10,000 tokens to user address (for testing)
const tenThousandTokensWithDecimals = parseEther("10000");
const userTokenContractInstance = randomTokenContract.connect(userAddress);
const mintTxn = await userTokenContractInstance.freeMint(
tenThousandTokensWithDecimals
);
await mintTxn.wait();

// Have user infinite approve the token sender contract for transferring 'RandomToken'
const approveTxn = await userTokenContractInstance.approve(
tokenSenderContract.address,
BigNumber.from(
// This is uint256's max value (2^256 - 1) in hex
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
)
);
await approveTxn.wait();

// Have user sign message to transfer 10 tokens to recipient
let nonce = 1;

const transferAmountOfTokens = parseEther("10");
const messageHash = await tokenSenderContract.getHash(
userAddress.address,
transferAmountOfTokens,
recipientAddress.address,
randomTokenContract.address,
nonce
);
const signature = await userAddress.signMessage(arrayify(messageHash));

// Have the relayer execute the transaction on behalf of the user
const relayerSenderContractInstance =
tokenSenderContract.connect(relayerAddress);
const metaTxn = await relayerSenderContractInstance.transfer(
userAddress.address,
transferAmountOfTokens,
recipientAddress.address,
randomTokenContract.address,
nonce,
signature
);
await metaTxn.wait();

// Check the user's balance decreased, and recipient got 10 tokens
let userBalance = await randomTokenContract.balanceOf(userAddress.address);
let recipientBalance = await randomTokenContract.balanceOf(
recipientAddress.address
);

expect(userBalance.eq(parseEther("9990"))).to.be.true;
expect(recipientBalance.eq(parseEther("10"))).to.be.true;

// Increment the nonce
nonce++;

// Have user sign a second message, with a different nonce, to transfer 10 more tokens
const messageHash2 = await tokenSenderContract.getHash(
userAddress.address,
transferAmountOfTokens,
recipientAddress.address,
randomTokenContract.address,
nonce
);
const signature2 = await userAddress.signMessage(arrayify(messageHash2));
// Have the relayer execute the transaction on behalf of the user
const metaTxn2 = await relayerSenderContractInstance.transfer(
userAddress.address,
transferAmountOfTokens,
recipientAddress.address,
randomTokenContract.address,
nonce,
signature2
);
await metaTxn2.wait();

// Check the user's balance decreased, and recipient got 10 tokens
userBalance = await randomTokenContract.balanceOf(userAddress.address);
recipientBalance = await randomTokenContract.balanceOf(
recipientAddress.address
);

expect(userBalance.eq(parseEther("9980"))).to.be.true;
expect(recipientBalance.eq(parseEther("20"))).to.be.true;
});

it("Should not let signature replay happen", async function () {
// Deploy the contracts
const RandomTokenFactory = await ethers.getContractFactory("RandomToken");
const randomTokenContract = await RandomTokenFactory.deploy();
await randomTokenContract.deployed();

const MetaTokenSenderFactory = await ethers.getContractFactory(
"TokenSender"
);
const tokenSenderContract = await MetaTokenSenderFactory.deploy();
await tokenSenderContract.deployed();

// Get three addresses, treat one as the user address
// one as the relayer address, and one as a recipient address
const [_, userAddress, relayerAddress, recipientAddress] =
await ethers.getSigners();

// Mint 10,000 tokens to user address (for testing)
const tenThousandTokensWithDecimals = parseEther("10000");
const userTokenContractInstance = randomTokenContract.connect(userAddress);
const mintTxn = await userTokenContractInstance.freeMint(
tenThousandTokensWithDecimals
);
await mintTxn.wait();

// Have user infinite approve the token sender contract for transferring 'RandomToken'
const approveTxn = await userTokenContractInstance.approve(
tokenSenderContract.address,
BigNumber.from(
// This is uint256's max value (2^256 - 1) in hex
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
)
);
await approveTxn.wait();

// Have user sign message to transfer 10 tokens to recipient
let nonce = 1;

const transferAmountOfTokens = parseEther("10");
const messageHash = await tokenSenderContract.getHash(
userAddress.address,
transferAmountOfTokens,
recipientAddress.address,
randomTokenContract.address,
nonce
);
const signature = await userAddress.signMessage(arrayify(messageHash));

// Have the relayer execute the transaction on behalf of the user
const relayerSenderContractInstance =
tokenSenderContract.connect(relayerAddress);
const metaTxn = await relayerSenderContractInstance.transfer(
userAddress.address,
transferAmountOfTokens,
recipientAddress.address,
randomTokenContract.address,
nonce,
signature
);
await metaTxn.wait();

// Have the relayer attempt to execute the same transaction again with the same signature
// This time, we expect the transaction to be reverted because the signature has already been used.
expect(
relayerSenderContractInstance.transfer(
userAddress.address,
transferAmountOfTokens,
recipientAddress.address,
randomTokenContract.address,
nonce,
signature
)
).to.be.revertedWith("Already executed!");
});
});

这里的第一个测试是让用户用两个不同的 nonces 签署两个不同的签名,并且中继器都执行了这两个签名。然而,在第二个测试中,中继器试图执行同一个签名两次。在中继器第二次试图使用相同的签名时,我们希望交易能被还原。

如果你在这里运行 npx hardhat 测试,并且所有的测试都成功了,这意味着带有重放攻击的第二个交易被还原了。

这表明签名重放不可能再发生了,而这个漏洞是安全的! 🥳🥳