ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Go后端实现EIP-712签名验证:从结构化数据到防重放攻击

Go后端实现EIP-712签名验证:从结构化数据到防重放攻击 做链上应用的老哥应该都遇到过这个场景用户要授权一笔资产或者在链下验证身份后端需要拿到一个来自用户地址的签名再拿这个签名确认请求没有被篡改。我最近在 Go 后端项目里把这一套完整跑通核心用的就是 EIP-712 签名标准。EIP-712 的全称是 Ethereum typed data signing简单说就是给用户展示一张看得懂的“签名表格”然后对结构化数据做签名。这个方案不仅安全而且兼顾后端可验证性和前端用户体验是当前链上登录、授权、跨链桥、订单撮合这些场景的标准做法。这篇文章既算是给我自己的一个技术总结也希望给所有正在做前后端分离、又需要链上签名验证的同学一个可复用的参考。我尽量把后端生成签名数据、前端调用钱包签名、后端验签的完整链路以及中间涉及的类型哈希、domain separator、nonce 防护这些内容都讲清楚代码也是直接能跑的级别。1. 项目概述与整体方案设计1.1 为什么是 EIP-712传统签名的痛点在 EIP-712 之前常见的做法是让用户对一段任意字符串做personal_sign签名。用户看到的就是一串十六进制乱码比如0x68656c6c6f...根本不知道这段乱码将来会被后端拿去干什么。这带来一个很现实的风险钓鱼应用可以诱导用户签一段恶意数据然后把签名重放到其他协议里我见过不少用户因为这种签名丢失资产。EIP-712 的核心理念是把“签名内容”结构化。签名的对象不再是一段黑盒字节而是一张有字段名、有类型的“表单”比如转账人、接收方、金额、过期时间。钱包插件MetaMask、Rabby、Coinbase Wallet 等会把这张表单逐行渲染给用户看用户知道自己到底在签什么后安全性直接提升一大截。同时对于后端来说EIP-712 的数据结构是固定的、可解析的我们可以针对每个业务字段做校验而不像传统签名那样只能核对“是不是某个地址签的”。它天然适合做订单撮合、投票、授权委托、跨链消息这类需要语义验证的业务场景。我这次的项目做的就是链下授权码验证后端下发一个包含业务参数的签名载荷前端钱包签名后端验签通过后才放行后续操作。1.2 整体流程设计前后端分离场景下EIP-712 签名验证的完整流程大致如下前端向 Go 后端请求“待签名数据”。后端生成一个 EIP-712 结构化数据对象包含 domain、message 以及其他类型定义同时用服务端私钥生成一个一次性 nonce可选。后端把 JSON 返回给前端。前端调用钱包的eth_signTypedData_v4接口或者用 ethers.js 的signTypedData方法让用户确认并签名。前端把返回的 hex 签名65 字节r、s、v回传给后端。后端从签名中恢复签名者地址并与预期地址比对同时校验 nonce、过期时间等字段。校验通过后后端执行具体业务逻辑并标记 nonce 已使用防止重放。这个流程里后端承担两个职责生成签名数据、验证签名结果。前端只负责展示和签名。核心难点集中在第 2 步和第 6 步也就是 EIP-712 类型哈希的构造以及 ECDSA 签名恢复地址的过程。1.3 技术选型及选型理由后端语言我用的是 Go主要是因为它编译后单文件部署方便而且 go-ethereum 的crypto包基本已经把 keccak256、secp256k1 签名、地址恢复这些底层操作封装好了不需要自己写复杂的椭圆曲线运算。前端签名部分我直接用的 ethers.js 6.x 的TypedDataEncoder和signTypedData方法。ethers.js 封装得很好不需要手动拼接 JSON-RPC 请求。如果团队偏向原始调用也可以直接用window.ethereum.request({ method: eth_signTypedData_v4 })效果完全一样。有一点我特别想强调不要把“生成签名”也放在前端。前端只负责让用户签名生成载荷、校验签名这些工作一定要放在后端。否则用户随便改个参数就能绕过业务校验整个签名体系就形同虚设。2. EIP-712 数据结构的核心细节2.1 typed data 的 JSON 骨架EIP-712 要求签名对象是一个规范的 JSON 结构通常包含四层types、primaryType、domain、message。下面是我项目里的真实示例{ types: { EIP712Domain: [ { name: name, type: string }, { name: version, type: string }, { name: chainId, type: uint256 }, { name: verifyingContract, type: address } ], Authorization: [ { name: tokenId, type: uint256 }, { name: amount, type: uint256 }, { name: from, type: address }, { name: to, type: address }, { name: nonce, type: uint256 }, { name: deadline, type: uint256 } ] }, primaryType: Authorization, domain: { name: MyAuthApp, version: 1, chainId: 11155111, verifyingContract: 0xYourContractAddress }, message: { tokenId: 8888, amount: 1000000000000000000, from: 0xUserAddress, to: 0xRecipientAddress, nonce: 1700000000, deadline: 1700003600 } }其中EIP712Domain是标准规定的固定 domain 类型字段顺序也是标准固定的不能随意变更。Authorization是业务自定义的主类型primaryType告诉签名消费者“这条消息的结构定义在 types 里的哪一项”。message则是真正的业务数据。这里有个容易踩坑的点types里的字段顺序必须和 EIP-712 哈希规则一致因为类型哈希是直接把字段拼接成字符串后做 keccak256 的字段顺序变了哈希完全不同签名验签必然失败。2.2 domain 参数设计name、version、chainId、verifyingContractdomain的作用是隔离签名应用。不同的 dApp 使用不同的 domain可以防止“一个签名到处用”的跨协议重放攻击。name应用名称比如MyAuthApp。version版本号通常是字符串1。chainId链 ID。主网是 1Sepolia 测试网是 11155111这里必须和用户钱包当前连接的链一致否则钱包会提示签名错误或干脆拒绝。verifyingContract签名数据最终关联的合约地址。如果这个签名是给链上合约用的必须填合约部署地址如果只是支付接口则可以填零地址但需要保证所有环境统一。我在实际项目里遇到过好几次 chainId 不匹配的问题因为本地开发用 Ganache 或 Hardhat 节点是 31337测试网是 11155111主网是 1。这个值最好由后端根据当前环境动态生成而不是在前端写死不然换环境就崩。2.3 类型系统与哈希原理EIP-712 的核心是一套基于 keccak256 的哈希规则我先把公式列出来typeHash keccak256(encodeType(primaryType)) domainSeparator keccak256( encodeType(EIP712Domain) || encodeData(domain) ) structHash keccak256( typeHash || encodeData(message) ) digest keccak256( 0x1901 || domainSeparator || structHash )简单理解我们先给业务结构体生成一个“模板哈希”再把 domain 和 message 分别编码最终合并成一个 32 字节的 digest。这个 digest 才是用户签名时看到的原始内容。这里的encodeType有个细节当业务类型里嵌套了其他结构体时比如Authorization里有一个Item类型那么类型定义必须先列被引用的类型再列主类型。例如Item(uint256 id,string name)Authorization(uint256 tokenId,Item item)顺序不对哈希就会错。实现时我会做一个递归收集把依赖类型放在主类型之前。3. Go 后端生成签名数据的实操过程3.1 定义数据模型在 Go 里我习惯先用结构体把业务消息定义出来方便后续做字段校验。比如type AuthorizationMessage struct { TokenId *big.Int json:tokenId Amount *big.Int json:amount From common.Address json:from To common.Address json:to Nonce *big.Int json:nonce Deadline *big.Int json:deadline }这里有几个小细节uint256在 Go 里必须用*big.Int不要用int64因为数字可能超出 64 位范围。address类型对应common.Address可以直接调用Hex()方法转成字符串。字段的 JSON tag 必须和 types 里的 column 名称一一对应否则序列化后 m 字段缺失签名验证失败。3.2 自建哈希工具EIP-712 的哈希算法在网上有多个版本我建议直接用官方参考实现的思路做不要图省事只对 message 做个personal_sign。我封装了三个核心函数encodeType、encodeData、hashStruct。func encodeType(primaryType string, types map[string][]Field) string { // 先递归收集依赖类型保证依赖类型出现在主类型之前 // 然后按 EIP-712 规则拼接成 string // 例如Authorization(address owner,uint256 tokenId) var buf bytes.Buffer // 实现详见参考标准或开源库 return buf.String() } func encodeData(structName string, data interface{}, types map[string][]Field) ([]byte, error) { fields : types[structName] typeHash : crypto.Keccak256([]byte(encodeType(structName, types))) enc : make([]byte, 0) enc append(enc, typeHash...) for _, f : range fields { v : reflect.ValueOf(data).Elem().FieldByName(toCamel(f.Name)) // 根据 f.Type 做 ABI 编码标量直接左填充 32 字节 // string / bytes 类型先做 keccak256然后作为 32 字节处理 // struct 类型递归调用 hashStruct // array 类型先对每个元素编码然后整体做 keccak256 enc append(enc, encodeField(f.Type, v)...) } return enc, nil } func hashStruct(structName string, data interface{}, types map[string][]Field) common.Hash { enc, _ : encodeData(structName, data, types) return crypto.Keccak256Hash(enc) }这块在实际实现时比较繁琐尤其要处理reflect的各种边界情况。如果不想造轮子也可以用github.com/ethereum/go-ethereum/signer/core包里的TypedData结构和HashTypedData方法它已经实现了标准的 EIP-712 哈希。不过我还是建议至少自己写一遍encodeType这样出错时你能快速定位是字段顺序问题、还是类型定义问题。3.3 计算 domainSeparator 和最终 digest有了基础哈希函数domainSeparator 和最终 digest 就顺理成章了。我专门写了一个包装函数func buildDigest(chainID *big.Int, contractAddr common.Address, msg AuthorizationMessage) (common.Hash, error) { domain : map[string]interface{}{ name: MyAuthApp, version: 1, chainId: chainID, verifyingContract: contractAddr, } typedData : TypedData{ Types: AllTypes, PrimaryType: Authorization, Domain: domain, Message: msg, } domainSeparator, _ : typedData.HashStruct(EIP712Domain, typedData.Domain) structHash, _ : typedData.HashStruct(typedData.PrimaryType, typedData.Message) raw : []byte{0x19, 0x01} raw append(raw, domainSeparator[:]...) raw append(raw, structHash[:]...) return crypto.Keccak256Hash(raw), nil }这里需要注意0x19 0x01前缀这是 EIP-712 的固定前缀用来区分普通以太坊交易签名和 typed data 签名。少了它整个签名语义就彻底变了。为了验证自己写的哈希是否正确我强烈建议先用 Hardhat 或 Foundry 写一个测试合约用同一个 typed data 让合约的verify方法跑一遍确保本地哈希与链上恢复出的地址一致再继续接前端。3.4 后端接口与验签实现签名数据生成后我通过一个 HTTP 接口返回给前端http.HandleFunc(/api/sign-data, func(w http.ResponseWriter, r *http.Request) { // 从请求中解析业务参数比如 tokenId、to 地址 // 生成 nonce写入 Redis设置过期时间 // 使用当前链的 chainId 和合约地址构建 typed data JSON json.NewEncoder(w).Encode(typedDataMap) })前端拿到之后经过钱包签名再把签名值回传到另一个接口http.HandleFunc(/api/verify-signature, func(w http.ResponseWriter, r *http.Request) { var req struct { From string json:from Signature string json:signature Nonce string json:nonce } json.NewDecoder(r.Body).Decode(req) // 校验 nonce 是否被使用过避免重放 // 重新构建 digest digest, _ : buildDigest(chainID, contractAddr, msg) sigBytes, err : hexutil.Decode(req.Signature) // 如果 v 值是 27/28需要转换成 0/1 再调用 SigToPub if sigBytes[64] 27 { sigBytes[64] - 27 } pubKey, err : crypto.SigToPub(digest[:], sigBytes) recoveredAddr : crypto.PubkeyToAddress(*pubKey) // 对比 recoveredAddr 和 req.From // 对比 nonce、deadline if !strings.EqualFold(recoveredAddr.Hex(), req.From) { http.Error(w, invalid signature, http.StatusUnauthorized) return } // 业务放行 })验签完还有一件事必须做把 nonce 标记为已使用。我用 Redis 的SETNX原子操作实现key 是nonce:nonce值可以是请求方地址过期时间就和 nonce 有效期一致。这样即使签名被拦截重放二次提交依然会被拒绝。4. 前端签名与回传完整流程4.1 前端获取并校验待签名数据前端部分我用 ethers.js 6.x逻辑相当简洁。先从后端拿到 typed data JSONconst res await fetch(/api/sign-data, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ tokenId: 8888, to: 0xRecipient }) }); const typedData await res.json();拿到后可以先用TypedDataEncoder.getPayload()之类的工具做一次解析确认结构完整。更重要的是把 message 里的from和当前钱包地址做一次比对防止页面数据与实际签名地址不一致。if (!moduleAddress.toLowerCase().endsWith(typedData.message.from.toLowerCase())) { throw new Error(钱包地址不匹配); }4.2 调用钱包签名ethers.js 6 的签名调用最稳定import { ethers } from ethers; const provider new ethers.BrowserProvider(window.ethereum); const signer await provider.getSigner(); const signature await signer.signTypedData( typedData.domain, typedData.types, typedData.message );signTypedData方法内部会自动把types里的EIP712Domain和primaryType做成钱包可识别的请求。如果你要兼容老钱包也可以直接调 RPCconst signature await window.ethereum.request({ method: eth_signTypedData_v4, params: [signer.address, JSON.stringify(typedData)] });两种方式最终拿到的都是 65 字节的十六进制字符串以0x开头前 64 字节是 r 和 s最后 2 字节是 v。这块有个常识性的地方用户看到的 sign 页面是钱包渲染出来的如果字段名不规范或带中文钱包显示可能会乱码所以业务字段命名尽量用英文驼峰。4.3 签名回传与错误处理签名拿到后直接 POST 给后端同时带上地址和 nonceconst serialized { from: signer.address, signature, nonce: typedData.message.nonce }; const verifyRes await fetch(/api/verify-signature, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(serialized) });常见错误集中在三步签名错误、nonce 已过期、地址不匹配。我在前端会把这三类错误用不同的 toast 提示区分尤其是 nonce 过期需要引导用户重新请求签名数据而不是反复签名旧数据。在实际测试中我发现MetaMask 默认不支持重新签名同一个 nonce所以每次生成签名最好都让后端生成新的 nonce否则前端刷新后再次调用会有缓存问题。5. 常见问题与排查技巧实录5.1 签名验证恢复出的地址对不上这是新手最容易卡住的问题。基本逃不开下面几个原因v 值没做降位处理。MetaMask 返回的 v 是 27/28go-ethereum 的crypto.SigToPub期望的是 0/1。忘记减 27恢复地址大概率是错的。字段顺序或类型名字写错。Authorization(address owner,uint256 tokenId)和Authorization(uint256 tokenId,address owner)的 typeHash 完全不同看起来只差一个字段但哈希结果天差地别。数组编码方式没搞对。数组需要先对每个元素做偏移量编码再做整体 keccak256不能直接拼接。digest 重复计算时有结构差异。比如 domain 里多了salt、chainId写成字符串而不是数字都会导致哈希不一致。我的排查方式是打印三段关键值typeHash、domainSeparator、structHash再和前端以太坊库的计算结果对比。只要这三段一致签名后恢复地址基本就对了。5.2 chainId 与网络不匹配前端钱包在签名时会对 chainId 做硬校验。如果 typed data 里的 chainId 是 1但钱包连接的是 Sepolia 测试网11155111钱包会直接拒绝签名。我的做法是在会话开始时记录用户当前链 ID并且在生成签名数据的请求中把它回传后端以这个为准生成 domain。还有一个细节跨链桥或多链 sDApp 要特别谨慎不要把主网合约地址当测试网的 verifyingContract 用。我建议在代码里维护一个[chainId] - { name, version, verifyingContract }的映射表自动匹配。5.3 重放攻击与 nonce 防护即使 EIP-712 的 domain 可以防止跨应用重放同一个应用内的重放还是需要自己处理。我的标准做法是 nonce deadline 双防护。nonce服务端生成一个随机数或单调递增数字签名后由后端记录状态。验签时检查 nonce 未被使用验签通过后原子性标记已用。deadline给签名设置有效期。验签时校验当前时间是否小于 deadline避免签名被无限期保存后利用。实际生产中我遇到过一种攻击思路攻击者先请求一个签名然后过几天再尝试提交。如果没有 deadline只要 nonce 没被消费签名永远有效。deadline 加上 15 分钟有效期可以大幅压缩这种攻击窗口。nonce 的存储我用 Redis但要注意并发问题。直接GET再SET会有竞态条件正确做法是SETNX nonce:value address EX ttl。如果返回 0说明已经存在直接拒绝。5.4 多环境配置与测试建议本地、测试网、主网环境的 domain 参数不能共用。我最崩溃的一次是把本地测试的 domain 直接留到了生产环境用户签名成功了但后端起了一个完全不同的 domain验签永远失败。建议在配置文件里把链相关参数拆开chains: 11155111: name: MyAuthApp version: 1 verifyingContract: 0xSepoliaContract 1: name: MyAuthApp version: 1 verifyingContract: 0xMainnetContract同时签名正确性测试也建议自动化。我用 Go 写了一个单元测试用同一份 typed data 分别调用 Go 的 hash 和 ethers.js 的 hash断言两边的 digest 一致。这样即使后来改了字段或类型也能第一时间发现。6. 最后再分享一点我的实操体会从最早用personal_sign签字符串到现在完整跑通 EIP-712我最深的感受是这套方案真正的门槛不在签名本身而在“哈希构造的一致性”。哪怕一个小字段顺序不对、一个类型名大小写不一致都可能导致验签失败而且这种错误藏得特别深不是肉眼能看出来的。所以我在项目里加了两个调试开关一个是把生成的 typed data JSON 原样保存方便和前端钱包呈现的内容对比另一个是启动时打印一份 typeHash 和 domainSeparator配合钱包模拟器做快速定位。另外自测时尽量用数据传输不经过业务接口的“纯签名验证”接口先确保证签链路通了再接入业务逻辑排查起来会轻松很多。如果你也在做类似的前后端分离签名验证项目建议先把一个小而全的例子跑通比如一个仅包含地址和数字字段的Authorization再逐步扩充到复杂结构体。这样每一步的变量都很少出了问题一眼就能定位。EIP-712 这套标准在未来很长时间里都会是链上签名的基础设施提前吃透它后面做钱包、跨链、订单系统都能少走不少弯路。
返回列表