前面的文章我们介绍过,NFT 白名单校验主要有两种方式:
这篇文章我们就来介绍一下签名验证的实现方法。我们这篇文章不会注重于签名的数学原理,仅仅对于链上与链下交互的实现做介绍。
在开始之前,我们先要了解一个基础知识:我们平时使用的区块链地址,都分为私钥和地址。私钥可以生成地址,而反过来,地址不能生成私钥。那么如何证明一个地址是属于某一个人呢?我们可以给他一段信息,让他用私钥对这段信息进行签名。我们拿到这段签名,再结合这个地址,就可以证明,这段信息是否是由这个地址对应的私钥签名,从而也就证明了地址的所有权。
明白了这个简单的原理之后,我们来看看实际项目中的白名单签名验证是什么步骤:
接下来我们看看,合约中具体应该怎样实现。我们这里使用 OpenZeppelin 的 ECDSA 库来进行示范。在实际使用中,主要调用的方法为:
function recover(bytes32 hash, bytes memory signature) internal pure returns (address) {
(address recovered, RecoverError error) = tryRecover(hash, signature);
_throwError(error);
return recovered;
}
方法接收两个参数:
返回值是对这段信息签名的地址,也就是我们前面在步骤 3 中所说的管理员地址。也就是说,我们需要将管理员的地址设置在合约中进行比对,注意管理员的地址一定不能作为参数传入,否则用任意一个地址作签名之后再传入这个地址作验证,那结果一定是满足的。
对于这段代码调用的 tryRecover 中的逻辑我们就不再详述了,里面涉及到了数学原理,感兴趣的同学可以自行看看代码。
接下来我们利用 ECDSA 来实现验证逻辑:
pragma solidity 0.8.13;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract TestSig {
// 使用 using 方法,就可以直接使用 bytes32 类型调用方法
using ECDSA for bytes32;
address public owner;
constructor() {
// 管理员地址(仅测试,不要对这个地址转账)
owner = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
}
function verify(bytes memory signature) public view returns (bool) {
// 验证签名者是否是管理员
return recoverSigner(signature) == owner;
}
function recoverSigner(bytes memory signature) public view returns (address) {
// 注意这里待哈希的内容需要与链下签名方法保持一致即可
// 可以加盐或者其他数据来保持唯一性,防止重放攻击
// 这里简单起见,仅对调用者的地址进行哈希签名
bytes32 messageHash = keccak256(abi.encodePacked(msg.sender));
// 调用 recover 验证签名地址
address signer = messageHash.toEthSignedMessageHash().recover(signature);
return signer;
}
}
链上逻辑实现后,我们还要在链下对信息进行签名。我们使用 ethers
的 JavaScript 库进行签名操作,需要安装 ethers
的依赖:
npm install --save-dev ethers
代码:
const ethers = require('ethers');
const main = async () => {
// 管理员地址与私钥(仅测试,不要对这个地址转账)
const owner = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266';
const privateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const signer = new ethers.Wallet(privateKey);
console.log(signer.address)
// 由于我们之前是使用了 msg.sender 进行签名
// 因此这里需要改为合约的调用地址
let message = '0xxxxx';
// 计算信息的哈希值
let messageHash = ethers.utils.solidityKeccak256(['address'], [message]);
console.log("Message Hash: ", messageHash);
// 由于 ethers 库的要求,需要先对哈希值数组化
let messageBytes = ethers.utils.arrayify(messageHash);
console.log("messageBytes: ", messageBytes);
// 签名
let signature = await signer.signMessage(messageBytes);
console.log("Signature: ", signature);
}
const runMain = async () => {
try {
await main();
process.exit(0);
}
catch (error) {
console.log(error);
process.exit(1);
}
};
runMain();
这段代码就可以为我们生成签名数据,我们将签名数据传入合约的 verify
方法,就可以验证签名信息的准确性。
我们这里的签名信息仅仅是对合约发送者进行签名,这可能会造成签名信息被重复利用从而造成重放攻击。因此我们一般在实际项目开发中都会加上一些其他的信息,比如合约本身的地址(这样签名就确认只能应用于这个合约),或者盐(随机数确保随机)。
如果加上合约本身的地址作源信息,那么代码就需要改成:
// Solidity
bytes32 messageHash = keccak256(abi.encodePacked(address(this), msg.sender));
// Javascript
let messageHash = ethers.utils.solidityKeccak256(['address', 'address'], [message, message]);
关于 solidityKeccak256
的使用方法可以参考文档。
加盐的方法与上面类似,小幽灵(The Weirdo Ghost Gang)这个项目代码的白名单验证实现中就加入的盐的信息:
签名验证的数学逻辑比较复杂,但是已经有成熟的库将其封装好了,对于我们实际使用来说,难度不大,基本都是套路,多看看代码,多写几遍就很熟练了。