Solidity合约中签名验证的一点实践
背景
在目前NFT概念国内外火爆的背景下,涌现了很多项目,特别是公链以太坊上,社区与新团队更是层出不穷,让人眼花缭乱。
而一个新项目上线的成功与否,往往与其社区支持力度息息相关。现在很多新项目方为了拥有更多的热度,人为的设置了白名单这个玩法和门槛,于是我们可以看到Discord频道里的人们为了肝白,可以绞尽脑汁、废寝忘食。毕竟,拿到了白名单的人,是会被承诺可以提前pre mint,对于热门项目来说,这几乎是个稳赚不赔的投资。
而对于ERC721标准协议的内容来说,并没有白名单这个说法,那么从技术的角度来说,是怎么实现这个功能的呢。实际上,这里还是个逐渐演进的过程。
白名单
最早,在有项目方开始逐渐使用白名单机制的时候,由于白名单一般只给出几百个,所以实现方式还是比较原始的。而因为当时普遍的项目架构都是前端网页调用智能合约就完事了,并没有引入后端进来,所以做法往往是把白名单地址列表由项目方直接写入到合约中,然后用户在发起pre mint请求时,方法里会判断用户地址是否在该地址列表中。
这种方式从原理上当然没有问题,而且也体现了区块链不可篡改、公开透明的特性。不过由于以太坊上高昂的gas费,以及目前白名单人数一般都是数以千计,所以为了自身成本考虑,几乎所有项目都逐渐放弃了这种方式,而是改用另外两种机制:
- 链下(即后端服务)对单个白名单地址签名,合约只需存储签名地址。
- 对白名单地址列表整体构建Merkle树,合约只需存储Merkle的root hash。
本文对第一种链下签名,链上验证的方式进行阐述与实践。需要用户对solidity语言和区块链概念有一定的了解。
整体流程大致为:
- 用户在前端网页操作发起pre mint时,弹出信息提示用户对该请求进行签名
- 请求(包含地址、签名、签名内容)发到后端,校验签名后,查询地址是否在白名单列表中。
- 如果确实存在,由后端特定地址的私钥对用户地址进行签名,然后把该签名返回给前端。
- 前端调用钱包,把后端返回的签名数据作为参数传给合约pre mint方法
- 合约验证该签名确实是后端特定地址签署的,并且内容与用户地址吻合,则通过校验,并且保存该地址到合约中,避免用户重复发起。
合约
在流行的第三方库OpenZeppelin中,实际上已经实现了合约验证的方法,用户的自定义合约里只有引入ECDSA这个library即可。验证签名的源代码如下:
function tryRecover(bytes32 hash, bytes memory signature) internal pure returns (address, RecoverError) {
// Check the signature length
// - case 65: r,s,v signature (standard)
// - case 64: r,vs signature (cf https://eips.ethereum.org/EIPS/eip-2098) _Available since v4.1._
if (signature.length == 65) {
bytes32 r;
bytes32 s;
uint8 v;
// ecrecover takes the signature parameters, and the only way to get them
// currently is to use assembly.
assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}
return tryRecover(hash, v, r, s);
} else if (signature.length == 64) {
bytes32 r;
bytes32 vs;
// ecrecover takes the signature parameters, and the only way to get them
// currently is to use assembly.
assembly {
r := mload(add(signature, 0x20))
vs := mload(add(signature, 0x40))
}
return tryRecover(hash, r, vs);
} else {
return (address(0), RecoverError.InvalidSignatureLength);
}
}
}
具体的验证逻辑不在此详述,方法里主要的逻辑是涉及到了签名的结构,因为以太坊中签名是由r、s、v长度固定三部分构成,所以这里通过长度来还原,然后可以还原出签署该签名的地址。
注意到方法参数,第一个名为hash,固定长度32字节,也就是说后端应对某个hash值(后文会提到)进行签名。
校验签名的合约示例代码如下:
address signer = 0xXXXX;
function _verify(bytes32 dataHash, bytes memory signature, address account) private pure returns (bool) {
return dataHash.toEthSignedMessageHash().recover(signature) == account;
}
function pubVerify(bytes memory signature, bytes32 msgHash) public view returns (bool) {
bool r = _verify(msgHash, signature, signer);
return r;
}
注意:
- signer为写在合约里的后端特定地址
- recover方法包装了上文的tryRecover
- 对签名原文msgStr进行了hash(即keccak256)后,又使用了toEthSignedMessageHash方法进行处理。这是因为由于多链的存在,以太坊规范里要求拼接一段的前缀。在OpenZeppelin库的方法源代码如下:
function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
function toEthSignedMessageHash(bytes memory s) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", Strings.toString(s.length), s));
}
- 上面有2个重载方法,如果是第一种,在拼接之后,又对整体取了一次hash,所以对应后端也要做2次hash。如果是第二种,则不需要对原文取hash,直接传入即可。但是由于交易参数公开透明,也为了保护用户的隐私,往往不希望传原文,所以这里我们选择第一种。
后端
后端整体逻辑上是根据合约的验证流程,对原文数据进行签名处理。这里选用java中常用的web3j库来处理。整体签名代码如下:
import org.web3j.crypto.*;
import org.web3j.utils.Numeric;
import org.web3j.crypto.Sign.SignatureData;
public static String sign(String msg, String pwd, String path){
try {
Credentials ownerCredentials = WalletUtils.loadCredentials(pwd, path);
byte[] sha3Msg = Hash.sha3(msg.getBytes());
Sign.SignatureData signMessage = Sign.signPrefixedMessage(sha3Msg, ownerCredentials.getEcKeyPair());
byte[] signatureBytes = new byte[65];
System.arraycopy(signMessage.getR(),0, signatureBytes,0, signMessage.getR().length);
System.arraycopy(signMessage.getS(),0, signatureBytes,32, signMessage.getS().length);
signatureBytes[64] = signMessage.getV()[0];
return Numeric.toHexString(signatureBytes);
}catch (Exception e){
log.error(e.getMessage());
}
return null;
}
步骤为:
- 通过密码和keystore文件路径加载本地钱包,即前文提到的后端特定地址。
- 对原文取hash(即Hash.sha3,等同于合约中的keccak256)
- 通过私钥,对原文进行带特定前缀的签名
- 使用签名的rsv字段,构建签名的16进制字符串
- 把16进制的签名以及原文hash返回给前端即可
优势
不仅是白名单,只要是需要项目方提供数据的场景,都可以用这种链下签名,链上验证的方式。而很多链游就是这么做的,比如游戏内很多赚取游戏币(Token)的场景,本身是没有上链的,只是和传统游戏一样,保存在了后端的数据库中,而当用户真正想要提取币,转到交易市场的时候,就可以提交请求,由游戏后端服务器来查询该用户可以提取的数量,签名后发到合约里进行链上Token的转移。
缺陷
这种签名验证方式,需要项目方把keystore文件保存在服务器上,而密码很多项目方都直接是写在配置文件中,甚至直接把私钥保存到服务器上,安全性得不到保障,一旦泄漏,攻击者可以发起任何签名相关的攻击请求。
参考
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol
https://blog.csdn.net/topc2000/article/details/119921231
下一篇: js打飞机 博客分类: js js大飞机
推荐阅读