数字签名的原理与应用(智能合约白名单的实现)

一、数字签名的简介

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、以太坊签名交易的具体流程:

1682392206376105.png

  • 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 按钮

1682404370262877.png

点击 Continue 完成验证:

1682404471903034.png

继续点击 Publish 按钮,会发布并生成一个可以直接访问的公网地址:https://goerli.etherscan.io/verifySig/39

1682404549863552.png


三、通过一个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>

页面效果如下:

1682417948602610.png

5、连接钱包,进行测试:

1682418231445105.png

点击签名,即可得到最终的签名数据:

1682418287818126.png

我们可以到 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的实现方式更加高效!

jiguiquan@163.com

文章作者信息...

留下你的评论

*评论支持代码高亮<pre class="prettyprint linenums">代码</pre>

相关推荐