Ethereum 验证签名(EOA + EIP-1271)
背景:两种「签名者」
在 Ethereum 上,「谁能对一段消息给出签名」分两种账户:
- EOA(外部账户):由私钥控制,标准 ECDSA 签名,可用公钥反推地址。本仓库
signature.md已讲过这种的签名与验签。 - 合约账户(如 Gnosis Safe):没有私钥。它「签名」的方式是实现一个合约函数
isValidSignature(hash, signature) → bytes4,由合约内部逻辑决定这个hash是否被认可。标准叫 EIP-1271。
前端传来一个 (address, message, signature) 三元组时,你事先并不知道对方是 EOA 还是合约钱包。因此健壮的验签要两条路都走:
- 先按 EOA 规则反推地址,看是否等于
address; - 不等(或签名格式不像 EOA)时,再按 EIP-1271 去链上问那个
address合约:「这个签名你认吗?」
下面这套代码就是这条「EOA 优先、EIP-1271 兜底」的链路。
EIP-1271 部分:去链上问合约
const eip1271ABI = `[{
"inputs": [
{"type": "bytes32", "name": "_hash"},
{"type": "bytes", "name": "_signature"}
],
"name": "isValidSignature",
"outputs": [
{"type": "bytes4", "name": "magicValue"}
],
"stateMutability": "view",
"type": "function"
}]`
// eip1271MagicValue 是 isValidSignature 成功时应返回的魔术值, 移除了 "0x" 前缀
const eip1271MagicValue = "1626ba7e"
func VerifySafeSignature(safeAddress string, message string, signature string) (bool, error) {
// 连接以太坊节点
client, err := ethclient.Dial("https://intensive-boldest-sailboat.quiknode.pro/xx/")
if err != nil {
return false, fmt.Errorf("failed to connect to Ethereum node: %v", err)
}
defer client.Close()
// 将字符串地址转换为 common.Address
contractAddress := common.HexToAddress(safeAddress)
// 获取地址对应的代码
code, err := client.CodeAt(context.Background(), contractAddress, nil)
if err != nil {
return false, fmt.Errorf("failed to get code at address: %v", err)
}
// 如果没有代码,则不是合约,因此不是 Safe 钱包
if len(code) == 0 {
return false, nil
}
// 解析 ABI
parsedABI, err := abi.JSON(strings.NewReader(eip1271ABI))
if err != nil {
return false, fmt.Errorf("failed to parse ABI: %v", err)
}
// 获取消息哈希(accounts.TextHash 会自动加上 "\x19Ethereum Signed Message:\n" 前缀)
messageHash := accounts.TextHash(utils.UnsafeBytes(message))
var messageHash32 [32]byte
copy(messageHash32[:], messageHash)
// 打包调用数据
packedData, err := parsedABI.Pack("isValidSignature", messageHash32, common.FromHex(signature))
if err != nil {
return false, fmt.Errorf("failed to pack data: %v", err)
}
// 调用合约(view 调用,不消耗 gas,只读)
result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &contractAddress,
Data: packedData,
}, nil)
if err != nil {
return false, fmt.Errorf("contract call failed: %v", err)
}
// 检查返回的魔术值
if len(result) < 4 {
return false, fmt.Errorf("insufficient return value length")
}
// 将返回值转换为十六进制字符串,比较魔术值是否匹配
return common.Bytes2Hex(result[:4]) == eip1271MagicValue, nil
}
逐段拆解:
- 先
CodeAt探一下有没有合约代码。地址里没代码的一定是 EOA,不可能实现 EIP-1271,直接返回false(不是错误,只是「这条合约验签路不通」)。 accounts.TextHash是关键:它内部帮你拼好 Ethereum 的消息前缀\x19Ethereum Signed Message:\n<len>,再做 Keccak256。也就是说验签用的 hash 必须和签名端用的 hash 完全一致——如果前端是用ethers.signMessage(text)签的,它也是加同样前缀再 hash,这里用TextHash就对得上(对照本仓库signature.md里手动拼 prefix 的写法,这里省了手工活)。abi.Pack("isValidSignature", hash32, sig):把函数调用编码成合约 calldata。isValidSignature入参是bytes32 hash+bytes signature,出参bytes4 magicValue。CallContract是只读 view 调用,不花 gas、不需要签名者私钥,任何人都能查——这正是 EIP-1271 验签的本质:把「认不认这个签名」的判定权交给合约自己。- 判定看返回值前 4 字节:EIP-1271 规定「认可」时返回魔术值
0x1626ba7e。代码里eip1271MagicValue去掉了0x前缀的1626ba7e,与common.Bytes2Hex(result[:4])直接比字符串。
注意
ethclient.Dial的 URL 这里是个占位(.../xx/)。真实项目应像发邮件章节那样从配置读取 RPC 地址,不要硬编码进仓库,更不要共用一个不知来源的公开节点。
EOA 部分:本地反推地址
func Verify(address string, message string, signature string) bool {
// 消息通常会被哈希化后再被签名
hashed := accounts.TextHash(utils.UnsafeBytes(message))
sig := common.FromHex(signature)
if len(sig) != crypto.SignatureLength { // 因为签名应该是65字节长: r (32 bytes) + s (32 bytes) + v (1 byte)
isValid, err := VerifySafeSignature(address, message, signature)
if err == nil && isValid {
return true
}
log.Error().Msg("Incorrect signature length")
return false
}
if sig[crypto.RecoveryIDOffset] != 27 && sig[crypto.RecoveryIDOffset] != 28 {
log.Error().Msg("Invalid Ethereum signature (V is not 27 or 28)")
return false
}
sig[crypto.RecoveryIDOffset] -= 27 // 转换V为0或1
// 使用签名来恢复公钥
pubKey, err := crypto.SigToPub(hashed, sig)
if err != nil {
log.Error().Err(err).Msg("Unable to recover public key from signature")
return false
}
sigPubBytes := crypto.FromECDSAPub(pubKey)
signatureNoRecoverID := sig[:len(sig)-1] // remove recovery id
verified := crypto.VerifySignature(sigPubBytes, hashed, signatureNoRecoverID)
if !verified {
log.Error().Msg("Invalid Ethereum signature")
return false
}
recoveredAddress := crypto.PubkeyToAddress(*pubKey)
// 检查恢复的地址是否与已知的地址匹配
// 这里需要您有该地址或者与前端收集的地址进行比对
expectedAddress := common.HexToAddress(address)
if strings.ToLower(recoveredAddress.Hex()) == strings.ToLower(expectedAddress.Hex()) {
return true
}
// 如果上面的验证失败,尝试通过 Safe 合约的 isValidSignature 验证
isValid, err := VerifySafeSignature(address, message, signature)
if err != nil {
log.Error().Err(err).Msg("Safe signature verification failed")
return false
}
return isValid
}
逐段拆解:
crypto.SignatureLength= 65:标准 ECDSA 签名是r(32) + s(32) + v(1)。若长度不是 65,基本不可能是裸 EOA 签名,代码直接转去走 EIP-1271 合约验签。v的两种取值:Ethereum 签名的 recovery id 常写成27或28(「比特币式」),而 go-ethereum 的crypto.SigToPub期望0或1,所以sig[RecoveryIDOffset] -= 27做一次归一化。crypto.SigToPub(hashed, sig):用(hash, sig)反推出签名者的公钥——这就是 ECDSA 的「从签名恢复公钥」。crypto.VerifySignature(pubBytes, hash, sigNoRecoverID):用恢复出的公钥再验一次签名完整性(入参是去掉 recovery id 的 64 字节r||s)。这一步是「公钥对得上、签名本身没被篡改」的双重保险。- 核心判定:
recoveredAddress == expectedAddress。即「用这个签名反推出来的地址,是不是前端声称的那个地址」。相等 → 证明前端确实持有该地址的私钥(或得到了其授权)。 - 兜底:EOA 反推地址不匹配时,再调一次
VerifySafeSignature去链上问合约。于是同一个入口Verify同时支持两类钱包。
调用流程总览
Verify(address, message, signature)
│
├─ sig 长度 != 65 ? ──► VerifySafeSignature(合约验签)
│
├─ sig 长度 == 65
│ ├─ v 不是 27/28 ? ──► 失败
│ ├─ SigToPub + VerifySignature ──► 反推地址
│ ├─ 反推地址 == 预期地址 ? ──► ✅ 成功(EOA)
│ └─ 不等 ? ──► VerifySafeSignature(合约验签兜底)
│
└─ 任一路径返回 true 即验签通过
几个必须注意的坑
-
hash 前缀必须前后端一致。这里用
accounts.TextHash(带\x19Ethereum Signed Message:前缀)。前端若用signTypedData(EIP-712)而不是signMessage,hash 算法完全不同,这份代码验不过——需要另写 EIP-712 的 hash 构造。验签前务必和后端的签名端、前端的签名端对齐「消息是怎么 hash 的」。 -
EIP-1271 是「信任合约判定的」。合约说认,就认。这意味着安全性完全依赖那个合约地址的实现——必须是你信任的 Safe 部署地址,不能是用户随便传来的任意合约。生产环境应把受认可的合约地址白名单化,而不是对任意
address都去调isValidSignature(否则用户可部署一个「永远返回 true」的恶意合约来伪造验签)。 -
每调用一次
VerifySafeSignature就ethclient.Dial建一个新连接。高并发场景应复用ethclient.Client(全局单例 / 连接池),避免频繁建连。 -
utils.UnsafeBytes来自 gofiber,只是string → []byte的零拷贝转换;不引 gofiber 时换成[]byte(message)即可。 -
CallContract的区块参数传nil表示用最新区块。若你需要「在某个历史时刻该签名是否有效」(如链上多签阈值曾变化),应传入具体blockNumber。
与 signature.md 的关系
signature.md:讲怎么签(手动拼\x19Ethereum Signed Message:前缀 → Keccak256 →crypto.Sign→v += 27),以及最基础的 EOA 验签。- 本章:讲怎么验得更稳——在 EOA 验签基础上,补上 EIP-1271 合约钱包的链上验签兜底,覆盖「用户用 Safe 等智能合约钱包登录」的真实场景。
两者验签用的 hash 算法一致(TextHash 等价于 signature.md 里手拼的 prefix + Keccak256),可对照阅读。