数字签名是一种数学机制,用于验证数字信息的真实性和完整性。在区块链和智能合约中,数字签名扮演着至关重要的角色,它能够:
在以太坊中,数字签名使用 ECDSA 算法(椭圆曲线数字签名算法)。
其实,如果你使用过 MetaMask 或其他钱包发送交易,你已经在使用数字签名了!
当你在 MetaMask 中点击"确认"发送交易时:
签名(链下):
验证(节点):
这就是为什么:
本文介绍的是消息签名,它与交易签名其实是一样的(使用相同的签名算法),仅仅是场景不同,签名的内容有所不同。
交易签名签署的内容是交易数据(to, value, data, nonce、gas等),这个是约定的数据格式,由节点验证并执行。
本文介绍消息签名,可以自定义签名消息或数据结构,然后在合约中验证用户身份或授权某些操作。
在智能合约开发中,消息签名有多个重要用途:
用户可以在链下对某个操作进行签名授权,然后由其他人提交到链上执行。这样可以:
验证某个地址的所有者确实想要执行某个操作,而不是被恶意第三方冒充。
以太坊上签名使用ECDSA 算法,但对于使用上来说,工作原理可以简化为:
消息 + 私钥 → 签名
消息 + 签名 → 验证恢复出地址 → 比对地址是否正确
签名过程:使用私钥对消息进行签名,生成签名数据
验证过程:使用消息和签名恢复出签名者的地址,然后验证这个地址是否符合预期
以太坊签名内容包含三个部分(r, s, v),完整签名是 65 字节的十六进制字符串:
0x[r(32字节)][s(32字节)][v(1字节)]
在验证签名时,需要将这 65 字节拆分成三个部分使用。
Solidity 提供了内置函数 ecrecover 来恢复签名者的地址:
pragma solidity ^0.8.0;
contract SignatureVerifier {
// 验证签名
function verify(
address _signer,
string memory _message,
bytes memory _signature
) public pure returns (bool) {
// 1. 对消息进行哈希
bytes32 messageHash = keccak256(abi.encodePacked(_message));
// 2. 添加以太坊签名前缀
bytes32 ethSignedMessageHash = getEthSignedMessageHash(messageHash);
// 3. 从签名中恢复地址
address recoveredSigner = recoverSigner(ethSignedMessageHash, _signature);
// 4. 比较地址
return recoveredSigner == _signer;
}
// 添加以太坊签名前缀
function getEthSignedMessageHash(bytes32 _messageHash)
public pure returns (bytes32)
{
return keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
_messageHash
));
}
// 从签名中恢复签名者地址
function recoverSigner(
bytes32 _ethSignedMessageHash,
bytes memory _signature
) public pure returns (address) {
(bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);
return ecrecover(_ethSignedMessageHash, v, r, s);
}
// 将签名拆分为 r, s, v
function splitSignature(bytes memory sig)
public pure returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "Invalid signature length");
assembly {
// 前 32 字节是长度,跳过
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
}
}
关于以太坊签名前缀, 以太坊在签名时会添加一个特殊的前缀:
\x19Ethereum Signed Message:\n32, 这个前缀的作用让消息的签名与交易签名区隔开,明确表示这是一个消息签名,由于签名和验证需要基于同样的内容,因此链下签名和链上验证时都必须使用相同的前缀。
OpenZeppelin 提供了更安全和易用的签名验证工具:
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
contract SignatureVerifierOZ {
using ECDSA for bytes32;
using MessageHashUtils for bytes32;
function verify(
address signer,
string memory message,
bytes memory signature
) public pure returns (bool) {
// 1. 对消息进行哈希
bytes32 messageHash = keccak256(abi.encodePacked(message));
// 2. 添加以太坊签名前缀
bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
// 3. 恢复签名者地址并验证
address recoveredSigner = ethSignedMessageHash.recover(signature);
return recoveredSigner == signer;
}
}
const { ethers } = require("ethers");
async function signMessage() {
// 创建钱包
const wallet = new ethers.Wallet(privateKey);
// 要签名的消息
const message = "Hello, Ethereum!";
// 签名(会自动添加以太坊前缀)
const signature = await wallet.signMessage(message);
console.log("Signature:", signature);
// 输出: 0x[130个字符的十六进制字符串]
return signature;
}
// 验证签名
function verifySignature(message, signature, expectedAddress) {
const recoveredAddress = ethers.utils.verifyMessage(message, signature);
return recoveredAddress === expectedAddress;
}
import { createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { mainnet } from 'viem/chains'
async function signMessage() {
// 从私钥创建账户
const account = privateKeyToAccount('0x...')
const message = 'Hello, Ethereum!'
// 签名(会自动添加以太坊前缀)
const signature = await account.signMessage({ message })
console.log('Signature:', signature)
// 输出: 0x[130个字符的十六进制字符串]
return signature
}
// 验证签名
import { verifyMessage } from 'viem'
async function verifySignature(message, signature, expectedAddress) {
const valid = await verifyMessage({
address: expectedAddress,
message,
signature,
})
return valid
}
NFT 项目常用签名来控制白名单用户的铸造权限:
pragma solidity ^0.8.0;
contract WhitelistNFT {
address public signer;
mapping(address => bool) public hasMinted;
constructor(address _signer) {
signer = _signer;
}
function mint(bytes memory signature) external {
require(!hasMinted[msg.sender], "Already minted");
// 验证签名
bytes32 messageHash = keccak256(abi.encodePacked(msg.sender));
bytes32 ethSignedMessageHash = getEthSignedMessageHash(messageHash);
require(
recoverSigner(ethSignedMessageHash, signature) == signer,
"Invalid signature"
);
hasMinted[msg.sender] = true;
// 铸造 NFT...
}
function getEthSignedMessageHash(bytes32 _messageHash)
internal pure returns (bytes32)
{
return keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
_messageHash
));
}
function recoverSigner(bytes32 _ethSignedMessageHash, bytes memory _signature)
internal pure returns (address)
{
(bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);
return ecrecover(_ethSignedMessageHash, v, r, s);
}
function splitSignature(bytes memory sig)
internal pure returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "Invalid signature length");
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
}
}
签名可以被多次使用,需要采取措施防止重放:
// ❌ 危险:签名可以被重复使用
function vulnerable(bytes memory signature) public {
require(verify(msg.sender, "approve", signature), "Invalid signature");
// 执行操作...
}
// ✅ 安全:使用 nonce 防止重放
mapping(address => uint) public nonces;
function safe(bytes memory signature) public {
bytes32 messageHash = keccak256(abi.encodePacked(
msg.sender,
nonces[msg.sender]
));
require(verifyWithHash(msg.sender, messageHash, signature), "Invalid signature");
nonces[msg.sender]++; // 增加 nonce
// 执行操作...
}
确保签名的消息包含足够的上下文信息:
// ❌ 危险:消息过于简单
bytes32 messageHash = keccak256(abi.encodePacked("approve"));
// ✅ 安全:包含完整上下文
bytes32 messageHash = keccak256(abi.encodePacked(
address(this), // 合约地址
msg.sender, // 用户地址
amount, // 金额
nonce, // nonce
block.chainid // 链 ID
));
最可能原因:链下和链上的消息哈希不一致
调试方法:
function debug(
string memory message,
bytes memory signature
) public pure returns (
bytes32 messageHash,
bytes32 ethSignedMessageHash,
address recoveredSigner
) {
messageHash = keccak256(abi.encodePacked(message));
ethSignedMessageHash = keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
messageHash
));
(bytes32 r, bytes32 s, uint8 v) = splitSignature(signature);
recoveredSigner = ecrecover(ethSignedMessageHash, v, r, s);
}
本节我们学习了数字签名在以太坊中的应用:
使用签名要注意:
\x19Ethereum Signed Message:\n32