Ethereum 验证签名(EOA + EIP-1271)

背景:两种「签名者」

在 Ethereum 上,「谁能对一段消息给出签名」分两种账户:

  1. EOA(外部账户):由私钥控制,标准 ECDSA 签名,可用公钥反推地址。本仓库 signature.md 已讲过这种的签名与验签。
  2. 合约账户(如 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 常写成 2728(「比特币式」),而 go-ethereum 的 crypto.SigToPub 期望 01,所以 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 即验签通过

几个必须注意的坑

  1. hash 前缀必须前后端一致。这里用 accounts.TextHash(带 \x19Ethereum Signed Message: 前缀)。前端若用 signTypedData(EIP-712)而不是 signMessage,hash 算法完全不同,这份代码验不过——需要另写 EIP-712 的 hash 构造。验签前务必和后端的签名端、前端的签名端对齐「消息是怎么 hash 的」。

  2. EIP-1271 是「信任合约判定的」。合约说认,就认。这意味着安全性完全依赖那个合约地址的实现——必须是你信任的 Safe 部署地址,不能是用户随便传来的任意合约。生产环境应把受认可的合约地址白名单化,而不是对任意 address 都去调 isValidSignature(否则用户可部署一个「永远返回 true」的恶意合约来伪造验签)。

  3. 每调用一次 VerifySafeSignatureethclient.Dial 建一个新连接。高并发场景应复用 ethclient.Client(全局单例 / 连接池),避免频繁建连。

  4. utils.UnsafeBytes 来自 gofiber,只是 string → []byte 的零拷贝转换;不引 gofiber 时换成 []byte(message) 即可。

  5. CallContract 的区块参数传 nil 表示用最新区块。若你需要「在某个历史时刻该签名是否有效」(如链上多签阈值曾变化),应传入具体 blockNumber

与 signature.md 的关系

  • signature.md:讲怎么签(手动拼 \x19Ethereum Signed Message: 前缀 → Keccak256 → crypto.Signv += 27),以及最基础的 EOA 验签。
  • 本章:讲怎么验得更稳——在 EOA 验签基础上,补上 EIP-1271 合约钱包的链上验签兜底,覆盖「用户用 Safe 等智能合约钱包登录」的真实场景。

两者验签用的 hash 算法一致(TextHash 等价于 signature.md 里手拼的 prefix + Keccak256),可对照阅读。