使用社交账号登录
当你使用MetaMask登录DApp时,点击"签名"按钮的那一刻,背后发生了什么?为什么钱包签名能证明你的身份?本文将深入解析以太坊签名机制的核心原理,并提供完整的代码示例。
钱包签名的基础是非对称加密(Asymmetric Cryptography),它使用一对密钥:
私钥(Private Key) → 公钥(Public Key) → 地址(Address)
比喻理解: 这就像一把特殊的锁:
这个过程具有严格的单向性:
私钥 → 公钥 ✓(可以推导)
公钥 → 私钥 ✗(数学上不可行)
公钥 → 地址 ✓(可以推导)
地址 → 公钥 ✗(哈希算法单向)
安全性基础: 即使攻击者知道你的地址和公钥,也无法反推出私钥。这就像知道一个数字的平方根很简单,但要从一个巨大的数字找到它的唯一因数分解却极其困难。
1. 生成256位随机数 → 私钥
2. 使用secp256k1椭圆曲线 → 计算公钥(64字节)
3. Keccak256哈希公钥 → 取后20字节 → 地址(0x开头)
以太坊使用ECDSA(Elliptic Curve Digital Signature Algorithm)在secp256k1曲线上进行签名。
为什么选择椭圆曲线?
签名过程:
验证过程:
关键洞察: 签名不仅证明了"我签了这个消息",还能让任何人验证"这确实是某个地址的持有者签的",而无需暴露私钥。
椭圆曲线方程(secp256k1):
y² = x³ + 7 (mod p)
点乘运算:
椭圆曲线上的"乘法"实际上是重复的点加法:
Q = k · P
其中:
安全性基础: 已知P和Q,找到k是离散对数难题(Discrete Logarithm Problem),在数学上被认为不可解(需要2^128次运算,即使量子计算机也需要数百年)。
🔗 互动学习: ECC在线演示 - 直观体验椭圆曲线上的点运算。
假设我们直接对消息进行签名,不加任何前缀:
问题1:交易伪装攻击
以太坊交易也是一串数据,如果恶意网站精心构造,可能让签名消息看起来无害,但实际上是一笔转账交易:
问题2:跨链重放攻击
问题3:长度延展攻击
2021年,某DEX遭遇签名重放攻击:
用户在网站A上签名授权了一笔交易,攻击者捕获这个签名,然后在网站B上重放,由于签名没有绑定特定上下文,导致用户资产被盗。
损失: 超过500万美元的代币被盗。
EIP-191定义了签名数据版本控制,格式为:
0x19 <1 byte version> <version specific data> <data to sign>
版本类型:
0x00: 带验证者地址的数据0x01: 结构化数据(EIP-712使用)0x45: 个人签名(E代表Ethereum)对于个人签名(最常用):
"\x19Ethereum Signed Message:\n" + len(message) + message
前端签名(使用ethers.js):
后端验证(Go):
生产环境用下面代码即可,accounts.TextHash已经帮我们实现了自动添加前缀的功能, 上面的代码只是为了演示签名过程,实际使用时,如果使用了封装完善的工具包,前端只需要将用户输入的消息和签名一起发送到后端即可,前端和后端不需要手动添加前缀。
EIP-191前缀确保:
\x19字节确保签名数据永远不会被误解为有效的RLP编码交易EIP-191的不足: 只能签名纯文本或哈希值,对于复杂的链上操作(如订单、投票、授权),用户看到的是一团乱码:
EIP-712的改进: 让用户以人类可读的方式查看结构化数据:
Sign message:
Domain: Uniswap V3
Spender: 0x...
Token: USDC
Amount: 1000
Deadline: 2024-12-31 23:59:59
EIP-712将数据签名分为三部分:
定义数据的结构和字段类型:
类比理解: 这就像定义一个表单,规定每个字段叫什么名字、是什么类型。
绑定签名的使用场景,防止跨域重放:
作用: 确保签名只在特定应用、特定链、特定合约上有效。
比喻: 就像支票上印的银行名称和账号,确保支票只能在指定银行兑现。
实际要签名的数据:
前端签名(ethers.js v6):
后端验证(Go):
注意看下面的定义, 也就是说EIP-712是可以签署任意结构消息的,只要定义好结构体,然后按照EIP-712的规则进行签名即可,这样就可以实现多签,多签的签名验证也是按照EIP-712的规则进行的。
完整流程:
为什么是 \x19\x01?
\x19:区分于普通交易(EIP-191)\x01:标识这是EIP-712类型的签名1. Uniswap Permit(免gas授权):
2. OpenSea订单签名:
3. DAO投票:
签名生成(简化):
1. 选择随机数 k
2. 计算点 R = k · G(G是基点)
3. r = R.x mod n
4. s = k⁻¹ · (hash + r · privateKey) mod n
签名验证:
1. w = s⁻¹ mod n
2. u₁ = hash · w, u₂ = r · w
3. P = u₁ · G + u₂ · PublicKey
4. 验证 P.x mod n == r
| 攻击类型 | 时间复杂度 | 实际意义 |
|---|---|---|
| 暴力破解私钥 | O(2^256) | 宇宙终结前不可能 |
| 破解ECDSA | O(2^128) | 所有计算机联合运算数十亿年 |
| 量子计算攻击 | O(2^64) | 需要数百万量子比特(目前~1000) |
实际威胁: 私钥泄露、钓鱼攻击、恶意授权,而非数学破解。
深入理解椭圆曲线密码学的数学原理:
相关标准文档:
1. 对消息进行哈希:hash = keccak256(message)
2. 使用私钥生成签名:(r, s, v) = sign(hash, privateKey)
- r, s: 签名的两个数学分量(各32字节)
- v: 恢复标识符(1字节,用于从签名恢复公钥)
1. 从签名恢复公钥:publicKey = ecrecover(hash, v, r, s)
2. 从公钥生成地址:address = keccak256(publicKey)[12:]
3. 比较地址是否匹配:recoveredAddress == expectedAddress
// ❌ 危险的做法
const message = "Transfer 1 ETH to Bob";
const hash = keccak256(message);
const signature = sign(hash, privateKey);
// 用户看到的
"Login to MyDApp"
// 实际构造的数据(RLP编码后)
[nonce, gasPrice, gasLimit, to, value, data]
// 如果两者哈希碰撞,用户的签名就能执行转账!
// 用户在以太坊主网签名
const message = "Approve 1000 USDT";
signature = sign(message, privateKey);
// 攻击者将相同签名提交到BSC链
// 如果没有链ID绑定,签名在BSC上也有效!
// 原始消息
"Transfer 1 ETH" // 13字节
// 攻击者在后面添加垃圾数据
"Transfer 1 ETH\x00\x00..." // 13字节 + 垃圾
// 如果不包含长度信息,两者可能产生相同哈希
import { ethers } from "ethers";
// Connect wallet
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
// Message to sign
const message = "Hello Web3";
// Sign with EIP-191 (ethers automatically adds prefix)
const signature = await signer.signMessage(message);
console.log("Signature:", signature);
// Output: 0x... (132 characters: 0x + 65 bytes hex)
package main
import (
"crypto/ecdsa"
"encoding/hex"
"fmt"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
)
// VerifySignature verifies EIP-191 signature
func VerifySignature(message, signatureHex, expectedAddress string) (bool, error) {
// Remove 0x prefix from signature
signatureHex = strings.TrimPrefix(signatureHex, "0x")
// Decode signature (65 bytes: r(32) + s(32) + v(1))
signature, err := hex.DecodeString(signatureHex)
if err != nil {
return false, fmt.Errorf("invalid signature format: %w", err)
}
if len(signature) != 65 {
return false, fmt.Errorf("signature must be 65 bytes, got %d", len(signature))
}
// Adjust v value (Ethereum uses 27/28, we need 0/1)
if signature[64] >= 27 {
signature[64] -= 27
}
// Create EIP-191 prefixed message hash
prefixedMessage := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)
prefixedHash := crypto.Keccak256Hash([]byte(prefixedMessage))
// Recover public key from signature
publicKeyECDSA, err := crypto.SigToPub(prefixedHash.Bytes(), signature)
if err != nil {
return false, fmt.Errorf("failed to recover public key: %w", err)
}
// Get address from public key
recoveredAddress := crypto.PubkeyToAddress(*publicKeyECDSA)
// Compare addresses (case-insensitive)
expected := common.HexToAddress(expectedAddress)
return strings.EqualFold(recoveredAddress.Hex(), expected.Hex()), nil
}
func main() {
message := "Hello Web3"
signature := "0x..." // from frontend
userAddress := "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"
isValid, err := VerifySignature(message, signature, userAddress)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Signature valid: %v\n", isValid)
}
package main
import (
"strings"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
)
func VerifySignature(message, signatureHex, expectedAddress string) (bool, error) {
sig, err := hexutil.Decode(signatureHex)
if err != nil {
return false, fmt.Errorf("invalid signature: %w", err)
}
msgHash := accounts.TextHash([]byte(message))
if sig[crypto.RecoveryIDOffset] == 27 || sig[crypto.RecoveryIDOffset] == 28 {
sig[crypto.RecoveryIDOffset] -= 27
}
pk, err := crypto.SigToPub(msgHash, sig)
if err != nil {
return false, fmt.Errorf("failed recover public key from sig ('%s'), %w", signatureHex, err)
}
recoveredAddr := crypto.PubkeyToAddress(*pk)
return strings.EqualFold(expectedAddress, recoveredAddr.Hex())
}
// 用户在MetaMask中看到的(EIP-191)
Sign this message:
0x1234567890abcdef...
// 用户根本不知道自己在签什么!
const types = {
// 域分隔符类型(固定)
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" }
],
// 自定义消息类型
Transfer: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "nonce", type: "uint256" }
]
};
const domain = {
name: "MyDApp", // 应用名称
version: "1", // 版本号
chainId: 1, // 链ID(1=以太坊主网)
verifyingContract: "0x..." // 验证合约地址
};
const message = {
from: "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4",
to: "0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2",
amount: 1000,
nonce: 1
};
import { ethers } from "ethers";
async function signEIP712() {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const userAddress = await signer.getAddress();
// 1. Define domain
const domain = {
name: "MyDApp",
version: "1",
chainId: 1,
verifyingContract: "0x1234567890123456789012345678901234567890"
};
// 2. Define types
const types = {
Transfer: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "nonce", type: "uint256" }
]
};
// 3. Define message
const message = {
from: userAddress,
to: "0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2",
amount: ethers.parseUnits("100", 18), // 100 tokens
nonce: 1
};
// 4. Sign
const signature = await signer.signTypedData(domain, types, message);
console.log("Signature:", signature);
return { domain, types, message, signature };
}
package main
import (
"encoding/hex"
"fmt"
"math/big"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/signer/core/apitypes"
)
// EIP712Domain represents the domain separator
type EIP712Domain struct {
Name string
Version string
ChainId *big.Int
VerifyingContract common.Address
}
// Transfer represents the message to sign
type Transfer struct {
From common.Address
To common.Address
Amount *big.Int
Nonce *big.Int
}
// VerifyEIP712Signature verifies EIP-712 typed data signature
func VerifyEIP712Signature(
domain EIP712Domain,
transfer Transfer,
signatureHex string,
expectedAddress string,
) (bool, error) {
// 1. Define typed data structure
typedData := apitypes.TypedData{
Types: apitypes.Types{
"EIP712Domain": []apitypes.Type{
{Name: "name", Type: "string"},
{Name: "version", Type: "string"},
{Name: "chainId", Type: "uint256"},
{Name: "verifyingContract", Type: "address"},
},
"Transfer": []apitypes.Type{
{Name: "from", Type: "address"},
{Name: "to", Type: "address"},
{Name: "amount", Type: "uint256"},
{Name: "nonce", Type: "uint256"},
},
},
PrimaryType: "Transfer",
Domain: apitypes.TypedDataDomain{
Name: domain.Name,
Version: domain.Version,
ChainId: (*hexutil.Big)(domain.ChainId),
VerifyingContract: domain.VerifyingContract.Hex(),
},
Message: apitypes.TypedDataMessage{
"from": transfer.From.Hex(),
"to": transfer.To.Hex(),
"amount": (*hexutil.Big)(transfer.Amount),
"nonce": (*hexutil.Big)(transfer.Nonce),
},
}
// 2. Calculate typed data hash
domainSeparator, err := typedData.HashStruct("EIP712Domain", typedData.Domain.Map())
if err != nil {
return false, fmt.Errorf("failed to hash domain: %w", err)
}
typedDataHash, err := typedData.HashStruct(typedData.PrimaryType, typedData.Message)
if err != nil {
return false, fmt.Errorf("failed to hash message: %w", err)
}
// 3. Create final digest: keccak256("\x19\x01" + domainSeparator + typedDataHash)
rawData := []byte(fmt.Sprintf("\x19\x01%s%s",
string(domainSeparator),
string(typedDataHash)))
digest := crypto.Keccak256Hash(rawData)
// 4. Decode signature
signatureHex = strings.TrimPrefix(signatureHex, "0x")
signature, err := hex.DecodeString(signatureHex)
if err != nil {
return false, fmt.Errorf("invalid signature format: %w", err)
}
if len(signature) != 65 {
return false, fmt.Errorf("signature must be 65 bytes")
}
// Adjust v value
if signature[64] >= 27 {
signature[64] -= 27
}
// 5. Recover public key and address
publicKeyECDSA, err := crypto.SigToPub(digest.Bytes(), signature)
if err != nil {
return false, fmt.Errorf("failed to recover public key: %w", err)
}
recoveredAddress := crypto.PubkeyToAddress(*publicKeyECDSA)
expected := common.HexToAddress(expectedAddress)
return strings.EqualFold(recoveredAddress.Hex(), expected.Hex()), nil
}
func main() {
// Example usage
domain := EIP712Domain{
Name: "MyDApp",
Version: "1",
ChainId: big.NewInt(1),
VerifyingContract: common.HexToAddress("0x1234567890123456789012345678901234567890"),
}
transfer := Transfer{
From: common.HexToAddress("0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"),
To: common.HexToAddress("0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2"),
Amount: big.NewInt(100),
Nonce: big.NewInt(1),
}
signature := "0x..." // from frontend
userAddress := "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
isValid, err := VerifyEIP712Signature(domain, transfer, signature, userAddress)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("EIP-712 signature valid: %v\n", isValid)
}
type TypedDataMessage = map[string]interface{}
1. 计算域分隔符哈希:
domainSeparator = keccak256(
typeHash("EIP712Domain") +
encode(domain)
)
2. 计算消息哈希:
structHash = keccak256(
typeHash("Transfer") +
encode(message)
)
3. 最终签名哈希:
digest = keccak256(
"\x19\x01" +
domainSeparator +
structHash
)
4. 使用私钥签名 digest
// 传统方式:需要两笔交易
// 1. approve(spender, amount) - 花费gas
// 2. transferFrom(from, to, amount)
// EIP-712方式:只需一笔交易
// 1. 用户签名permit消息(免费)
// 2. 合约验证签名并转账(一次交易)
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" }
]
};
// 用户挂单卖NFT,不需要链上交易
const types = {
Order: [
{ name: "trader", type: "address" },
{ name: "collection", type: "address" },
{ name: "tokenId", type: "uint256" },
{ name: "price", type: "uint256" },
{ name: "expirationTime", type: "uint256" }
]
};
// 链下投票,最后统一提交
const types = {
Vote: [
{ name: "proposalId", type: "uint256" },
{ name: "support", type: "bool" },
{ name: "voter", type: "address" }
]
};
// ✅ 推荐:使用EIP-712签名授权
const signature = await signer.signTypedData(domain, types, message);
// ✅ 验证时检查域分隔符
if (domain.chainId !== await provider.getNetwork().chainId) {
throw new Error("Wrong chain!");
}
// ❌ 避免:让用户签名裸数据
const hash = keccak256(data);
const sig = await signer.signMessage(hash); // 用户无法理解