欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2024-03-19 13:27:04
...

背景

在目前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

相关标签: 区块链