一、数字签名的简介
1、什么是数字签名?
数字签名是区块链的关键技术之一,可以在不暴露私钥的前提下证明地址的所有权;
该技术主要用来签署交易(当然也可以用来签署其他任意消息);
本文会讲解数字签名技术在以太坊协议中的用法;
以太坊使用的数字签名算法叫双椭圆曲线数字签名算法(ECDSA),基于双椭圆曲线“私钥-公钥”对的数字签名算法。它主要起到了三个作用:
-
身份认证:证明签名方是私钥的持有人。
-
不可否认:发送方不能否认发送过这个消息。
-
完整性:消息在传输过程中无法被修改。
2、什么是ECDSA签名?
ECDSA英文全称为Elliptic Curve Digital Signature Algorithm(椭圆曲线数字签名算法),可以说:ECDSA是比特币和以太坊的信任基础设施的核心;
ECDSA可理解为以太坊、比特币对消息、交易进行签名与验证的算法与流程;
优点是:
1). 在已知公钥的情况下,无法推导出该公钥对应的私钥。
2). 可以通过某些方法来证明某人拥有一个公钥所对应的私钥,而此过程不会暴露关于私钥的任何信息。
在智能合约层面,我们不必多关注其算法的细节,只需理解其流程,看得懂已有项目代码,可以在项目写出对应功能代码即可。
具体的ECDSA算法细节,可以查看网上专门的教程,
3、以以太坊为例,签名和验签的过程是什么样的?
-
签名过程:ECDSA_正向算法(消息 + 私钥 + 随机数)= 签名
-
其中消息是公开的,私钥是隐私的,经过ECDSA正向算法可得到签名,即r、s、v(不用纠结与r、s、v到底什么,只需要知道这就是签名即可)
-
验证过程:ECDSA_反向算法(消息 + 签名)= 公钥
-
其中消息是公开的,签名是公开的,经过ECDSA反向算法可得到公钥,然后对比已公开的公钥
在以太坊、比特币中这个算法是经过二开的ECDSA(原始的ECDSA只有r、s组成,以太坊/比特币的ECDSA由r、s、v组成)。
4、以太坊签名交易的具体流程:
-
RLP(,,,,,,,,):一种序列化的方式,其与网络传输中json的序列化/反序列化有一些不同,RLP不仅兼顾网络传输,其编码特性更确保了编码后的一致性,因为每笔交易过程中要进行Keccak256,如果不能保证编码后的一致性,会导致其Hash值不同,那么验证者就无法验证交易是否由同一个人发出。具体可参考:以太坊RLP编码;
-
Keccak256 :以太坊的Hash算法,生成32个字节Hash值(256bits)。
1). 构建原始交易对象:
-
nonce: 记录发起交易的账户已执行交易总数。Nonce的值随着每个新交易的执行不断增加,这能让网络了解执行交易需要遵循的顺序,并且作为交易的重放保护。
-
gasPrice: 该交易每单位gas的价格,Gas价格目前以Gwei为单位(即10^9wei),其范围是大于0.1Gwei,可进行灵活设置。
-
gasLimit: 该交易支付的最高gas上限,该上限能确保在出现交易执行问题(比如陷入无限循环)之时,交易账户不会耗尽所有资金。一旦交易执行完毕,剩余所有gas会返还至交易账户。
-
to: 该交易被送往的地址(调用的合约地址或转账对方的账户地址)。
-
value: 交易携带的以太币总量。
-
data:
-
若该交易是以太币交易,则data为空;
-
若是部署合约,则data为合约的bytecode;
-
若是合约调用,则需要从合约ABI中获取函数签名,并取函数签名hash值前4字节与所有参数的编码方式值进行拼接而成,具体可参阅:Ethereum的合约ABI拓展
-
chainId:防止跨链重放攻击。 ->EIP155
2). 签署交易
签署交易可使用MetaMask,也可以直接使用ethers库。
-
MetaMask(下文会有实战)
// 1. 构建 provider let ethereum = window.ethereum; const provider = new ethers.providers.Web3Provider(ethereum); let signer = await provider.getSigner(); // 2. 签名内容进行 solidityKeccak256格式 Hash let message = ethers.utils.solidityKeccak256(["string"], ["HelloWorld"]); // 3.转成UTF8 bytes let arrayifyMessage = ethers.utils.arrayify(message); // 4.使用私钥进行消息签名 let flatSignature = await signer.signMessage(arrayifyMessage); console.log(flatSignature);
-
ethers库(下文也会有实战)
const ethers = require("ethers") require("dotenv").config() async function main() { // 将RPC与私钥存储在环境变量中 // RPC节点连接,直接用alchemy即可 let provider = new ethers.providers.JsonRpcProvider(process.env.RPC_URL) // 新建钱包对象 let wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider) // 返回这个地址已经发送过多少次交易 const nonce = await wallet.getTransactionCount() // 构造raw TX tx = { nonce: nonce, gasPrice: 100000000000, gasLimit: 1000000, to: null, value: 0, data: "", chainId: 1, //也可以自动获取chainId = provider.getNetwork() } // 签名,其中过程见下面详述 let resp = await wallet.signTransaction(tx) console.log(resp) // 发送交易 const sentTxResponse = await wallet.sendTransaction(tx); }
5、以上签名过程wallet.signTransaction中发生了什么?
-
对(nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0)进行RLP编码;
-
对上面的RLP编码值进行Keccak256 ;
-
对上面的Keccak256值进行ECDSA私钥签名(即正向算法);
-
对上面的ECDSA私钥签名(v、r、s)结果与交易消息再次进行RPL编码,即RLP(nonce, gasPrice, gasLimit, to, value, data, v, r, s),可得到最终签名;
6、验证过程:交易签名发送后,以太坊节点如何进行身份认证、不可否认、完整性?
-
对上面最终的RPL解码,可得到(nonce, gasPrice, gasLimit, to, value, data, v, r, s);
-
对(nonce, gasPrice, gasLimit, to, value, data)和(v,r,s)ECDSA验证(即反向算法),得到签名者的address;
-
对上面得到的签名者的address与签名者公钥推导的address进行比对,相等即完成身份认证、不可否认性、完整性。
二、通过Hardhat完成整个签名以及Solidity验签过程
1、初始化一个Hardhat框架:
参考:Merkle Tree的原理与使用(智能合约白名单的实现)
2、简单编写一个签名验证合约Signature.sol:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.4; contract Signature { function verify(address _signer, string memory _message, uint8 v, bytes32 r, bytes32 s) external pure returns(bool) { bytes32 messageHash = keccak256(abi.encodePacked(_message)); bytes32 messageDigest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)); return ecrecover(messageDigest, v, r, s) == _signer; } }
3、为上面的合约编写一个测试用例Signature.js:
const { expect } = require("chai"); const { ethers } = require("hardhat"); describe("对jiguiquan进行签名并在链上进行验签", function(){ it("Verify", async function(){ // 获取当前用户信息 const [owner] = await ethers.getSigners(); console.log("当前地址为:", owner.address); // 部署合约 const signature = await ethers.getContractFactory("Signature"); const signatureContract = await signature.deploy(); await signatureContract.deployed(); console.log("合约部署成功,部署地址为:", signatureContract.address); // 对消息jiguiquan进行签名 const message = "jiguiquan"; const messageHash = ethers.utils.solidityKeccak256(["string"],[message]); const messageHashByte = ethers.utils.arrayify(messageHash); const sign = await owner.signMessage(messageHashByte); console.log("得到的签名字符串为:", sign); const signVRS = ethers.utils.splitSignature(sign); console.log("v:", signVRS.v); console.log("r:", signVRS.r); console.log("s:", signVRS.s); //调用合约进行验证 const verified = await signatureContract.verify(owner.address, message, signVRS.v, signVRS.r, signVRS.s); console.log("合约返回的验证结果为:", verified); expect(verified).to.equal(true); }) })
4、执行上面的测试用例:
PS E:\Study-Code\blockchain\Sign1> npx hardhat test 对jiguiquan进行签名并在链上进行验签 当前地址为: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 合约部署成功,部署地址为: 0x5FbDB2315678afecb367f032d93F642f64180aa3 得到的签名字符串为: 0x34ea64e1a24df22b4211405696707af68f92b52fa4e0e9070076b69f8877bceb5e02029d8d2643b6400e877648b363d1a13e93ed30d51a9cd37a6ad51f1d0f751b v: 27 r: 0x34ea64e1a24df22b4211405696707af68f92b52fa4e0e9070076b69f8877bceb s: 0x5e02029d8d2643b6400e877648b363d1a13e93ed30d51a9cd37a6ad51f1d0f75 合约返回的验证结果为: true ? Verify (910ms) 1 passing (913ms)
验证通过!
5、其实得到签名后,我们还可以在以下网页直接进行验证:
https://goerli.etherscan.io/verifiedSignatures#
右上角有个 Verify Signature 按钮
点击 Continue 完成验证:
继续点击 Publish 按钮,会发布并生成一个可以直接访问的公网地址:https://goerli.etherscan.io/verifySig/39
三、通过一个Vue项目,完成通过MetaMask钱包签名交易
1、初始化一个Vue项目(vue项目常用的配置就不赘述了):
参考:vue项目初始化准备工作
2、安装ethers:
npm install ethers@5.4
3、编写vuex的 ./store/index.js 核心文件:
import Vue from 'vue' import Vuex from 'vuex' import { ethers } from "ethers"; import createPersistedState from 'vuex-persistedstate' Vue.use(Vuex) export default new Vuex.Store({ state: { provider: {}, net: 0, gasPrice: 5000000000, account: '', block: 0 }, mutations: { SETPROVIDER: (state, provider) => { state.provider = provider }, SETBLOCK: (state, block) => { state.block = block }, SETNET: (state, net) => { state.net = net }, SETGASPRICE: (state, gasPrice) => { state.gasPrice = gasPrice }, SETACCOUNTS: (state, account) => { state.account = account } }, actions: { async setWebProvider ({ commit }) { var web3Provider if (window.ethereum) { web3Provider = window.ethereum try { await web3Provider.request({ method: 'wallet_switchEthereumChain', params: [ { chainId: '0x5' } ] }) } catch (error) { console.error('User denied account access') } const provider = new ethers.providers.Web3Provider(web3Provider); commit('SETPROVIDER', provider) const block = await provider.getBlockNumber(); commit("SETBLOCK", block); const net = await (await provider.getNetwork()).chainId; commit("SETNET", net); const gasPrice = await (await provider.getGasPrice()).toNumber(); commit("SETGASPRICE", gasPrice) const feeData = await provider.getFeeData(); console.log("当前建议的Gas设置", feeData); const selectedAddress = provider.provider.selectedAddress if(selectedAddress){ commit("SETACCOUNTS", selectedAddress); } web3Provider.on('chainChanged', function (networkIDstring) { commit('SETNET', networkIDstring); }) web3Provider.on('accountsChanged', function (accounts) { commit('SETACCOUNTS', accounts[0]); }) } }, async connectWallet () { var web3Provider if (window.ethereum) { web3Provider = window.ethereum try { await web3Provider.request({ method: 'eth_requestAccounts' }) } catch (error) { console.error('User denied account access') } } } }, plugins: [ createPersistedState({ key: 'vue-sign', paths: ['account','net','block','gasPrice'] }) ] })
4、编写核心 App.vue 文件,增加一点页面元素:
<template> <div id="app" style="width: 80%; height:800px; margin: 0 auto; border: 1px solid black;"> <br><br> <button @click="connectWallet">连接钱包</button> <div>当前地址为:{{account}}</div> <hr> <div>输入需要签名的消息:</div> <input type="text" v-model="message" style="width:600px;"> <br><br> <button @click="signMsg">使用MetaMask钱包进行签名</button> <div>签名后的消息为:</div> <textarea name="sign" id="" cols="30" rows="10" :value="sign" style="width:600px;"></textarea> </div> </template> <script> import { mapState } from 'vuex' import { ethers } from 'ethers' export default { name: 'App', data() { return { "message":"", "sign":"" } }, methods: { connectWallet(){ if (!this.account) { this.$store.dispatch('connectWallet') } }, async signMsg(){ console.log("对消息进行签名"); // 1、获得provider let signer = await this.provider.getSigner(); // 2、对签名内容进行 solidityKeccak256格式 Hash const messageHash = ethers.utils.solidityKeccak256(["string"], [this.message]); // 3、获得签名Hash的字节数组 const messageHashByte = ethers.utils.arrayify(messageHash); // 4、让当前Signer(小狐狸钱包)对这个数据进行签名 const sign = await signer.signMessage(messageHashByte); this.sign = sign; } }, computed: { ...mapState(['account', 'provider']), }, beforeCreate () { this.$store.dispatch('setWebProvider') } } </script> <style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } </style>
页面效果如下:
5、连接钱包,进行测试:
点击签名,即可得到最终的签名数据:
我们可以到 https://goerli.etherscan.io/verifiedSignatures# 平台进行验签,成功后的公网连接如下:https://goerli.etherscan.io/verifySig/40
所以通过Vue + ethers + Metamask进行签名也成功了!
四、借助数字签名实现NFT白名单(Hardhat测试)
思考:通过上面的篇幅,我们知道 签名/验签 的过程其实就是 私钥签名/公钥验签,那么我们可以这么做:
在本地,拿到白名单用户地址列表,然后使用自己钱包的私钥对这些地址进行签名,收集好每个地址的签名;
将签名时使用的地址作为公钥,存储在合约里面,用于用户验证;
这样,当用户调用合约时,只需要传入自己的签名即可,合约中得到msgSender(原消息体)、sign,再结合签名账户的地址必然可以验证调用者是否为白名单成员!
1、编写一个白名单测试合约Whitelist.sol:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.4; contract Whitelist { address private SIGNER; constructor(address _signer){ SIGNER = _signer; } /** * 1、之所以user地址是传入,是为了方便Hardhat测试传入,线上可以通过msgSender获得 * 2、_maxMint:用户最大可以Mint的数量,keccak256进行hash的元素必须与线下生成签名时候完全一致 * 3、_signature就是线下生产的签名,其中包含白名单的地址用户address + 每个地址可以Mint的数量 */ function verify(address user, uint8 _maxMint, bytes memory _signature) public view returns (bool) { bytes32 message = keccak256(abi.encodePacked(user, _maxMint)); bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", message)); address signList = recoverSigner(hash, _signature); return signList == SIGNER; } // 从_msgHash和签名_signature中恢复signer地址(公钥) function recoverSigner(bytes32 _msgHash, bytes memory _signature) internal pure returns (address){ // 检查签名长度,65是标准r,s,v签名的长度 require(_signature.length == 65, "invalid signature length"); bytes32 r; bytes32 s; uint8 v; // 目前只能用assembly (内联汇编)来从签名中获得r,s,v的值 assembly { /* 前32 bytes存储签名的长度 (动态数组存储规则) add(sig, 32) = sig的指针 + 32 等效为略过signature的前32 bytes mload(p) 载入从内存地址p起始的接下来32 bytes数据 */ // 读取长度数据后的32 bytes r := mload(add(_signature, 0x20)) // 读取之后的32 bytes s := mload(add(_signature, 0x40)) // 读取最后一个byte v := byte(0, mload(add(_signature, 0x60))) } // 使用ecrecover(全局函数):利用 msgHash 和 r,s,v 恢复 signer 地址 return ecrecover(_msgHash, v, r, s); } }
2、编写一个测试类WhitelistTest.js:
const { expect } = require("chai"); const { ethers } = require("hardhat"); describe("对多个地址用户进行签名,在合约中进行验签", function(){ it("Verify", async function(){ // 获取一批地址,singer的地址是要放入到合约中的 // 其他地址将被作为消息体的一部分进行签名 const [signer, addr1] = await ethers.getSigners(); console.log("签名者地址为:", signer.address); // 部署合约 const whitelist = await ethers.getContractFactory("Whitelist"); const whitelistContract = await whitelist.deploy(signer.address); await whitelistContract.deployed(); console.log("合约部署成功,部署地址为:", whitelistContract.address); // 对消息jiguiquan进行签名 const maxMint = 2; const messageHash = ethers.utils.solidityKeccak256(["address", "uint8"],[addr1.address, maxMint]); const messageHashByte = ethers.utils.arrayify(messageHash); const sign = await signer.signMessage(messageHashByte); console.log("得到的签名字符串为:", sign); //调用合约进行验证 let verified = await whitelistContract.verify(addr1.address, 1, sign); console.log("期望验证失败:", verified); expect(verified).to.equal(false); verified = await whitelistContract.verify(addr1.address, 2, sign); console.log("期望验证成功:", verified); expect(verified).to.equal(true); verified = await whitelistContract.verify(addr1.address, 3, sign); console.log("期望验证失败:", verified); expect(verified).to.equal(false); }) })
3、运行测试用例:
PS E:\Study-Code\blockchain\Sign1> npx hardhat test .\test\WhitelistTest.js 对多个地址用户进行签名,在合约中进行验签 签名者地址为: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 合约部署成功,部署地址为: 0x5FbDB2315678afecb367f032d93F642f64180aa3 得到的签名字符串为: 0xc86bbb6f1754e9a249cf3f362538fe8b0bded915f4bc722d374290c7738523f0362cb4c139be61490fa9658334e95dee006748846529848a1071c7b506b7f1081c 期望验证失败: false 期望验证成功: true 期望验证失败: false ✔ Verify (1038ms) 1 passing (1s)
白名单用例测试成功!
其实,通过签名的方式白名单,应该比Merkle Tree的实现方式更加高效!