验证用户的链下签名(尤其是遵循EIP-712标准的签名)是DApp后端确保用户身份、确保交易合法性的核心环节。以下是具体实现流程、原理和Golang代码示例:

一、EIP-712签名验证的核心原理

EIP-712定义了结构化数据的签名规范,解决了传统eth_sign签名中用户无法直观识别签名内容的问题。验证流程的核心是:

  1. 还原签名者地址:通过签名和原始数据,反向计算出签名者的公钥和地址。
  2. 校验签名有效性:确保签名对应的原始数据未被篡改,且签名者是预期用户。
  3. 防重放攻击:通过签名中包含的chainId,限制签名仅在指定链上有效(遵循EIP-155)。

二、验证步骤(分前端和后端)

1. 前端准备(用户签名)

用户通过钱包(如MetaMask)对EIP-712结构化数据签名,前端需将以下信息传递给后端:

  • 签名者地址(signerAddress):用户钱包地址(预期的签名者)。
  • 原始结构化数据(typedData):符合EIP-712格式的数据(包含domaintypesmessage等)。
  • 签名结果(signature):钱包返回的65字节签名(r + s + v)。

前端示例(JavaScript)

// 1. 构造EIP-712结构化数据
const typedData = {
  domain: {
    name: "MyDApp", // DApp名称
    version: "1.0", // 版本
    chainId: 11155111, // Sepolia测试网链ID
    verifyingContract: "0x123...", // 可选:关联的合约地址
  },
  types: {
    Transaction: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
      { name: "nonce", type: "uint256" }, // 防重放的随机数
    ],
  },
  primaryType: "Transaction",
  message: {
    to: "0x456...",
    amount: "1000000000000000000", // 1 ETH(wei单位)
    nonce: "123456", // 随机数,避免同数据重复签名
  },
};

// 2. 请求用户签名
const signature = await window.ethereum.request({
  method: "eth_signTypedData_v4",
  params: [userAddress, JSON.stringify(typedData)],
});

// 3. 发送到后端验证
fetch("/api/verify-signature", {
  method: "POST",
  body: JSON.stringify({
    signerAddress: userAddress,
    typedData: typedData,
    signature: signature,
  }),
});
2. 后端验证(Golang实现)

后端需完成3个关键步骤:解析签名、生成数据哈希、验证签名者地址。

核心依赖

  • go-ethereum:提供签名解析、公钥恢复等工具。
  • go-ethereum/signer/core/apitypes:处理EIP-712结构化数据哈希。
package main

import (
	"encoding/json"
	"fmt"
	"math/big"

	"github.com/ethereum/go-ethereum/accounts/abi"
	"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"
)

// 验证EIP-712签名的主函数
func verifyEIP712Signature(
	signerAddress string, // 预期的签名者地址(前端传递)
	typedData apitypes.TypedData, // EIP-712结构化数据
	signature string, // 前端传递的签名(hex格式)
	chainID *big.Int, // 当前链ID(用于防重放验证)
) (bool, error) {
	// 1. 校验输入地址格式
	expectedAddr := common.HexToAddress(signerAddress)
	if expectedAddr == common.Address{} {
		return false, fmt.Errorf("无效的签名者地址")
	}

	// 2. 解析签名为字节数组
	sigBytes, err := hexutil.Decode(signature)
	if err != nil || len(sigBytes) != 65 {
		return false, fmt.Errorf("无效的签名格式(必须是65字节)")
	}

	// 3. 处理EIP-155的v值(恢复原始v)
	// 签名中的v包含链ID偏移,需还原为0或1(原始ECC签名的v值)
	// 公式:v = sigBytes[64] - 27 - 2*chainID(EIP-155定义)
	// 验证时只需确保还原后v为0或1,即可确认链ID匹配
	v := sigBytes[64]
	if v < 27 {
		return false, fmt.Errorf("签名v值不符合EIP-155规范")
	}
	originalV := int(v - 27)
	if originalV != 0 && originalV != 1 {
		return false, fmt.Errorf("签名链ID不匹配当前网络")
	}

	// 4. 生成EIP-712结构化数据的哈希
	// 哈希 = keccak256("\x19\x01" + domainSeparator + hashStruct(message))
	hash, err := apitypes.TypedDataHash(typedData)
	if err != nil {
		return false, fmt.Errorf("生成数据哈希失败: %v", err)
	}

	// 5. 从签名和哈希中恢复公钥
	// ECDSA签名验证:recover公钥 = ecrecover(hash, v, r, s)
	pubKey, err := crypto.Ecrecover(hash.Bytes(), sigBytes)
	if err != nil {
		return false, fmt.Errorf("恢复公钥失败: %v", err)
	}

	// 6. 将公钥转换为地址并验证
	recoveredAddr := crypto.PubkeyToAddress(*crypto.ToECDSAPub(pubKey))
	if recoveredAddr != expectedAddr {
		return false, fmt.Errorf("签名者不匹配(预期: %s, 实际: %s)", expectedAddr, recoveredAddr)
	}

	return true, nil
}

// 示例:从HTTP请求中解析参数并验证
func main() {
	// 模拟前端传递的参数
	signerAddress := "0x71c7656ec7ab88b098defb751b7401b5f6dbfaf"
	signature := "0x...65字节签名..." // 实际签名需替换
	chainID := big.NewInt(11155111) // Sepolia链ID

	// 解析前端传递的typedData(JSON字符串转结构体)
	typedDataJSON := `{
		"domain": {
			"name": "MyDApp",
			"version": "1.0",
			"chainId": 11155111,
			"verifyingContract": "0x123..."
		},
		"types": {
			"Transaction": [
				{"name": "to", "type": "address"},
				{"name": "amount", "type": "uint256"},
				{"name": "nonce", "type": "uint256"}
			]
		},
		"primaryType": "Transaction",
		"message": {
			"to": "0x456...",
			"amount": "1000000000000000000",
			"nonce": "123456"
		}
	}`
	var typedData apitypes.TypedData
	if err := json.Unmarshal([]byte(typedDataJSON), &typedData); err != nil {
		panic(fmt.Sprintf("解析typedData失败: %v", err))
	}

	// 执行验证
	valid, err := verifyEIP712Signature(signerAddress, typedData, signature, chainID)
	if err != nil {
		fmt.Printf("验证失败: %v\n", err)
		return
	}
	if valid {
		fmt.Println("签名验证成功,用户身份合法")
		// 验证通过后:执行后续业务(如创建订单、发起链上交易)
	} else {
		fmt.Println("签名验证失败,拒绝操作")
	}
}

三、关键细节与安全注意事项

  1. 防重放攻击强化

    • 除了EIP-155的chainId,建议在message中加入nonce(随机数)或timestamp(时间戳),避免同一结构化数据被重复使用。
    • 后端需记录已使用的nonce,防止重复提交。
  2. 数据完整性校验

    • 验证前需校验typedData中的domain信息(如nameversion),确保签名针对当前DApp,避免跨应用重放。
  3. 错误处理

    • 签名无效的常见原因:签名被篡改、chainId不匹配、typedData与签名时不一致。
    • 后端应返回具体错误信息(如“签名者不匹配”“链ID错误”),方便前端提示用户。
  4. 性能优化

    • 高频验证场景(如批量订单)可缓存TypedDataHash的计算结果,减少重复哈希运算。

四、应用场景

  • 用户身份验证:替代传统密码登录,通过签名证明“用户拥有该钱包私钥”。
  • 交易授权:用户签名确认链下订单(如DEX挂单),后端验证后再发起链上交易。
  • 权限管理:签名授权第三方操作资产(如授权NFT marketplace转移NFT)。

通过上述流程,后端可安全地验证用户的链下签名,确保DApp操作的合法性和用户身份的真实性。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐