Solidity合约中签名验证的一点实践

背景

在目前NFT概念国内外火爆的背景下,涌现了很多项目,特别是公链以太坊上,社区与新团队更是层出不穷,让人眼花缭乱。

而一个新项目上线的成功与否,往往与其社区支持力度息息相关。现在很多新项目方为了拥有更多的热度,人为的设置了白名单这个玩法和门槛,于是我们可以看到Discord频道里的人们为了肝白,可以绞尽脑汁、废寝忘食。毕竟,拿到了白名单的人,是会被承诺可以提前pre mint,对于热门项目来说,这几乎是个稳赚不赔的投资。

而对于ERC721标准协议的内容来说,并没有白名单这个说法,那么从技术的角度来说,是怎么实现这个功能的呢。实际上,这里还是个逐渐演进的过程。

白名单

最早,在有项目方开始逐渐使用白名单机制的时候,由于白名单一般只给出几百个,所以实现方式还是比较原始的。而因为当时普遍的项目架构都是前端网页调用智能合约就完事了,并没有引入后端进来,所以做法往往是把白名单地址列表由项目方直接写入到合约中,然后用户在发起pre mint请求时,方法里会判断用户地址是否在该地址列表中。

这种方式从原理上当然没有问题,而且也体现了区块链不可篡改、公开透明的特性。不过由于以太坊上高昂的gas费,以及目前白名单人数一般都是数以千计,所以为了自身成本考虑,几乎所有项目都逐渐放弃了这种方式,而是改用另外两种机制:

  1. 链下(即后端服务)对单个白名单地址签名,合约只需存储签名地址。
  2. 对白名单地址列表整体构建Merkle树,合约只需存储Merkle的root hash。

本文对第一种链下签名,链上验证的方式进行阐述与实践。需要用户对solidity语言和区块链概念有一定的了解。
整体流程大致为:

  1. 用户在前端网页操作发起pre mint时,弹出信息提示用户对该请求进行签名
  2. 请求(包含地址、签名、签名内容)发到后端,校验签名后,查询地址是否在白名单列表中。
  3. 如果确实存在,由后端特定地址的私钥对用户地址进行签名,然后把该签名返回给前端。
  4. 前端调用钱包,把后端返回的签名数据作为参数传给合约pre mint方法
  5. 合约验证该签名确实是后端特定地址签署的,并且内容与用户地址吻合,则通过校验,并且保存该地址到合约中,避免用户重复发起。

合约

在流行的第三方库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;
}

注意:

  1. signer为写在合约里的后端特定地址
  2. recover方法包装了上文的tryRecover
  3. 对签名原文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));
}
  1. 上面有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;

}

步骤为:

  1. 通过密码和keystore文件路径加载本地钱包,即前文提到的后端特定地址。
  2. 对原文取hash(即Hash.sha3,等同于合约中的keccak256)
  3. 通过私钥,对原文进行带特定前缀的签名
  4. 使用签名的rsv字段,构建签名的16进制字符串
  5. 把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

猜你喜欢

转载自blog.csdn.net/Alex_Jeram/article/details/122646646