一、数字签名的简介
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的实现方式更加高效!



